From f56bf77a1f37e8d56df0f93a55d85d3d4792b5ad Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:10:30 +0200 Subject: [PATCH 01/26] feat(analytics): add X and LinkedIn analytics on ClickHouse --- .env.example | 4 + apps/dashboard/knip.json | 2 +- apps/dashboard/package.json | 1 + .../scripts/register-analytics-schedule.ts | 26 + .../(dashboard)/[slug]/analytics/loading.tsx | 5 + .../[slug]/analytics/page-client.tsx | 297 ++++++++++ .../app/(dashboard)/[slug]/analytics/page.tsx | 25 + .../(dashboard)/[slug]/analytics/skeleton.tsx | 39 ++ .../workflows/social-analytics-sync/route.ts | 40 ++ .../components/analytics/account-filter.tsx | 72 +++ .../analytics/account-series-chart-card.tsx | 122 +++++ .../src/components/analytics/chart-legend.tsx | 56 ++ .../src/components/analytics/chart-marker.tsx | 37 ++ .../components/analytics/followers-card.tsx | 151 ++++++ .../analytics/posting-performance-card.tsx | 82 +++ .../components/analytics/summary-stats.tsx | 77 +++ .../components/analytics/top-posts-card.tsx | 119 ++++ .../components/command-palette/registry.ts | 9 + .../src/components/dashboard/nav-main.tsx | 7 + .../src/components/layout/section-header.tsx | 23 + apps/dashboard/src/constants/analytics.ts | 20 + apps/dashboard/src/lib/analytics/constants.ts | 6 + .../src/lib/analytics/record-post.ts | 43 ++ apps/dashboard/src/lib/analytics/rows.ts | 91 ++++ .../src/lib/analytics/twitter-sync.ts | 124 +++++ .../src/lib/hooks/use-social-analytics.ts | 77 +++ apps/dashboard/src/lib/orpc/router.ts | 2 + .../src/lib/orpc/routers/analytics.ts | 308 +++++++++++ .../src/lib/social-connect/publish.ts | 15 + apps/dashboard/src/lib/workflows/start.ts | 10 + apps/dashboard/src/schemas/analytics.ts | 19 + apps/dashboard/src/types/analytics.ts | 198 +++++++ apps/dashboard/src/utils/analytics-charts.ts | 125 +++++ .../src/workflows/social-analytics-sync.ts | 40 ++ .../workflows/steps/social-analytics-steps.ts | 73 +++ bun.lock | 29 + packages/analytics/package.json | 23 + packages/analytics/src/tinybird/client.ts | 185 +++++++ .../analytics/src/tinybird/datasources.ts | 117 ++++ packages/analytics/src/tinybird/endpoints.ts | 393 ++++++++++++++ packages/analytics/src/utils/datetime.ts | 13 + packages/analytics/tinybird.config.json | 6 + packages/analytics/tsconfig.json | 10 + packages/ui/dither-kit.json | 156 ++++++ packages/ui/package.json | 7 + .../src/components/dither-kit/area-chart.tsx | 25 + .../ui/src/components/dither-kit/area.tsx | 102 ++++ .../ui/src/components/dither-kit/avatar.tsx | 212 ++++++++ .../src/components/dither-kit/bar-canvas.tsx | 227 ++++++++ .../src/components/dither-kit/bar-chart.tsx | 14 + packages/ui/src/components/dither-kit/bar.tsx | 83 +++ .../components/dither-kit/block-legend.tsx | 63 +++ .../dither-kit/cartesian-canvas.tsx | 405 ++++++++++++++ .../components/dither-kit/cartesian-root.tsx | 192 +++++++ .../components/dither-kit/chart-context.tsx | 510 ++++++++++++++++++ .../components/dither-kit/common-context.tsx | 48 ++ .../src/components/dither-kit/dither-paint.ts | 177 ++++++ packages/ui/src/components/dither-kit/dot.tsx | 90 ++++ .../ui/src/components/dither-kit/grid.tsx | 48 ++ .../ui/src/components/dither-kit/legend.tsx | 69 +++ packages/ui/src/components/dither-kit/lib.ts | 8 + .../ui/src/components/dither-kit/palette.ts | 40 ++ .../ui/src/components/dither-kit/pixel.ts | 109 ++++ .../components/dither-kit/polar-context.tsx | 382 +++++++++++++ .../src/components/dither-kit/polar-root.tsx | 173 ++++++ .../ui/src/components/dither-kit/polar.ts | 122 +++++ .../components/dither-kit/reference-line.tsx | 50 ++ .../ui/src/components/dither-kit/scales.ts | 109 ++++ .../components/dither-kit/series-context.tsx | 23 + .../src/components/dither-kit/sparkline.tsx | 66 +++ .../ui/src/components/dither-kit/tooltip.tsx | 116 ++++ .../dither-kit/use-chart-dimensions.ts | 37 ++ .../ui/src/components/dither-kit/x-axis.tsx | 44 ++ .../ui/src/components/dither-kit/y-axis.tsx | 33 ++ turbo.json | 2 + 75 files changed, 6862 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/scripts/register-analytics-schedule.ts create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/analytics/loading.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/analytics/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx create mode 100644 apps/dashboard/src/app/api/workflows/social-analytics-sync/route.ts create mode 100644 apps/dashboard/src/components/analytics/account-filter.tsx create mode 100644 apps/dashboard/src/components/analytics/account-series-chart-card.tsx create mode 100644 apps/dashboard/src/components/analytics/chart-legend.tsx create mode 100644 apps/dashboard/src/components/analytics/chart-marker.tsx create mode 100644 apps/dashboard/src/components/analytics/followers-card.tsx create mode 100644 apps/dashboard/src/components/analytics/posting-performance-card.tsx create mode 100644 apps/dashboard/src/components/analytics/summary-stats.tsx create mode 100644 apps/dashboard/src/components/analytics/top-posts-card.tsx create mode 100644 apps/dashboard/src/components/layout/section-header.tsx create mode 100644 apps/dashboard/src/constants/analytics.ts create mode 100644 apps/dashboard/src/lib/analytics/constants.ts create mode 100644 apps/dashboard/src/lib/analytics/record-post.ts create mode 100644 apps/dashboard/src/lib/analytics/rows.ts create mode 100644 apps/dashboard/src/lib/analytics/twitter-sync.ts create mode 100644 apps/dashboard/src/lib/hooks/use-social-analytics.ts create mode 100644 apps/dashboard/src/lib/orpc/routers/analytics.ts create mode 100644 apps/dashboard/src/schemas/analytics.ts create mode 100644 apps/dashboard/src/types/analytics.ts create mode 100644 apps/dashboard/src/utils/analytics-charts.ts create mode 100644 apps/dashboard/src/workflows/social-analytics-sync.ts create mode 100644 apps/dashboard/src/workflows/steps/social-analytics-steps.ts create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/src/tinybird/client.ts create mode 100644 packages/analytics/src/tinybird/datasources.ts create mode 100644 packages/analytics/src/tinybird/endpoints.ts create mode 100644 packages/analytics/src/utils/datetime.ts create mode 100644 packages/analytics/tinybird.config.json create mode 100644 packages/analytics/tsconfig.json create mode 100644 packages/ui/dither-kit.json create mode 100644 packages/ui/src/components/dither-kit/area-chart.tsx create mode 100644 packages/ui/src/components/dither-kit/area.tsx create mode 100644 packages/ui/src/components/dither-kit/avatar.tsx create mode 100644 packages/ui/src/components/dither-kit/bar-canvas.tsx create mode 100644 packages/ui/src/components/dither-kit/bar-chart.tsx create mode 100644 packages/ui/src/components/dither-kit/bar.tsx create mode 100644 packages/ui/src/components/dither-kit/block-legend.tsx create mode 100644 packages/ui/src/components/dither-kit/cartesian-canvas.tsx create mode 100644 packages/ui/src/components/dither-kit/cartesian-root.tsx create mode 100644 packages/ui/src/components/dither-kit/chart-context.tsx create mode 100644 packages/ui/src/components/dither-kit/common-context.tsx create mode 100644 packages/ui/src/components/dither-kit/dither-paint.ts create mode 100644 packages/ui/src/components/dither-kit/dot.tsx create mode 100644 packages/ui/src/components/dither-kit/grid.tsx create mode 100644 packages/ui/src/components/dither-kit/legend.tsx create mode 100644 packages/ui/src/components/dither-kit/lib.ts create mode 100644 packages/ui/src/components/dither-kit/palette.ts create mode 100644 packages/ui/src/components/dither-kit/pixel.ts create mode 100644 packages/ui/src/components/dither-kit/polar-context.tsx create mode 100644 packages/ui/src/components/dither-kit/polar-root.tsx create mode 100644 packages/ui/src/components/dither-kit/polar.ts create mode 100644 packages/ui/src/components/dither-kit/reference-line.tsx create mode 100644 packages/ui/src/components/dither-kit/scales.ts create mode 100644 packages/ui/src/components/dither-kit/series-context.tsx create mode 100644 packages/ui/src/components/dither-kit/sparkline.tsx create mode 100644 packages/ui/src/components/dither-kit/tooltip.tsx create mode 100644 packages/ui/src/components/dither-kit/use-chart-dimensions.ts create mode 100644 packages/ui/src/components/dither-kit/x-axis.tsx create mode 100644 packages/ui/src/components/dither-kit/y-axis.tsx diff --git a/.env.example b/.env.example index 99ef4f13e..498798abe 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,10 @@ UNKEY_API_ID= # Twitter TWITTER_BEARER_TOKEN= +# Tinybird (social analytics) +TINYBIRD_TOKEN= +TINYBIRD_BASE_URL=https://api.tinybird.co + # Social account linking provider POST_FOR_ME_API_KEY= POST_FOR_ME_API_KEY_TWITTER= diff --git a/apps/dashboard/knip.json b/apps/dashboard/knip.json index 6a7ef08ea..e7651f9f9 100644 --- a/apps/dashboard/knip.json +++ b/apps/dashboard/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/**/*.test.ts"], + "entry": ["src/**/*.test.ts", "scripts/*.ts"], "ignoreIssues": { "src/app/(dashboard)/**/constants/**/*.ts": ["exports"], "src/components/**/*.tsx": ["types"], diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 7701b3f3d..fe1c9c41c 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -38,6 +38,7 @@ "@lexical/utils": "^0.39.0", "@neoconfetti/react": "^1.0.0", "@notra/ai": "workspace:*", + "@notra/analytics": "workspace:*", "@notra/content-generation": "workspace:*", "@notra/db": "workspace:*", "@notra/email": "workspace:*", diff --git a/apps/dashboard/scripts/register-analytics-schedule.ts b/apps/dashboard/scripts/register-analytics-schedule.ts new file mode 100644 index 000000000..689b0e63f --- /dev/null +++ b/apps/dashboard/scripts/register-analytics-schedule.ts @@ -0,0 +1,26 @@ +import { getAppUrl } from "@notra/ai/qstash/triggers"; +import { Client as QStashClient } from "@upstash/qstash"; + +const HOURLY_CRON = "0 * * * *"; +const SCHEDULE_ID = "social-analytics-sync-hourly"; + +const token = process.env.QSTASH_TOKEN; +if (!token) { + throw new Error("QSTASH_TOKEN is not configured"); +} + +const client = new QStashClient({ token }); +const destination = `${getAppUrl()}/api/workflows/social-analytics-sync`; + +const result = await client.schedules.create({ + scheduleId: SCHEDULE_ID, + destination, + cron: HOURLY_CRON, + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, +}); + +console.log( + `Registered schedule ${result.scheduleId ?? SCHEDULE_ID} -> ${destination} (${HOURLY_CRON})` +); +process.exit(0); diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/loading.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/loading.tsx new file mode 100644 index 000000000..8b21d8a63 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/loading.tsx @@ -0,0 +1,5 @@ +import { AnalyticsPageSkeleton } from "./skeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx new file mode 100644 index 000000000..0ae340bd0 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx @@ -0,0 +1,297 @@ +"use client"; + +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { AccountFilter } from "@/components/analytics/account-filter"; +import { AccountSeriesChartCard } from "@/components/analytics/account-series-chart-card"; +import { FollowersCard } from "@/components/analytics/followers-card"; +import { PostingPerformanceCard } from "@/components/analytics/posting-performance-card"; +import { SummaryStats } from "@/components/analytics/summary-stats"; +import { TopPostsCard } from "@/components/analytics/top-posts-card"; +import { EmptyState } from "@/components/empty-state"; +import { PageContainer } from "@/components/layout/container"; +import { SectionHeader } from "@/components/layout/section-header"; +import { useOrganizationsContext } from "@/components/providers/organization-provider"; +import { + ACCOUNT_SERIES_COLORS, + ANALYTICS_TIMESERIES_DAYS, +} from "@/constants/analytics"; +import { + useEngagementTimeseries, + useFollowerGrowth, + useNotraAdoption, + usePostingPerformance, + useSocialOverview, + useTopPosts, +} from "@/lib/hooks/use-social-analytics"; +import type { TimelineMarker } from "@/types/analytics"; +import { + accountSeriesKey, + buildAccountSeriesRows, + buildPostingPerformanceRows, + buildTimelineDays, + markerIndexForDate, +} from "@/utils/analytics-charts"; +import { AnalyticsPageSkeleton } from "./skeleton"; + +interface PageClientProps { + organizationSlug: string; +} + +export default function PageClient({ organizationSlug }: PageClientProps) { + const { getOrganization, activeOrganization } = useOrganizationsContext(); + const orgFromList = getOrganization(organizationSlug); + const organization = + activeOrganization?.slug === organizationSlug + ? activeOrganization + : orgFromList; + const organizationId = organization?.id ?? ""; + + const { data: overview, isPending: isOverviewPending } = + useSocialOverview(organizationId); + const { data: engagement } = useEngagementTimeseries(organizationId); + const { data: followerGrowth } = useFollowerGrowth(organizationId); + const { data: topPosts } = useTopPosts(organizationId); + const { data: performance } = usePostingPerformance(organizationId); + const { data: adoption } = useNotraAdoption(organizationId); + + const [hoverIndex, setHoverIndex] = useState(null); + const [hiddenKeys, setHiddenKeys] = useState>(new Set()); + + const accounts = useMemo( + () => overview?.accounts ?? [], + [overview?.accounts] + ); + + const accountConfig = useMemo(() => { + const config: ChartConfig = {}; + accounts.forEach((account, index) => { + config[accountSeriesKey(account.provider, account.providerAccountId)] = { + label: `@${account.username}`, + color: + ACCOUNT_SERIES_COLORS[index % ACCOUNT_SERIES_COLORS.length] ?? "blue", + }; + }); + return config; + }, [accounts]); + + const allKeys = useMemo( + () => + accounts.map((account) => + accountSeriesKey(account.provider, account.providerAccountId) + ), + [accounts] + ); + + const visibleKeys = useMemo( + () => allKeys.filter((key) => !hiddenKeys.has(key)), + [allKeys, hiddenKeys] + ); + + const selectedKeys = useMemo(() => new Set(visibleKeys), [visibleKeys]); + + const timelineDays = useMemo( + () => buildTimelineDays(ANALYTICS_TIMESERIES_DAYS), + [] + ); + + const engagementRows = useMemo( + () => + buildAccountSeriesRows( + timelineDays, + visibleKeys, + engagement?.points ?? [], + (point) => + (point.likes ?? 0) + (point.replies ?? 0) + (point.reposts ?? 0) + ), + [timelineDays, visibleKeys, engagement?.points] + ); + + const impressionRows = useMemo( + () => + buildAccountSeriesRows( + timelineDays, + visibleKeys, + engagement?.points ?? [], + (point) => point.impressions ?? 0 + ), + [timelineDays, visibleKeys, engagement?.points] + ); + + const postRows = useMemo( + () => + buildAccountSeriesRows( + timelineDays, + visibleKeys, + engagement?.points ?? [], + (point) => point.posts + ), + [timelineDays, visibleKeys, engagement?.points] + ); + + const markers = useMemo(() => { + const result: TimelineMarker[] = []; + const joined = markerIndexForDate( + timelineDays, + adoption?.organizationCreatedAt ?? null + ); + if (joined !== null) { + result.push({ index: joined, label: "Joined Notra" }); + } + const firstPost = markerIndexForDate( + timelineDays, + adoption?.firstNotraPostAt ?? null + ); + if (firstPost !== null && firstPost !== joined) { + result.push({ index: firstPost, label: "First Notra post" }); + } + return result; + }, [timelineDays, adoption]); + + const performanceRows = useMemo( + () => buildPostingPerformanceRows(performance?.points ?? []), + [performance?.points] + ); + + const toggleAccount = (key: string) => { + setHiddenKeys((previous) => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + if (isOverviewPending) { + return ; + } + + if (accounts.length === 0) { + return ( + +
+
+

Analytics

+

+ Performance of your connected X and LinkedIn accounts +

+
+ + Connect an account + + } + description="Connect an X or LinkedIn account to start tracking followers, impressions, and engagement." + title="No connected accounts" + /> +
+
+ ); + } + + return ( + +
+
+
+

Analytics

+

+ {accounts.length} {accounts.length === 1 ? "account" : "accounts"}{" "} + connected · hover any chart to compare the same day everywhere +

+
+ +
+ + {overview?.configured === false && ( +

+ Analytics ingestion is not configured yet. Connected accounts are + shown, but stats will appear once the analytics backend is set up. +

+ )} + + + +
+ +
+ + + + accountConfig[key]?.color ?? "blue"} + hiddenKeys={hiddenKeys} + points={followerGrowth?.points ?? []} + /> +
+
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page.tsx new file mode 100644 index 000000000..9cb7ca3d3 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import PageClient from "./page-client"; +import { AnalyticsPageSkeleton } from "./skeleton"; + +export const metadata: Metadata = { + title: "Analytics", +}; + +async function Page({ + params, +}: { + params: Promise<{ + slug: string; + }>; +}) { + const { slug } = await params; + + return ( + }> + + + ); +} +export default Page; diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx new file mode 100644 index 000000000..c60f8ca10 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Skeleton } from "@notra/ui/components/ui/skeleton"; +import { useId } from "react"; +import { PageContainer } from "@/components/layout/container"; + +const ACCOUNT_CARD_COUNT = 2; +const CHART_CARD_COUNT = 2; + +export function AnalyticsPageSkeleton() { + const id = useId(); + return ( + +
+
+ + +
+
+ {Array.from({ length: ACCOUNT_CARD_COUNT }).map((_, index) => ( + + ))} +
+
+ {Array.from({ length: CHART_CARD_COUNT }).map((_, index) => ( + + ))} +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/api/workflows/social-analytics-sync/route.ts b/apps/dashboard/src/app/api/workflows/social-analytics-sync/route.ts new file mode 100644 index 000000000..3a59abc95 --- /dev/null +++ b/apps/dashboard/src/app/api/workflows/social-analytics-sync/route.ts @@ -0,0 +1,40 @@ +import { getAppUrl } from "@notra/ai/qstash/triggers"; +import { flattenError } from "zod"; +import { verifyQstashSignature } from "@/lib/workflows/qstash-verify"; +import { startSocialAnalyticsSyncRun } from "@/lib/workflows/start"; +import { socialAnalyticsSyncPayloadSchema } from "@/schemas/analytics"; + +const ROUTE_PATH = "/api/workflows/social-analytics-sync"; + +export async function POST(request: Request) { + const rawBody = await request.text(); + const verified = await verifyQstashSignature({ + request, + rawBody, + url: `${getAppUrl()}${ROUTE_PATH}`, + }); + if (!verified) { + return new Response("Unauthorized", { status: 401 }); + } + + let body: unknown = {}; + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + return new Response("Invalid JSON body", { status: 400 }); + } + } + + const parsed = socialAnalyticsSyncPayloadSchema.safeParse(body); + if (!parsed.success) { + console.error( + "[Social Analytics] Invalid sync payload:", + flattenError(parsed.error) + ); + return new Response("Invalid payload", { status: 400 }); + } + + const { runId } = await startSocialAnalyticsSyncRun(parsed.data); + return Response.json({ runId }, { status: 202 }); +} diff --git a/apps/dashboard/src/components/analytics/account-filter.tsx b/apps/dashboard/src/components/analytics/account-filter.tsx new file mode 100644 index 000000000..5bd48ad42 --- /dev/null +++ b/apps/dashboard/src/components/analytics/account-filter.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Linkedin02Icon, NewTwitterIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@notra/ui/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import type { SocialOverviewAccount } from "@/types/analytics"; +import { accountSeriesKey } from "@/utils/analytics-charts"; + +interface AccountFilterProps { + accounts: SocialOverviewAccount[]; + selectedKeys: Set; + onToggle: (key: string) => void; +} + +export function AccountFilter({ + accounts, + selectedKeys, + onToggle, +}: AccountFilterProps) { + return ( +
+ {accounts.map((account) => { + const key = accountSeriesKey( + account.provider, + account.providerAccountId + ); + const selected = selectedKeys.has(key); + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/analytics/account-series-chart-card.tsx b/apps/dashboard/src/components/analytics/account-series-chart-card.tsx new file mode 100644 index 000000000..2cbf4b477 --- /dev/null +++ b/apps/dashboard/src/components/analytics/account-series-chart-card.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Area, Line } from "@notra/ui/components/dither-kit/area"; +import { + AreaChart, + LineChart, +} from "@notra/ui/components/dither-kit/area-chart"; +import { Bar } from "@notra/ui/components/dither-kit/bar"; +import { BarChart } from "@notra/ui/components/dither-kit/bar-chart"; +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { Grid } from "@notra/ui/components/dither-kit/grid"; +import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; +import { XAxis } from "@notra/ui/components/dither-kit/x-axis"; +import { YAxis } from "@notra/ui/components/dither-kit/y-axis"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { ChartSeriesLegend } from "@/components/analytics/chart-legend"; +import { VerticalMarker } from "@/components/analytics/chart-marker"; +import type { AccountSeriesRow, TimelineMarker } from "@/types/analytics"; + +interface AccountSeriesChartCardProps { + title: string; + description: string; + kind: "area" | "line" | "bar"; + rows: AccountSeriesRow[]; + config: ChartConfig; + allKeys: string[]; + hiddenKeys: ReadonlySet; + onToggleSeries: (key: string) => void; + hoverIndex: number | null; + onHoverChange: (index: number | null) => void; + markers: TimelineMarker[]; + emptyMessage: string; +} + +const CHART_CLASS = "h-56 w-full"; + +function chartFor(kind: "area" | "line" | "bar") { + if (kind === "line") { + return LineChart; + } + if (kind === "bar") { + return BarChart; + } + return AreaChart; +} + +export function AccountSeriesChartCard({ + title, + description, + kind, + rows, + config, + allKeys, + hiddenKeys, + onToggleSeries, + hoverIndex, + onHoverChange, + markers, + emptyMessage, +}: AccountSeriesChartCardProps) { + const Chart = chartFor(kind); + const seriesKeys = allKeys.filter((key) => !hiddenKeys.has(key)); + const hasData = rows.length > 0 && seriesKeys.length > 0; + + return ( + + + {title} + {description} + + + {hasData ? ( + + + + + {seriesKeys.map((key) => { + if (kind === "line") { + return ; + } + if (kind === "bar") { + return ; + } + return ; + })} + {markers.map((marker) => ( + + ))} + + + ) : ( +

+ {emptyMessage} +

+ )} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/analytics/chart-legend.tsx b/apps/dashboard/src/components/analytics/chart-legend.tsx new file mode 100644 index 000000000..7bc6ea99a --- /dev/null +++ b/apps/dashboard/src/components/analytics/chart-legend.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { PALETTE, rgb } from "@notra/ui/components/dither-kit/palette"; +import { cn } from "@/lib/utils"; + +interface ChartSeriesLegendProps { + config: ChartConfig; + orderedKeys: string[]; + hiddenKeys: ReadonlySet; + onToggle: (key: string) => void; +} + +export function ChartSeriesLegend({ + config, + orderedKeys, + hiddenKeys, + onToggle, +}: ChartSeriesLegendProps) { + return ( +
+ {orderedKeys.map((key) => { + const entry = config[key]; + if (!entry) { + return null; + } + const hidden = hiddenKeys.has(key); + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/analytics/chart-marker.tsx b/apps/dashboard/src/components/analytics/chart-marker.tsx new file mode 100644 index 000000000..0eb6efd99 --- /dev/null +++ b/apps/dashboard/src/components/analytics/chart-marker.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useChartPart } from "@notra/ui/components/dither-kit/chart-context"; + +interface VerticalMarkerProps { + index: number | null; + label: string; +} + +export function VerticalMarker({ index, label }: VerticalMarkerProps) { + const ctx = useChartPart("VerticalMarker"); + if (!ctx.ready || index === null || index < 0 || index >= ctx.dataLength) { + return null; + } + + const x = ctx.xCenter(index); + return ( + + + + {label} + + + ); +} diff --git a/apps/dashboard/src/components/analytics/followers-card.tsx b/apps/dashboard/src/components/analytics/followers-card.tsx new file mode 100644 index 000000000..484ebbd1c --- /dev/null +++ b/apps/dashboard/src/components/analytics/followers-card.tsx @@ -0,0 +1,151 @@ +"use client"; + +import type { DitherColor } from "@notra/ui/components/dither-kit/palette"; +import { Sparkline } from "@notra/ui/components/dither-kit/sparkline"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@notra/ui/components/ui/avatar"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { cn } from "@/lib/utils"; +import type { + FollowerGrowthPoint, + SocialOverviewAccount, +} from "@/types/analytics"; +import { accountSeriesKey, formatMetric } from "@/utils/analytics-charts"; + +interface FollowersCardProps { + accounts: SocialOverviewAccount[]; + points: FollowerGrowthPoint[]; + hiddenKeys: ReadonlySet; + colorForKey: (key: string) => DitherColor; +} + +function seriesFor( + points: FollowerGrowthPoint[], + provider: string, + providerAccountId: string +): number[] { + return points + .filter( + (point) => + point.provider === provider && + point.providerAccountId === providerAccountId && + point.followersCount !== null + ) + .sort((a, b) => a.day.localeCompare(b.day)) + .map((point) => point.followersCount ?? 0); +} + +function DeltaBadge({ series }: { series: number[] }) { + const first = series.at(0); + const last = series.at(-1); + if (first === undefined || last === undefined || series.length < 2) { + return ( + tracking started + ); + } + const delta = last - first; + if (delta === 0) { + return ±0; + } + return ( + 0 ? "text-green-500" : "text-red-500" + )} + > + {delta > 0 ? "▲" : "▼"} {Math.abs(delta).toLocaleString()} + + ); +} + +export function FollowersCard({ + accounts, + points, + hiddenKeys, + colorForKey, +}: FollowersCardProps) { + const visible = accounts.filter( + (account) => + !hiddenKeys.has( + accountSeriesKey(account.provider, account.providerAccountId) + ) + ); + + return ( + + + Followers + + Live counts with change since tracking began; X exposes no earlier + history + + + + {visible.length === 0 ? ( +

+ No accounts selected +

+ ) : ( +
+ {visible.map((account) => { + const key = accountSeriesKey( + account.provider, + account.providerAccountId + ); + const series = seriesFor( + points, + account.provider, + account.providerAccountId + ); + return ( +
+ + {account.profileImageUrl && ( + + )} + + {account.username.slice(0, 2).toUpperCase()} + + +
+

+ @{account.username} +

+
+ + {formatMetric(account.followersCount)} + + +
+
+
+ {series.length >= 2 && ( + + )} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/analytics/posting-performance-card.tsx b/apps/dashboard/src/components/analytics/posting-performance-card.tsx new file mode 100644 index 000000000..67b43f658 --- /dev/null +++ b/apps/dashboard/src/components/analytics/posting-performance-card.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Bar } from "@notra/ui/components/dither-kit/bar"; +import { BarChart } from "@notra/ui/components/dither-kit/bar-chart"; +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { Grid } from "@notra/ui/components/dither-kit/grid"; +import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; +import { XAxis } from "@notra/ui/components/dither-kit/x-axis"; +import { YAxis } from "@notra/ui/components/dither-kit/y-axis"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { useState } from "react"; +import { ChartSeriesLegend } from "@/components/analytics/chart-legend"; +import type { PostingPerformanceChartRow } from "@/types/analytics"; + +interface PostingPerformanceCardProps { + rows: PostingPerformanceChartRow[]; +} + +const chartConfig: ChartConfig = { + avgEngagement: { label: "Avg engagement", color: "green" }, + posts: { label: "Posts", color: "grey" }, +}; + +const seriesKeys = Object.keys(chartConfig); + +export function PostingPerformanceCard({ rows }: PostingPerformanceCardProps) { + const [hiddenKeys, setHiddenKeys] = useState>(new Set()); + const visibleKeys = seriesKeys.filter((key) => !hiddenKeys.has(key)); + const hasData = rows.some((row) => row.posts > 0); + + const toggle = (key: string) => { + setHiddenKeys((previous) => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + return ( + + + Best days to post + + Average engagement per post and post volume by weekday, last 90 days + + + + {hasData && visibleKeys.length > 0 ? ( + + + + + {visibleKeys.map((key) => ( + + ))} + + + ) : ( +

+ No posting data yet +

+ )} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/analytics/summary-stats.tsx b/apps/dashboard/src/components/analytics/summary-stats.tsx new file mode 100644 index 000000000..b9ff4c197 --- /dev/null +++ b/apps/dashboard/src/components/analytics/summary-stats.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Card, CardContent } from "@notra/ui/components/ui/card"; +import { useMemo } from "react"; +import type { + EngagementTimeseriesPoint, + SocialOverviewAccount, +} from "@/types/analytics"; +import { formatMetric, sumMetric } from "@/utils/analytics-charts"; + +interface SummaryStatsProps { + accounts: SocialOverviewAccount[]; + points: EngagementTimeseriesPoint[]; +} + +interface StatTile { + label: string; + value: string; + hint: string; +} + +const PERCENT = 100; + +export function SummaryStats({ accounts, points }: SummaryStatsProps) { + const tiles = useMemo(() => { + const followers = sumMetric(accounts, (account) => account.followersCount); + let impressions = 0; + let interactions = 0; + let posts = 0; + for (const point of points) { + impressions += point.impressions ?? 0; + interactions += + (point.likes ?? 0) + (point.replies ?? 0) + (point.reposts ?? 0); + posts += point.posts; + } + const engagementRate = + impressions > 0 ? (interactions / impressions) * PERCENT : null; + + return [ + { + label: "Followers", + value: formatMetric(followers), + hint: "across connected accounts", + }, + { + label: "Impressions", + value: formatMetric(impressions), + hint: "posts from the last 30 days", + }, + { + label: "Interactions", + value: formatMetric(interactions), + hint: `${posts} posts, last 30 days`, + }, + { + label: "Engagement rate", + value: + engagementRate === null ? "N/A" : `${engagementRate.toFixed(1)}%`, + hint: "interactions per impression", + }, + ]; + }, [accounts, points]); + + return ( +
+ {tiles.map((tile) => ( + + +

{tile.label}

+

{tile.value}

+

{tile.hint}

+
+
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/analytics/top-posts-card.tsx b/apps/dashboard/src/components/analytics/top-posts-card.tsx new file mode 100644 index 000000000..618cb0537 --- /dev/null +++ b/apps/dashboard/src/components/analytics/top-posts-card.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { Linkedin02Icon, NewTwitterIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@notra/ui/components/ui/avatar"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { TOP_POST_CONTENT_PREVIEW_LENGTH } from "@/constants/analytics"; +import type { TopPostItem } from "@/types/analytics"; +import { formatDayLabel, formatMetric } from "@/utils/analytics-charts"; + +interface TopPostsCardProps { + posts: TopPostItem[]; +} + +function previewContent(content: string): string { + const singleLine = content.replace(/\s+/g, " ").trim(); + if (singleLine.length <= TOP_POST_CONTENT_PREVIEW_LENGTH) { + return singleLine; + } + return `${singleLine.slice(0, TOP_POST_CONTENT_PREVIEW_LENGTH)}…`; +} + +function PostAvatar({ post }: { post: TopPostItem }) { + const name = post.username ?? post.providerAccountId; + return ( + + {post.profileImageUrl && ( + + )} + + {name.slice(0, 2).toUpperCase()} + + + ); +} + +function PostRow({ post }: { post: TopPostItem }) { + const body = ( +
+ +
+

+ + {post.username ? `@${post.username}` : post.providerAccountId} + · + {formatDayLabel(post.postedAt.slice(0, 10))} +

+

{previewContent(post.content)}

+

+ {formatMetric(post.likes)} likes · {formatMetric(post.replies)}{" "} + replies · {formatMetric(post.reposts)} reposts + {post.impressions !== null && + ` · ${formatMetric(post.impressions)} impressions`} +

+
+ + {formatMetric(post.engagement)} + +
+ ); + + if (post.url) { + return ( + + {body} + + ); + } + return
{body}
; +} + +export function TopPostsCard({ posts }: TopPostsCardProps) { + return ( + + + Top posts + + Ranked by likes, replies, and reposts from the latest sync + + + + {posts.length === 0 ? ( +

+ No tracked posts yet +

+ ) : ( +
+ {posts.map((post) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/command-palette/registry.ts b/apps/dashboard/src/components/command-palette/registry.ts index 8f73df692..d968286bc 100644 --- a/apps/dashboard/src/components/command-palette/registry.ts +++ b/apps/dashboard/src/components/command-palette/registry.ts @@ -1,4 +1,5 @@ import { + Analytics01Icon, AnalyticsUpIcon, Calendar03Icon, Comment01Icon, @@ -42,6 +43,14 @@ export const COMMAND_ROUTES: CommandRoute[] = [ section: "Navigation", path: (slug) => `/${slug}/chat`, }, + { + id: "analytics", + label: "Analytics", + keywords: ["stats", "metrics", "followers", "engagement", "social"], + icon: Analytics01Icon, + section: "Navigation", + path: (slug) => `/${slug}/analytics`, + }, { id: "content", label: "Content", diff --git a/apps/dashboard/src/components/dashboard/nav-main.tsx b/apps/dashboard/src/components/dashboard/nav-main.tsx index 92a51e0d9..c1fc48331 100644 --- a/apps/dashboard/src/components/dashboard/nav-main.tsx +++ b/apps/dashboard/src/components/dashboard/nav-main.tsx @@ -1,6 +1,7 @@ "use client"; import { useFlag } from "@databuddy/sdk/react"; import { + Analytics01Icon, AnalyticsUpIcon, Calendar03Icon, Home01Icon, @@ -61,6 +62,12 @@ const navMainItems: NavMainItem[] = [ label: "Content", category: "workspace", }, + { + link: "/analytics", + icon: Analytics01Icon, + label: "Analytics", + category: "workspace", + }, { link: "/skills", icon: MagicWand01Icon, diff --git a/apps/dashboard/src/components/layout/section-header.tsx b/apps/dashboard/src/components/layout/section-header.tsx new file mode 100644 index 000000000..3b555f205 --- /dev/null +++ b/apps/dashboard/src/components/layout/section-header.tsx @@ -0,0 +1,23 @@ +interface SectionHeaderProps { + title: string; + description?: string; + action?: React.ReactNode; +} + +export function SectionHeader({ + title, + description, + action, +}: SectionHeaderProps) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action} +
+ ); +} diff --git a/apps/dashboard/src/constants/analytics.ts b/apps/dashboard/src/constants/analytics.ts new file mode 100644 index 000000000..aef7bf976 --- /dev/null +++ b/apps/dashboard/src/constants/analytics.ts @@ -0,0 +1,20 @@ +import type { DitherColor } from "@notra/ui/components/dither-kit/palette"; + +export const PROVIDER_DITHER_COLORS: Record = { + twitter: "grey", + linkedin: "blue", +}; + +export const ACCOUNT_SERIES_COLORS: DitherColor[] = [ + "blue", + "purple", + "green", + "orange", + "pink", + "red", + "grey", +]; + +export const ANALYTICS_TIMESERIES_DAYS = 30; +export const ANALYTICS_TOP_POSTS_LIMIT = 8; +export const TOP_POST_CONTENT_PREVIEW_LENGTH = 96; diff --git a/apps/dashboard/src/lib/analytics/constants.ts b/apps/dashboard/src/lib/analytics/constants.ts new file mode 100644 index 000000000..5c0ef09d2 --- /dev/null +++ b/apps/dashboard/src/lib/analytics/constants.ts @@ -0,0 +1,6 @@ +export const TWITTER_USER_BATCH_SIZE = 100; +export const TWITTER_TIMELINE_MAX_RESULTS = 100; +export const TWITTER_TIMELINE_MAX_PAGES = 5; +export const TWITTER_USER_FIELDS = + "public_metrics,verified,verified_type,profile_image_url,name"; +export const TWITTER_TWEET_FIELDS = "text,created_at,public_metrics"; diff --git a/apps/dashboard/src/lib/analytics/record-post.ts b/apps/dashboard/src/lib/analytics/record-post.ts new file mode 100644 index 000000000..9d22e87c5 --- /dev/null +++ b/apps/dashboard/src/lib/analytics/record-post.ts @@ -0,0 +1,43 @@ +import { + ingestSocialPostSources, + ingestSocialPosts, + isTinybirdConfigured, +} from "@notra/analytics/tinybird/client"; +import { toClickHouseDateTime } from "@notra/analytics/utils/datetime"; +import type { RecordPublishedSocialPostInput } from "@/types/analytics"; + +export async function recordPublishedSocialPost( + input: RecordPublishedSocialPostInput +): Promise { + if (!isTinybirdConfigured()) { + return; + } + const now = toClickHouseDateTime(new Date()); + try { + await ingestSocialPosts([ + { + organization_id: input.organizationId, + account_id: input.accountId, + provider: input.provider, + provider_account_id: input.providerAccountId, + platform_post_id: input.platformPostId, + url: input.url, + content: input.content, + posted_at: now, + captured_at: now, + }, + ]); + await ingestSocialPostSources([ + { + organization_id: input.organizationId, + provider: input.provider, + provider_account_id: input.providerAccountId, + platform_post_id: input.platformPostId, + source: "notra", + captured_at: now, + }, + ]); + } catch (error) { + console.error("[Analytics] Failed to record published post:", error); + } +} diff --git a/apps/dashboard/src/lib/analytics/rows.ts b/apps/dashboard/src/lib/analytics/rows.ts new file mode 100644 index 000000000..783083d7c --- /dev/null +++ b/apps/dashboard/src/lib/analytics/rows.ts @@ -0,0 +1,91 @@ +import type { + SocialAccountRow, + SocialAccountStatsRow, + SocialPostRow, + SocialPostStatsRow, +} from "@notra/analytics/tinybird/datasources"; +import { toClickHouseDateTime } from "@notra/analytics/utils/datetime"; +import type { + SyncableSocialAccount, + TwitterBatchUser, + TwitterTimelineTweet, +} from "@/types/analytics"; + +export function buildAccountRow( + account: SyncableSocialAccount, + capturedAt: Date +): SocialAccountRow { + return { + organization_id: account.organizationId, + account_id: account.id, + provider: account.provider, + provider_account_id: account.providerAccountId, + username: account.username, + display_name: account.displayName, + profile_image_url: account.profileImageUrl, + profile_url: null, + account_type: null, + verified: account.verified, + captured_at: toClickHouseDateTime(capturedAt), + }; +} + +export function buildTwitterAccountStatsRow( + account: SyncableSocialAccount, + user: TwitterBatchUser, + capturedAt: Date +): SocialAccountStatsRow { + const metrics = user.public_metrics; + return { + organization_id: account.organizationId, + account_id: account.id, + provider: account.provider, + provider_account_id: account.providerAccountId, + captured_at: toClickHouseDateTime(capturedAt), + followers_count: metrics?.followers_count ?? null, + following_count: metrics?.following_count ?? null, + posts_count: metrics?.tweet_count ?? null, + listed_count: metrics?.listed_count ?? null, + }; +} + +export function buildTweetPostRow( + account: SyncableSocialAccount, + tweet: TwitterTimelineTweet, + capturedAt: Date +): SocialPostRow { + return { + organization_id: account.organizationId, + account_id: account.id, + provider: account.provider, + provider_account_id: account.providerAccountId, + platform_post_id: tweet.id, + url: `https://x.com/${account.username}/status/${tweet.id}`, + content: tweet.text, + posted_at: toClickHouseDateTime( + tweet.created_at ? new Date(tweet.created_at) : capturedAt + ), + captured_at: toClickHouseDateTime(capturedAt), + }; +} + +export function buildTweetStatsRow( + account: SyncableSocialAccount, + tweet: TwitterTimelineTweet, + capturedAt: Date +): SocialPostStatsRow { + const metrics = tweet.public_metrics; + return { + organization_id: account.organizationId, + provider: account.provider, + provider_account_id: account.providerAccountId, + platform_post_id: tweet.id, + captured_at: toClickHouseDateTime(capturedAt), + impressions: metrics?.impression_count ?? null, + likes: metrics?.like_count ?? null, + replies: metrics?.reply_count ?? null, + reposts: metrics?.retweet_count ?? null, + quotes: metrics?.quote_count ?? null, + bookmarks: metrics?.bookmark_count ?? null, + }; +} diff --git a/apps/dashboard/src/lib/analytics/twitter-sync.ts b/apps/dashboard/src/lib/analytics/twitter-sync.ts new file mode 100644 index 000000000..1b75a14f7 --- /dev/null +++ b/apps/dashboard/src/lib/analytics/twitter-sync.ts @@ -0,0 +1,124 @@ +import type { + SocialAccountStatsRow, + SocialPostRow, + SocialPostStatsRow, +} from "@notra/analytics/tinybird/datasources"; +import { + TWITTER_TIMELINE_MAX_PAGES, + TWITTER_TIMELINE_MAX_RESULTS, + TWITTER_TWEET_FIELDS, + TWITTER_USER_BATCH_SIZE, + TWITTER_USER_FIELDS, +} from "@/lib/analytics/constants"; +import { + buildTweetPostRow, + buildTweetStatsRow, + buildTwitterAccountStatsRow, +} from "@/lib/analytics/rows"; +import type { + SyncableSocialAccount, + TwitterBatchUser, + TwitterBatchUsersResponse, + TwitterTimelineResponse, +} from "@/types/analytics"; +import { twitterAppFetch } from "@/utils/twitter-fetcher"; + +function isTwitterAnalyticsConfigured(): boolean { + return Boolean(process.env.TWITTER_BEARER_TOKEN); +} + +async function fetchTwitterUsersBatch( + usernames: string[] +): Promise { + const users: TwitterBatchUser[] = []; + for ( + let index = 0; + index < usernames.length; + index += TWITTER_USER_BATCH_SIZE + ) { + const batch = usernames.slice(index, index + TWITTER_USER_BATCH_SIZE); + const params = new URLSearchParams({ + usernames: batch.join(","), + "user.fields": TWITTER_USER_FIELDS, + }); + const response = await twitterAppFetch( + `https://api.x.com/2/users/by?${params.toString()}` + ); + if (!response.ok) { + continue; + } + const json: TwitterBatchUsersResponse = await response.json(); + users.push(...(json.data ?? [])); + } + return users; +} + +async function fetchUserTweets(userId: string) { + const tweets: TwitterTimelineResponse["data"] = []; + let paginationToken: string | undefined; + + for (let page = 0; page < TWITTER_TIMELINE_MAX_PAGES; page += 1) { + const params = new URLSearchParams({ + max_results: String(TWITTER_TIMELINE_MAX_RESULTS), + exclude: "replies,retweets", + "tweet.fields": TWITTER_TWEET_FIELDS, + }); + if (paginationToken) { + params.set("pagination_token", paginationToken); + } + const response = await twitterAppFetch( + `https://api.x.com/2/users/${encodeURIComponent(userId)}/tweets?${params.toString()}` + ); + if (!response.ok) { + break; + } + const json: TwitterTimelineResponse = await response.json(); + tweets.push(...(json.data ?? [])); + paginationToken = json.meta?.next_token; + if (!paginationToken) { + break; + } + } + + return tweets; +} + +export interface TwitterSyncRows { + accountStats: SocialAccountStatsRow[]; + posts: SocialPostRow[]; + postStats: SocialPostStatsRow[]; +} + +export async function collectTwitterRows( + accounts: SyncableSocialAccount[], + capturedAt: Date +): Promise { + const rows: TwitterSyncRows = { accountStats: [], posts: [], postStats: [] }; + if (accounts.length === 0 || !isTwitterAnalyticsConfigured()) { + return rows; + } + + const users = await fetchTwitterUsersBatch( + accounts.map((account) => account.username) + ); + const usersByUsername = new Map( + users.map((user) => [user.username.toLowerCase(), user]) + ); + + for (const account of accounts) { + const user = usersByUsername.get(account.username.toLowerCase()); + if (!user) { + continue; + } + rows.accountStats.push( + buildTwitterAccountStatsRow(account, user, capturedAt) + ); + const tweets = await fetchUserTweets(user.id); + for (const tweet of tweets) { + rows.posts.push(buildTweetPostRow(account, tweet, capturedAt)); + rows.postStats.push(buildTweetStatsRow(account, tweet, capturedAt)); + } + } + + return rows; +} diff --git a/apps/dashboard/src/lib/hooks/use-social-analytics.ts b/apps/dashboard/src/lib/hooks/use-social-analytics.ts new file mode 100644 index 000000000..2ecc60b08 --- /dev/null +++ b/apps/dashboard/src/lib/hooks/use-social-analytics.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { + EngagementTimeseriesResponse, + FollowerGrowthResponse, + NotraAdoptionResponse, + PostingPerformanceResponse, + SocialOverviewResponse, + TopPostsResponse, +} from "@/types/analytics"; +import { dashboardOrpc } from "../orpc/query"; + +const DEFAULT_TIMESERIES_DAYS = 30; +const DEFAULT_TOP_POSTS_LIMIT = 8; + +export function useSocialOverview(organizationId: string) { + return useQuery({ + ...dashboardOrpc.analytics.overview.queryOptions({ + input: { organizationId }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load analytics overview" }, + }); +} + +export function useEngagementTimeseries(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.analytics.engagementTimeseries.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_TIMESERIES_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load engagement data" }, + }); +} + +export function useTopPosts(organizationId: string, limit?: number) { + return useQuery({ + ...dashboardOrpc.analytics.topPosts.queryOptions({ + input: { organizationId, limit: limit ?? DEFAULT_TOP_POSTS_LIMIT }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load top posts" }, + }); +} + +export function useFollowerGrowth(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.analytics.followerGrowth.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_TIMESERIES_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load follower growth" }, + }); +} + +const DEFAULT_PERFORMANCE_DAYS = 90; + +export function usePostingPerformance(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.analytics.postingPerformance.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_PERFORMANCE_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load posting performance" }, + }); +} + +export function useNotraAdoption(organizationId: string) { + return useQuery({ + ...dashboardOrpc.analytics.adoption.queryOptions({ + input: { organizationId }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load adoption data" }, + }); +} diff --git a/apps/dashboard/src/lib/orpc/router.ts b/apps/dashboard/src/lib/orpc/router.ts index 09e4821b3..56a733c7d 100644 --- a/apps/dashboard/src/lib/orpc/router.ts +++ b/apps/dashboard/src/lib/orpc/router.ts @@ -1,3 +1,4 @@ +import { analyticsRouter } from "./routers/analytics"; import { apiKeysRouter } from "./routers/api-keys"; import { attachmentsRouter } from "./routers/attachments"; import { automationRouter } from "./routers/automation"; @@ -17,6 +18,7 @@ import { uploadRouter } from "./routers/upload"; import { userRouter } from "./routers/user"; export const dashboardRouter = { + analytics: analyticsRouter, apiKeys: apiKeysRouter, attachments: attachmentsRouter, automation: automationRouter, diff --git a/apps/dashboard/src/lib/orpc/routers/analytics.ts b/apps/dashboard/src/lib/orpc/routers/analytics.ts new file mode 100644 index 000000000..cad73c845 --- /dev/null +++ b/apps/dashboard/src/lib/orpc/routers/analytics.ts @@ -0,0 +1,308 @@ +import { + isTinybirdConfigured, + queryEngagementTimeseries, + queryFollowerGrowth, + queryNotraAdoption, + queryPostingPerformance, + querySocialOverview, + queryTopPosts, +} from "@notra/analytics/tinybird/client"; +import { db } from "@notra/db/drizzle"; +import { + connectedSocialAccounts, + organizations, + posts, +} from "@notra/db/schema"; +import { and, asc, eq } from "drizzle-orm"; +import { assertOrganizationAccess } from "@/lib/auth/organization"; +import { authorizedProcedure } from "@/lib/orpc/base"; +import { + analyticsOrganizationInputSchema, + analyticsTimeseriesInputSchema, + analyticsTopPostsInputSchema, +} from "@/schemas/analytics"; +import type { + EngagementTimeseriesResponse, + FollowerGrowthResponse, + NotraAdoptionResponse, + PostingPerformanceResponse, + SocialOverviewAccount, + SocialOverviewResponse, + TopPostsResponse, +} from "@/types/analytics"; + +function toNullableNumber(value: number | bigint | null): number | null { + if (value === null) { + return null; + } + return Number(value); +} + +export const analyticsRouter = { + overview: authorizedProcedure + .input(analyticsOrganizationInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const accounts = await db.query.connectedSocialAccounts.findMany({ + columns: { + id: true, + provider: true, + providerAccountId: true, + username: true, + displayName: true, + profileImageUrl: true, + verified: true, + }, + where: eq(connectedSocialAccounts.organizationId, input.organizationId), + }); + + const configured = isTinybirdConfigured(); + const result = configured + ? await querySocialOverview({ + organization_id: input.organizationId, + }).catch((error) => { + console.error("[Analytics] overview query failed:", error); + return null; + }) + : null; + + const statsByAccount = new Map( + (result?.data ?? []).map((row) => [ + `${row.provider}:${row.provider_account_id}`, + row, + ]) + ); + + const overviewAccounts: SocialOverviewAccount[] = accounts.map( + (account) => { + const stats = statsByAccount.get( + `${account.provider}:${account.providerAccountId}` + ); + return { + provider: account.provider, + providerAccountId: account.providerAccountId, + accountId: account.id, + username: account.username, + displayName: account.displayName, + profileImageUrl: account.profileImageUrl, + verified: account.verified ?? false, + followersCount: toNullableNumber(stats?.followers_count ?? null), + followingCount: toNullableNumber(stats?.following_count ?? null), + postsCount: toNullableNumber(stats?.posts_count ?? null), + trackedPosts: toNullableNumber(stats?.tracked_posts ?? null), + impressions: toNullableNumber(stats?.impressions ?? null), + likes: toNullableNumber(stats?.likes ?? null), + replies: toNullableNumber(stats?.replies ?? null), + reposts: toNullableNumber(stats?.reposts ?? null), + quotes: toNullableNumber(stats?.quotes ?? null), + bookmarks: toNullableNumber(stats?.bookmarks ?? null), + statsCapturedAt: stats?.stats_captured_at ?? null, + }; + } + ); + + return { configured, accounts: overviewAccounts }; + }), + engagementTimeseries: authorizedProcedure + .input(analyticsTimeseriesInputSchema) + .handler( + async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryEngagementTimeseries({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[Analytics] query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + points: (result?.data ?? []).map((row) => ({ + day: row.day, + provider: row.provider, + providerAccountId: row.provider_account_id, + posts: Number(row.posts), + impressions: toNullableNumber(row.impressions), + likes: toNullableNumber(row.likes), + replies: toNullableNumber(row.replies), + reposts: toNullableNumber(row.reposts), + })), + }; + } + ), + topPosts: authorizedProcedure + .input(analyticsTopPostsInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [result, accounts] = await Promise.all([ + queryTopPosts({ + organization_id: input.organizationId, + limit: input.limit, + }).catch((error) => { + console.error("[Analytics] top posts query failed:", error); + return null; + }), + db.query.connectedSocialAccounts.findMany({ + columns: { + provider: true, + providerAccountId: true, + username: true, + profileImageUrl: true, + }, + where: eq( + connectedSocialAccounts.organizationId, + input.organizationId + ), + }), + ]); + + const accountsByKey = new Map( + accounts.map((account) => [ + `${account.provider}:${account.providerAccountId}`, + account, + ]) + ); + + return { + configured: isTinybirdConfigured(), + posts: (result?.data ?? []).map((row) => { + const account = accountsByKey.get( + `${row.provider}:${row.provider_account_id}` + ); + return { + provider: row.provider, + platformPostId: row.platform_post_id, + providerAccountId: row.provider_account_id, + username: account?.username ?? null, + profileImageUrl: account?.profileImageUrl ?? null, + content: row.content, + url: row.url, + postedAt: row.posted_at, + impressions: toNullableNumber(row.impressions), + likes: toNullableNumber(row.likes), + replies: toNullableNumber(row.replies), + reposts: toNullableNumber(row.reposts), + bookmarks: toNullableNumber(row.bookmarks), + engagement: Number(row.engagement), + }; + }), + }; + }), + adoption: authorizedProcedure + .input(analyticsOrganizationInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [organization, firstPublishedPost, adoptionResult] = + await Promise.all([ + db.query.organizations.findFirst({ + columns: { createdAt: true }, + where: eq(organizations.id, input.organizationId), + }), + db.query.posts.findFirst({ + columns: { createdAt: true }, + where: and( + eq(posts.organizationId, input.organizationId), + eq(posts.status, "published") + ), + orderBy: [asc(posts.createdAt)], + }), + queryNotraAdoption({ organization_id: input.organizationId }).catch( + (error) => { + console.error("[Analytics] adoption query failed:", error); + return null; + } + ), + ]); + + const adoptionRow = adoptionResult?.data.at(0); + const trackedFirstPost = adoptionRow?.first_notra_post_at ?? null; + + return { + configured: isTinybirdConfigured(), + organizationCreatedAt: organization?.createdAt.toISOString() ?? null, + firstNotraPostAt: + trackedFirstPost ?? + firstPublishedPost?.createdAt.toISOString() ?? + null, + notraPosts: Number(adoptionRow?.notra_posts ?? 0), + }; + }), + postingPerformance: authorizedProcedure + .input(analyticsTimeseriesInputSchema) + .handler( + async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryPostingPerformance({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[Analytics] posting performance query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + points: (result?.data ?? []).map((row) => ({ + weekday: Number(row.weekday), + posts: Number(row.posts), + engagement: Number(row.engagement), + impressions: toNullableNumber(row.impressions), + avgEngagement: Number(row.avg_engagement), + })), + }; + } + ), + followerGrowth: authorizedProcedure + .input(analyticsTimeseriesInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryFollowerGrowth({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[Analytics] follower growth query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + points: (result?.data ?? []).map((row) => ({ + day: row.day, + provider: row.provider, + providerAccountId: row.provider_account_id, + followersCount: toNullableNumber(row.followers_count), + })), + }; + }), +}; diff --git a/apps/dashboard/src/lib/social-connect/publish.ts b/apps/dashboard/src/lib/social-connect/publish.ts index 6ecd4ba73..49d497d4d 100644 --- a/apps/dashboard/src/lib/social-connect/publish.ts +++ b/apps/dashboard/src/lib/social-connect/publish.ts @@ -3,6 +3,7 @@ import { connectedSocialAccounts } from "@notra/db/schema"; import { and, eq } from "drizzle-orm"; import { Effect } from "effect"; import type { SocialPostResult } from "post-for-me/resources/social-post-results"; +import { recordPublishedSocialPost } from "@/lib/analytics/record-post"; import { getSocialConnectClient, isSocialConnectConfigured, @@ -131,6 +132,20 @@ export const publishSocialPost = Effect.fn("publishSocialPost")(function* ( ? `https://x.com/${account.username}/status/${platformPostId}` : null); + if (platformPostId) { + yield* Effect.promise(() => + recordPublishedSocialPost({ + organizationId: params.organizationId, + accountId: params.accountId, + provider: account.provider, + providerAccountId: account.providerAccountId, + platformPostId, + url: postUrl, + content: params.content, + }) + ); + } + return { postId: post.id, platformPostId, diff --git a/apps/dashboard/src/lib/workflows/start.ts b/apps/dashboard/src/lib/workflows/start.ts index e8a072c86..a29693ac8 100644 --- a/apps/dashboard/src/lib/workflows/start.ts +++ b/apps/dashboard/src/lib/workflows/start.ts @@ -7,6 +7,7 @@ import { IRIS_START_CLAIM_SCOPE, IRIS_START_CLAIM_TTL_SECONDS, } from "@/constants/iris"; +import { socialAnalyticsSyncPayloadSchema } from "@/schemas/analytics"; import { brandGuidelinesWorkflowPayloadSchema } from "@/schemas/brand-guidelines"; import { eventWorkflowPayloadSchema, @@ -28,6 +29,7 @@ import { irisControllerRun } from "@/workflows/iris-controller"; import { onDemandContentWorkflow } from "@/workflows/on-demand-content"; import { onboardingAgentWorkflow } from "@/workflows/onboarding-agent"; import { scheduleContentWorkflow } from "@/workflows/schedule-content"; +import { socialAnalyticsSyncWorkflow } from "@/workflows/social-analytics-sync"; export async function startBrandAnalysisRun( payload: BrandAnalysisPayload @@ -106,6 +108,14 @@ export async function startEventRun(payload: { return { runId: run.runId }; } +export async function startSocialAnalyticsSyncRun(payload: { + organizationId?: string; +}): Promise<{ runId: string }> { + const parsed = socialAnalyticsSyncPayloadSchema.parse(payload); + const run = await start(socialAnalyticsSyncWorkflow, [parsed]); + return { runId: run.runId }; +} + export async function startOnDemandRun( payload: unknown ): Promise<{ runId: string }> { diff --git a/apps/dashboard/src/schemas/analytics.ts b/apps/dashboard/src/schemas/analytics.ts new file mode 100644 index 000000000..92c8bf6cb --- /dev/null +++ b/apps/dashboard/src/schemas/analytics.ts @@ -0,0 +1,19 @@ +import { number, object, string } from "zod"; + +export const socialAnalyticsSyncPayloadSchema = object({ + organizationId: string().min(1).optional(), +}); + +export const analyticsOrganizationInputSchema = object({ + organizationId: string().min(1), +}); + +export const analyticsTimeseriesInputSchema = object({ + organizationId: string().min(1), + days: number().int().min(1).max(365).optional(), +}); + +export const analyticsTopPostsInputSchema = object({ + organizationId: string().min(1), + limit: number().int().min(1).max(50).optional(), +}); diff --git a/apps/dashboard/src/types/analytics.ts b/apps/dashboard/src/types/analytics.ts new file mode 100644 index 000000000..155af9348 --- /dev/null +++ b/apps/dashboard/src/types/analytics.ts @@ -0,0 +1,198 @@ +export interface SocialAnalyticsSyncPayload { + organizationId?: string; +} + +export interface SocialAnalyticsSyncResult { + status: "completed" | "skipped" | "invalid_payload"; + syncedAccounts?: number; + syncedPosts?: number; +} + +export interface SyncableSocialAccount { + id: string; + organizationId: string; + provider: string; + providerAccountId: string; + username: string; + displayName: string | null; + profileImageUrl: string | null; + verified: boolean; +} + +export interface TwitterPublicMetrics { + followers_count?: number; + following_count?: number; + tweet_count?: number; + listed_count?: number; +} + +export interface TwitterTweetPublicMetrics { + retweet_count?: number; + reply_count?: number; + like_count?: number; + quote_count?: number; + bookmark_count?: number; + impression_count?: number; +} + +export interface TwitterBatchUser { + id: string; + username: string; + name?: string; + verified?: boolean; + verified_type?: string; + profile_image_url?: string; + public_metrics?: TwitterPublicMetrics; +} + +export interface TwitterBatchUsersResponse { + data?: TwitterBatchUser[]; +} + +export interface TwitterTimelineTweet { + id: string; + text: string; + created_at?: string; + public_metrics?: TwitterTweetPublicMetrics; +} + +export interface TwitterTimelineResponse { + data?: TwitterTimelineTweet[]; + meta?: { + next_token?: string; + }; +} + +export interface RecordPublishedSocialPostInput { + organizationId: string; + accountId: string; + provider: string; + providerAccountId: string; + platformPostId: string; + url: string | null; + content: string; +} + +export interface SocialOverviewAccount { + provider: string; + providerAccountId: string; + accountId: string; + username: string; + displayName: string | null; + profileImageUrl: string | null; + verified: boolean; + followersCount: number | null; + followingCount: number | null; + postsCount: number | null; + trackedPosts: number | null; + impressions: number | null; + likes: number | null; + replies: number | null; + reposts: number | null; + quotes: number | null; + bookmarks: number | null; + statsCapturedAt: string | null; +} + +export interface SocialOverviewResponse { + configured: boolean; + accounts: SocialOverviewAccount[]; +} + +export interface EngagementTimeseriesPoint { + day: string; + provider: string; + providerAccountId: string; + posts: number; + impressions: number | null; + likes: number | null; + replies: number | null; + reposts: number | null; +} + +export interface EngagementTimeseriesResponse { + configured: boolean; + points: EngagementTimeseriesPoint[]; +} + +export interface TopPostItem { + provider: string; + platformPostId: string; + providerAccountId: string; + username: string | null; + profileImageUrl: string | null; + content: string; + url: string | null; + postedAt: string; + impressions: number | null; + likes: number | null; + replies: number | null; + reposts: number | null; + bookmarks: number | null; + engagement: number; +} + +export interface TopPostsResponse { + configured: boolean; + posts: TopPostItem[]; +} + +export interface FollowerGrowthPoint { + day: string; + provider: string; + providerAccountId: string; + followersCount: number | null; +} + +export interface FollowerGrowthResponse { + configured: boolean; + points: FollowerGrowthPoint[]; +} + +export interface FollowerChartRow { + day: string; + [accountKey: string]: string | number; +} + +export interface AnalyticsStatCard { + label: string; + value: number | null; + hint?: string; +} + +export interface PostingPerformancePoint { + weekday: number; + posts: number; + engagement: number; + impressions: number | null; + avgEngagement: number; +} + +export interface PostingPerformanceResponse { + configured: boolean; + points: PostingPerformancePoint[]; +} + +export interface PostingPerformanceChartRow { + day: string; + avgEngagement: number; + posts: number; +} + +export interface AccountSeriesRow { + day: string; + rawDay: string; + [accountKey: string]: string | number; +} + +export interface NotraAdoptionResponse { + configured: boolean; + organizationCreatedAt: string | null; + firstNotraPostAt: string | null; + notraPosts: number; +} + +export interface TimelineMarker { + index: number | null; + label: string; +} diff --git a/apps/dashboard/src/utils/analytics-charts.ts b/apps/dashboard/src/utils/analytics-charts.ts new file mode 100644 index 000000000..58d829fa3 --- /dev/null +++ b/apps/dashboard/src/utils/analytics-charts.ts @@ -0,0 +1,125 @@ +import type { + AccountSeriesRow, + EngagementTimeseriesPoint, + FollowerGrowthPoint, + PostingPerformanceChartRow, + PostingPerformancePoint, + SocialOverviewAccount, +} from "@/types/analytics"; + +const compactFormatter = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}); + +const dayLabelFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +const MS_PER_DAY = 86_400_000; +const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +export function formatMetric(value: number | null): string { + if (value === null) { + return "N/A"; + } + return compactFormatter.format(value); +} + +export function formatDayLabel(day: string): string { + const date = new Date(`${day}T00:00:00Z`); + if (Number.isNaN(date.getTime())) { + return day; + } + return dayLabelFormatter.format(date); +} + +export function accountSeriesKey( + provider: string, + providerAccountId: string +): string { + return `${provider}:${providerAccountId}`; +} + +export function buildTimelineDays(days: number): string[] { + const today = new Date(); + const result: string[] = []; + for (let offset = days - 1; offset >= 0; offset -= 1) { + const date = new Date(today.getTime() - offset * MS_PER_DAY); + result.push(date.toISOString().slice(0, 10)); + } + return result; +} + +export function buildAccountSeriesRows( + timelineDays: string[], + accountKeys: string[], + points: EngagementTimeseriesPoint[], + metric: (point: EngagementTimeseriesPoint) => number +): AccountSeriesRow[] { + const valuesByDay = new Map>(); + for (const point of points) { + const key = accountSeriesKey(point.provider, point.providerAccountId); + const dayValues = valuesByDay.get(point.day) ?? new Map(); + dayValues.set(key, (dayValues.get(key) ?? 0) + metric(point)); + valuesByDay.set(point.day, dayValues); + } + + return timelineDays.map((day) => { + const row: AccountSeriesRow = { day: formatDayLabel(day), rawDay: day }; + const dayValues = valuesByDay.get(day); + for (const key of accountKeys) { + row[key] = dayValues?.get(key) ?? 0; + } + return row; + }); +} + +export function markerIndexForDate( + timelineDays: string[], + isoDate: string | null +): number | null { + if (!isoDate) { + return null; + } + const day = isoDate.slice(0, 10); + const first = timelineDays.at(0); + const last = timelineDays.at(-1); + if (!(first && last) || day > last) { + return null; + } + if (day < first) { + return null; + } + const index = timelineDays.indexOf(day); + return index === -1 ? null : index; +} + +export function sumMetric( + accounts: SocialOverviewAccount[], + metric: (account: SocialOverviewAccount) => number | null +): number | null { + let total: number | null = null; + for (const account of accounts) { + const value = metric(account); + if (value !== null) { + total = (total ?? 0) + value; + } + } + return total; +} + +export function buildPostingPerformanceRows( + points: PostingPerformancePoint[] +): PostingPerformanceChartRow[] { + const byWeekday = new Map(points.map((point) => [point.weekday, point])); + return WEEKDAY_LABELS.map((label, index) => { + const point = byWeekday.get(index + 1); + return { + day: label, + avgEngagement: point?.avgEngagement ?? 0, + posts: point?.posts ?? 0, + }; + }); +} diff --git a/apps/dashboard/src/workflows/social-analytics-sync.ts b/apps/dashboard/src/workflows/social-analytics-sync.ts new file mode 100644 index 000000000..6a1458603 --- /dev/null +++ b/apps/dashboard/src/workflows/social-analytics-sync.ts @@ -0,0 +1,40 @@ +import { flattenError } from "zod"; +import { socialAnalyticsSyncPayloadSchema } from "@/schemas/analytics"; +import type { + SocialAnalyticsSyncPayload, + SocialAnalyticsSyncResult, +} from "@/types/analytics"; +import { + listSyncableAccounts, + snapshotAccountDimensions, + syncTwitterAnalytics, +} from "./steps/social-analytics-steps"; + +export async function socialAnalyticsSyncWorkflow( + payload: SocialAnalyticsSyncPayload +): Promise { + "use workflow"; + + const parseResult = socialAnalyticsSyncPayloadSchema.safeParse(payload); + if (!parseResult.success) { + console.error( + "[Social Analytics] Invalid payload:", + flattenError(parseResult.error) + ); + return { status: "invalid_payload" }; + } + + const accounts = await listSyncableAccounts(parseResult.data.organizationId); + if (accounts.length === 0) { + return { status: "completed", syncedAccounts: 0, syncedPosts: 0 }; + } + + const syncedAccounts = await snapshotAccountDimensions(accounts); + const twitterResult = await syncTwitterAnalytics(accounts); + + return { + status: "completed", + syncedAccounts, + syncedPosts: twitterResult.posts, + }; +} diff --git a/apps/dashboard/src/workflows/steps/social-analytics-steps.ts b/apps/dashboard/src/workflows/steps/social-analytics-steps.ts new file mode 100644 index 000000000..35a7c6078 --- /dev/null +++ b/apps/dashboard/src/workflows/steps/social-analytics-steps.ts @@ -0,0 +1,73 @@ +import { + ingestSocialAccountStats, + ingestSocialAccounts, + ingestSocialPostStats, + ingestSocialPosts, + isTinybirdConfigured, +} from "@notra/analytics/tinybird/client"; +import { db } from "@notra/db/drizzle"; +import { connectedSocialAccounts } from "@notra/db/schema"; +import { eq } from "drizzle-orm"; +import { buildAccountRow } from "@/lib/analytics/rows"; +import { collectTwitterRows } from "@/lib/analytics/twitter-sync"; +import type { SyncableSocialAccount } from "@/types/analytics"; + +export async function listSyncableAccounts( + organizationId?: string +): Promise { + "use step"; + if (!isTinybirdConfigured()) { + return []; + } + const accounts = await db.query.connectedSocialAccounts.findMany({ + columns: { + id: true, + organizationId: true, + provider: true, + providerAccountId: true, + username: true, + displayName: true, + profileImageUrl: true, + verified: true, + }, + ...(organizationId + ? { where: eq(connectedSocialAccounts.organizationId, organizationId) } + : {}), + }); + + return accounts.map((account) => ({ + id: account.id, + organizationId: account.organizationId, + provider: account.provider, + providerAccountId: account.providerAccountId, + username: account.username, + displayName: account.displayName, + profileImageUrl: account.profileImageUrl, + verified: account.verified ?? false, + })); +} + +export async function snapshotAccountDimensions( + accounts: SyncableSocialAccount[] +): Promise { + "use step"; + const capturedAt = new Date(); + const rows = accounts.map((account) => buildAccountRow(account, capturedAt)); + await ingestSocialAccounts(rows); + return rows.length; +} + +export async function syncTwitterAnalytics( + accounts: SyncableSocialAccount[] +): Promise<{ accountStats: number; posts: number }> { + "use step"; + const capturedAt = new Date(); + const twitterAccounts = accounts.filter( + (account) => account.provider === "twitter" + ); + const rows = await collectTwitterRows(twitterAccounts, capturedAt); + await ingestSocialAccountStats(rows.accountStats); + await ingestSocialPosts(rows.posts); + await ingestSocialPostStats(rows.postStats); + return { accountStats: rows.accountStats.length, posts: rows.posts.length }; +} diff --git a/bun.lock b/bun.lock index f6dafac8d..85af5afd8 100644 --- a/bun.lock +++ b/bun.lock @@ -159,6 +159,7 @@ "@lexical/utils": "^0.39.0", "@neoconfetti/react": "^1.0.0", "@notra/ai": "workspace:*", + "@notra/analytics": "workspace:*", "@notra/content-generation": "workspace:*", "@notra/db": "workspace:*", "@notra/email": "workspace:*", @@ -381,6 +382,18 @@ "typescript": "5.9.2", }, }, + "packages/analytics": { + "name": "@notra/analytics", + "version": "0.0.1", + "dependencies": { + "@tinybirdco/sdk": "^0.0.82", + }, + "devDependencies": { + "@notra/typescript-config": "workspace:*", + "@types/node": "^24.0.0", + "typescript": "5.9.2", + }, + }, "packages/content-generation": { "name": "@notra/content-generation", "version": "0.0.1", @@ -478,9 +491,12 @@ "@xyflow/react": "^12.10.0", "ai": "6.0.206", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "cmdk": "^1.1.1", "cnfast": "^0.0.6", "color": "^5.0.3", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "lucide-react": "^0.562.0", @@ -500,6 +516,7 @@ "shiki": "^3.21.0", "sonner": "^2.0.7", "streamdown": "^2.0.1", + "tailwind-merge": "^3.6.0", "tokenlens": "^1.3.1", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.1", @@ -507,6 +524,8 @@ "devDependencies": { "@notra/typescript-config": "*", "@tailwindcss/postcss": "^4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", "@types/node": "^22.15.3", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", @@ -1243,6 +1262,8 @@ "@notra/ai": ["@notra/ai@workspace:packages/ai"], + "@notra/analytics": ["@notra/analytics@workspace:packages/analytics"], + "@notra/content-generation": ["@notra/content-generation@workspace:packages/content-generation"], "@notra/db": ["@notra/db@workspace:packages/db"], @@ -2033,6 +2054,8 @@ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.1", "", {}, "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA=="], + "@tinybirdco/sdk": ["@tinybirdco/sdk@0.0.82", "", { "dependencies": { "@clack/prompts": "^1.0.0", "chokidar": "^4.0.0", "commander": "^12.0.0", "dotenv": "^16.0.0", "esbuild": "^0.25.0", "picocolors": "^1.1.1", "zod": "^3.25.0" }, "bin": { "tinybird": "bin/tinybird.js" } }, "sha512-aG8LNE0FGJWlAuOvypi6rXwaLGLnBKB3dQMcQa4EH/4LKxsJ2qrDiQaTf9flaSrpktPhL/bNzhYgIF5/Nh9d/A=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -5253,6 +5276,12 @@ "@tanstack/react-pacer/@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], + "@tinybirdco/sdk/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@tinybirdco/sdk/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "@tinybirdco/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@toolwind/corner-shape/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "@types/cors/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], diff --git a/packages/analytics/package.json b/packages/analytics/package.json new file mode 100644 index 000000000..4e8143e95 --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,23 @@ +{ + "name": "@notra/analytics", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "check-types": "tsc --noEmit", + "tinybird:dev": "tinybird dev", + "tinybird:build": "tinybird build", + "tinybird:deploy": "tinybird deploy" + }, + "exports": { + "./*": "./src/*.ts" + }, + "dependencies": { + "@tinybirdco/sdk": "^0.0.82" + }, + "devDependencies": { + "@notra/typescript-config": "workspace:*", + "@types/node": "^24.0.0", + "typescript": "5.9.2" + } +} diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts new file mode 100644 index 000000000..15d7cc353 --- /dev/null +++ b/packages/analytics/src/tinybird/client.ts @@ -0,0 +1,185 @@ +import { type IngestResult, type QueryResult, Tinybird } from "@tinybirdco/sdk"; +import { + type SocialAccountRow, + type SocialAccountStatsRow, + type SocialPostRow, + type SocialPostSourceRow, + type SocialPostStatsRow, + socialAccountStats, + socialAccounts, + socialPostSources, + socialPostStats, + socialPosts, +} from "./datasources"; +import { + type EngagementTimeseriesParams, + type EngagementTimeseriesRow, + engagementTimeseries, + type FollowerGrowthParams, + type FollowerGrowthRow, + followerGrowth, + type NotraAdoptionRow, + notraAdoption, + type PostingPerformanceParams, + type PostingPerformanceRow, + postingPerformance, + type SocialOverviewParams, + type SocialOverviewRow, + socialOverview, + type TopPostsParams, + type TopPostsRow, + topPosts, +} from "./endpoints"; + +export function isTinybirdConfigured(): boolean { + return Boolean(process.env.TINYBIRD_TOKEN); +} + +function createTinybirdClient() { + return new Tinybird({ + token: process.env.TINYBIRD_TOKEN, + baseUrl: process.env.TINYBIRD_BASE_URL ?? "https://api.tinybird.co", + devMode: false, + datasources: { + socialAccounts, + socialAccountStats, + socialPosts, + socialPostStats, + socialPostSources, + }, + pipes: { + socialOverview, + engagementTimeseries, + topPosts, + followerGrowth, + postingPerformance, + notraAdoption, + }, + }); +} + +let cachedClient: ReturnType | null = null; + +function getTinybirdClient() { + if (!isTinybirdConfigured()) { + return null; + } + if (!cachedClient) { + cachedClient = createTinybirdClient(); + } + return cachedClient; +} + +async function ingestRows( + rows: TRow[], + ingest: ( + client: NonNullable>, + batch: TRow[] + ) => Promise +): Promise { + const client = getTinybirdClient(); + if (!client || rows.length === 0) { + return null; + } + return await ingest(client, rows); +} + +export function ingestSocialAccounts( + rows: SocialAccountRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.socialAccounts.ingestBatch(batch) + ); +} + +export function ingestSocialAccountStats( + rows: SocialAccountStatsRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.socialAccountStats.ingestBatch(batch) + ); +} + +export function ingestSocialPosts( + rows: SocialPostRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.socialPosts.ingestBatch(batch) + ); +} + +export function ingestSocialPostStats( + rows: SocialPostStatsRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.socialPostStats.ingestBatch(batch) + ); +} + +export function ingestSocialPostSources( + rows: SocialPostSourceRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.socialPostSources.ingestBatch(batch) + ); +} + +export async function querySocialOverview( + params: SocialOverviewParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.socialOverview.query(params); +} + +export async function queryEngagementTimeseries( + params: EngagementTimeseriesParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.engagementTimeseries.query(params); +} + +export async function queryTopPosts( + params: TopPostsParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.topPosts.query(params); +} + +export async function queryFollowerGrowth( + params: FollowerGrowthParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.followerGrowth.query(params); +} + +export async function queryPostingPerformance( + params: PostingPerformanceParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.postingPerformance.query(params); +} + +export async function queryNotraAdoption(params: { + organization_id: string; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.notraAdoption.query(params); +} diff --git a/packages/analytics/src/tinybird/datasources.ts b/packages/analytics/src/tinybird/datasources.ts new file mode 100644 index 000000000..79ac06436 --- /dev/null +++ b/packages/analytics/src/tinybird/datasources.ts @@ -0,0 +1,117 @@ +import { defineDatasource, engine, type InferRow, t } from "@tinybirdco/sdk"; + +export const socialAccounts = defineDatasource("social_accounts", { + description: + "Connected social account dimension snapshots for Twitter/X and LinkedIn", + schema: { + organization_id: t.string(), + account_id: t.string(), + provider: t.string().lowCardinality(), + provider_account_id: t.string(), + username: t.string(), + display_name: t.string().nullable(), + profile_image_url: t.string().nullable(), + profile_url: t.string().nullable(), + account_type: t.string().nullable(), + verified: t.bool(), + captured_at: t.dateTime(), + }, + engine: engine.replacingMergeTree({ + sortingKey: ["organization_id", "provider", "provider_account_id"], + ver: "captured_at", + }), +}); + +export const socialAccountStats = defineDatasource("social_account_stats", { + description: + "Append-only account-level stat snapshots; metrics a platform does not expose are null", + schema: { + organization_id: t.string(), + account_id: t.string(), + provider: t.string().lowCardinality(), + provider_account_id: t.string(), + captured_at: t.dateTime(), + followers_count: t.uint64().nullable(), + following_count: t.uint64().nullable(), + posts_count: t.uint64().nullable(), + listed_count: t.uint64().nullable(), + }, + engine: engine.mergeTree({ + sortingKey: [ + "organization_id", + "provider", + "provider_account_id", + "captured_at", + ], + partitionKey: "toYYYYMM(captured_at)", + }), +}); + +export const socialPosts = defineDatasource("social_posts", { + description: + "Published post dimension rows for Twitter/X and LinkedIn, keyed by platform post id", + schema: { + organization_id: t.string(), + account_id: t.string(), + provider: t.string().lowCardinality(), + provider_account_id: t.string(), + platform_post_id: t.string(), + url: t.string().nullable(), + content: t.string(), + posted_at: t.dateTime(), + captured_at: t.dateTime(), + }, + engine: engine.replacingMergeTree({ + sortingKey: ["organization_id", "provider", "platform_post_id"], + ver: "captured_at", + }), +}); + +export const socialPostStats = defineDatasource("social_post_stats", { + description: + "Append-only post-level stat snapshots; metrics a platform does not expose are null", + schema: { + organization_id: t.string(), + provider: t.string().lowCardinality(), + provider_account_id: t.string(), + platform_post_id: t.string(), + captured_at: t.dateTime(), + impressions: t.uint64().nullable(), + likes: t.uint64().nullable(), + replies: t.uint64().nullable(), + reposts: t.uint64().nullable(), + quotes: t.uint64().nullable(), + bookmarks: t.uint64().nullable(), + }, + engine: engine.mergeTree({ + sortingKey: [ + "organization_id", + "provider", + "platform_post_id", + "captured_at", + ], + partitionKey: "toYYYYMM(captured_at)", + }), +}); + +export const socialPostSources = defineDatasource("social_post_sources", { + description: + "Append-only ledger marking posts that were published through Notra", + schema: { + organization_id: t.string(), + provider: t.string().lowCardinality(), + provider_account_id: t.string(), + platform_post_id: t.string(), + source: t.string().lowCardinality(), + captured_at: t.dateTime(), + }, + engine: engine.mergeTree({ + sortingKey: ["organization_id", "provider", "platform_post_id"], + }), +}); + +export type SocialAccountRow = InferRow; +export type SocialAccountStatsRow = InferRow; +export type SocialPostRow = InferRow; +export type SocialPostStatsRow = InferRow; +export type SocialPostSourceRow = InferRow; diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts new file mode 100644 index 000000000..bd90981b2 --- /dev/null +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -0,0 +1,393 @@ +import { + defineEndpoint, + type InferOutputRow, + type InferParams, + node, + p, + t, +} from "@tinybirdco/sdk"; + +export const socialOverview = defineEndpoint("social_overview", { + description: + "Latest per-account snapshot with follower counts and lifetime engagement totals", + params: { + organization_id: p.string().describe("Organization id"), + }, + nodes: [ + node({ + name: "latest_account_stats", + sql: ` + SELECT + provider, + provider_account_id, + argMax(account_id, captured_at) AS account_id, + argMax(followers_count, captured_at) AS followers_count, + argMax(following_count, captured_at) AS following_count, + argMax(posts_count, captured_at) AS posts_count, + max(captured_at) AS stats_captured_at + FROM social_account_stats + WHERE organization_id = {{String(organization_id)}} + GROUP BY provider, provider_account_id + `, + }), + node({ + name: "latest_post_stats", + sql: ` + SELECT + provider, + provider_account_id, + count() AS tracked_posts, + sum(impressions) AS impressions, + sum(likes) AS likes, + sum(replies) AS replies, + sum(reposts) AS reposts, + sum(quotes) AS quotes, + sum(bookmarks) AS bookmarks + FROM ( + SELECT + provider, + provider_account_id, + platform_post_id, + argMax(impressions, captured_at) AS impressions, + argMax(likes, captured_at) AS likes, + argMax(replies, captured_at) AS replies, + argMax(reposts, captured_at) AS reposts, + argMax(quotes, captured_at) AS quotes, + argMax(bookmarks, captured_at) AS bookmarks + FROM social_post_stats + WHERE organization_id = {{String(organization_id)}} + GROUP BY provider, provider_account_id, platform_post_id + ) + GROUP BY provider, provider_account_id + `, + }), + node({ + name: "overview", + sql: ` + SELECT + accounts.provider AS provider, + accounts.provider_account_id AS provider_account_id, + argMax(accounts.account_id, accounts.captured_at) AS account_id, + argMax(accounts.username, accounts.captured_at) AS username, + argMax(accounts.display_name, accounts.captured_at) AS display_name, + argMax(accounts.profile_image_url, accounts.captured_at) AS profile_image_url, + argMax(accounts.verified, accounts.captured_at) AS verified, + any(stats.followers_count) AS followers_count, + any(stats.following_count) AS following_count, + any(stats.posts_count) AS posts_count, + any(posts.tracked_posts) AS tracked_posts, + any(posts.impressions) AS impressions, + any(posts.likes) AS likes, + any(posts.replies) AS replies, + any(posts.reposts) AS reposts, + any(posts.quotes) AS quotes, + any(posts.bookmarks) AS bookmarks, + any(stats.stats_captured_at) AS stats_captured_at + FROM social_accounts AS accounts + LEFT JOIN latest_account_stats AS stats + ON stats.provider = accounts.provider + AND stats.provider_account_id = accounts.provider_account_id + LEFT JOIN latest_post_stats AS posts + ON posts.provider = accounts.provider + AND posts.provider_account_id = accounts.provider_account_id + WHERE accounts.organization_id = {{String(organization_id)}} + GROUP BY accounts.provider, accounts.provider_account_id + ORDER BY followers_count DESC NULLS LAST + `, + }), + ], + output: { + provider: t.string(), + provider_account_id: t.string(), + account_id: t.string(), + username: t.string(), + display_name: t.string().nullable(), + profile_image_url: t.string().nullable(), + verified: t.bool(), + followers_count: t.uint64().nullable(), + following_count: t.uint64().nullable(), + posts_count: t.uint64().nullable(), + tracked_posts: t.uint64().nullable(), + impressions: t.uint64().nullable(), + likes: t.uint64().nullable(), + replies: t.uint64().nullable(), + reposts: t.uint64().nullable(), + quotes: t.uint64().nullable(), + bookmarks: t.uint64().nullable(), + stats_captured_at: t.dateTime().nullable(), + }, +}); + +export const engagementTimeseries = defineEndpoint("engagement_timeseries", { + description: + "Daily post counts and engagement per account, attributed to each post's publish date", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "latest_post_metrics", + sql: ` + SELECT + provider, + platform_post_id, + argMax(impressions, captured_at) AS impressions, + argMax(likes, captured_at) AS likes, + argMax(replies, captured_at) AS replies, + argMax(reposts, captured_at) AS reposts + FROM social_post_stats + WHERE organization_id = {{String(organization_id)}} + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "post_days", + sql: ` + SELECT + provider, + platform_post_id, + argMax(provider_account_id, captured_at) AS provider_account_id, + min(posted_at) AS first_posted_at + FROM social_posts + WHERE organization_id = {{String(organization_id)}} + AND posted_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "daily_totals", + sql: ` + SELECT + toDate(posts.first_posted_at) AS day, + posts.provider AS provider, + posts.provider_account_id AS provider_account_id, + count() AS posts, + sum(stats.impressions) AS impressions, + sum(stats.likes) AS likes, + sum(stats.replies) AS replies, + sum(stats.reposts) AS reposts + FROM post_days AS posts + LEFT JOIN latest_post_metrics AS stats + ON stats.provider = posts.provider + AND stats.platform_post_id = posts.platform_post_id + GROUP BY day, provider, provider_account_id + ORDER BY day ASC + `, + }), + ], + output: { + day: t.date(), + provider: t.string(), + provider_account_id: t.string(), + posts: t.uint64(), + impressions: t.uint64().nullable(), + likes: t.uint64().nullable(), + replies: t.uint64().nullable(), + reposts: t.uint64().nullable(), + }, +}); + +export const topPosts = defineEndpoint("top_posts", { + description: "Best performing posts by latest engagement snapshot", + params: { + organization_id: p.string().describe("Organization id"), + limit: p.int32().optional(10).describe("Number of posts"), + }, + nodes: [ + node({ + name: "latest_stats", + sql: ` + SELECT + provider, + platform_post_id, + argMax(impressions, captured_at) AS impressions, + argMax(likes, captured_at) AS likes, + argMax(replies, captured_at) AS replies, + argMax(reposts, captured_at) AS reposts, + argMax(bookmarks, captured_at) AS bookmarks + FROM social_post_stats + WHERE organization_id = {{String(organization_id)}} + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "ranked", + sql: ` + SELECT + posts.provider AS provider, + posts.platform_post_id AS platform_post_id, + argMax(posts.content, posts.captured_at) AS content, + argMax(posts.url, posts.captured_at) AS url, + argMax(posts.provider_account_id, posts.captured_at) AS provider_account_id, + min(posts.posted_at) AS posted_at, + any(stats.impressions) AS impressions, + any(stats.likes) AS likes, + any(stats.replies) AS replies, + any(stats.reposts) AS reposts, + any(stats.bookmarks) AS bookmarks, + coalesce(any(stats.likes), 0) + + coalesce(any(stats.replies), 0) + + coalesce(any(stats.reposts), 0) AS engagement + FROM social_posts AS posts + LEFT JOIN latest_stats AS stats + ON stats.provider = posts.provider + AND stats.platform_post_id = posts.platform_post_id + WHERE posts.organization_id = {{String(organization_id)}} + GROUP BY posts.provider, posts.platform_post_id + ORDER BY engagement DESC, posted_at DESC + LIMIT {{Int32(limit, 10)}} + `, + }), + ], + output: { + provider: t.string(), + platform_post_id: t.string(), + provider_account_id: t.string(), + content: t.string(), + url: t.string().nullable(), + posted_at: t.dateTime(), + impressions: t.uint64().nullable(), + likes: t.uint64().nullable(), + replies: t.uint64().nullable(), + reposts: t.uint64().nullable(), + bookmarks: t.uint64().nullable(), + engagement: t.uint64(), + }, +}); + +export const postingPerformance = defineEndpoint("posting_performance", { + description: + "Post volume and average engagement grouped by weekday of publish", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(90).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "post_metrics", + sql: ` + SELECT + provider, + platform_post_id, + argMax(likes, captured_at) AS likes, + argMax(replies, captured_at) AS replies, + argMax(reposts, captured_at) AS reposts, + argMax(impressions, captured_at) AS impressions + FROM social_post_stats + WHERE organization_id = {{String(organization_id)}} + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "post_weekdays", + sql: ` + SELECT + provider, + platform_post_id, + toDayOfWeek(min(posted_at)) AS weekday + FROM social_posts + WHERE organization_id = {{String(organization_id)}} + AND posted_at >= now() - INTERVAL {{Int32(days, 90)}} DAY + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "weekday_totals", + sql: ` + SELECT + posts.weekday AS weekday, + count() AS posts, + sum(coalesce(stats.likes, 0) + coalesce(stats.replies, 0) + coalesce(stats.reposts, 0)) AS engagement, + sum(stats.impressions) AS impressions, + round(sum(coalesce(stats.likes, 0) + coalesce(stats.replies, 0) + coalesce(stats.reposts, 0)) / count(), 1) AS avg_engagement + FROM post_weekdays AS posts + LEFT JOIN post_metrics AS stats + ON stats.provider = posts.provider + AND stats.platform_post_id = posts.platform_post_id + GROUP BY weekday + ORDER BY weekday ASC + `, + }), + ], + output: { + weekday: t.uint64(), + posts: t.uint64(), + engagement: t.uint64(), + impressions: t.uint64().nullable(), + avg_engagement: t.float64(), + }, +}); + +export type PostingPerformanceParams = InferParams; +export type PostingPerformanceRow = InferOutputRow; + +export const followerGrowth = defineEndpoint("follower_growth", { + description: "Daily follower counts per account", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "daily_followers", + sql: ` + SELECT + toDate(captured_at) AS day, + provider, + provider_account_id, + argMax(followers_count, captured_at) AS followers_count + FROM social_account_stats + WHERE organization_id = {{String(organization_id)}} + AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + GROUP BY day, provider, provider_account_id + ORDER BY day ASC + `, + }), + ], + output: { + day: t.date(), + provider: t.string(), + provider_account_id: t.string(), + followers_count: t.uint64().nullable(), + }, +}); + +export type SocialOverviewParams = InferParams; +export type SocialOverviewRow = InferOutputRow; +export type EngagementTimeseriesParams = InferParams< + typeof engagementTimeseries +>; +export type EngagementTimeseriesRow = InferOutputRow< + typeof engagementTimeseries +>; +export type TopPostsParams = InferParams; +export type TopPostsRow = InferOutputRow; +export type FollowerGrowthParams = InferParams; +export type FollowerGrowthRow = InferOutputRow; + +export const notraAdoption = defineEndpoint("notra_adoption", { + description: "When the organization first published through Notra", + params: { + organization_id: p.string().describe("Organization id"), + }, + nodes: [ + node({ + name: "adoption", + sql: ` + SELECT + min(captured_at) AS first_notra_post_at, + count() AS notra_posts + FROM social_post_sources + WHERE organization_id = {{String(organization_id)}} + AND source = 'notra' + `, + }), + ], + output: { + first_notra_post_at: t.dateTime().nullable(), + notra_posts: t.uint64(), + }, +}); + +export type NotraAdoptionRow = InferOutputRow; diff --git a/packages/analytics/src/utils/datetime.ts b/packages/analytics/src/utils/datetime.ts new file mode 100644 index 000000000..c0ed92a08 --- /dev/null +++ b/packages/analytics/src/utils/datetime.ts @@ -0,0 +1,13 @@ +const PAD_LENGTH = 2; + +function pad(value: number): string { + return String(value).padStart(PAD_LENGTH, "0"); +} + +export function toClickHouseDateTime(date: Date): string { + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad( + date.getUTCDate() + )} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad( + date.getUTCSeconds() + )}`; +} diff --git a/packages/analytics/tinybird.config.json b/packages/analytics/tinybird.config.json new file mode 100644 index 000000000..59b8dbf35 --- /dev/null +++ b/packages/analytics/tinybird.config.json @@ -0,0 +1,6 @@ +{ + "include": ["src/tinybird/datasources.ts", "src/tinybird/endpoints.ts"], + "token": "${TINYBIRD_TOKEN}", + "baseUrl": "https://api.us-east.aws.tinybird.co", + "devMode": "branch" +} diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json new file mode 100644 index 000000000..e4ee95df2 --- /dev/null +++ b/packages/analytics/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@notra/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "strictNullChecks": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/ui/dither-kit.json b/packages/ui/dither-kit.json new file mode 100644 index 000000000..4a11f8143 --- /dev/null +++ b/packages/ui/dither-kit.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://tripwire.sh/schema/dither-kit.json", + "lockfileVersion": 1, + "registry": "https://tripwire.sh", + "mode": "source", + "components": { + "area-chart": { + "version": "0.1.0", + "hash": "sha256:197d088204a38580fec861579c89e8c5aff7fe158222ce9b7a1a4b0a605e415e", + "files": [ + { + "path": "components/dither-kit/area-chart.tsx", + "hash": "sha256:307883b537e005386a8090499eaf3ea636767a10235271181cb089c3477eab1e" + }, + { + "path": "components/dither-kit/area.tsx", + "hash": "sha256:ecfb990f9083dd4c813eb431e3674822b7272076294de4ae038e81552d678532" + }, + { + "path": "components/dither-kit/cartesian-canvas.tsx", + "hash": "sha256:180a1525cbe10ec35bc79f689bd018cfdf115ebb4e1e6b1ff31d4bd37ecc6f09" + }, + { + "path": "components/dither-kit/sparkline.tsx", + "hash": "sha256:b5dab0b68af7ebc976ec51a74776761ac26d79f4aacd9c453a6e79c49e8d11ff" + } + ] + }, + "avatar": { + "version": "0.1.0", + "hash": "sha256:c7a639c023dcfb9f1365f11de35986d265de494b60c1887807aedf6504acf0cd", + "files": [ + { + "path": "components/dither-kit/avatar.tsx", + "hash": "sha256:bf9736b474a5e050b19b23fb4d858924758d6df1d0a1006eed95f1fc42b84cb5" + }, + { + "path": "components/dither-kit/lib.ts", + "hash": "sha256:41493601eb53a70f7629f6e50bad233f7185eaad42ba2397ca56ac1afcf1bb5f" + }, + { + "path": "components/dither-kit/palette.ts", + "hash": "sha256:fd020c230ad818a5d9d6dfdfcb6ef50b0dba012c77f44f1a61449fc9a65947f7" + }, + { + "path": "components/dither-kit/pixel.ts", + "hash": "sha256:eea1493d54a86d164b40d69c95956238777331ded7a556f099230dab161ab5a7" + } + ] + }, + "bar-chart": { + "version": "0.1.0", + "hash": "sha256:fb4a2e9bba84e3558e0e162de42eaf07a741557bfa43167c0e319cdfa75acd0d", + "files": [ + { + "path": "components/dither-kit/bar-canvas.tsx", + "hash": "sha256:80ed40b87eb3dbf3d9f768cf42b3b55f4f738db8b193c1a616f7abf525d83f87" + }, + { + "path": "components/dither-kit/bar-chart.tsx", + "hash": "sha256:e19ce3540f15756aba615eb19072aeec81d8ac71e51219691cc1d0be004c18bb" + }, + { + "path": "components/dither-kit/bar.tsx", + "hash": "sha256:2904ff1b0813ae20b976f5a78d57a314729ea3e890b18ba762bbb46d2f550057" + } + ] + }, + "core": { + "version": "0.1.0", + "hash": "sha256:c2d71baa1313a822cbef24de39414e21df7029fc80ad297ab8471d3d727235e9", + "files": [ + { + "path": "components/dither-kit/block-legend.tsx", + "hash": "sha256:4ddf215c6187ac0ca2640501c0342699bc210b33c1cec1d3e138ee3d38f77433" + }, + { + "path": "components/dither-kit/cartesian-root.tsx", + "hash": "sha256:6b5297d75de22ae5a0fc1960bef0419a4984b54ff493e7acd527aa0ccb150098" + }, + { + "path": "components/dither-kit/chart-context.tsx", + "hash": "sha256:955657f21fdbf7e04b80917be52b7c3839eb535b8d07f15053c06d9320683d8f" + }, + { + "path": "components/dither-kit/common-context.tsx", + "hash": "sha256:aafb26404977148144a3d81da81a678d5956e14024f394ccdc7ae881dd07e52a" + }, + { + "path": "components/dither-kit/dither-paint.ts", + "hash": "sha256:1bcd3d7de3ae3091caf63ed4c2c862d80d1899c9b55a70078acb12f7b44e51c7" + }, + { + "path": "components/dither-kit/dot.tsx", + "hash": "sha256:207bf90662237064940d14a4d8411135d75027b97a9582c0e61c8af74ea5d65a" + }, + { + "path": "components/dither-kit/grid.tsx", + "hash": "sha256:ad09d80b3898a4d3f588ff1f2f4f82984864c3918cb09d31350be6ac18d2446f" + }, + { + "path": "components/dither-kit/legend.tsx", + "hash": "sha256:2a067ebdc484c7bd9efb2afc4e8a0aa6718a14ec44ccd7bb94c871d192e501e6" + }, + { + "path": "components/dither-kit/lib.ts", + "hash": "sha256:41493601eb53a70f7629f6e50bad233f7185eaad42ba2397ca56ac1afcf1bb5f" + }, + { + "path": "components/dither-kit/palette.ts", + "hash": "sha256:fd020c230ad818a5d9d6dfdfcb6ef50b0dba012c77f44f1a61449fc9a65947f7" + }, + { + "path": "components/dither-kit/polar-context.tsx", + "hash": "sha256:514ac9a4984b036598a14ecd7acc29ee121e9259c8a85309d7ed3c1e22af6831" + }, + { + "path": "components/dither-kit/polar-root.tsx", + "hash": "sha256:63cecb6193ce3cd296b93b018e2441ccdefcc055cdc31bf131c808f0cf0c0c52" + }, + { + "path": "components/dither-kit/polar.ts", + "hash": "sha256:3ad3f99bac2ee21e6580abd6903720af11cedcb8d8c69a06c40690be6229672c" + }, + { + "path": "components/dither-kit/reference-line.tsx", + "hash": "sha256:b0df8cf1144ab56fcce577bac4a39e06d9215a1fdf81bb112a4190a0445b138b" + }, + { + "path": "components/dither-kit/scales.ts", + "hash": "sha256:62ec74acdd3e335b959423a228b47edd70c14f07a468a385d039f5c6b66ebc2b" + }, + { + "path": "components/dither-kit/series-context.tsx", + "hash": "sha256:1802f1f488cc46542294f533c55fae03a4b067feca0761df75ce21de8a6a429a" + }, + { + "path": "components/dither-kit/tooltip.tsx", + "hash": "sha256:d053ac2135c6f29d011cb0a83be29a797f8954ccbb02916402d2d3c2a52b17f2" + }, + { + "path": "components/dither-kit/use-chart-dimensions.ts", + "hash": "sha256:74b7f2a08cce5b891348eed845fc8c218ed16fce362ef4f35587ec4cc864c834" + }, + { + "path": "components/dither-kit/x-axis.tsx", + "hash": "sha256:7997bd96c341816a0f1cc6f966ad386fedc78be39e51201fa368dab1fd943d62" + }, + { + "path": "components/dither-kit/y-axis.tsx", + "hash": "sha256:96d8cad3d7ca87a2a6f092399a60ba2f32612413a190f2885da67854229cdeff" + } + ] + } + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 64032957a..85cf0cb01 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,7 @@ "./postcss.config": "./postcss.config.mjs", "./lib/*": "./src/lib/*.ts", "./components/*": "./src/components/*.tsx", + "./components/dither-kit/palette": "./src/components/dither-kit/palette.ts", "./components/shared/*": "./src/components/shared/*.tsx", "./components/kibo-ui/*": "./src/components/kibo-ui/*/index.tsx", "./hooks/*": "./src/hooks/*.ts", @@ -27,9 +28,12 @@ "@xyflow/react": "^12.10.0", "ai": "6.0.206", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "cmdk": "^1.1.1", "cnfast": "^0.0.6", "color": "^5.0.3", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "lucide-react": "^0.562.0", @@ -49,6 +53,7 @@ "shiki": "^3.21.0", "sonner": "^2.0.7", "streamdown": "^2.0.1", + "tailwind-merge": "^3.6.0", "tokenlens": "^1.3.1", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.1" @@ -56,6 +61,8 @@ "devDependencies": { "@notra/typescript-config": "*", "@tailwindcss/postcss": "^4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", "@types/node": "^22.15.3", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", diff --git a/packages/ui/src/components/dither-kit/area-chart.tsx b/packages/ui/src/components/dither-kit/area-chart.tsx new file mode 100644 index 000000000..a8c622091 --- /dev/null +++ b/packages/ui/src/components/dither-kit/area-chart.tsx @@ -0,0 +1,25 @@ +"use client" + +import { CartesianCanvas } from "./cartesian-canvas" +import { type CartesianChartProps, CartesianRoot } from "./cartesian-root" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +/** Composable dither **area** chart. Compose ``, ``, axes, … inside. */ +export function AreaChart( + props: CartesianChartProps +) { + return +} + +/** Composable dither **line** chart — `` series with a glow under the line. */ +export function LineChart( + props: CartesianChartProps +) { + return +} + +export type AreaChartProps = CartesianChartProps diff --git a/packages/ui/src/components/dither-kit/area.tsx b/packages/ui/src/components/dither-kit/area.tsx new file mode 100644 index 000000000..e48fdfa27 --- /dev/null +++ b/packages/ui/src/components/dither-kit/area.tsx @@ -0,0 +1,102 @@ +"use client" + +import { type ReactNode, useEffect } from "react" +import { + type AreaVariant, + type SeriesKind, + type StrokeVariant, + useChartPart, +} from "./chart-context" +import { SeriesContext } from "./series-context" + +export type SeriesProps = { + dataKey: string + variant?: AreaVariant + strokeVariant?: StrokeVariant + isClickable?: boolean + children?: ReactNode +} + +/** + * Shared implementation for the continuous series (``, ``). The + * dithered fill/line is painted on the canvas; this registers the series so the + * canvas knows how to draw it, wires click-to-select via a transparent band + * polygon, and exposes the series to child ``/`` markers. + */ +function CartesianSeries({ + part, + kind, + dataKey, + variant = "gradient", + strokeVariant = "solid", + isClickable = false, + children, +}: SeriesProps & { part: string; kind: SeriesKind }) { + const ctx = useChartPart(part, kind === "line" ? "line" : "area") + const { registerSeries, unregisterSeries } = ctx + + if (process.env.NODE_ENV !== "production" && !ctx.config[dataKey]) { + console.warn( + `<${part} dataKey="${dataKey}" />: "${dataKey}" is not in the chart \`config\`. Add it so the series has a colour and label.` + ) + } + + useEffect(() => { + registerSeries({ dataKey, kind, variant, strokeVariant }) + return () => unregisterSeries(dataKey) + }, [dataKey, kind, variant, strokeVariant, registerSeries, unregisterSeries]) + + const band = ctx.bands[dataKey] + if (!ctx.ready || !band) return null + + const seed = ctx.seedOf(dataKey) + const emphasis = ctx.selectedDataKey ?? ctx.focusDataKey + const dimmed = emphasis !== null && emphasis !== dataKey + const onClick = isClickable + ? () => ctx.selectDataKey(ctx.selectedDataKey === dataKey ? null : dataKey) + : undefined + + // Transparent hit polygon tracing the series' own band, so clicking a series + // selects *that* series. The Legend offers the same toggle accessibly. + // One pass out along the top edge, one pass back along the floor. + let hitPath: string | null = null + if (isClickable) { + const parts: string[] = [] + band.forEach((b, i) => { + parts.push(`${i === 0 ? "M" : "L"}${ctx.xCenter(i)},${ctx.y(b[1])}`) + }) + for (let i = band.length - 1; i >= 0; i -= 1) { + parts.push(`L${ctx.xCenter(i)},${ctx.y(band[i]?.[0] ?? 0)}`) + } + hitPath = `${parts.join(" ")} Z` + } + + return ( + <> + {hitPath && ( + // biome-ignore lint/a11y/noStaticElementInteractions: progressive enhancement; the Legend offers the same toggle accessibly + + )} + + {children} + + + ) +} + +export type AreaProps = SeriesProps + +/** One area series — dithered fill from the value line down to its floor. */ +export function Area(props: AreaProps) { + return +} + +/** One line series — bright line with a thin dither glow hugging it. */ +export function Line(props: AreaProps) { + return +} diff --git a/packages/ui/src/components/dither-kit/avatar.tsx b/packages/ui/src/components/dither-kit/avatar.tsx new file mode 100644 index 000000000..a1f03785a --- /dev/null +++ b/packages/ui/src/components/dither-kit/avatar.tsx @@ -0,0 +1,212 @@ +"use client" + +import { useEffect, useRef } from "react" +import { cn } from "./lib" +import { rgb } from "./palette" +import { + BAYER4, + clamp01, + fnv1a, + hueFill, + type PixelBloom, + pixelBloomStyle, + pixelPrefersReducedMotion, + xorshift32, +} from "./pixel" + +// 8×8 cells, mirrored across one axis → 32 free pattern bits. With the mirror +// axis bit and 180 hues that's 2^33 × 180 ≈ 1.5 trillion distinct avatars. +const GRID = 8 +const CELL_PX = 4 // backing px per cell → a 32×32 canvas, scaled up pixelated + +export type AvatarMirror = "auto" | "horizontal" | "vertical" + +export type DitherAvatarProps = { + /** The seed — same name, same avatar, every time. */ + name: string + /** Hue override (0–360). Derived from the name when omitted. */ + hue?: number + /** Mirror axis. "auto" picks one from the name — half the avatars fold + * left/right, half fold top/bottom. */ + mirror?: AvatarMirror + /** Square size in px. Omit to size via className (e.g. `size-12`). */ + size?: number + /** Glow on the dither fill. */ + bloom?: PixelBloom + /** Play the Bayer-ordered materialize entrance. */ + animate?: boolean + animationDuration?: number + /** Bump to replay the entrance. */ + replayToken?: number + className?: string +} + +type AvatarModel = { + on: boolean[] // GRID×GRID, row-major + density: number[] // per-cell dither density for on cells + fill: [number, number, number] +} + +/** + * Derive the full 8×8 cell grid from the name: 32 pattern bits + the mirror + * axis + the hue + per-cell densities, all from one deterministic PRNG stream. + * Every draw happens unconditionally so overriding `hue` or `mirror` never + * shifts the pattern. + */ +function avatarModel( + name: string, + hueProp: number | undefined, + mirrorProp: AvatarMirror +): AvatarModel { + const rand = xorshift32(fnv1a(name)) + const bits = Array.from({ length: 32 }, () => rand() < 0.5) + const drawnVertical = rand() < 0.5 + const drawnHue = Math.floor(rand() * 180) * 2 + const halfDensity = Array.from({ length: 32 }, () => 0.55 + rand() * 0.45) + + const vertical = + mirrorProp === "auto" ? drawnVertical : mirrorProp === "vertical" + const hue = hueProp ?? drawnHue + + const on = new Array(GRID * GRID) + const density = new Array(GRID * GRID) + for (let r = 0; r < GRID; r++) { + for (let c = 0; c < GRID; c++) { + // Fold across the chosen axis: left/right symmetric ("horizontal" + // mirror) or top/bottom symmetric ("vertical"). + const i = vertical + ? Math.min(r, GRID - 1 - r) * GRID + c + : r * (GRID / 2) + Math.min(c, GRID - 1 - c) + on[r * GRID + c] = bits[i] ?? false + density[r * GRID + c] = halfDensity[i] ?? 0 + } + } + return { on, density, fill: hueFill(hue) } +} + +/** + * Paint the avatar, optionally sweeping cells in with the Bayer-ordered + * materialize entrance. Lives outside the component (same shape as the chart + * canvases). Returns a cleanup that cancels the entrance loop. + */ +function paintAvatar( + canvas: HTMLCanvasElement, + bloomCanvas: HTMLCanvasElement | null, + model: AvatarModel, + animate: boolean, + duration: number +): (() => void) | undefined { + const ctx = canvas.getContext("2d") + if (!ctx) return undefined + const px = GRID * CELL_PX + canvas.width = px + canvas.height = px + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = px + bloomCanvas.height = px + } + + const draw = (progress: number) => { + ctx.clearRect(0, 0, px, px) + for (let r = 0; r < GRID; r++) { + for (let c = 0; c < GRID; c++) { + if (!model.on[r * GRID + c]) continue + // Cells materialize in Bayer order — the entrance is made of the same + // matrix as the texture. + const start = (BAYER4[r % 4]?.[c % 4] ?? 0) * 0.7 + const cellAlpha = clamp01((progress - start) / 0.3) + if (cellAlpha <= 0) continue + const density = model.density[r * GRID + c] ?? 0 + const base = 0.35 + 0.65 * density + for (let py = 0; py < CELL_PX; py++) { + for (let pxi = 0; pxi < CELL_PX; pxi++) { + const gx = c * CELL_PX + pxi + const gy = r * CELL_PX + py + const lit = density > (BAYER4[gy & 3]?.[gx & 3] ?? 0) + // On/off cells modulate alpha tiers of the one fill colour, so the + // avatar holds up on light and dark backgrounds alike. + const alpha = (lit ? base : base * 0.35) * cellAlpha + ctx.fillStyle = rgb(model.fill, 1, alpha) + ctx.fillRect(gx, gy, 1, 1) + } + } + } + } + if (bloomCtx) { + bloomCtx.clearRect(0, 0, px, px) + bloomCtx.drawImage(canvas, 0, 0) + } + } + + if (!animate || pixelPrefersReducedMotion()) { + draw(1) + return undefined + } + + let raf = 0 + const startTime = performance.now() + const tick = (now: number) => { + const t = clamp01((now - startTime) / duration) + draw(1 - (1 - t) ** 3) + if (t < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) +} + +/** + * Generative dithered avatar — a mirrored 8×8 pixel glyph derived from a name, + * rendered with the ordered-dither texture the charts are made of. Same name, + * same avatar; ~1.5 trillion combinations across pattern, mirror axis, and hue. + */ +export function DitherAvatar({ + name, + hue, + mirror = "auto", + size, + bloom = "off", + animate = true, + animationDuration = 600, + replayToken = 0, + className, +}: DitherAvatarProps) { + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + return paintAvatar( + canvas, + bloomRef.current, + avatarModel(name, hue, mirror), + animate, + animationDuration + ) + }, [name, hue, mirror, animate, animationDuration, replayToken, bloom]) + + const bloomStyle = pixelBloomStyle(bloom) + + return ( +
+ + {bloomStyle && ( + + )} +
+ ) +} diff --git a/packages/ui/src/components/dither-kit/bar-canvas.tsx b/packages/ui/src/components/dither-kit/bar-canvas.tsx new file mode 100644 index 000000000..145175c77 --- /dev/null +++ b/packages/ui/src/components/dither-kit/bar-canvas.tsx @@ -0,0 +1,227 @@ +"use client" + +import { useEffect, useMemo, useRef } from "react" +import { useChart } from "./chart-context" +import { + backingSize, + bloomLayerStyle, + clamp01, + easeOutCubic, + paintColumn, + prefersReducedMotion, +} from "./dither-paint" + +type Bars = { top: number[]; base: number[] } // per data index, in backing rows + +// Fraction of the timeline spent staggering bar starts — the rest is each bar's +// own grow window, so the rise sweeps across the chart as a wave. +const STAGGER = 0.55 + +/** + * Dither canvas for bar charts. Each category owns a band; grouped series split + * it into side-by-side bars, stacked series share its full width and pile in y. + * Every bar is filled with the shared {@link paintColumn} ordered dither. Bars + * grow up from their base in a staggered left-to-right wave (eased), and the + * hovered category lifts while the rest dim. + */ +export function BarCanvas() { + const ctx = useChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + const { ready, configKeys, bands, y } = ctx + + // Memoized: per-series bar tops/bases (backing rows) over the data indices. + // The canvas re-renders on every hover/cursor tick, so pin this map to the + // exact ctx fields it reads plus the backing geometry — a bar hover must not + // rebuild every band's geometry. + const targets = useMemo(() => { + const out: Record = {} + if (!ready) return out + const h = height || 1 + for (const key of configKeys) { + const band = bands[key] + if (!band) continue + out[key] = { + top: band.map((b) => (y(b[1]) / h) * (rows - 1)), + base: band.map((b) => (y(b[0]) / h) * (rows - 1)), + } + } + return out + }, [ready, configKeys, bands, y, height, rows]) + + // The RAF loop reads these through refs so it always sees the latest values; + // refs are written in an effect (never during render) — mutating a ref + // mid-render tears under Strict Mode / concurrent rendering. + const state = useRef(ctx) + const targetsRef = useRef(targets) + useEffect(() => { + state.current = ctx + targetsRef.current = targets + }) + + useEffect(() => { + const canvas = canvasRef.current + const c = canvas?.getContext("2d") + if (!(canvas && c) || cols <= 0 || rows <= 0) return + canvas.width = cols + canvas.height = rows + + const bloomCanvas = bloomRef.current + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + const fx = cols / Math.max(width, 1) + + // Eased grow factor for bar `i` at global progress `prog`. + const barProgress = (i: number, len: number, prog: number) => { + if (!animate) return 1 + const start = len > 1 ? (i / (len - 1)) * STAGGER : 0 + return easeOutCubic(clamp01((prog - start) / (1 - STAGGER))) + } + + const paint = (prog: number) => { + const s = state.current + c.clearRect(0, 0, cols, rows) + const stacked = s.stackType === "stacked" || s.stackType === "percent" + const keys = s.configKeys + keys.forEach((key, si) => { + const t = targetsRef.current[key] + if (!t) return + const seed = s.seedOf(key) + const variant = s.seriesSpecs[key]?.variant ?? "gradient" + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1 + for (let i = 0; i < s.dataLength; i++) { + const bp = barProgress(i, s.dataLength, prog) + const base = t.base[i] ?? rows - 1 + const grown = base + ((t.top[i] ?? base) - base) * bp + // Bars grow from the zero baseline toward the value. Positive values + // sit above the baseline (smaller pixel), negative ones below it — + // paintColumn wants the higher edge first, so order the pair. + const top = Math.min(grown, base) + const bottom = Math.max(grown, base) + const active = s.hoverIndex === i + const hoverDim = + s.hoverIndex != null && !active && s.isMouseInChart ? 0.5 : 1 + const slot = s.barSlot(i, si, keys.length) + const c0 = Math.round(slot.x * fx) + const c1 = Math.round((slot.x + slot.width) * fx) + for (let x = c0; x < c1; x++) { + paintColumn(c, x, top, bottom, seed, { + variant, + intensity: intensity + (active ? 0.4 : 0), + dim: selDim * hoverDim, + stacked, + }) + } + } + }) + } + + let raf = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let intensity = 0 + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + let lastHover: number | null | undefined = Symbol() as never + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready) return + if (bloomCtx) { + const on = + s.bloom !== "off" && + (!s.bloomOnHover || s.isMouseInChart || s.hovered) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 // re-play the wave on data change / replay + lastProg = -1 + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + + if (prog !== lastProg) { + lastProg = prog + needsFill = true + } + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + if (s.hoverIndex !== lastHover) { + lastHover = s.hoverIndex + needsFill = true + } + const itTarget = s.isMouseInChart || s.hovered ? 1 : 0 + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * (reduce ? 1 : 0.16) + needsFill = true + } else intensity = itTarget + + // Live tweak repaint (variant, stacking) without replaying the wave. + const paintSig = `${s.stackType}|${s.configKeys + .map((k) => s.seriesSpecs[k]?.variant ?? "") + .join(",")}` + if (paintSig !== lastPaintSig) { + lastPaintSig = paintSig + needsFill = true + } + + if (!needsFill) return + paint(prog) + needsFill = false + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, [cols, rows, width]) + + const bloomActive = ctx.bloomOnHover + ? ctx.isMouseInChart || ctx.hovered + : true + const bloom = bloomLayerStyle(ctx.bloom, bloomActive) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/packages/ui/src/components/dither-kit/bar-chart.tsx b/packages/ui/src/components/dither-kit/bar-chart.tsx new file mode 100644 index 000000000..423e3f580 --- /dev/null +++ b/packages/ui/src/components/dither-kit/bar-chart.tsx @@ -0,0 +1,14 @@ +"use client" + +import { BarCanvas } from "./bar-canvas" +import { type CartesianChartProps, CartesianRoot } from "./cartesian-root" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +/** Composable dither **bar** chart — `` series, grouped or stacked. */ +export function BarChart(props: CartesianChartProps) { + return +} diff --git a/packages/ui/src/components/dither-kit/bar.tsx b/packages/ui/src/components/dither-kit/bar.tsx new file mode 100644 index 000000000..15c8217d6 --- /dev/null +++ b/packages/ui/src/components/dither-kit/bar.tsx @@ -0,0 +1,83 @@ +"use client" + +import { type ReactNode, useEffect } from "react" +import { + type AreaVariant, + type StrokeVariant, + useChartPart, +} from "./chart-context" +import { SeriesContext } from "./series-context" + +export type BarProps = { + dataKey: string + variant?: AreaVariant + strokeVariant?: StrokeVariant + isClickable?: boolean + children?: ReactNode +} + +/** + * One bar series. The dithered bars are painted on the canvas; this registers + * the series and (when `isClickable`) lays transparent hit rects over each bar + * — using the shared `barSlot` geometry so clicks line up with the pixels — to + * select the series. The Legend offers the same toggle accessibly. + */ +export function Bar({ + dataKey, + variant = "gradient", + strokeVariant = "solid", + isClickable = false, + children, +}: BarProps) { + const ctx = useChartPart("Bar", "bar") + const { registerSeries, unregisterSeries } = ctx + + if (process.env.NODE_ENV !== "production" && !ctx.config[dataKey]) { + console.warn( + `: "${dataKey}" is not in the chart \`config\`. Add it so the series has a colour and label.` + ) + } + + useEffect(() => { + registerSeries({ dataKey, kind: "bar", variant, strokeVariant }) + return () => unregisterSeries(dataKey) + }, [dataKey, variant, strokeVariant, registerSeries, unregisterSeries]) + + const band = ctx.bands[dataKey] + if (!ctx.ready || !band) return null + + const seed = ctx.seedOf(dataKey) + const dimmed = ctx.selectedDataKey !== null && ctx.selectedDataKey !== dataKey + const si = ctx.configKeys.indexOf(dataKey) + const n = ctx.configKeys.length + const onClick = () => + ctx.selectDataKey(ctx.selectedDataKey === dataKey ? null : dataKey) + + return ( + <> + {isClickable && + band.map((b, i) => { + const slot = ctx.barSlot(i, si, n) + const top = ctx.y(b[1]) + const base = ctx.y(b[0]) + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: progressive enhancement; the Legend offers the same toggle accessibly + + ) + })} + + {children} + + + ) +} diff --git a/packages/ui/src/components/dither-kit/block-legend.tsx b/packages/ui/src/components/dither-kit/block-legend.tsx new file mode 100644 index 000000000..54d14c20d --- /dev/null +++ b/packages/ui/src/components/dither-kit/block-legend.tsx @@ -0,0 +1,63 @@ +"use client" + +import type { ChartConfig } from "./chart-context" +import { cn } from "./lib" +import { rgb, seedOfColor } from "./palette" + +/** + * An in-flow legend rendered as a sibling of the chart rather than an overlay. + * + * The overlay {@link Legend} is pinned absolutely to the top of the plot, so + * with more than ~3 entries (or a narrow container) its wrapped rows sit on top + * of the chart. `` lives in normal document flow, so it can never + * overlap the plot at any width — use it for multi-entry charts (donuts, many + * series) and reserve the overlay `` for ≤2–3 entries. + * + * It needs no chart context: feed it the same `config` you pass the chart, and + * optionally a `values` map to show a number beside each entry (e.g. allocation + * shares or totals). + */ +export function BlockLegend({ + config, + values, + valueFormatter = (v) => String(v), + align = "start", + className, +}: { + config: ChartConfig + values?: Record + valueFormatter?: (value: number) => string + align?: "start" | "center" | "end" + className?: string +}) { + return ( +
    + {Object.entries(config).map(([name, entry]) => { + const seed = seedOfColor(entry.color) + const value = values?.[name] + return ( +
  • + + {entry.label ?? name} + {value !== undefined ? ( + {valueFormatter(value)} + ) : null} +
  • + ) + })} +
+ ) +} diff --git a/packages/ui/src/components/dither-kit/cartesian-canvas.tsx b/packages/ui/src/components/dither-kit/cartesian-canvas.tsx new file mode 100644 index 000000000..55bb2006a --- /dev/null +++ b/packages/ui/src/components/dither-kit/cartesian-canvas.tsx @@ -0,0 +1,405 @@ +"use client" + +import { type RefObject, useEffect, useMemo, useRef } from "react" +import { type ChartContextValue, useChart } from "./chart-context" +import { + backingSize, + bloomLayerStyle, + easeInOutCubic, + paintColumn, + prefersReducedMotion, + resample, +} from "./dither-paint" +import { rgb } from "./palette" + +type Star = { key: string; xi: number; depth: number; phase: number } +type Surface = { top: number[]; floor: number[] } + +type LoopArgs = { + canvas: HTMLCanvasElement + bloomCanvas: HTMLCanvasElement | null + cols: number + rows: number + state: RefObject + targets: RefObject> + stars: RefObject +} + +/** + * The requestAnimationFrame paint loop — eases each series toward its target + * surface, paints the dither fill (with the entrance reveal), then layers the + * crosshair marker and winking stars on top. Lives outside the component so the + * component stays small and this hot closure isn't re-created on every render. + * Returns a cleanup that cancels the loop. + */ +function startCartesianLoop({ + canvas, + bloomCanvas, + cols, + rows, + state, + targets, + stars, +}: LoopArgs): (() => void) | undefined { + const c = canvas.getContext("2d") + if (!c || cols <= 0 || rows <= 0) return undefined + canvas.width = cols + canvas.height = rows + + const off = document.createElement("canvas") + off.width = cols + off.height = rows + const octx = off.getContext("2d") + if (!octx) return undefined + + // Bloom layer: a blurred, additive copy of the crisp canvas. + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const EASE = reduce ? 1 : 0.18 + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + const current: Record = {} + + // `reveal` (0–1) sweeps the fill in left-to-right on first paint. + const paintFill = (intensity: number, reveal: number) => { + octx.clearRect(0, 0, cols, rows) + const s = state.current + const stacked = s.stackType === "stacked" || s.stackType === "percent" + const revealCols = Math.ceil(reveal * cols) + s.configKeys.forEach((key, si) => { + const cur = current[key] + if (!cur) return + const seed = s.seedOf(key) + const variant = s.seriesSpecs[key]?.variant ?? "gradient" + const isLine = + (s.seriesSpecs[key]?.kind ?? + (s.chartType === "line" ? "line" : "area")) === "line" + const emphasis = s.selectedDataKey ?? s.focusDataKey + const dim = emphasis !== null && emphasis !== key ? 0.3 : 1 + // Overlapping (non-stacked) layers thin out front-to-back so they + // read as distinct layers instead of a muddy blend. + const sparse = stacked ? 0 : si * 0.14 + for (let x = 0; x < cols; x++) { + if (x > revealCols) break + // For a value that dips below the zero baseline the value line ends up + // *below* the floor in pixels; paintColumn needs the higher edge first, + // so order the pair (a no-op for the common positive case). + const a = cur.top[x] ?? 0 + const b = cur.floor[x] ?? 0 + paintColumn(octx, x, Math.min(a, b), Math.max(a, b), seed, { + variant, + intensity, + dim, + stacked: stacked && !isLine, + sparse, + }) + } + }) + } + + let raf = 0 + let tick = 0 + let last = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let entranceReported = !animate + let intensity = 0 + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready) return + // Keep the bloom layer in sync with the crisp canvas while it's active. + if (bloomCtx) { + const on = + s.bloom !== "off" && (!s.bloomOnHover || s.isMouseInChart || s.hovered) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + const tgt = targets.current + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 // re-play the entrance on data change / replay + lastProg = -1 + entranceReported = false + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + const progChanged = prog !== lastProg + // Tell the context the reveal is done so DOM markers fade in in sync. + if (prog >= 1 && !entranceReported) { + entranceReported = true + s.markEntranceDone() + } + + let moving = false + for (const key of s.configKeys) { + const t = tgt[key] + if (!t) continue + const cur = current[key] + if (!cur || cur.top.length !== cols) { + current[key] = { top: t.top.slice(), floor: t.floor.slice() } + needsFill = true + continue + } + for (let x = 0; x < cols; x++) { + const tTop = t.top[x] ?? 0 + const tFloor = t.floor[x] ?? 0 + const dt = tTop - (cur.top[x] ?? 0) + const df = tFloor - (cur.floor[x] ?? 0) + if (Math.abs(dt) > 0.01 || Math.abs(df) > 0.01) { + cur.top[x] = (cur.top[x] ?? 0) + dt * EASE + cur.floor[x] = (cur.floor[x] ?? 0) + df * EASE + moving = true + } else { + cur.top[x] = tTop + cur.floor[x] = tFloor + } + } + } + for (const key of Object.keys(current)) { + if (!tgt[key]) { + delete current[key] + needsFill = true + } + } + if (moving) needsFill = true + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + + const itTarget = s.isMouseInChart || s.hovered ? 1 : 0 + let settling = false + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * 0.16 + settling = true + needsFill = true + } else intensity = itTarget + + // Live hover wins; the controlled markerIndex (e.g. a committed point) + // is the fallback shown when nothing is hovered. + const marker = s.hoverIndex != null ? s.hoverIndex : s.markerIndex + const winkDue = !reduce && now - last >= 100 + // Repaint when a tweak-driven paint input changes (variant, stacking) so + // the panel updates the fill live — without resetting the entrance reveal. + const paintSig = `${s.stackType}|${s.configKeys + .map((k) => s.seriesSpecs[k]?.variant ?? "") + .join(",")}` + const sigChanged = paintSig !== lastPaintSig + if (sigChanged) { + lastPaintSig = paintSig + needsFill = true + } + if ( + !( + moving || + settling || + winkDue || + marker != null || + progChanged || + sigChanged + ) + ) + return + if (progChanged) { + lastProg = prog + needsFill = true + } + if (winkDue) { + last = now + tick += 1 + } + + // Reveal front (left-to-right) — stars + crosshair stay behind it so + // they don't float over the not-yet-drawn area during the entrance. + const reveal = animate ? easeInOutCubic(prog) : 1 + const revealCols = reveal * cols + + if (needsFill) { + paintFill(intensity, reveal) + needsFill = false + } + c.clearRect(0, 0, cols, rows) + c.drawImage(off, 0, 0) + + const mx = + marker != null && s.dataLength > 1 + ? Math.round((marker / (s.dataLength - 1)) * (cols - 1)) + : -1 + if (mx >= 0 && mx <= revealCols) { + for (const key of s.configKeys) { + const cur = current[key] + if (!cur) continue + const seed = s.seedOf(key) + const my = Math.round(cur.top[mx] ?? 0) + // Full-height column + a chunky marker block at the point — the + // series colour at higher opacity, so it reads on either theme. + c.fillStyle = rgb(seed.fill, 1, 0.55) + for (let y = my; y < rows; y++) c.fillRect(mx, y, 1, 1) + c.fillStyle = rgb(seed.fill) + c.fillRect(mx - 1, my - 1, 3, 3) + } + } + + for (const star of stars.current) { + const cur = current[star.key] + if (!cur) continue + const sx = Math.round( + (star.xi / Math.max(s.dataLength - 1, 1)) * (cols - 1) + ) + if (sx > revealCols) continue // behind the reveal front + const top = cur.top[sx] ?? 0 + const floor = cur.floor[sx] ?? rows - 1 + const sy = Math.round(top + star.depth * (floor - top)) + const tw = reduce ? 0.85 : (Math.sin((tick + star.phase) * 0.35) + 1) / 2 + const lift = tw * (0.7 + 0.3 * intensity) + if (lift < 0.55 || sy < 0 || sy >= rows) continue + // Sparkles glint in the series colour via opacity (the `lift` wink) + // rather than a lighter shade — so they never read as stray white + // pixels on a light background. + const starColor = s.seedOf(star.key).fill + c.fillStyle = rgb(starColor, 1, lift) + c.fillRect(sx, sy, 1, 1) + // At the peak of a wink the star flares into a 4-point glint. + if (tw > 0.9) { + c.fillStyle = rgb(starColor, 1, lift * 0.6 * (tw - 0.9) * 10) + c.fillRect(sx - 1, sy, 1, 1) + c.fillRect(sx + 1, sy, 1, 1) + c.fillRect(sx, sy - 1, 1, 1) + c.fillRect(sx, sy + 1, 1, 1) + } + } + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) +} + +/** + * Continuous dither canvas for area and line charts. Each series is reduced to a + * `[top, floor]` band per backing column: areas fill from their value line down + * to their floor; lines fill only a thin glow band hugging the line. The shared + * {@link paintColumn} renders the ordered-dither scatter, capped by the bright + * series line, with winking stars + scrub crosshair on top. + */ +export function CartesianCanvas() { + const ctx = useChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + const { ready, chartType, configKeys, bands, seriesSpecs, y, dataLength } = ctx + + // Memoized: the pricey bit in the render path — a `resample` per series to + // the backing column count. The canvas re-renders on every hover/cursor tick + // (it consumes ctx), so without this the whole surface is rebuilt each time. + // Pinned to the exact ctx fields it reads, plus the backing geometry. + const targets = useMemo(() => { + const out: Record = {} + if (!ready) return out + const h = height || 1 + const glow = Math.max(6, Math.round(rows * 0.16)) + const defaultKind = chartType === "line" ? "line" : "area" + for (const key of configKeys) { + const band = bands[key] + if (!band) continue + const line = (seriesSpecs[key]?.kind ?? defaultKind) === "line" + const top = band.map((b) => (y(b[1]) / h) * (rows - 1)) + const floor = band.map((b, i) => + line ? Math.min(rows - 1, (top[i] ?? 0) + glow) : (y(b[0]) / h) * (rows - 1) + ) + out[key] = { top: resample(top, cols), floor: resample(floor, cols) } + } + return out + }, [ready, chartType, configKeys, bands, seriesSpecs, y, height, rows, cols]) + + // Memoized: the star field is deterministic — only its shape (series × + // column count) matters, so it need not be rebuilt on unrelated re-renders. + const stars = useMemo(() => { + const out: Star[] = [] + const per = Math.max(4, Math.round(cols / 14)) + configKeys.forEach((key, k) => { + for (let i = 0; i < per; i++) { + const seed = i * 67 + 13 + k * 131 + out.push({ + key, + xi: seed % Math.max(dataLength, 1), + depth: ((seed * 53 + 7) % 100) / 100, + phase: (seed * 41) % 360, + }) + } + }) + return out + }, [configKeys, dataLength, cols]) + + // The RAF loop reads these through refs so it always sees the latest values + // without re-subscribing. Refs are written in an effect (never during + // render) — mutating a ref mid-render is a React anti-pattern that tears + // under Strict Mode / concurrent rendering. + const stateRef = useRef(ctx) + const targetsRef = useRef(targets) + const starsRef = useRef(stars) + useEffect(() => { + stateRef.current = ctx + targetsRef.current = targets + starsRef.current = stars + }) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + return startCartesianLoop({ + canvas, + bloomCanvas: bloomRef.current, + cols, + rows, + state: stateRef, + targets: targetsRef, + stars: starsRef, + }) + }, [cols, rows]) + + const bloomActive = ctx.bloomOnHover + ? ctx.isMouseInChart || ctx.hovered + : true + const bloom = bloomLayerStyle(ctx.bloom, bloomActive) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/packages/ui/src/components/dither-kit/cartesian-root.tsx b/packages/ui/src/components/dither-kit/cartesian-root.tsx new file mode 100644 index 000000000..590030a63 --- /dev/null +++ b/packages/ui/src/components/dither-kit/cartesian-root.tsx @@ -0,0 +1,192 @@ +"use client" + +import { + Children, + type ComponentType, + isValidElement, + type ReactNode, +} from "react" +import { + type ChartConfig, + ChartContext, + type ChartType, + type Margins, + useChartController, +} from "./chart-context" +import { CommonChartContext } from "./common-context" +import type { BloomInput } from "./dither-paint" +import { cn } from "./lib" +import type { StackType } from "./scales" +import { useChartDimensions } from "./use-chart-dimensions" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +const DEFAULT_MARGINS: Margins = { + top: 10, + right: 12, + bottom: 22, + left: 36, +} + +export type CartesianChartProps = { + data: TData[] + config: ChartConfig + children: ReactNode + stackType?: StackType + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number // change to re-play the entrance without remounting + /** Set false for a decorative sparkline: keeps the hover lift but no scrub + * crosshair / tooltip. */ + interactive?: boolean + /** Controlled crosshair position (e.g. a committed point) — overrides the + * internal hover when set. */ + markerIndex?: number | null + /** Parent-driven hover (e.g. the whole card/row) — lifts the fill. */ + hovered?: boolean + /** Glow on the dither fill. */ + bloom?: BloomInput + /** Only bloom while the chart is hovered. */ + bloomOnHover?: boolean + /** Fires with the scrubbed index as the pointer moves (null on leave). */ + onHoverChange?: (index: number | null) => void + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +/** Which render layer a composed part targets — defaults to the front SVG. */ +function layerOf(node: ReactNode): "back" | "dom" | "svg" { + if (!isValidElement(node) || typeof node.type === "string") return "svg" + return (node.type as { chartLayer?: "back" | "dom" }).chartLayer ?? "svg" +} + +/** + * Shared root for the cartesian dither charts (area, line, bar). Owns the + * measured size, the shared context, and pointer interaction; every visual is + * composed as children. Back chrome (grid) sits behind the dither canvas; the + * canvas paints the fill/line/bars + stars; front chrome (axes, dots) and DOM + * legend/tooltip layer on top. `chartType` drives the scales/interaction and the + * `Canvas` prop supplies the family's painter (continuous for area/line, bars for + * bar) — so each chart ships only its own canvas. + */ +export function CartesianRoot({ + chartType, + Canvas, + data, + config, + children, + stackType = "default", + margins: marginsProp, + className, + animate = true, + animationDuration = 900, + replayToken = 0, + interactive = true, + markerIndex = null, + hovered = false, + bloom = "off", + bloomOnHover = false, + onHoverChange, + defaultSelectedDataKey = null, + onSelectionChange, +}: CartesianChartProps & { + chartType: ChartType + Canvas: ComponentType +}) { + const { ref, size } = useChartDimensions() + const margins = { ...DEFAULT_MARGINS, ...marginsProp } + + const ctx = useChartController({ + chartType, + // Safe: the controller only reads row[key] for the configured series keys. + data: data as Record[], + config, + stackType, + dimensions: size, + margins, + animate, + animationDuration, + replayToken, + markerIndex, + hovered, + bloom, + bloomOnHover, + defaultSelectedDataKey, + onSelectionChange, + }) + + const backChildren: ReactNode[] = [] + const svgChildren: ReactNode[] = [] + const domChildren: ReactNode[] = [] + Children.forEach(children, (child) => { + const layer = layerOf(child) + if (layer === "back") backChildren.push(child) + else if (layer === "dom") domChildren.push(child) + else svgChildren.push(child) + }) + + const onMove = (clientX: number) => { + const el = ref.current + if (!el) return + const rect = el.getBoundingClientRect() + const px = clientX - rect.left - margins.left + const index = ctx.indexAtX(px) + ctx.setHoverIndex(index) + ctx.setCursorX(clientX - rect.left) + onHoverChange?.(index) + } + + return ( + + +
ctx.setMouseInChart(true)} + onPointerMove={interactive ? (e) => onMove(e.clientX) : undefined} + onPointerLeave={() => { + ctx.setMouseInChart(false) + ctx.setHoverIndex(null) + onHoverChange?.(null) + }} + > + {ctx.ready && backChildren.length > 0 && ( + + + {backChildren} + + + )} + + {ctx.ready && ( + + + {svgChildren} + + + )} + {domChildren} +
+
+
+ ) +} + +export type AreaChartProps = CartesianChartProps diff --git a/packages/ui/src/components/dither-kit/chart-context.tsx b/packages/ui/src/components/dither-kit/chart-context.tsx new file mode 100644 index 000000000..6ff4740df --- /dev/null +++ b/packages/ui/src/components/dither-kit/chart-context.tsx @@ -0,0 +1,510 @@ +"use client" + +import type { ScaleLinear } from "d3-scale" +import { createContext, use, useCallback, useMemo, useState } from "react" +import type { CommonChart } from "./common-context" +import type { BloomInput } from "./dither-paint" +import type { DitherColor, Seed } from "./palette" +import { seedOfColor } from "./palette" +import { + buildBandScale, + buildXScale, + buildYScale, + computeBands, + indexAtBand, + nearestIndex, + type StackType, +} from "./scales" +import type { Dimensions } from "./use-chart-dimensions" + +/** Which chart root a part is composed under — drives the boundary guards. */ +export type ChartType = "area" | "bar" | "line" | "pie" | "radar" + +export type ChartConfig = Record + +export type Margins = { + top: number + right: number + bottom: number + left: number +} + +type Row = Record + +export type AreaVariant = "gradient" | "dotted" | "hatched" | "solid" +export type StrokeVariant = "solid" | "dashed" +export type SeriesKind = "area" | "line" | "bar" + +/** What each series part (, , ) registers so the canvas + * knows which series to paint and how. */ +export type SeriesSpec = { + dataKey: string + kind: SeriesKind + variant: AreaVariant + strokeVariant: StrokeVariant +} + +export type ChartContextValue = { + chartType: ChartType // which root this part is under + config: ChartConfig + configKeys: string[] // series order — drives stacking + legend + data: Row[] + dataLength: number + stackType: StackType + + margins: Margins + plot: { width: number; height: number } // inner drawing area + ready: boolean // true once measured (width > 0) + + xCenter: (index: number) => number // category centre px within the plot + bandwidth: number // category slot width (0 for point/area scales) + indexAtX: (px: number) => number // nearest category for a pointer x + // Bar geometry in plot px — one source of truth for the canvas + click rects. + barSlot: ( + index: number, + seriesIndex: number, + seriesCount: number + ) => { x: number; width: number } + y: ScaleLinear // value → px within the plot + bands: Record // per-series [y0, y1] per row + max: number + min: number // most-negative value (0 when nothing dips below the baseline) + + // Interaction state, shared by every part. + selectedDataKey: string | null + selectDataKey: (key: string | null) => void + /** Legend-hover spotlight — dims every series but this one while set. */ + focusDataKey: string | null + setFocusDataKey: (key: string | null) => void + hoverIndex: number | null + setHoverIndex: (index: number | null) => void + markerIndex: number | null // controlled crosshair override (e.g. committed point) + cursorX: number + setCursorX: (px: number) => void + isMouseInChart: boolean + setMouseInChart: (over: boolean) => void + hovered: boolean // parent-driven hover (e.g. the whole card) — lifts the fill + bloom: BloomInput // glow on the dither canvas + bloomOnHover: boolean // only bloom while hovered + + // Series register themselves so the canvas knows what (and how) to paint. + seriesSpecs: Record + registerSeries: (spec: SeriesSpec) => void + unregisterSeries: (dataKey: string) => void + + // Entrance animation (prop-driven). `revision` bumps when the data changes or + // the replay token advances, so the canvas can re-play its entrance. + animate: boolean + animationDuration: number + revision: number + entranceDone: boolean // true once the entrance has played — gates SVG markers + markEntranceDone: () => void // the canvas calls this when its reveal completes + + // Helpers. + seedOf: (key: string) => Seed + common: CommonChart // shared surface for / +} + +const ChartContext = createContext(null) + +const ROOT_OF: Record = { + area: "", + bar: "", + line: "", + pie: "", + radar: "", +} + +/** Generic accessor for internal layers (canvas/overlay) that work for any root. */ +export function useChart() { + const ctx = use(ChartContext) + if (!ctx) { + throw new Error( + "Chart parts must be used within a chart root (e.g. )." + ) + } + return ctx +} + +/** + * Boundary guard for a composable part. Throws a precise error when used outside + * a root, or inside the wrong chart type — e.g. `` placed in an area + * chart. `kind` omitted means the part works under any root (grid, axes, …). + */ +export function useChartPart( + part: string, + kind?: ChartType | ChartType[] +): ChartContextValue { + const ctx = use(ChartContext) + if (!ctx) { + const where = kind + ? ROOT_OF[(Array.isArray(kind) ? kind[0] : kind) ?? "area"] + : "a chart root" + throw new Error(`<${part} /> must be used within ${where}.`) + } + if (kind) { + const allowed = Array.isArray(kind) ? kind : [kind] + if (!allowed.includes(ctx.chartType)) { + throw new Error( + `<${part} /> is not valid inside ${ROOT_OF[ctx.chartType]} — it belongs in ${allowed + .map((k) => ROOT_OF[k]) + .join(" or ")}.` + ) + } + } + return ctx +} + +export { ChartContext } + +/** A counter that advances whenever `data` changes identity or `token` advances + * — drives entrance replays without remounting. Uses the adjust-state-during- + * render pattern (https://react.dev/reference/react/useState) instead of a ref: + * the revision is derived purely from render inputs, so it stays consistent + * across the memoized values below rather than lagging a render behind. */ +export function useRevision(data: unknown, token: number) { + const [prev, setPrev] = useState({ data, token, revision: 0 }) + if (prev.data !== data || prev.token !== token) { + const next = { data, token, revision: prev.revision + 1 } + setPrev(next) + return next.revision + } + return prev.revision +} + +/** + * Builds the shared context value: resolves the plot rect from the measured + * size minus margins, computes the x/y scales and the per-series stack bands, + * and owns the selection + hover state every part reads. + */ +export function useChartController({ + chartType, + data, + config, + stackType, + dimensions, + margins, + animate = true, + animationDuration = 900, + replayToken = 0, + markerIndex = null, + hovered = false, + bloom = "off", + bloomOnHover = false, + defaultSelectedDataKey = null, + onSelectionChange, +}: { + chartType: ChartType + data: Row[] + config: ChartConfig + stackType: StackType + dimensions: Dimensions + margins: Margins + animate?: boolean + animationDuration?: number + replayToken?: number + markerIndex?: number | null + hovered?: boolean + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +}): ChartContextValue { + // This object becomes the ChartContext value, so its identity — and the + // identity of every function/object it carries — must stay stable across + // renders that don't change the underlying inputs. Otherwise every consumer + // (axes, legend, tooltip, dots) re-renders on every parent render. So the + // expensive derivations, the exposed callbacks, and the returned value are + // memoized below; only cheap scalars (bandwidth, ready, plot sizes) are left + // bare, since they're just recomputed reads, not identities anyone depends on. + + // Memoized: configKeys is the dep that drives `bands`, `common` and the + // canvas `targets` memo — a fresh array each render would bust all of them. + const configKeys = useMemo(() => Object.keys(config), [config]) + const revision = useRevision(data, replayToken) + + const [selectedDataKey, setSelectedDataKey] = useState( + defaultSelectedDataKey + ) + const [focusDataKey, setFocusDataKey] = useState(null) + const [hoverIndex, setHoverIndex] = useState(null) + const [cursorX, setCursorX] = useState(0) + const [isMouseInChart, setMouseInChart] = useState(false) + const [seriesSpecs, setSeriesSpecs] = useState>({}) + + // useCallback because the series effects in area.tsx/bar.tsx list these as + // deps — without stable identities the unregister/register effect re-fires + // every render and its setState pair loops ("Maximum update depth exceeded"). + const registerSeries = useCallback((spec: SeriesSpec) => { + setSeriesSpecs((prev) => { + const cur = prev[spec.dataKey] + return cur && + cur.kind === spec.kind && + cur.variant === spec.variant && + cur.strokeVariant === spec.strokeVariant + ? prev + : { ...prev, [spec.dataKey]: spec } + }) + }, []) + const unregisterSeries = useCallback((dataKey: string) => { + setSeriesSpecs((prev) => { + if (!(dataKey in prev)) return prev + const next = { ...prev } + delete next[dataKey] + return next + }) + }, []) + + // Stable so the memoized value keeps its identity; only re-created when the + // caller's selection handler does. + const selectDataKey = useCallback( + (key: string | null) => { + setSelectedDataKey(key) + onSelectionChange?.(key) + }, + [onSelectionChange] + ) + + // The root spreads `{ ...DEFAULT_MARGINS, ...marginsProp }` fresh every + // render, so `margins` never keeps its identity. Pin one off the four numbers + // so it doesn't, on its own, invalidate the value or the plot geometry. + const { top: mTop, right: mRight, bottom: mBottom, left: mLeft } = margins + const stableMargins = useMemo( + () => ({ top: mTop, right: mRight, bottom: mBottom, left: mLeft }), + [mTop, mRight, mBottom, mLeft] + ) + + const plotWidth = Math.max(0, dimensions.width - mLeft - mRight) + const plotHeight = Math.max(0, dimensions.height - mTop - mBottom) + const ready = plotWidth > 0 && plotHeight > 0 + + // The entrance gate flips true when the canvas reveal completes (via + // `markEntranceDone`) so DOM markers fade in with the fill, and re-arms on + // each replay. Adjust-state-during-render instead of an effect, so the reset + // lands in the same render as the revision bump. + const [entrance, setEntrance] = useState({ revision, done: !animate }) + if (entrance.revision !== revision) { + setEntrance({ revision, done: !animate }) + } + const entranceDone = entrance.revision === revision ? entrance.done : !animate + // Stable across renders at the same revision; the canvas holds this in a ref. + const markEntranceDone = useCallback( + () => setEntrance({ revision, done: true }), + [revision] + ) + + // Memoized: the priciest derivation in the render path — it walks every + // row × series to build the stack bands. Hover/cursor state changes must not + // recompute it, only a real data/series/stack change. + const { bands, max, min } = useMemo( + () => computeBands(data, configKeys, stackType), + [data, configKeys, stackType] + ) + + const isBar = chartType === "bar" + // The d3 scale factories are memoized so `y` keeps a stable identity: the + // canvas `targets` memo (cartesian-canvas / bar-canvas) deps on ctx.y, and + // xCenter/indexAtX/barSlot below close over these. + const xPoint = useMemo( + () => buildXScale(data.length, plotWidth), + [data.length, plotWidth] + ) + const xBand = useMemo( + () => buildBandScale(data.length, plotWidth), + [data.length, plotWidth] + ) + const bandwidth = isBar ? xBand.bandwidth() : 0 + const xCenter = useCallback( + (i: number) => + isBar ? (xBand(i) ?? 0) + xBand.bandwidth() / 2 : (xPoint(i) ?? 0), + [isBar, xBand, xPoint] + ) + const indexAtX = useCallback( + (px: number) => + isBar + ? indexAtBand(px, data.length, plotWidth) + : nearestIndex(px, data.length, plotWidth), + [isBar, data.length, plotWidth] + ) + const stacked = stackType === "stacked" || stackType === "percent" + const barSlot = useCallback( + (i: number, si: number, n: number) => { + const center = xCenter(i) + if (stacked) { + const w = bandwidth * 0.9 + return { x: center - w / 2, width: w } + } + const slot = bandwidth / Math.max(n, 1) + return { + x: center - bandwidth / 2 + si * slot + slot * 0.08, + width: slot * 0.84, + } + }, + [xCenter, stacked, bandwidth] + ) + const y = useMemo( + () => buildYScale(min, max, plotHeight), + [min, max, plotHeight] + ) + + // Stable so `common` and the value stay stable; re-created only on config. + const seedOf = useCallback( + (key: string) => seedOfColor(config[key]?.color ?? "grey"), + [config] + ) + + // Memoized: this is the value handed to CommonChartContext (Legend/Tooltip), + // so it needs its own stable identity independent of the parent value. + const common: CommonChart = useMemo(() => ({ + names: configKeys, + labelOf: (n) => config[n]?.label ?? n, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + tooltipLeft: Math.max(48, Math.min(plotWidth + mLeft - 48, cursorX)), + // Follow the highest hovered node so the card rides the data path, but + // keep enough headroom that the upward-lifted card never clips the top. + tooltipTop: (() => { + const floor = mTop + 44 + if (hoverIndex == null) return floor + let minY = Number.POSITIVE_INFINITY + for (const key of configKeys) { + const b = bands[key]?.[hoverIndex] + if (b) minY = Math.min(minY, y(b[1])) + } + if (!Number.isFinite(minY)) return floor + return Math.max(floor, mTop + minY) + })(), + heading: (i, labelKey) => + labelKey ? String(data[i]?.[labelKey] ?? "") : null, + itemsAt: (i) => + configKeys.map((name) => { + const raw = data[i]?.[name] + return { + name, + label: config[name]?.label ?? name, + value: typeof raw === "number" ? raw : 0, + seed: seedOf(name), + dimmed: (() => { + const emphasis = selectedDataKey ?? focusDataKey + return emphasis !== null && emphasis !== name + })(), + } + }), + }), [ + configKeys, + config, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + plotWidth, + mLeft, + mTop, + cursorX, + bands, + y, + data, + ]) + + // Memoized: this is the ChartContext value. A fresh object here would + // re-render every consumer on every parent render — the whole reason the + // pieces above are stabilized. Rebuilds only when a listed input changes + // (which is exactly when a consumer needs the update). The useState setters + // are listed but never change identity, so they never trigger a rebuild. + return useMemo( + () => ({ + chartType, + config, + configKeys, + data, + dataLength: data.length, + stackType, + margins: stableMargins, + plot: { width: plotWidth, height: plotHeight }, + ready, + xCenter, + bandwidth, + indexAtX, + barSlot, + y, + bands, + max, + min, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + markerIndex, + cursorX, + setCursorX, + isMouseInChart, + setMouseInChart, + hovered, + bloom, + bloomOnHover, + seriesSpecs, + registerSeries, + unregisterSeries, + animate, + animationDuration, + revision, + entranceDone, + markEntranceDone, + seedOf, + common, + }), + [ + chartType, + config, + configKeys, + data, + stackType, + stableMargins, + plotWidth, + plotHeight, + ready, + xCenter, + bandwidth, + indexAtX, + barSlot, + y, + bands, + max, + min, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + markerIndex, + cursorX, + setCursorX, + isMouseInChart, + setMouseInChart, + hovered, + bloom, + bloomOnHover, + seriesSpecs, + registerSeries, + unregisterSeries, + animate, + animationDuration, + revision, + entranceDone, + markEntranceDone, + seedOf, + common, + ] + ) +} diff --git a/packages/ui/src/components/dither-kit/common-context.tsx b/packages/ui/src/components/dither-kit/common-context.tsx new file mode 100644 index 000000000..dae0cc208 --- /dev/null +++ b/packages/ui/src/components/dither-kit/common-context.tsx @@ -0,0 +1,48 @@ +"use client" + +import { createContext, use } from "react" +import type { Seed } from "./palette" + +/** A single tooltip row — one series (cartesian/radar) or one slice (pie). */ +export type TooltipItem = { + name: string + label: string + value: number + seed: Seed + dimmed: boolean +} + +/** + * The minimal surface shared by every chart family, so `` and + * `` work identically whether they sit in a cartesian, bar, or polar + * root. Each root publishes one of these alongside its family-specific context. + */ +export type CommonChart = { + names: string[] // legend entries — series keys (cartesian) or slice names (pie) + labelOf: (name: string) => string + seedOf: (name: string) => Seed + selectedDataKey: string | null + selectDataKey: (key: string | null) => void + /** Transient legend-hover emphasis — spotlights one series (others dim) + * while the pointer rests on its legend entry. Selection still wins. */ + focusDataKey: string | null + setFocusDataKey: (key: string | null) => void + hoverIndex: number | null + heading: (index: number, labelKey?: string) => string | null + itemsAt: (index: number) => TooltipItem[] + ready: boolean + tooltipLeft: number // clamped px for the floating tooltip + tooltipTop: number // px — follows the hovered node (cartesian) / cursor (polar) +} + +export const CommonChartContext = createContext(null) + +export function useCommonChart() { + const ctx = use(CommonChartContext) + if (!ctx) { + throw new Error( + " / must be used within a chart root." + ) + } + return ctx +} diff --git a/packages/ui/src/components/dither-kit/dither-paint.ts b/packages/ui/src/components/dither-kit/dither-paint.ts new file mode 100644 index 000000000..0bdd5a9ec --- /dev/null +++ b/packages/ui/src/components/dither-kit/dither-paint.ts @@ -0,0 +1,177 @@ +import type { AreaVariant } from "./chart-context" +import { rgb, type Seed } from "./palette" + +// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the exact matrix +// the legacy chart dithers with. +export const BAYER = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +].map((row) => row.map((v) => (v + 0.5) / 16)) + +export const CELL = 2 // css px per dither cell — chunky enough to read pixelated +export const MAX_COLS = 520 +export const MAX_ROWS = 200 +// Opacity of the top border outline (just under solid, so it reads as a soft +// edge rather than a hard line). See the note on colour vs opacity below. +export const BORDER_ALPHA = 0.72 +// Opacity of a dither "off" cell relative to an "on" cell. The scatter modulates +// between these two tiers of the *same* colour instead of leaving holes, so the +// background never shows through as stark white on a light theme. +export const OFF_TIER = 0.4 + +export type PaintOpts = { + variant: AreaVariant + intensity: number // 0–1 hover lift + dim: number // selection dim multiplier (0.3 dimmed, 1 normal) + stacked: boolean // denser + solid floor when layers stack + sparse?: number // raise the dither threshold (thin out) — front layers +} + +// Colour vs opacity — the guiding rule for the whole engine: +// +// Work with opacities instead of different shades of the same color. This will +// make sure it looks good on both light and dark mode. +// +// So every pixel is the series' single `fill` colour and we vary only its alpha. +// The old lighter `line` / near-white `star` shades were dropped: a shade that +// pops on a dark background reads as a jarring bright speck on a light one, while +// the same colour at a lower opacity simply blends into whatever sits behind it. + +/** + * Fill one backing-canvas column `x` from row `top` down to `floor` with the + * ordered-dither scatter — solid at the floor, dissolving upward so it *fades + * out toward the value line* — then cap the top with a soft border outline in + * the series colour. Density drives opacity (see the note above), so the fade + * reads correctly against both light and dark backgrounds. The single source of + * the dither look across area / line / bar. + */ +export function paintColumn( + octx: CanvasRenderingContext2D, + x: number, + top: number, + floor: number, + seed: Seed, + { variant, intensity, dim, stacked, sparse = 0 }: PaintOpts +) { + const t = Math.round(top) + const f = Math.round(floor) + const depth = f - t + if (depth <= 0) { + octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim) + octx.fillRect(x, t, 1, 1) + return + } + const bias = (variant === "dotted" ? 0.12 : 0) + (stacked ? 0.2 : 0) - sparse + for (let y = t; y < f; y++) { + // Inverted falloff: 0 at the top line, 1 at the floor — dense at the + // bottom, thinning as it rises toward the outline. + let density = (y - t) / depth + if (stacked) density = 0.5 + 0.5 * density + if (variant === "hatched" && ((x + y) & 3) >= 2) continue + const lit = + variant === "solid" || + density > (BAYER[y & 3]?.[x & 3] ?? 0) - 0.1 * intensity - bias + // "dotted" keeps real gaps for its open look; every other variant covers + // the cell and lets the dither ride the alpha (on = full tier, off = a + // faint tint) so nothing shows the background through as white. + if (variant === "dotted" && !lit) continue + // Density → alpha (see the colour-vs-opacity note above). + const k = (0.3 + density * 0.7) * (1 + 0.22 * intensity) + const alpha = clamp01((lit ? k : k * OFF_TIER) * dim) + octx.fillStyle = rgb(seed.fill, 1, alpha) + octx.fillRect(x, y, 1, 1) + } + // Top border outline — the shape's edge now that the fill fades out here. + // Kept just under full opacity, with a faint feather row beneath, so it reads + // as a soft edge rather than a hard line floating over the fade. + octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim) + octx.fillRect(x, t, 1, 1) + if (depth > 1) { + octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * 0.5 * dim) + octx.fillRect(x, t + 1, 1, 1) + } +} + +/** Linear-resample a per-index fraction array to `cols` columns. */ +export function resample(src: number[], cols: number): number[] { + const out = new Array(cols) + const last = Math.max(src.length - 1, 1) + for (let c = 0; c < cols; c++) { + const t = (c / Math.max(cols - 1, 1)) * last + const i = Math.floor(t) + const f = t - i + const a = src[i] ?? 0 + const b = src[Math.min(i + 1, src.length - 1)] ?? a + out[c] = a + (b - a) * f + } + return out +} + +/** Backing-canvas resolution for a plot rect — low-res, scaled up `pixelated`. */ +export function backingSize(width: number, height: number) { + return { + cols: Math.min(MAX_COLS, Math.max(8, Math.round(width / CELL))), + rows: Math.min(MAX_ROWS, Math.max(8, Math.round(height / CELL))), + } +} + +// Bloom — a real "shader" glow that comes from the colours themselves: a blurred +// copy of the rendered canvas, composited additively (`plus-lighter`) so each +// hue blooms in its own colour instead of a grey wash. Lives on a second canvas +// layered over the crisp one (which stays sharp/pixelated). +export type BloomLevel = "off" | "low" | "high" | "aura" +export type BloomBlend = "plus-lighter" | "screen" | "lighten" +export type BloomConfig = { + blur: number // px + brightness: number // 1 = none + opacity: number // 0–1 + /** Saturation of the glow — >1 keeps it vividly in the dither's colour + * instead of washing toward white. */ + saturate?: number + blend?: BloomBlend // additive by default +} +/** A preset name, a full config, or "off". */ +export type BloomInput = BloomLevel | BloomConfig + +const PRESET: Record, BloomConfig> = { + low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 }, + high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 }, + aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 }, +} + +export type BloomStyle = { + filter: string + opacity: number + mixBlendMode: BloomBlend + imageRendering: "auto" +} + +/** Style for the bloom *layer* canvas (a blurred, additive copy). null when off. */ +export function bloomLayerStyle( + input: BloomInput, + active: boolean +): BloomStyle | null { + if (!active || input === "off") return null + const cfg = typeof input === "string" ? PRESET[input] : input + return { + filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate ?? 1})`, + opacity: cfg.opacity, + mixBlendMode: cfg.blend ?? "plus-lighter", + imageRendering: "auto", + } +} + +// Easing — gentle start + soft settle so entrances don't feel linear. +export const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2 +export const easeOutCubic = (t: number) => 1 - (1 - t) ** 3 +export const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t) + +/** Whether the OS asks for reduced motion (snap + steady stars). */ +export function prefersReducedMotion() { + return ( + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false + ) +} diff --git a/packages/ui/src/components/dither-kit/dot.tsx b/packages/ui/src/components/dither-kit/dot.tsx new file mode 100644 index 000000000..07eb2ea30 --- /dev/null +++ b/packages/ui/src/components/dither-kit/dot.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useChart } from "./chart-context" +import { rgb, type Seed } from "./palette" +import { useSeries } from "./series-context" + +export type DotVariant = "border" | "colored-border" | "filled" + +function dotPaint(variant: DotVariant, seed: Seed) { + switch (variant) { + case "colored-border": + return { + fill: "var(--card, #0b0b0c)", + stroke: rgb(seed.line), + strokeWidth: 1.5, + } + case "filled": + return { fill: rgb(seed.star), stroke: rgb(seed.line), strokeWidth: 1 } + default: + return { + fill: "var(--card, #0b0b0c)", + stroke: rgb(seed.star, 0.8), + strokeWidth: 1, + } + } +} + +/** A marker at every data point along the series' top line. */ +export function Dot({ + variant = "border", + r = 2, +}: { + variant?: DotVariant + r?: number +}) { + const ctx = useChart() + const { dataKey, seed } = useSeries("Dot") + const band = ctx.bands[dataKey] + if (!ctx.ready || !band) return null + const paint = dotPaint(variant, seed) + + return ( + // Fade in once the fill has drawn so dots don't float over the entrance. + + {band.map((b, i) => ( + + ))} + + ) +} + +/** A single marker at the hovered point — keys off the shared hover index. */ +export function ActiveDot({ + variant = "colored-border", + r = 3, +}: { + variant?: DotVariant + r?: number +}) { + const ctx = useChart() + const { dataKey, seed } = useSeries("ActiveDot") + const band = ctx.bands[dataKey] + if (!ctx.ready || !band || ctx.hoverIndex == null || !ctx.entranceDone) + return null + const b = band[ctx.hoverIndex] + if (!b) return null + const paint = dotPaint(variant, seed) + const cx = ctx.xCenter(ctx.hoverIndex) + const cy = ctx.y(b[1]) + + return ( + + {/* Soft halo so the active point is unmistakable over the dither. */} + + + + ) +} diff --git a/packages/ui/src/components/dither-kit/grid.tsx b/packages/ui/src/components/dither-kit/grid.tsx new file mode 100644 index 000000000..b781d788e --- /dev/null +++ b/packages/ui/src/components/dither-kit/grid.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useChartPart } from "./chart-context" + +export function Grid({ + horizontal = true, + vertical = false, + strokeDasharray = "3 3", +}: { + horizontal?: boolean + vertical?: boolean + strokeDasharray?: string +}) { + const ctx = useChartPart("Grid") + if (!ctx.ready) return null + const { width } = ctx.plot + + return ( + + {horizontal && + ctx.y + .ticks(4) + .map((t) => ( + + ))} + {vertical && + ctx.data.map((_, i) => ( + + ))} + + ) +} + +// Render beneath the dither canvas so grid lines sit behind the fill. +Grid.chartLayer = "back" as const diff --git a/packages/ui/src/components/dither-kit/legend.tsx b/packages/ui/src/components/dither-kit/legend.tsx new file mode 100644 index 000000000..f6ced6823 --- /dev/null +++ b/packages/ui/src/components/dither-kit/legend.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useCommonChart } from "./common-context" +import { cn } from "./lib" +import { rgb } from "./palette" + +/** Series/slice legend. With `isClickable`, each entry toggles its selection. + * Works in every chart family via the shared common context. + * + * Note: this is an absolute overlay pinned to the top of the plot, so it's best + * for ≤2–3 entries. With more entries (or a narrow container) it wraps onto + * extra rows that overlay the chart — reach for the in-flow `` + * instead, which renders as a sibling and can't overlap at any width. */ +export function Legend({ + isClickable = false, + align = "right", +}: { + isClickable?: boolean + align?: "left" | "center" | "right" +}) { + const chart = useCommonChart() + + return ( +
+ {chart.names.map((name) => { + const seed = chart.seedOf(name) + const emphasis = chart.selectedDataKey ?? chart.focusDataKey + const dimmed = emphasis !== null && emphasis !== name + return ( + + ) + })} +
+ ) +} + +Legend.chartLayer = "dom" as const diff --git a/packages/ui/src/components/dither-kit/lib.ts b/packages/ui/src/components/dither-kit/lib.ts new file mode 100644 index 000000000..da71254e2 --- /dev/null +++ b/packages/ui/src/components/dither-kit/lib.ts @@ -0,0 +1,8 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +/** Tailwind-aware className combiner — local copy so the chart pack is + * self-contained and portable as a registry. */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/packages/ui/src/components/dither-kit/palette.ts b/packages/ui/src/components/dither-kit/palette.ts new file mode 100644 index 000000000..93faa58ea --- /dev/null +++ b/packages/ui/src/components/dither-kit/palette.ts @@ -0,0 +1,40 @@ +export type Rgb = [number, number, number] + +export type DitherColor = + | "green" + | "blue" + | "purple" + | "pink" + | "orange" + | "red" + | "grey" + +export type Seed = { fill: Rgb; line: Rgb; star: Rgb } + +// Each seed: the area-fill hue, the bright series line, and the star sparkle. +export const PALETTE: Record = { + green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] }, + blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] }, + purple: { + fill: [150, 110, 255], + line: [200, 175, 255], + star: [225, 210, 255], + }, + pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] }, + orange: { + fill: [255, 150, 50], + line: [255, 195, 130], + star: [255, 220, 175], + }, + red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] }, + // No-data: a muted grey so empty metrics read as "nothing here". + grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] }, +} + +export const rgb = ([r, g, b]: Rgb, k = 1, a = 1) => + `rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})` + +export const seedOfColor = (color: DitherColor): Seed => PALETTE[color] + +export const isDitherColor = (value: unknown): value is DitherColor => + typeof value === "string" && value in PALETTE diff --git a/packages/ui/src/components/dither-kit/pixel.ts b/packages/ui/src/components/dither-kit/pixel.ts new file mode 100644 index 000000000..3a0dbef66 --- /dev/null +++ b/packages/ui/src/components/dither-kit/pixel.ts @@ -0,0 +1,109 @@ +import { type DitherColor, PALETTE, type Rgb } from "./palette" + +// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the same matrix +// the charts dither with. +export const BAYER4 = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +].map((row) => row.map((v) => (v + 0.5) / 16)) + +export const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t) + +/** 32-bit FNV-1a hash — turns any string seed into a stable uint32. */ +export function fnv1a(str: string): number { + let h = 0x811c9dc5 + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return h >>> 0 +} + +/** Tiny deterministic PRNG (xorshift32) — returns floats in [0, 1). */ +export function xorshift32(seed: number): () => number { + let s = seed || 0x9e3779b9 + return () => { + s ^= s << 13 + s >>>= 0 + s ^= s >>> 17 + s ^= s << 5 + s >>>= 0 + return s / 0x100000000 + } +} + +/** A named palette colour or a raw hue (0–360). */ +export type PixelColor = DitherColor | number + +/** Hue (0–360) → an rgb fill tuned to sit alongside the chart palette. */ +export function hueFill(hue: number): Rgb { + const h = ((hue % 360) + 360) % 360 + const s = 0.85 + const l = 0.58 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + const [r, g, b] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x] + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ] +} + +/** Resolve a {@link PixelColor} to its rgb fill. */ +export function fillOf(color: PixelColor): Rgb { + return typeof color === "number" ? hueFill(color) : PALETTE[color].fill +} + +// Bloom — same recipe as the charts: a blurred copy of the crisp canvas, +// composited additively so the glow stays in the dither's own colour. +export type PixelBloom = "off" | "low" | "high" | "aura" + +const BLOOM_PRESET: Record< + Exclude, + { blur: number; brightness: number; opacity: number; saturate: number } +> = { + low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 }, + high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 }, + aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 }, +} + +export type PixelBloomStyle = { + filter: string + opacity: number + mixBlendMode: "plus-lighter" + imageRendering: "auto" +} + +/** Style for the bloom layer canvas. null when off. */ +export function pixelBloomStyle(bloom: PixelBloom): PixelBloomStyle | null { + if (bloom === "off") return null + const cfg = BLOOM_PRESET[bloom] + return { + filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate})`, + opacity: cfg.opacity, + mixBlendMode: "plus-lighter", + imageRendering: "auto", + } +} + +/** Whether the OS asks for reduced motion (skip entrances). */ +export function pixelPrefersReducedMotion(): boolean { + return ( + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false + ) +} diff --git a/packages/ui/src/components/dither-kit/polar-context.tsx b/packages/ui/src/components/dither-kit/polar-context.tsx new file mode 100644 index 000000000..0fd06b32d --- /dev/null +++ b/packages/ui/src/components/dither-kit/polar-context.tsx @@ -0,0 +1,382 @@ +"use client" + +import { createContext, use, useCallback, useMemo, useState } from "react" +import { + type AreaVariant, + type ChartConfig, + type ChartType, + type Margins, + useRevision, +} from "./chart-context" +import type { CommonChart } from "./common-context" +import type { BloomInput } from "./dither-paint" +import type { Seed } from "./palette" +import { seedOfColor } from "./palette" +import { type PieSlice, pieSlices, type RadarAxis, radarAxes } from "./polar" +import type { Dimensions } from "./use-chart-dimensions" + +type Row = Record + +const ROOT_OF: Record = { + pie: "", + radar: "", +} + +export type PolarChartContextValue = { + chartType: ChartType + config: ChartConfig + configKeys: string[] + data: Row[] + dataLength: number + ready: boolean + plot: { width: number; height: number } + margins: Margins + center: { x: number; y: number } + outerRadius: number + innerRadius: number + animate: boolean + animationDuration: number + revision: number + bloom: BloomInput + bloomOnHover: boolean + + seedOf: (key: string) => Seed + variantOf: (key: string) => AreaVariant + registerVariant: (key: string, variant: AreaVariant) => void + unregisterVariant: (key: string) => void + + selectedDataKey: string | null + selectDataKey: (key: string | null) => void + /** Legend-hover spotlight — dims every series but this one while set. */ + focusDataKey: string | null + setFocusDataKey: (key: string | null) => void + hoverIndex: number | null + setHoverIndex: (i: number | null) => void + setCursor: (px: number, py: number) => void + isMouseInChart: boolean + setMouseInChart: (over: boolean) => void + + pie: PieSlice[] | null // present for pie charts + radar: { axes: RadarAxis[]; max: number } | null // present for radar charts + + common: CommonChart +} + +const PolarChartContext = createContext(null) + +export function usePolarChart() { + const ctx = use(PolarChartContext) + if (!ctx) { + throw new Error("Polar chart parts must be used within a polar chart root.") + } + return ctx +} + +/** Boundary guard for polar parts (``, ``). */ +export function usePolarPart(part: string, kind: "pie" | "radar") { + const ctx = use(PolarChartContext) + if (!ctx) { + throw new Error(`<${part} /> must be used within ${ROOT_OF[kind]}.`) + } + if (ctx.chartType !== kind) { + throw new Error( + `<${part} /> is not valid inside ${ROOT_OF[ctx.chartType]} — it belongs in ${ROOT_OF[kind]}.` + ) + } + return ctx +} + +export { PolarChartContext } + +export function usePolarController({ + chartType, + data, + config, + dataKey, + nameKey, + innerRadiusRatio, + dimensions, + margins, + animate = true, + animationDuration = 900, + replayToken = 0, + bloom = "off", + bloomOnHover = false, + defaultSelectedDataKey = null, + onSelectionChange, +}: { + chartType: "pie" | "radar" + data: Row[] + config: ChartConfig + dataKey: string + nameKey: string + innerRadiusRatio: number + dimensions: Dimensions + margins: Margins + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +}): PolarChartContextValue { + // This object becomes the PolarChartContext value, so its identity — and the + // identity of every function/object it carries — must stay stable across + // renders that don't change the inputs; otherwise every consumer (legend, + // tooltip, slices, axes) re-renders on every parent render. The expensive + // derivations, exposed callbacks, and returned value are memoized below; + // cheap scalars (radii, ready) are left bare as plain recomputed reads. + + // Memoized: drives `pie`/`radar`/`common` — a fresh array would bust them. + const configKeys = useMemo(() => Object.keys(config), [config]) + const revision = useRevision(data, replayToken) + + const [selectedDataKey, setSelectedDataKey] = useState( + defaultSelectedDataKey + ) + const [focusDataKey, setFocusDataKey] = useState(null) + const [hoverIndex, setHoverIndex] = useState(null) + const [cursorX, setCursorX] = useState(0) + const [cursorY, setCursorY] = useState(0) + const [isMouseInChart, setMouseInChart] = useState(false) + // Stable (only wraps two useState setters) so the value keeps its identity. + const setCursor = useCallback((px: number, py: number) => { + setCursorX(px) + setCursorY(py) + }, []) + const [variants, setVariants] = useState>({}) + + // useCallback for the same reason as registerSeries in chart-context.tsx: + // pie.tsx/radar.tsx list these as effect deps, so without stable identities + // the unregister/register effect re-fires and its setState pair loops. + const registerVariant = useCallback((key: string, variant: AreaVariant) => { + setVariants((prev) => + prev[key] === variant ? prev : { ...prev, [key]: variant } + ) + }, []) + const unregisterVariant = useCallback((key: string) => { + setVariants((prev) => { + if (!(key in prev)) return prev + const next = { ...prev } + delete next[key] + return next + }) + }, []) + + // Stable so the value keeps its identity; re-created only on config change. + const selectDataKey = useCallback( + (key: string | null) => { + setSelectedDataKey(key) + onSelectionChange?.(key) + }, + [onSelectionChange] + ) + + // The root spreads margins fresh every render; pin a stable object off the + // four numbers so it doesn't, on its own, invalidate the value. + const { top: mTop, right: mRight, bottom: mBottom, left: mLeft } = margins + const stableMargins = useMemo( + () => ({ top: mTop, right: mRight, bottom: mBottom, left: mLeft }), + [mTop, mRight, mBottom, mLeft] + ) + + const plotWidth = Math.max(0, dimensions.width - mLeft - mRight) + const plotHeight = Math.max(0, dimensions.height - mTop - mBottom) + const ready = plotWidth > 0 && plotHeight > 0 + const pad = chartType === "radar" ? 20 : 6 + const outerRadius = Math.max(0, Math.min(plotWidth, plotHeight) / 2 - pad) + const innerRadius = chartType === "pie" ? outerRadius * innerRadiusRatio : 0 + const centerX = plotWidth / 2 + const centerY = plotHeight / 2 + + // Stable so `common` and the value stay stable; re-created only on config. + const seedOf = useCallback( + (key: string) => seedOfColor(config[key]?.color ?? "grey"), + [config] + ) + // "*" is the pie-wide variant set by ; radar registers per series key. + const variantOf = useCallback( + (key: string) => variants[key] ?? variants["*"] ?? "gradient", + [variants] + ) + + // Memoized: slice geometry — recomputing it on every hover/cursor tick would + // rebuild the pie layout needlessly. + const pie = useMemo( + () => (chartType === "pie" ? pieSlices(data, dataKey, nameKey) : null), + [chartType, data, dataKey, nameKey] + ) + + // Memoized: walks every row × series for the axis max, then builds the axes. + const radar = useMemo(() => { + if (chartType !== "radar") return null + let max = 0 + for (const row of data) { + for (const key of configKeys) { + const v = Number(row[key]) || 0 + if (v > max) max = v + } + } + return { axes: radarAxes(data, nameKey), max: max || 1 } + }, [chartType, data, configKeys, nameKey]) + + // Memoized: this is the value handed to CommonChartContext (Legend/Tooltip), + // so it needs its own stable identity independent of the parent value. + const common: CommonChart = useMemo(() => { + const tooltipLeft = Math.max(48, Math.min(plotWidth + mLeft - 48, cursorX)) + const tooltipTop = Math.max(mTop + 44, cursorY) + const emphasis = selectedDataKey ?? focusDataKey + if (chartType === "pie" && pie) { + const names = pie.map((s) => s.name) + return { + names, + tooltipTop, + labelOf: (n) => config[n]?.label ?? n, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + tooltipLeft, + heading: (i) => pie[i]?.name ?? null, + itemsAt: (i) => { + const s = pie[i] + if (!s) return [] + return [ + { + name: s.name, + label: config[s.name]?.label ?? s.name, + value: s.value, + seed: seedOf(s.name), + dimmed: emphasis !== null && emphasis !== s.name, + }, + ] + }, + } + } + // radar + return { + names: configKeys, + tooltipTop, + labelOf: (n) => config[n]?.label ?? n, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + tooltipLeft, + heading: (i) => radar?.axes[i]?.label ?? null, + itemsAt: (i) => + configKeys.map((name) => { + const raw = data[i]?.[name] + return { + name, + label: config[name]?.label ?? name, + value: typeof raw === "number" ? raw : 0, + seed: seedOf(name), + dimmed: emphasis !== null && emphasis !== name, + } + }), + } + }, [ + chartType, + config, + configKeys, + data, + pie, + radar, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + plotWidth, + mLeft, + mTop, + cursorX, + cursorY, + ]) + + // Memoized: this is the PolarChartContext value. A fresh object here would + // re-render every consumer on every parent render — the reason the pieces + // above are stabilized. Rebuilds only when a listed input changes. The + // useState setters are listed but never change identity. + return useMemo( + () => ({ + chartType, + config, + configKeys, + data, + dataLength: data.length, + ready, + plot: { width: plotWidth, height: plotHeight }, + margins: stableMargins, + center: { x: centerX, y: centerY }, + outerRadius, + innerRadius, + animate, + animationDuration, + revision, + bloom, + bloomOnHover, + seedOf, + variantOf, + registerVariant, + unregisterVariant, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + setCursor, + isMouseInChart, + setMouseInChart, + pie, + radar, + common, + }), + [ + chartType, + config, + configKeys, + data, + ready, + plotWidth, + plotHeight, + stableMargins, + centerX, + centerY, + outerRadius, + innerRadius, + animate, + animationDuration, + revision, + bloom, + bloomOnHover, + seedOf, + variantOf, + registerVariant, + unregisterVariant, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + setCursor, + isMouseInChart, + setMouseInChart, + pie, + radar, + common, + ] + ) +} diff --git a/packages/ui/src/components/dither-kit/polar-root.tsx b/packages/ui/src/components/dither-kit/polar-root.tsx new file mode 100644 index 000000000..0b393778b --- /dev/null +++ b/packages/ui/src/components/dither-kit/polar-root.tsx @@ -0,0 +1,173 @@ +"use client" + +import { + Children, + type ComponentType, + isValidElement, + type ReactNode, +} from "react" +import type { ChartConfig, Margins } from "./chart-context" +import { CommonChartContext } from "./common-context" +import type { BloomInput } from "./dither-paint" +import { cn } from "./lib" +import { axisAtAngle, sliceAtAngle } from "./polar" +import { PolarChartContext, usePolarController } from "./polar-context" +import { useChartDimensions } from "./use-chart-dimensions" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +const DEFAULT_POLAR_MARGINS: Margins = { + top: 22, + right: 14, + bottom: 14, + left: 14, +} + +function layerOf(node: ReactNode): "back" | "dom" | "svg" { + if (!isValidElement(node) || typeof node.type === "string") return "svg" + return (node.type as { chartLayer?: "back" | "dom" }).chartLayer ?? "svg" +} + +export type PolarRootProps = { + chartType: "pie" | "radar" + /** Family painter — `PieCanvas` or `RadarCanvas`; ships with each chart. */ + Canvas: ComponentType + /** Extra back-layer SVG content (e.g. the radar frame). */ + backDecoration?: ReactNode + data: TData[] + config: ChartConfig + children: ReactNode + dataKey: string + nameKey: string + innerRadius?: number // 0–1 ratio (donut); pie only + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +export function PolarRoot({ + chartType, + Canvas, + backDecoration, + data, + config, + children, + dataKey, + nameKey, + innerRadius = 0, + margins: marginsProp, + className, + animate = true, + animationDuration = 900, + replayToken = 0, + bloom = "off", + bloomOnHover = false, + defaultSelectedDataKey = null, + onSelectionChange, +}: PolarRootProps) { + const { ref, size } = useChartDimensions() + const margins = { ...DEFAULT_POLAR_MARGINS, ...marginsProp } + + const ctx = usePolarController({ + chartType, + // Safe: the controller only reads row[key] for the configured keys. + data: data as Record[], + config, + dataKey, + nameKey, + innerRadiusRatio: innerRadius, + dimensions: size, + margins, + animate, + animationDuration, + replayToken, + bloom, + bloomOnHover, + defaultSelectedDataKey, + onSelectionChange, + }) + + const backChildren: ReactNode[] = [] + const svgChildren: ReactNode[] = [] + const domChildren: ReactNode[] = [] + Children.forEach(children, (child) => { + const layer = layerOf(child) + if (layer === "back") backChildren.push(child) + else if (layer === "dom") domChildren.push(child) + else svgChildren.push(child) + }) + + const onMove = (clientX: number, clientY: number) => { + const el = ref.current + if (!el) return + const rect = el.getBoundingClientRect() + const dx = clientX - rect.left - margins.left - ctx.center.x + const dy = clientY - rect.top - margins.top - ctx.center.y + const angle = Math.atan2(dy, dx) + const r = Math.hypot(dx, dy) + if (chartType === "pie" && ctx.pie) { + const inside = r <= ctx.outerRadius && r >= ctx.innerRadius + const i = inside ? sliceAtAngle(ctx.pie, angle) : -1 + ctx.setHoverIndex(i >= 0 ? i : null) + } else if (ctx.radar) { + ctx.setHoverIndex(axisAtAngle(ctx.radar.axes, angle)) + } + ctx.setCursor(clientX - rect.left, clientY - rect.top) + } + + return ( + + +
ctx.setMouseInChart(true)} + onPointerMove={(e) => onMove(e.clientX, e.clientY)} + onPointerLeave={() => { + ctx.setMouseInChart(false) + ctx.setHoverIndex(null) + }} + > + {ctx.ready && ( + + + {backDecoration} + {backChildren} + + + )} + + {ctx.ready && ( + + + {svgChildren} + + + )} + {domChildren} +
+
+
+ ) +} diff --git a/packages/ui/src/components/dither-kit/polar.ts b/packages/ui/src/components/dither-kit/polar.ts new file mode 100644 index 000000000..d92a80741 --- /dev/null +++ b/packages/ui/src/components/dither-kit/polar.ts @@ -0,0 +1,122 @@ +type Row = Record + +const TOP = -Math.PI / 2 +const TAU = Math.PI * 2 + +export type PieSlice = { + name: string + value: number + start: number // radians + end: number + mid: number +} + +/** Slice angles from each data row's value under `dataKey`, named by `nameKey`. */ +export function pieSlices( + data: Row[], + dataKey: string, + nameKey: string +): PieSlice[] { + const vals = data.map((r) => Math.max(0, Number(r[dataKey]) || 0)) + const total = vals.reduce((a, b) => a + b, 0) || 1 + let a = TOP + return data.map((r, i) => { + const span = ((vals[i] ?? 0) / total) * TAU + const slice = { + name: String(r[nameKey] ?? i), + value: vals[i] ?? 0, + start: a, + end: a + span, + mid: a + span / 2, + } + a += span + return slice + }) +} + +/** Which slice a pointer angle falls in (or -1). */ +export function sliceAtAngle(slices: PieSlice[], angle: number): number { + // Normalize so comparisons against [start, end) (which begin at TOP) work. + let a = angle + while (a < TOP) a += TAU + while (a >= TOP + TAU) a -= TAU + return slices.findIndex((s) => a >= s.start && a < s.end) +} + +export type RadarAxis = { label: string; angle: number } + +/** Evenly-spaced spokes, one per data row, labelled by `nameKey`. */ +export function radarAxes(data: Row[], nameKey: string): RadarAxis[] { + const n = Math.max(data.length, 1) + return data.map((r, i) => ({ + label: String(r[nameKey] ?? i), + angle: TOP + (i / n) * TAU, + })) +} + +/** Nearest radar spoke to a pointer angle. */ +export function axisAtAngle(axes: RadarAxis[], angle: number): number { + let best = 0 + let bestD = Infinity + axes.forEach((ax, i) => { + let d = Math.abs(((angle - ax.angle + Math.PI * 3) % TAU) - Math.PI) + d = Math.abs(d) + if (d < bestD) { + bestD = d + best = i + } + }) + return best +} + +export const polarX = (cx: number, r: number, angle: number) => + cx + Math.cos(angle) * r +export const polarY = (cy: number, r: number, angle: number) => + cy + Math.sin(angle) * r + +/** Even-odd point-in-polygon test (polygon as flat [x0,y0,x1,y1,…]). */ +export function pointInPolygon( + px: number, + py: number, + poly: number[] +): boolean { + let inside = false + const n = poly.length / 2 + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = poly[i * 2] ?? 0 + const yi = poly[i * 2 + 1] ?? 0 + const xj = poly[j * 2] ?? 0 + const yj = poly[j * 2 + 1] ?? 0 + if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) { + inside = !inside + } + } + return inside +} + +/** Distance from a point to the nearest edge of a polygon — drives the radial + * dither density (dense near the edge, thinning to the centre). */ +export function distToPolygonEdge( + px: number, + py: number, + poly: number[] +): number { + let best = Infinity + const n = poly.length / 2 + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = poly[i * 2] ?? 0 + const yi = poly[i * 2 + 1] ?? 0 + const xj = poly[j * 2] ?? 0 + const yj = poly[j * 2 + 1] ?? 0 + const dx = xj - xi + const dy = yj - yi + const len2 = dx * dx + dy * dy || 1 + let t = ((px - xi) * dx + (py - yi) * dy) / len2 + t = Math.max(0, Math.min(1, t)) + const ex = xi + t * dx - px + const ey = yi + t * dy - py + const d = Math.hypot(ex, ey) + if (d < best) best = d + } + return best +} diff --git a/packages/ui/src/components/dither-kit/reference-line.tsx b/packages/ui/src/components/dither-kit/reference-line.tsx new file mode 100644 index 000000000..76297941f --- /dev/null +++ b/packages/ui/src/components/dither-kit/reference-line.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useChartPart } from "./chart-context" + +/** + * A horizontal marker line at a value on the y-axis — most useful as the zero + * baseline for diverging data (``), or to mark a target + * / threshold. Renders in the front SVG layer so it stays visible over the + * dither fill; pass an optional `label` to annotate it at the right edge. + */ +export function ReferenceLine({ + y = 0, + label, + strokeDasharray = "4 4", + className = "stroke-muted-foreground/60", +}: { + y?: number + label?: string + strokeDasharray?: string + className?: string +}) { + const ctx = useChartPart("ReferenceLine") + if (!ctx.ready) return null + + const { width } = ctx.plot + const py = ctx.y(y) + + return ( + + + {label ? ( + + {label} + + ) : null} + + ) +} diff --git a/packages/ui/src/components/dither-kit/scales.ts b/packages/ui/src/components/dither-kit/scales.ts new file mode 100644 index 000000000..ed0fd2fcf --- /dev/null +++ b/packages/ui/src/components/dither-kit/scales.ts @@ -0,0 +1,109 @@ +import { scaleBand, scaleLinear, scalePoint } from "d3-scale" +import { stack as d3Stack, stackOffsetExpand } from "d3-shape" + +export type StackType = "default" | "stacked" | "percent" + +type Row = Record + +const num = (v: unknown) => + typeof v === "number" && Number.isFinite(v) ? v : 0 + +/** + * Per-series [y0, y1] bands for every row. For `default` every series sits on + * the zero baseline (y0 = 0), so a negative value yields `[0, v]` with `v < 0` + * and draws below the baseline; for `stacked`/`percent` they pile on top of + * each other via d3's stack layout (which splits negatives below zero). The + * shape `bands[key][i] = [y0, y1]` is what both the SVG area paths and the + * canvas overlay read from. `max`/`min` bound the value range so the y-scale + * can span a diverging (below-zero) domain. + */ +export function computeBands( + data: Row[], + keys: string[], + stackType: StackType +): { bands: Record; max: number; min: number } { + if (stackType === "default") { + const bands: Record = {} + let max = 0 + let min = 0 + for (const key of keys) { + bands[key] = data.map((row) => { + const v = num(row[key]) + if (v > max) max = v + if (v < min) min = v + return [0, v] + }) + } + // Only fall back to a unit span when there's no range at all (empty / + // all-zero) — a purely negative series keeps max = 0 so the baseline + // stays pinned to the top of the plot. + const flat = max === 0 && min === 0 + return { bands, max: flat ? 1 : max, min } + } + + const series = d3Stack() + .keys(keys) + .value((row, key) => num(row[key])) + .offset(stackType === "percent" ? stackOffsetExpand : (undefined as never))( + data + ) + + const bands: Record = {} + let max = 0 + let min = 0 + series.forEach((layer) => { + bands[layer.key] = layer.map((point) => { + if (point[1] > max) max = point[1] + if (point[0] < min) min = point[0] + return [point[0], point[1]] + }) + }) + const flat = max === 0 && min === 0 + return { bands, max: flat ? 1 : max, min } +} + +/** x positions for each row index, evenly spread across the plot width. */ +export function buildXScale(length: number, plotWidth: number) { + return scalePoint() + .domain(Array.from({ length }, (_, i) => i)) + .range([0, plotWidth]) +} + +/** Banded x for bar categories — each index owns a slot of `bandwidth` width. */ +export function buildBandScale(length: number, plotWidth: number) { + return scaleBand() + .domain(Array.from({ length }, (_, i) => i)) + .range([0, plotWidth]) + .paddingInner(0.28) + .paddingOuter(0.18) +} + +/** Index of the category whose band a horizontal pixel offset falls in. */ +export function indexAtBand(px: number, length: number, plotWidth: number) { + if (length <= 0 || plotWidth <= 0) return 0 + const t = Math.max(0, Math.min(0.999, px / plotWidth)) + return Math.min(length - 1, Math.floor(t * length)) +} + +/** + * value → vertical pixel. The domain always includes zero, so charts with only + * positive values keep a floor at the plot bottom, while diverging data (values + * below zero) draws below a zero baseline that sits somewhere inside the plot. + */ +export function buildYScale(min: number, max: number, plotHeight: number) { + const lo = Math.min(0, min) + const hi = Math.max(0, max) + // Guard a degenerate (zero-width) domain so `nice()` and the range map stay + // finite even when every value is exactly zero. + return scaleLinear() + .domain([lo, hi === lo ? lo + 1 : hi]) + .nice() + .range([plotHeight, 0]) +} + +/** Index of the row nearest a horizontal pixel offset within the plot. */ +export function nearestIndex(px: number, length: number, plotWidth: number) { + if (length <= 1 || plotWidth <= 0) return 0 + const t = Math.max(0, Math.min(1, px / plotWidth)) + return Math.round(t * (length - 1)) +} diff --git a/packages/ui/src/components/dither-kit/series-context.tsx b/packages/ui/src/components/dither-kit/series-context.tsx new file mode 100644 index 000000000..35e287530 --- /dev/null +++ b/packages/ui/src/components/dither-kit/series-context.tsx @@ -0,0 +1,23 @@ +"use client" + +import { createContext, use } from "react" +import type { Seed } from "./palette" + +export type SeriesContextValue = { + dataKey: string + seed: Seed + dimmed: boolean +} + +export const SeriesContext = createContext(null) + +/** Boundary guard for series-scoped markers (``, ``). */ +export function useSeries(part: string) { + const ctx = use(SeriesContext) + if (!ctx) { + throw new Error( + `<${part} /> must be rendered inside a series (e.g. ).` + ) + } + return ctx +} diff --git a/packages/ui/src/components/dither-kit/sparkline.tsx b/packages/ui/src/components/dither-kit/sparkline.tsx new file mode 100644 index 000000000..5afe3ffc5 --- /dev/null +++ b/packages/ui/src/components/dither-kit/sparkline.tsx @@ -0,0 +1,66 @@ +"use client" + +import { useMemo } from "react" +import { Area } from "./area" +import { AreaChart } from "./area-chart" +import type { AreaVariant } from "./chart-context" +import type { BloomInput } from "./dither-paint" +import type { DitherColor } from "./palette" + +export type SparklineProps = { + /** Plain numeric series — the common sparkline case. */ + data: number[] + color: DitherColor + variant?: AreaVariant + /** Controlled crosshair position (e.g. a committed point). */ + markerIndex?: number | null + /** Parent-driven hover (e.g. the whole card/row) — lifts the fill. */ + hovered?: boolean + /** Glow on the dither fill. */ + bloom?: BloomInput + /** Only bloom while hovered. */ + bloomOnHover?: boolean + /** Play the entrance sweep — off by default for a calm spark. */ + animate?: boolean + className?: string +} + +/** + * Thin wrapper over {@link AreaChart} for the decorative-sparkline case: a + * single `number[]` series, no axes/grid/tooltip, no scrub crosshair (unless a + * `markerIndex` is supplied). Keeps the hover brightness lift. + */ +export function Sparkline({ + data, + color, + variant = "gradient", + markerIndex = null, + hovered = false, + bloom = "off", + bloomOnHover = false, + animate = false, + className, +}: SparklineProps) { + // Memoized explicitly so the chart works without React Compiler: `rows` + // identity drives the entrance-replay revision, so a fresh array every + // render would re-trigger the revision's state adjustment each pass. + const rows = useMemo(() => data.map((v) => ({ v })), [data]) + const config = useMemo(() => ({ v: { color } }), [color]) + + return ( + + + + ) +} diff --git a/packages/ui/src/components/dither-kit/tooltip.tsx b/packages/ui/src/components/dither-kit/tooltip.tsx new file mode 100644 index 000000000..03736301a --- /dev/null +++ b/packages/ui/src/components/dither-kit/tooltip.tsx @@ -0,0 +1,116 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" +import { useState } from "react" +import { useCommonChart } from "./common-context" +import { cn } from "./lib" +import { rgb } from "./palette" + +export type TooltipVariant = "default" | "frosted-glass" + +const VARIANT: Record = { + default: "bg-popover", + "frosted-glass": "bg-popover/70 backdrop-blur-sm", +} + +/** + * Floating hover tooltip. Reads the shared common context so it works in every + * chart family. It glides between points and fades in/out (instead of snapping), + * and dims unselected series/slices. + */ +export function Tooltip({ + labelKey, + valueFormatter, + variant = "default", + sortItems = "desc", +}: { + labelKey?: string + valueFormatter?: (value: number, name: string) => string + variant?: TooltipVariant + /** Row ordering: highest value first ("desc"), lowest first ("asc"), or + * series registration order ("none"). */ + sortItems?: "desc" | "asc" | "none" +}) { + const chart = useCommonChart() + const show = chart.ready && chart.hoverIndex != null + + // Retain the last hovered index so the card keeps its content while fading + // out — adjust-state-during-render (no refs in render). + const [lastIndex, setLastIndex] = useState(0) + if (chart.hoverIndex != null && chart.hoverIndex !== lastIndex) { + setLastIndex(chart.hoverIndex) + } + const index = chart.hoverIndex ?? lastIndex + + const heading = chart.heading(index, labelKey) + const rawItems = chart.itemsAt(index) + const items = + sortItems === "none" + ? rawItems + : [...rawItems].sort((a, b) => + sortItems === "desc" ? b.value - a.value : a.value - b.value + ) + + return ( + + {show && items.length > 0 && ( + + {heading && ( +
+ {heading} +
+ )} +
+ {items.map((item) => ( +
+ + {item.label} + + {valueFormatter + ? valueFormatter(item.value, item.name) + : item.value.toLocaleString()} + +
+ ))} +
+
+ )} +
+ ) +} + +Tooltip.chartLayer = "dom" as const diff --git a/packages/ui/src/components/dither-kit/use-chart-dimensions.ts b/packages/ui/src/components/dither-kit/use-chart-dimensions.ts new file mode 100644 index 000000000..6a69a1fad --- /dev/null +++ b/packages/ui/src/components/dither-kit/use-chart-dimensions.ts @@ -0,0 +1,37 @@ +import { useLayoutEffect, useRef, useState } from "react" + +export type Dimensions = { width: number; height: number } + +/** + * Tracks an element's CSS pixel size via {@link ResizeObserver}. Uses + * `clientWidth`/`clientHeight` (the layout size) rather than + * `getBoundingClientRect()` so a parent `layoutId` morph — which scales the + * element via a transform — can't trick the chart into measuring a scaled size + * and locking its canvas to it. + */ +export function useChartDimensions() { + const ref = useRef(null) + const [size, setSize] = useState({ width: 0, height: 0 }) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + + const measure = () => { + const width = Math.max(0, el.clientWidth) + const height = Math.max(0, el.clientHeight) + setSize((prev) => + prev.width === width && prev.height === height + ? prev // guard against repeat fires + : { width, height } + ) + } + + const ro = new ResizeObserver(measure) + ro.observe(el) + measure() + return () => ro.disconnect() + }, []) + + return { ref, size } +} diff --git a/packages/ui/src/components/dither-kit/x-axis.tsx b/packages/ui/src/components/dither-kit/x-axis.tsx new file mode 100644 index 000000000..b9a505c8c --- /dev/null +++ b/packages/ui/src/components/dither-kit/x-axis.tsx @@ -0,0 +1,44 @@ +"use client" + +import { useChartPart } from "./chart-context" + +export function XAxis({ + dataKey, + tickFormatter, + tickMargin = 8, + maxTicks = 8, +}: { + dataKey?: string + tickFormatter?: (value: unknown, index: number) => string + tickMargin?: number + maxTicks?: number +}) { + const ctx = useChartPart("XAxis") + if (!ctx.ready) return null + + const step = Math.max(1, Math.ceil(ctx.dataLength / maxTicks)) + const y = ctx.plot.height + tickMargin + + return ( + + {ctx.data.map((row, i) => { + if (i % step !== 0) return null + const raw = dataKey ? row[dataKey] : i + const label = tickFormatter ? tickFormatter(raw, i) : String(raw ?? "") + return ( + + {label} + + ) + })} + + ) +} diff --git a/packages/ui/src/components/dither-kit/y-axis.tsx b/packages/ui/src/components/dither-kit/y-axis.tsx new file mode 100644 index 000000000..b03db570c --- /dev/null +++ b/packages/ui/src/components/dither-kit/y-axis.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useChartPart } from "./chart-context" + +export function YAxis({ + tickFormatter, + tickCount = 4, + tickMargin = 8, +}: { + tickFormatter?: (value: number) => string + tickCount?: number + tickMargin?: number +}) { + const ctx = useChartPart("YAxis") + if (!ctx.ready) return null + + return ( + + {ctx.y.ticks(tickCount).map((t) => ( + + {tickFormatter ? tickFormatter(t) : t} + + ))} + + ) +} diff --git a/turbo.json b/turbo.json index abdf95758..7d0302790 100644 --- a/turbo.json +++ b/turbo.json @@ -44,6 +44,8 @@ "UNKEY_ROOT_KEY", "UNKEY_API_ID", "TWITTER_BEARER_TOKEN", + "TINYBIRD_TOKEN", + "TINYBIRD_BASE_URL", "POST_FOR_ME_API_KEY", "POST_FOR_ME_API_KEY_TWITTER", "POST_FOR_ME_API_KEY_LINKEDIN", From b0b9c20586b72b620a9b09fda23092fac8b4b941 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:11:45 +0200 Subject: [PATCH 02/26] feat(analytics): add account leaderboard with tracked affiliates --- .../[slug]/analytics/page-client.tsx | 12 + .../components/analytics/leaderboard-card.tsx | 305 + apps/dashboard/src/constants/analytics.ts | 2 + .../src/lib/analytics/leaderboard.ts | 98 + .../src/lib/analytics/tracked-accounts.ts | 47 + .../src/lib/hooks/use-social-analytics.ts | 62 +- .../src/lib/orpc/routers/analytics.ts | 240 + apps/dashboard/src/schemas/analytics.ts | 23 +- apps/dashboard/src/types/analytics.ts | 63 + .../workflows/steps/social-analytics-steps.ts | 54 +- packages/analytics/src/tinybird/client.ts | 14 + packages/analytics/src/tinybird/endpoints.ts | 86 + packages/db/migrations/0062_brave_menace.sql | 17 + .../db/migrations/meta/0062_snapshot.json | 8995 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 40 + 16 files changed, 10061 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/src/components/analytics/leaderboard-card.tsx create mode 100644 apps/dashboard/src/lib/analytics/leaderboard.ts create mode 100644 apps/dashboard/src/lib/analytics/tracked-accounts.ts create mode 100644 packages/db/migrations/0062_brave_menace.sql create mode 100644 packages/db/migrations/meta/0062_snapshot.json diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx index 0ae340bd0..16d52cda3 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx @@ -6,6 +6,7 @@ import { useMemo, useState } from "react"; import { AccountFilter } from "@/components/analytics/account-filter"; import { AccountSeriesChartCard } from "@/components/analytics/account-series-chart-card"; import { FollowersCard } from "@/components/analytics/followers-card"; +import { LeaderboardCard } from "@/components/analytics/leaderboard-card"; import { PostingPerformanceCard } from "@/components/analytics/posting-performance-card"; import { SummaryStats } from "@/components/analytics/summary-stats"; import { TopPostsCard } from "@/components/analytics/top-posts-card"; @@ -224,6 +225,17 @@ export default function PageClient({ organizationSlug }: PageClientProps) { +
+ + +
+
0 + ? `${((interactions / account.impressions) * 100).toFixed(1)}%` + : "N/A"; + return [ + { label: "Followers", value: formatMetric(account.followersCount) }, + { label: "Impressions", value: formatMetric(account.impressions) }, + { label: "Likes", value: formatMetric(account.likes) }, + { label: "Replies", value: formatMetric(account.replies) }, + { label: "Reposts", value: formatMetric(account.reposts) }, + { label: "Quotes", value: formatMetric(account.quotes) }, + { label: "Bookmarks", value: formatMetric(account.bookmarks) }, + { label: "Eng. rate", value: engagementRate }, + ]; +} + +function RankChange({ entry }: { entry: LeaderboardEntry }) { + if (entry.rankChange === null || entry.rankChange === 0) { + return ; + } + const up = entry.rankChange > 0; + return ( + + {up ? "▲" : "▼"} {Math.abs(entry.rankChange)} + + ); +} + +function LeaderboardRow({ + entry, + organizationId, + detail, + expanded, + onToggleExpand, +}: { + entry: LeaderboardEntry; + organizationId: string; + detail: SocialOverviewAccount | null; + expanded: boolean; + onToggleExpand: () => void; +}) { + const untrack = useUntrackAccount(organizationId); + + return ( +
+
+ + + {entry.trackedAccountId && ( + + )} + +
+ {expanded && detail && ( +
+ {detailMetrics(detail).map((metric) => ( +
+
{metric.label}
+
+ {metric.value} +
+
+ ))} +
+ )} + {expanded && !detail && ( +

+ Lifetime stats appear after this account's first sync +

+ )} +
+ ); +} + +export function LeaderboardCard({ + organizationId, + accountDetails, +}: LeaderboardCardProps) { + const [days, setDays] = useState(7); + const [handle, setHandle] = useState(""); + const [expandedKey, setExpandedKey] = useState(null); + const detailsByUsername = new Map( + accountDetails.map((account) => [account.username.toLowerCase(), account]) + ); + const { data } = useLeaderboard(organizationId, days); + const track = useTrackAccount(organizationId); + + const entries = data?.entries ?? []; + + const handleTrack = () => { + const username = handle.trim().replace(LEADING_AT, ""); + if (username.length === 0) { + return; + } + track.mutate(username, { onSuccess: () => setHandle("") }); + }; + + return ( + + +
+

+ Leaderboard +

+

+ Connected and tracked accounts ranked by interactions +

+
+ +
+ +
+ Rank + + Δ + + + Account + Interactions + Impressions + Posts + +
+ {entries.length === 0 ? ( +

+ No accounts yet +

+ ) : ( +
+ {entries.map((entry) => ( + + setExpandedKey((previous) => + previous === entry.key ? null : entry.key + ) + } + organizationId={organizationId} + /> + ))} +
+ )} +
+ setHandle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleTrack(); + } + }} + placeholder="@handle to track (teammates, affiliates)" + value={handle} + /> + +
+
+
+ ); +} diff --git a/apps/dashboard/src/constants/analytics.ts b/apps/dashboard/src/constants/analytics.ts index aef7bf976..b42ca2707 100644 --- a/apps/dashboard/src/constants/analytics.ts +++ b/apps/dashboard/src/constants/analytics.ts @@ -18,3 +18,5 @@ export const ACCOUNT_SERIES_COLORS: DitherColor[] = [ export const ANALYTICS_TIMESERIES_DAYS = 30; export const ANALYTICS_TOP_POSTS_LIMIT = 8; export const TOP_POST_CONTENT_PREVIEW_LENGTH = 96; + +export const LEADERBOARD_WINDOWS = [7, 30] as const; diff --git a/apps/dashboard/src/lib/analytics/leaderboard.ts b/apps/dashboard/src/lib/analytics/leaderboard.ts new file mode 100644 index 000000000..426ce9d77 --- /dev/null +++ b/apps/dashboard/src/lib/analytics/leaderboard.ts @@ -0,0 +1,98 @@ +import type { + LeaderboardAccount, + LeaderboardEntry, + LeaderboardWindowTotals, +} from "@/types/analytics"; + +function leaderboardAccountKey( + provider: string, + providerAccountId: string +): string { + return `${provider}:${providerAccountId}`; +} + +interface RankableTotals { + key: string; + interactions: number; + impressions: number; +} + +function rankByInteractions(items: RankableTotals[]): Map { + const sorted = [...items].sort((left, right) => { + if (right.interactions !== left.interactions) { + return right.interactions - left.interactions; + } + return right.impressions - left.impressions; + }); + return new Map(sorted.map((item, index) => [item.key, index + 1])); +} + +export function buildLeaderboardEntries( + accounts: LeaderboardAccount[], + totals: LeaderboardWindowTotals[] +): LeaderboardEntry[] { + const totalsByKey = new Map( + totals.map((row) => [ + leaderboardAccountKey(row.provider, row.providerAccountId), + row, + ]) + ); + + const current: RankableTotals[] = []; + const previous: RankableTotals[] = []; + + for (const account of accounts) { + const key = leaderboardAccountKey( + account.provider, + account.providerAccountId + ); + const row = totalsByKey.get(key); + current.push({ + key, + interactions: row?.interactions ?? 0, + impressions: row?.impressions ?? 0, + }); + const hasPreviousData = + (row?.previousPosts ?? 0) > 0 || (row?.previousInteractions ?? 0) > 0; + if (hasPreviousData && row) { + previous.push({ + key, + interactions: row.previousInteractions, + impressions: row.previousImpressions, + }); + } + } + + const currentRanks = rankByInteractions(current); + const previousRanks = rankByInteractions(previous); + + const entries = accounts.map((account) => { + const key = leaderboardAccountKey( + account.provider, + account.providerAccountId + ); + const row = totalsByKey.get(key); + const rank = currentRanks.get(key) ?? accounts.length; + const previousRank = previousRanks.get(key) ?? null; + return { + key, + provider: account.provider, + providerAccountId: account.providerAccountId, + username: account.username, + displayName: account.displayName, + profileImageUrl: account.profileImageUrl, + verified: account.verified, + verifiedType: account.verifiedType, + isConnected: account.isConnected, + trackedAccountId: account.trackedAccountId, + rank, + previousRank, + rankChange: previousRank === null ? null : previousRank - rank, + interactions: row?.interactions ?? 0, + impressions: row?.impressions ?? null, + posts: row?.posts ?? 0, + }; + }); + + return entries.sort((left, right) => left.rank - right.rank); +} diff --git a/apps/dashboard/src/lib/analytics/tracked-accounts.ts b/apps/dashboard/src/lib/analytics/tracked-accounts.ts new file mode 100644 index 000000000..d5926bbff --- /dev/null +++ b/apps/dashboard/src/lib/analytics/tracked-accounts.ts @@ -0,0 +1,47 @@ +import { normalizeTwitterProfileImageUrl } from "@/constants/twitter"; +import type { + ResolvedTwitterAccount, + TwitterUserByUsernameResponse, +} from "@/types/analytics"; +import { twitterAppFetch } from "@/utils/twitter-fetcher"; + +const TWITTER_RESOLVE_USER_FIELDS = + "name,profile_image_url,public_metrics,verified,verified_type"; +const LEADING_AT_REGEX = /^@/; + +export async function resolveTwitterAccount( + username: string +): Promise { + const handle = username.trim().replace(LEADING_AT_REGEX, ""); + if (handle.length === 0) { + return null; + } + + const params = new URLSearchParams({ + "user.fields": TWITTER_RESOLVE_USER_FIELDS, + }); + const response = await twitterAppFetch( + `https://api.x.com/2/users/by/username/${encodeURIComponent(handle)}?${params.toString()}` + ); + if (!response.ok) { + return null; + } + + const json: TwitterUserByUsernameResponse = await response.json(); + const user = json.data; + if (!user?.id) { + return null; + } + + const verifiedType = user.verified_type ?? "none"; + return { + providerAccountId: user.id, + username: user.username, + displayName: user.name ?? null, + profileImageUrl: user.profile_image_url + ? normalizeTwitterProfileImageUrl(user.profile_image_url) + : null, + verified: user.verified === true || verifiedType !== "none", + verifiedType, + }; +} diff --git a/apps/dashboard/src/lib/hooks/use-social-analytics.ts b/apps/dashboard/src/lib/hooks/use-social-analytics.ts index 2ecc60b08..5735924df 100644 --- a/apps/dashboard/src/lib/hooks/use-social-analytics.ts +++ b/apps/dashboard/src/lib/hooks/use-social-analytics.ts @@ -1,9 +1,12 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import type { EngagementTimeseriesResponse, FollowerGrowthResponse, + LeaderboardResponse, + LeaderboardWindow, NotraAdoptionResponse, PostingPerformanceResponse, SocialOverviewResponse, @@ -66,6 +69,63 @@ export function usePostingPerformance(organizationId: string, days?: number) { }); } +export function useLeaderboard( + organizationId: string, + days: LeaderboardWindow +) { + return useQuery({ + ...dashboardOrpc.analytics.leaderboard.queryOptions({ + input: { organizationId, days }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load leaderboard" }, + }); +} + +export function useTrackAccount(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (username: string) => + dashboardOrpc.analytics.trackAccount.call({ organizationId, username }), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ + queryKey: dashboardOrpc.analytics.leaderboard.key(), + }); + toast.success(`Tracking @${result.username}`); + }, + onError: (error) => { + toast.error( + error instanceof Error && error.message + ? error.message + : "Failed to track account" + ); + }, + }); +} + +export function useUntrackAccount(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (trackedAccountId: string) => + dashboardOrpc.analytics.untrackAccount.call({ + organizationId, + trackedAccountId, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: dashboardOrpc.analytics.leaderboard.key(), + }); + }, + onError: (error) => { + toast.error( + error instanceof Error && error.message + ? error.message + : "Failed to stop tracking account" + ); + }, + }); +} + export function useNotraAdoption(organizationId: string) { return useQuery({ ...dashboardOrpc.analytics.adoption.queryOptions({ diff --git a/apps/dashboard/src/lib/orpc/routers/analytics.ts b/apps/dashboard/src/lib/orpc/routers/analytics.ts index cad73c845..941b3eb74 100644 --- a/apps/dashboard/src/lib/orpc/routers/analytics.ts +++ b/apps/dashboard/src/lib/orpc/routers/analytics.ts @@ -1,5 +1,10 @@ import { + ingestSocialAccountStats, + ingestSocialAccounts, + ingestSocialPostStats, + ingestSocialPosts, isTinybirdConfigured, + queryAccountLeaderboard, queryEngagementTimeseries, queryFollowerGrowth, queryNotraAdoption, @@ -12,24 +17,36 @@ import { connectedSocialAccounts, organizations, posts, + trackedSocialAccounts, } from "@notra/db/schema"; import { and, asc, eq } from "drizzle-orm"; +import { buildLeaderboardEntries } from "@/lib/analytics/leaderboard"; +import { buildAccountRow } from "@/lib/analytics/rows"; +import { resolveTwitterAccount } from "@/lib/analytics/tracked-accounts"; +import { collectTwitterRows } from "@/lib/analytics/twitter-sync"; import { assertOrganizationAccess } from "@/lib/auth/organization"; import { authorizedProcedure } from "@/lib/orpc/base"; import { analyticsOrganizationInputSchema, analyticsTimeseriesInputSchema, analyticsTopPostsInputSchema, + leaderboardInputSchema, + trackAccountInputSchema, + untrackAccountInputSchema, } from "@/schemas/analytics"; import type { EngagementTimeseriesResponse, FollowerGrowthResponse, + LeaderboardAccount, + LeaderboardResponse, NotraAdoptionResponse, PostingPerformanceResponse, SocialOverviewAccount, SocialOverviewResponse, + SyncableSocialAccount, TopPostsResponse, } from "@/types/analytics"; +import { badRequest, notFound } from "../utils/errors"; function toNullableNumber(value: number | bigint | null): number | null { if (value === null) { @@ -38,6 +55,17 @@ function toNullableNumber(value: number | bigint | null): number | null { return Number(value); } +async function syncTrackedAccountNow( + account: SyncableSocialAccount +): Promise { + const capturedAt = new Date(); + await ingestSocialAccounts([buildAccountRow(account, capturedAt)]); + const rows = await collectTwitterRows([account], capturedAt); + await ingestSocialAccountStats(rows.accountStats); + await ingestSocialPosts(rows.posts); + await ingestSocialPostStats(rows.postStats); +} + export const analyticsRouter = { overview: authorizedProcedure .input(analyticsOrganizationInputSchema) @@ -278,6 +306,218 @@ export const analyticsRouter = { }; } ), + leaderboard: authorizedProcedure + .input(leaderboardInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [connected, tracked, result] = await Promise.all([ + db.query.connectedSocialAccounts.findMany({ + columns: { + provider: true, + providerAccountId: true, + username: true, + displayName: true, + profileImageUrl: true, + verified: true, + verifiedType: true, + }, + where: eq( + connectedSocialAccounts.organizationId, + input.organizationId + ), + }), + db.query.trackedSocialAccounts.findMany({ + columns: { + id: true, + provider: true, + providerAccountId: true, + username: true, + displayName: true, + profileImageUrl: true, + verified: true, + verifiedType: true, + }, + where: eq(trackedSocialAccounts.organizationId, input.organizationId), + }), + queryAccountLeaderboard({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[Analytics] leaderboard query failed:", error); + return null; + }), + ]); + + const connectedKeys = new Set( + connected.flatMap((account) => [ + `${account.provider}:${account.providerAccountId}`, + `${account.provider}:@${account.username.toLowerCase()}`, + ]) + ); + + const accounts: LeaderboardAccount[] = [ + ...connected.map((account) => ({ + provider: account.provider, + providerAccountId: account.providerAccountId, + username: account.username, + displayName: account.displayName, + profileImageUrl: account.profileImageUrl, + verified: account.verified ?? false, + verifiedType: account.verifiedType, + isConnected: true, + trackedAccountId: null, + })), + ...tracked + .filter( + (account) => + !( + connectedKeys.has( + `${account.provider}:${account.providerAccountId}` + ) || + connectedKeys.has( + `${account.provider}:@${account.username.toLowerCase()}` + ) + ) + ) + .map((account) => ({ + provider: account.provider, + providerAccountId: account.providerAccountId, + username: account.username, + displayName: account.displayName, + profileImageUrl: account.profileImageUrl, + verified: account.verified, + verifiedType: account.verifiedType, + isConnected: false, + trackedAccountId: account.id, + })), + ]; + + const totals = (result?.data ?? []).map((row) => ({ + provider: row.provider, + providerAccountId: row.provider_account_id, + posts: Number(row.posts), + interactions: Number(row.interactions), + impressions: Number(row.impressions), + previousPosts: Number(row.prev_posts), + previousInteractions: Number(row.prev_interactions), + previousImpressions: Number(row.prev_impressions), + })); + + return { + configured: isTinybirdConfigured(), + days: input.days, + entries: buildLeaderboardEntries(accounts, totals), + }; + }), + trackAccount: authorizedProcedure + .input(trackAccountInputSchema) + .handler(async ({ context, input }) => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const resolved = await resolveTwitterAccount(input.username); + if (!resolved) { + throw badRequest(`Could not find the X account @${input.username}`); + } + + const connectedTwitterAccounts = + await db.query.connectedSocialAccounts.findMany({ + columns: { providerAccountId: true, username: true }, + where: and( + eq(connectedSocialAccounts.organizationId, input.organizationId), + eq(connectedSocialAccounts.provider, "twitter") + ), + }); + const existingConnected = connectedTwitterAccounts.some( + (account) => + account.providerAccountId === resolved.providerAccountId || + account.username.toLowerCase() === resolved.username.toLowerCase() + ); + if (existingConnected) { + return { trackedAccountId: null, username: resolved.username }; + } + + const existingTracked = await db.query.trackedSocialAccounts.findFirst({ + columns: { id: true }, + where: and( + eq(trackedSocialAccounts.organizationId, input.organizationId), + eq(trackedSocialAccounts.provider, "twitter"), + eq( + trackedSocialAccounts.providerAccountId, + resolved.providerAccountId + ) + ), + }); + if (existingTracked) { + return { + trackedAccountId: existingTracked.id, + username: resolved.username, + }; + } + + const trackedAccountId = crypto.randomUUID(); + await db.insert(trackedSocialAccounts).values({ + id: trackedAccountId, + organizationId: input.organizationId, + provider: "twitter", + providerAccountId: resolved.providerAccountId, + username: resolved.username, + displayName: resolved.displayName, + profileImageUrl: resolved.profileImageUrl, + verified: resolved.verified, + verifiedType: resolved.verifiedType, + }); + + try { + await syncTrackedAccountNow({ + id: trackedAccountId, + organizationId: input.organizationId, + provider: "twitter", + providerAccountId: resolved.providerAccountId, + username: resolved.username, + displayName: resolved.displayName, + profileImageUrl: resolved.profileImageUrl, + verified: false, + }); + } catch (error) { + console.error("[Analytics] tracked account sync failed:", error); + } + + return { trackedAccountId, username: resolved.username }; + }), + untrackAccount: authorizedProcedure + .input(untrackAccountInputSchema) + .handler(async ({ context, input }) => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const deleted = await db + .delete(trackedSocialAccounts) + .where( + and( + eq(trackedSocialAccounts.id, input.trackedAccountId), + eq(trackedSocialAccounts.organizationId, input.organizationId) + ) + ) + .returning({ id: trackedSocialAccounts.id }); + + if (deleted.length === 0) { + throw notFound("Tracked account not found"); + } + + return { trackedAccountId: input.trackedAccountId }; + }), followerGrowth: authorizedProcedure .input(analyticsTimeseriesInputSchema) .handler(async ({ context, input }): Promise => { diff --git a/apps/dashboard/src/schemas/analytics.ts b/apps/dashboard/src/schemas/analytics.ts index 92c8bf6cb..9e5b6e815 100644 --- a/apps/dashboard/src/schemas/analytics.ts +++ b/apps/dashboard/src/schemas/analytics.ts @@ -1,4 +1,7 @@ -import { number, object, string } from "zod"; +import { literal, number, object, string, union } from "zod"; + +const LEADERBOARD_USERNAME_MAX_LENGTH = 30; +const LEADING_AT_REGEX = /^@/; export const socialAnalyticsSyncPayloadSchema = object({ organizationId: string().min(1).optional(), @@ -17,3 +20,21 @@ export const analyticsTopPostsInputSchema = object({ organizationId: string().min(1), limit: number().int().min(1).max(50).optional(), }); + +export const leaderboardInputSchema = object({ + organizationId: string().min(1), + days: union([literal(7), literal(30)]).default(7), +}); + +export const trackAccountInputSchema = object({ + organizationId: string().min(1), + username: string() + .min(1) + .max(LEADERBOARD_USERNAME_MAX_LENGTH) + .transform((value) => value.trim().replace(LEADING_AT_REGEX, "")), +}); + +export const untrackAccountInputSchema = object({ + organizationId: string().min(1), + trackedAccountId: string().min(1), +}); diff --git a/apps/dashboard/src/types/analytics.ts b/apps/dashboard/src/types/analytics.ts index 155af9348..13114cf9b 100644 --- a/apps/dashboard/src/types/analytics.ts +++ b/apps/dashboard/src/types/analytics.ts @@ -49,6 +49,19 @@ export interface TwitterBatchUsersResponse { data?: TwitterBatchUser[]; } +export interface TwitterUserByUsernameResponse { + data?: TwitterBatchUser; +} + +export interface ResolvedTwitterAccount { + providerAccountId: string; + username: string; + displayName: string | null; + profileImageUrl: string | null; + verified: boolean; + verifiedType: string | null; +} + export interface TwitterTimelineTweet { id: string; text: string; @@ -192,6 +205,56 @@ export interface NotraAdoptionResponse { notraPosts: number; } +export type LeaderboardWindow = 7 | 30; + +export interface LeaderboardAccount { + provider: string; + providerAccountId: string; + username: string; + displayName: string | null; + profileImageUrl: string | null; + verified: boolean; + verifiedType: string | null; + isConnected: boolean; + trackedAccountId: string | null; +} + +export interface LeaderboardWindowTotals { + provider: string; + providerAccountId: string; + posts: number; + interactions: number; + impressions: number; + previousPosts: number; + previousInteractions: number; + previousImpressions: number; +} + +export interface LeaderboardEntry { + key: string; + provider: string; + providerAccountId: string; + username: string; + displayName: string | null; + profileImageUrl: string | null; + verified: boolean; + verifiedType: string | null; + isConnected: boolean; + trackedAccountId: string | null; + rank: number; + previousRank: number | null; + rankChange: number | null; + interactions: number; + impressions: number | null; + posts: number; +} + +export interface LeaderboardResponse { + configured: boolean; + days: LeaderboardWindow; + entries: LeaderboardEntry[]; +} + export interface TimelineMarker { index: number | null; label: string; diff --git a/apps/dashboard/src/workflows/steps/social-analytics-steps.ts b/apps/dashboard/src/workflows/steps/social-analytics-steps.ts index 35a7c6078..854498cec 100644 --- a/apps/dashboard/src/workflows/steps/social-analytics-steps.ts +++ b/apps/dashboard/src/workflows/steps/social-analytics-steps.ts @@ -6,7 +6,10 @@ import { isTinybirdConfigured, } from "@notra/analytics/tinybird/client"; import { db } from "@notra/db/drizzle"; -import { connectedSocialAccounts } from "@notra/db/schema"; +import { + connectedSocialAccounts, + trackedSocialAccounts, +} from "@notra/db/schema"; import { eq } from "drizzle-orm"; import { buildAccountRow } from "@/lib/analytics/rows"; import { collectTwitterRows } from "@/lib/analytics/twitter-sync"; @@ -35,7 +38,22 @@ export async function listSyncableAccounts( : {}), }); - return accounts.map((account) => ({ + const tracked = await db.query.trackedSocialAccounts.findMany({ + columns: { + id: true, + organizationId: true, + provider: true, + providerAccountId: true, + username: true, + displayName: true, + profileImageUrl: true, + }, + ...(organizationId + ? { where: eq(trackedSocialAccounts.organizationId, organizationId) } + : {}), + }); + + const connected: SyncableSocialAccount[] = accounts.map((account) => ({ id: account.id, organizationId: account.organizationId, provider: account.provider, @@ -45,6 +63,38 @@ export async function listSyncableAccounts( profileImageUrl: account.profileImageUrl, verified: account.verified ?? false, })); + + const connectedKeys = new Set( + connected.flatMap((account) => [ + `${account.organizationId}:${account.provider}:${account.providerAccountId}`, + `${account.organizationId}:${account.provider}:@${account.username.toLowerCase()}`, + ]) + ); + + const trackedOnly: SyncableSocialAccount[] = tracked + .filter( + (account) => + !( + connectedKeys.has( + `${account.organizationId}:${account.provider}:${account.providerAccountId}` + ) || + connectedKeys.has( + `${account.organizationId}:${account.provider}:@${account.username.toLowerCase()}` + ) + ) + ) + .map((account) => ({ + id: account.id, + organizationId: account.organizationId, + provider: account.provider, + providerAccountId: account.providerAccountId, + username: account.username, + displayName: account.displayName, + profileImageUrl: account.profileImageUrl, + verified: false, + })); + + return [...connected, ...trackedOnly]; } export async function snapshotAccountDimensions( diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index 15d7cc353..6a8ee75b5 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -12,6 +12,9 @@ import { socialPosts, } from "./datasources"; import { + type AccountLeaderboardParams, + type AccountLeaderboardRow, + accountLeaderboard, type EngagementTimeseriesParams, type EngagementTimeseriesRow, engagementTimeseries, @@ -54,6 +57,7 @@ function createTinybirdClient() { followerGrowth, postingPerformance, notraAdoption, + accountLeaderboard, }, }); } @@ -183,3 +187,13 @@ export async function queryNotraAdoption(params: { } return await client.notraAdoption.query(params); } + +export async function queryAccountLeaderboard( + params: AccountLeaderboardParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.accountLeaderboard.query(params); +} diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts index bd90981b2..bf302626e 100644 --- a/packages/analytics/src/tinybird/endpoints.ts +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -188,6 +188,92 @@ export const engagementTimeseries = defineEndpoint("engagement_timeseries", { }, }); +export const accountLeaderboard = defineEndpoint("account_leaderboard", { + description: + "Per-account interactions for the trailing window and the window before it", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(7).describe("Length of each window in days"), + }, + nodes: [ + node({ + name: "leaderboard_post_metrics", + sql: ` + SELECT + provider, + platform_post_id, + argMax(impressions, captured_at) AS impressions, + argMax(likes, captured_at) AS likes, + argMax(replies, captured_at) AS replies, + argMax(reposts, captured_at) AS reposts + FROM social_post_stats + WHERE organization_id = {{String(organization_id)}} + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "leaderboard_window_posts", + sql: ` + SELECT + provider, + platform_post_id, + argMax(provider_account_id, captured_at) AS provider_account_id, + min(posted_at) AS first_posted_at + FROM social_posts + WHERE organization_id = {{String(organization_id)}} + AND posted_at >= now() - toIntervalDay({{Int32(days, 7)}} * 2) + AND posted_at <= now() + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "leaderboard_totals", + sql: ` + SELECT + window_posts.provider AS provider, + window_posts.provider_account_id AS provider_account_id, + countIf(window_posts.first_posted_at >= now() - toIntervalDay({{Int32(days, 7)}})) AS posts, + sumIf( + coalesce(metrics.likes, 0) + coalesce(metrics.replies, 0) + coalesce(metrics.reposts, 0), + window_posts.first_posted_at >= now() - toIntervalDay({{Int32(days, 7)}}) + ) AS interactions, + sumIf( + coalesce(metrics.impressions, 0), + window_posts.first_posted_at >= now() - toIntervalDay({{Int32(days, 7)}}) + ) AS impressions, + countIf(window_posts.first_posted_at < now() - toIntervalDay({{Int32(days, 7)}})) AS prev_posts, + sumIf( + coalesce(metrics.likes, 0) + coalesce(metrics.replies, 0) + coalesce(metrics.reposts, 0), + window_posts.first_posted_at < now() - toIntervalDay({{Int32(days, 7)}}) + ) AS prev_interactions, + sumIf( + coalesce(metrics.impressions, 0), + window_posts.first_posted_at < now() - toIntervalDay({{Int32(days, 7)}}) + ) AS prev_impressions + FROM leaderboard_window_posts AS window_posts + LEFT JOIN leaderboard_post_metrics AS metrics + ON metrics.provider = window_posts.provider + AND metrics.platform_post_id = window_posts.platform_post_id + GROUP BY window_posts.provider, window_posts.provider_account_id + ORDER BY interactions DESC + `, + }), + ], + output: { + provider: t.string(), + provider_account_id: t.string(), + posts: t.uint64(), + interactions: t.uint64(), + impressions: t.uint64(), + prev_posts: t.uint64(), + prev_interactions: t.uint64(), + prev_impressions: t.uint64(), + }, +}); + +export type AccountLeaderboardParams = InferParams; +export type AccountLeaderboardRow = InferOutputRow; + export const topPosts = defineEndpoint("top_posts", { description: "Best performing posts by latest engagement snapshot", params: { diff --git a/packages/db/migrations/0062_brave_menace.sql b/packages/db/migrations/0062_brave_menace.sql new file mode 100644 index 000000000..617e3fea8 --- /dev/null +++ b/packages/db/migrations/0062_brave_menace.sql @@ -0,0 +1,17 @@ +CREATE TABLE "tracked_social_accounts" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "provider" text NOT NULL, + "provider_account_id" text NOT NULL, + "username" text NOT NULL, + "display_name" text, + "profile_image_url" text, + "verified" boolean DEFAULT false NOT NULL, + "verified_type" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "tracked_social_accounts" ADD CONSTRAINT "tracked_social_accounts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "trackedSocialAccounts_organizationId_idx" ON "tracked_social_accounts" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "trackedSocialAccounts_org_provider_account_uidx" ON "tracked_social_accounts" USING btree ("organization_id","provider","provider_account_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0062_snapshot.json b/packages/db/migrations/meta/0062_snapshot.json new file mode 100644 index 000000000..7afb60199 --- /dev/null +++ b/packages/db/migrations/meta/0062_snapshot.json @@ -0,0 +1,8995 @@ +{ + "id": "15722568-6fad-4c25-a22b-220cdc45d546", + "prevId": "2014e8d0-299d-40d4-8e34-286db1c901d8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_userId_idx": { + "name": "accounts_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eve_session_id": { + "name": "eve_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "continuation_token": { + "name": "continuation_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_index": { + "name": "stream_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentSessions_eveSessionId_uidx": { + "name": "agentSessions_eveSessionId_uidx", + "columns": [ + { + "expression": "eve_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_organizationId_idx": { + "name": "agentSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_chatId_idx": { + "name": "agentSessions_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_organization_id_organizations_id_fk": { + "name": "agent_sessions_organization_id_organizations_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_chat_id_chat_sessions_id_fk": { + "name": "agent_sessions_chat_id_chat_sessions_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_actions": { + "name": "autonomy_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "external_ref": { + "name": "external_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyActions_organizationId_idx": { + "name": "autonomyActions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_runId_idx": { + "name": "autonomyActions_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_org_capability_idempotency_uidx": { + "name": "autonomyActions_org_capability_idempotency_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_organizationId_status_idx": { + "name": "autonomyActions_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_actions_organization_id_organizations_id_fk": { + "name": "autonomy_actions_organization_id_organizations_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_run_id_autonomy_runs_id_fk": { + "name": "autonomy_actions_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_actions_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_checkpoints": { + "name": "autonomy_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyCheckpoints_organizationId_idx": { + "name": "autonomyCheckpoints_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyCheckpoints_runId_idx": { + "name": "autonomyCheckpoints_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_checkpoints_organization_id_organizations_id_fk": { + "name": "autonomy_checkpoints_organization_id_organizations_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_run_id_autonomy_runs_id_fk": { + "name": "autonomy_checkpoints_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_checkpoints_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_claims": { + "name": "autonomy_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_key": { + "name": "claim_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyClaims_scope_claimKey_uidx": { + "name": "autonomyClaims_scope_claimKey_uidx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyClaims_expiresAt_idx": { + "name": "autonomyClaims_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_claims_organization_id_organizations_id_fk": { + "name": "autonomy_claims_organization_id_organizations_id_fk", + "tableFrom": "autonomy_claims", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_controller_leases": { + "name": "autonomy_controller_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyControllerLeases_organizationId_idx": { + "name": "autonomyControllerLeases_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_controller_leases_organization_id_organizations_id_fk": { + "name": "autonomy_controller_leases_organization_id_organizations_id_fk", + "tableFrom": "autonomy_controller_leases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_goals": { + "name": "autonomy_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_goal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "origin_signal_ids": { + "name": "origin_signal_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyGoals_organizationId_idx": { + "name": "autonomyGoals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_mandateId_idx": { + "name": "autonomyGoals_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_organizationId_status_idx": { + "name": "autonomyGoals_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_goals_organization_id_organizations_id_fk": { + "name": "autonomy_goals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_goals_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_goals_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_mandates": { + "name": "autonomy_mandates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_mandate_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyMandates_organizationId_idx": { + "name": "autonomyMandates_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyMandates_organizationId_name_uidx": { + "name": "autonomyMandates_organizationId_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_mandates_organization_id_organizations_id_fk": { + "name": "autonomy_mandates_organization_id_organizations_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_mandates_created_by_user_id_users_id_fk": { + "name": "autonomy_mandates_created_by_user_id_users_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_outbox": { + "name": "autonomy_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyOutbox_organizationId_idx": { + "name": "autonomyOutbox_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_org_destination_dedupeKey_uidx": { + "name": "autonomyOutbox_org_destination_dedupeKey_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_status_nextAttemptAt_idx": { + "name": "autonomyOutbox_status_nextAttemptAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_outbox_organization_id_organizations_id_fk": { + "name": "autonomy_outbox_organization_id_organizations_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_outbox_run_id_autonomy_runs_id_fk": { + "name": "autonomy_outbox_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_runs": { + "name": "autonomy_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_version": { + "name": "mandate_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "autonomy_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "planner_input_hash": { + "name": "planner_input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planner_output": { + "name": "planner_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyRuns_organizationId_idx": { + "name": "autonomyRuns_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_mandateId_idx": { + "name": "autonomyRuns_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_goalId_idx": { + "name": "autonomyRuns_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_organizationId_status_idx": { + "name": "autonomyRuns_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_runs_organization_id_organizations_id_fk": { + "name": "autonomy_runs_organization_id_organizations_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_runs_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_runs_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_signals": { + "name": "autonomy_signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_signal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "coalesced_into_signal_id": { + "name": "coalesced_into_signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomySignals_organizationId_idx": { + "name": "autonomySignals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_dedupeHash_uidx": { + "name": "autonomySignals_organizationId_dedupeHash_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_status_occurredAt_idx": { + "name": "autonomySignals_organizationId_status_occurredAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_signals_organization_id_organizations_id_fk": { + "name": "autonomy_signals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_signals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomySignals_coalescedIntoSignalId_fk": { + "name": "autonomySignals_coalescedIntoSignalId_fk", + "tableFrom": "autonomy_signals", + "tableTo": "autonomy_signals", + "columnsFrom": [ + "coalesced_into_signal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_tasks": { + "name": "autonomy_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "depends_on_task_ids": { + "name": "depends_on_task_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "autonomy_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyTasks_organizationId_idx": { + "name": "autonomyTasks_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_goalId_idx": { + "name": "autonomyTasks_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_runId_idx": { + "name": "autonomyTasks_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_organizationId_status_waitUntil_idx": { + "name": "autonomyTasks_organizationId_status_waitUntil_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_tasks_organization_id_organizations_id_fk": { + "name": "autonomy_tasks_organization_id_organizations_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_tasks_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_run_id_autonomy_runs_id_fk": { + "name": "autonomy_tasks_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_assets": { + "name": "brand_guideline_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_asset_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "brand_guideline_asset_variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineAssets_guidelineId_idx": { + "name": "brandGuidelineAssets_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_idx": { + "name": "brandGuidelineAssets_guideline_kind_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_variant_uidx": { + "name": "brandGuidelineAssets_guideline_kind_variant_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_assets_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_assets_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_assets", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_colors": { + "name": "brand_guideline_colors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_color_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "light_value": { + "name": "light_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dark_value": { + "name": "dark_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineColors_guidelineId_idx": { + "name": "brandGuidelineColors_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineColors_guideline_role_idx": { + "name": "brandGuidelineColors_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_colors_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_colors_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_colors", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_fonts": { + "name": "brand_guideline_fonts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_font_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_height": { + "name": "line_height", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineFonts_guidelineId_idx": { + "name": "brandGuidelineFonts_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineFonts_guideline_role_idx": { + "name": "brandGuidelineFonts_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_fonts", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_screenshots": { + "name": "brand_guideline_screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_screenshot_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_page": { + "name": "full_page", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineScreenshots_guidelineId_idx": { + "name": "brandGuidelineScreenshots_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineScreenshots_guideline_kind_uidx": { + "name": "brandGuidelineScreenshots_guideline_kind_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_screenshots", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_tokens": { + "name": "brand_guideline_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "brand_guideline_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineTokens_guidelineId_idx": { + "name": "brandGuidelineTokens_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineTokens_guideline_type_idx": { + "name": "brandGuidelineTokens_guideline_type_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_tokens", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guidelines": { + "name": "brand_guidelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_guideline_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_generated_at": { + "name": "last_generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_generation_error": { + "name": "last_generation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelines_brandSettingsId_uidx": { + "name": "brandGuidelines_brandSettingsId_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelines_status_idx": { + "name": "brandGuidelines_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guidelines_brand_settings_id_brand_settings_id_fk": { + "name": "brand_guidelines_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_guidelines", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_references": { + "name": "brand_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "reference_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_key": { + "name": "source_snapshot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_hash": { + "name": "source_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_captured_at": { + "name": "source_captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_document_id": { + "name": "supermemory_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_memory_id": { + "name": "supermemory_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_synced_at": { + "name": "supermemory_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supermemory_last_sync_error": { + "name": "supermemory_last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicable_to": { + "name": "applicable_to", + "type": "applicable_platform[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['all']::applicable_platform[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandReferences_brandSettingsId_idx": { + "name": "brandReferences_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandReferences_brandSettingsId_sourceUrl_idx": { + "name": "brandReferences_brandSettingsId_sourceUrl_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_references_brand_settings_id_brand_settings_id_fk": { + "name": "brand_references_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_references", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_settings": { + "name": "brand_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tone_profile": { + "name": "tone_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_tone": { + "name": "custom_tone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_instructions": { + "name": "custom_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'English'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSettings_org_name_uidx": { + "name": "brandSettings_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_org_default_uidx": { + "name": "brandSettings_org_default_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brand_settings\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_organizationId_idx": { + "name": "brandSettings_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_settings_organization_id_organizations_id_fk": { + "name": "brand_settings_organization_id_organizations_id_fk", + "tableFrom": "brand_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brandSettings_toneProfile_check": { + "name": "brandSettings_toneProfile_check", + "value": "\"brand_settings\".\"tone_profile\" IS NULL OR \"brand_settings\".\"tone_profile\" IN ('Conversational', 'Professional', 'Casual', 'Formal')" + } + }, + "isRLSEnabled": false + }, + "public.brand_sitemap_pages": { + "name": "brand_sitemap_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sitemap_id": { + "name": "sitemap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "brand_sitemap_page_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_target": { + "name": "redirect_target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text_ratio": { + "name": "text_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "internal_links": { + "name": "internal_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_links": { + "name": "external_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "crawled_at": { + "name": "crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemapPages_sitemapId_idx": { + "name": "brandSitemapPages_sitemapId_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_category_idx": { + "name": "brandSitemapPages_sitemap_category_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_url_uidx": { + "name": "brandSitemapPages_sitemap_url_uidx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk": { + "name": "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk", + "tableFrom": "brand_sitemap_pages", + "tableTo": "brand_sitemaps", + "columnsFrom": [ + "sitemap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_sitemaps": { + "name": "brand_sitemaps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_sitemap_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "total_pages": { + "name": "total_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_pages": { + "name": "indexed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_pages": { + "name": "failed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_crawl_started_at": { + "name": "last_crawl_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawled_at": { + "name": "last_crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawl_error": { + "name": "last_crawl_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemaps_brandSettingsId_idx": { + "name": "brandSitemaps_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemaps_brandSettings_url_uidx": { + "name": "brandSitemaps_brandSettings_url_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemaps_brand_settings_id_brand_settings_id_fk": { + "name": "brand_sitemaps_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_sitemaps", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatAttachments_organizationId_createdAt_idx": { + "name": "chatAttachments_organizationId_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatAttachments_userId_idx": { + "name": "chatAttachments_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_user_id_users_id_fk": { + "name": "chat_attachments_user_id_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_attachments_key_unique": { + "name": "chat_attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_channel_source": { + "name": "external_channel_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_channel_id": { + "name": "external_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatSessions_organizationId_idx": { + "name": "chatSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_organizationId_deletedAt_idx": { + "name": "chatSessions_organizationId_deletedAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_org_externalChannel_uidx": { + "name": "chatSessions_org_externalChannel_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_sessions\".\"external_channel_source\" IN ('discord', 'slack') AND \"chat_sessions\".\"external_channel_id\" IS NOT NULL AND \"chat_sessions\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connected_social_accounts": { + "name": "connected_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connectedSocialAccounts_organizationId_idx": { + "name": "connectedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connectedSocialAccounts_org_provider_account_uidx": { + "name": "connectedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connected_social_accounts_organization_id_organizations_id_fk": { + "name": "connected_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "connected_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_trigger_lookback_windows": { + "name": "content_trigger_lookback_windows", + "schema": "", + "columns": { + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window": { + "name": "window", + "type": "lookback_window", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk": { + "name": "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk", + "tableFrom": "content_trigger_lookback_windows", + "tableTo": "content_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_triggers": { + "name": "content_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Schedule'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_config": { + "name": "source_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_config": { + "name": "output_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_publish": { + "name": "auto_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contentTriggers_organizationId_idx": { + "name": "contentTriggers_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentTriggers_organization_dedupe_uidx": { + "name": "contentTriggers_organization_dedupe_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_triggers_organization_id_organizations_id_fk": { + "name": "content_triggers_organization_id_organizations_id_fk", + "tableFrom": "content_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubAppInstallations_organizationId_idx": { + "name": "githubAppInstallations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_createdByUserId_idx": { + "name": "githubAppInstallations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_organization_installation_uidx": { + "name": "githubAppInstallations_organization_installation_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_organization_id_organizations_id_fk": { + "name": "github_app_installations_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_app_installations_created_by_user_id_users_id_fk": { + "name": "github_app_installations_created_by_user_id_users_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integrations": { + "name": "github_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_installation_id": { + "name": "github_app_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_private": { + "name": "github_repository_private", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_enabled": { + "name": "repository_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubIntegrations_organizationId_idx": { + "name": "githubIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_createdByUserId_idx": { + "name": "githubIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_organization_owner_repo_uidx": { + "name": "githubIntegrations_organization_owner_repo_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_integrations_organization_id_organizations_id_fk": { + "name": "github_integrations_organization_id_organizations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_created_by_user_id_users_id_fk": { + "name": "github_integrations_created_by_user_id_users_id_fk", + "tableFrom": "github_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_github_app_installation_id_github_app_installations_id_fk": { + "name": "github_integrations_github_app_installation_id_github_app_installations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "github_app_installations", + "columnsFrom": [ + "github_app_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.granola_integrations": { + "name": "granola_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "granolaIntegrations_organizationId_idx": { + "name": "granolaIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "granolaIntegrations_createdByUserId_idx": { + "name": "granolaIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "granola_integrations_organization_id_organizations_id_fk": { + "name": "granola_integrations_organization_id_organizations_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "granola_integrations_created_by_user_id_users_id_fk": { + "name": "granola_integrations_created_by_user_id_users_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organizationId_idx": { + "name": "invitations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_integrations": { + "name": "linear_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_name": { + "name": "linear_organization_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_name": { + "name": "linear_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linearIntegrations_organizationId_idx": { + "name": "linearIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_createdByUserId_idx": { + "name": "linearIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_team_uidx": { + "name": "linearIntegrations_org_linearOrg_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_no_team_uidx": { + "name": "linearIntegrations_org_linearOrg_no_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"linear_integrations\".\"linear_team_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_integrations_organization_id_organizations_id_fk": { + "name": "linear_integrations_organization_id_organizations_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "linear_integrations_created_by_user_id_users_id_fk": { + "name": "linear_integrations_created_by_user_id_users_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_credentials": { + "name": "mcp_oauth_credentials", + "schema": "", + "columns": { + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_tokens": { + "name": "encrypted_tokens", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_refresh_at": { + "name": "access_token_refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_lease_id": { + "name": "refresh_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_lease_expires_at": { + "name": "refresh_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthCredentials_organizationId_idx": { + "name": "mcpOAuthCredentials_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthCredentials_connectedByUserId_idx": { + "name": "mcpOAuthCredentials_connectedByUserId_idx", + "columns": [ + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_organization_id_organizations_id_fk": { + "name": "mcp_oauth_credentials_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_connected_by_user_id_users_id_fk": { + "name": "mcp_oauth_credentials_connected_by_user_id_users_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "users", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthCredentials_org_server_fk": { + "name": "mcpOAuthCredentials_org_server_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpOAuthCredentials_status_check": { + "name": "mcpOAuthCredentials_status_check", + "value": "\"mcp_oauth_credentials\".\"status\" IN ('connected', 'refreshing', 'reauth_required')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_oauth_pending_authorizations": { + "name": "mcp_oauth_pending_authorizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_path": { + "name": "callback_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_code_verifier": { + "name": "encrypted_code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthPendingAuthorizations_organizationId_idx": { + "name": "mcpOAuthPendingAuthorizations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_userId_idx": { + "name": "mcpOAuthPendingAuthorizations_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_serverIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_serverIntegrationId_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_expiresAt_idx": { + "name": "mcpOAuthPendingAuthorizations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk": { + "name": "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_user_id_users_id_fk": { + "name": "mcp_oauth_pending_authorizations_user_id_users_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_org_server_fk": { + "name": "mcpOAuthPendingAuthorizations_org_server_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_pending_authorizations_state_hash_unique": { + "name": "mcp_oauth_pending_authorizations_state_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "state_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_integrations": { + "name": "mcp_server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connection'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_light_url": { + "name": "logo_light_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_dark_url": { + "name": "logo_dark_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_featured_at": { + "name": "store_featured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_status": { + "name": "store_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_tool_sync_at": { + "name": "last_tool_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tool_sync_status": { + "name": "tool_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "tool_sync_error": { + "name": "tool_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexed_tool_count": { + "name": "indexed_tool_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpServerIntegrations_resourceType_idx": { + "name": "mcpServerIntegrations_resourceType_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeStatus_idx": { + "name": "mcpServerIntegrations_storeStatus_idx", + "columns": [ + { + "expression": "store_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_organizationId_idx": { + "name": "mcpServerIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_createdByUserId_idx": { + "name": "mcpServerIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeSourceIntegrationId_idx": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_id_uidx": { + "name": "mcpServerIntegrations_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_resourceType_name_uidx": { + "name": "mcpServerIntegrations_org_resourceType_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_storeSource_uidx": { + "name": "mcpServerIntegrations_org_storeSource_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"store_source_integration_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeListing_slug_uidx": { + "name": "mcpServerIntegrations_storeListing_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"resource_type\" = 'store_listing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_integrations_organization_id_organizations_id_fk": { + "name": "mcp_server_integrations_organization_id_organizations_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_integrations_created_by_user_id_users_id_fk": { + "name": "mcp_server_integrations_created_by_user_id_users_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpServerIntegrations_storeSourceIntegrationId_fk": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpServerIntegrations_authType_check": { + "name": "mcpServerIntegrations_authType_check", + "value": "\"mcp_server_integrations\".\"auth_type\" IN ('none', 'headers', 'oauth')" + }, + "mcpServerIntegrations_storeStatus_check": { + "name": "mcpServerIntegrations_storeStatus_check", + "value": "\"mcp_server_integrations\".\"store_status\" IN ('draft', 'pending_review', 'live', 'rejected')" + }, + "mcpServerIntegrations_resourceType_check": { + "name": "mcpServerIntegrations_resourceType_check", + "value": "\"mcp_server_integrations\".\"resource_type\" IN ('connection', 'store_listing')" + }, + "mcpServerIntegrations_category_check": { + "name": "mcpServerIntegrations_category_check", + "value": "\"mcp_server_integrations\".\"category\" IS NULL OR \"mcp_server_integrations\".\"category\" IN ('AI', 'Source control', 'Project management', 'Communication', 'Design', 'Notes', 'Deploys', 'Productivity', 'Marketing', 'Publishing')" + }, + "mcpServerIntegrations_resourceState_check": { + "name": "mcpServerIntegrations_resourceState_check", + "value": "(\n (\"mcp_server_integrations\".\"resource_type\" = 'store_listing' AND \"mcp_server_integrations\".\"store_source_integration_id\" IS NULL)\n OR\n (\"mcp_server_integrations\".\"resource_type\" = 'connection' AND \"mcp_server_integrations\".\"store_status\" = 'draft' AND \"mcp_server_integrations\".\"review_note\" IS NULL AND \"mcp_server_integrations\".\"submitted_at\" IS NULL AND \"mcp_server_integrations\".\"reviewed_at\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.mcp_session_tool_activations": { + "name": "mcp_session_tool_activations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mcp_tool_index_id": { + "name": "mcp_tool_index_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_query": { + "name": "source_query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcpSessionToolActivations_session_tool_uidx": { + "name": "mcpSessionToolActivations_session_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_tool_index_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_session_idx": { + "name": "mcpSessionToolActivations_session_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_expiresAt_idx": { + "name": "mcpSessionToolActivations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_session_tool_activations_organization_id_organizations_id_fk": { + "name": "mcp_session_tool_activations_organization_id_organizations_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk": { + "name": "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "mcp_tool_index_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpSessionToolActivations_org_tool_fk": { + "name": "mcpSessionToolActivations_org_tool_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "organization_id", + "mcp_tool_index_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tool_index": { + "name": "mcp_tool_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_tool_name": { + "name": "server_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_present": { + "name": "action_phrase_present", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_past": { + "name": "action_phrase_past", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_indexed_at": { + "name": "last_indexed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpToolIndex_server_tool_uidx": { + "name": "mcpToolIndex_server_tool_uidx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_id_uidx": { + "name": "mcpToolIndex_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_runtime_tool_uidx": { + "name": "mcpToolIndex_org_runtime_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "runtime_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_organizationId_status_idx": { + "name": "mcpToolIndex_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_serverIntegrationId_status_idx": { + "name": "mcpToolIndex_serverIntegrationId_status_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_searchText_gin_idx": { + "name": "mcpToolIndex_searchText_gin_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "mcp_tool_index_organization_id_organizations_id_fk": { + "name": "mcp_tool_index_organization_id_organizations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpToolIndex_org_server_fk": { + "name": "mcpToolIndex_org_server_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "members_organizationId_idx": { + "name": "members_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_userId_idx": { + "name": "members_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessTokens_clientId_idx": { + "name": "oauthAccessTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_sessionId_idx": { + "name": "oauthAccessTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_userId_idx": { + "name": "oauthAccessTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_refreshId_idx": { + "name": "oauthAccessTokens_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClients_userId_idx": { + "name": "oauthClients_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consents": { + "name": "oauth_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthConsents_clientId_idx": { + "name": "oauthConsents_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsents_userId_idx": { + "name": "oauthConsents_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshTokens_clientId_idx": { + "name": "oauthRefreshTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_sessionId_idx": { + "name": "oauthRefreshTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_userId_idx": { + "name": "oauthRefreshTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_tokens_token_unique": { + "name": "oauth_refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_suggestions": { + "name": "onboarding_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "onboarding_suggestion_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingSuggestions_org_type_idx": { + "name": "onboardingSuggestions_org_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_suggestions_organization_id_organizations_id_fk": { + "name": "onboarding_suggestions_organization_id_organizations_id_fk", + "tableFrom": "onboarding_suggestions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_settings": { + "name": "organization_notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_content_creation": { + "name": "scheduled_content_creation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "scheduled_content_failed": { + "name": "scheduled_content_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scheduled_content_skipped": { + "name": "scheduled_content_skipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketing_emails": { + "name": "marketing_emails", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orgNotificationSettings_organizationId_uidx": { + "name": "orgNotificationSettings_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_notification_settings_organization_id_organizations_id_fk": { + "name": "organization_notification_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_notification_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_source": { + "name": "heard_about_notra_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_other": { + "name": "heard_about_notra_other", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_dismissed": { + "name": "onboarding_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_ran": { + "name": "onboarding_agent_ran", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_started_at": { + "name": "onboarding_agent_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_uidx": { + "name": "organizations_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_collections": { + "name": "post_collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "post_collection_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_source": { + "name": "name_source", + "type": "post_collection_name_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "content_types": { + "name": "content_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expected_post_count": { + "name": "expected_post_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_post_count": { + "name": "completed_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_collections_org_created_at_idx": { + "name": "post_collections_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_source_idx": { + "name": "post_collections_source_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_chat_source_uidx": { + "name": "post_collections_chat_source_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"post_collections\".\"source\" = 'chat' AND \"post_collections\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_collections_organization_id_organizations_id_fk": { + "name": "post_collections_organization_id_organizations_id_fk", + "tableFrom": "post_collections", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recommendations": { + "name": "recommendations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_org_slug_uidx": { + "name": "posts_org_slug_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"posts\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_createdAt_id_idx": { + "name": "posts_org_createdAt_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_collection_id_idx": { + "name": "posts_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_organization_id_organizations_id_fk": { + "name": "posts_organization_id_organizations_id_fk", + "tableFrom": "posts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_collection_id_post_collections_id_fk": { + "name": "posts_collection_id_post_collections_id_fk", + "tableFrom": "posts", + "tableTo": "post_collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_outputs": { + "name": "repository_outputs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositoryOutputs_repositoryId_idx": { + "name": "repositoryOutputs_repositoryId_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositoryOutputs_repository_outputType_uidx": { + "name": "repositoryOutputs_repository_outputType_uidx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "output_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_outputs_repository_id_github_integrations_id_fk": { + "name": "repository_outputs_repository_id_github_integrations_id_fk", + "tableFrom": "repository_outputs", + "tableTo": "github_integrations", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_userId_idx": { + "name": "sessions_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_organizationId_idx": { + "name": "skills_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_org_name_uidx": { + "name": "skills_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_organization_id_organizations_id_fk": { + "name": "skills_organization_id_organizations_id_fk", + "tableFrom": "skills", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_integrations": { + "name": "slack_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_bot_token": { + "name": "encrypted_bot_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_bot_user_id": { + "name": "slack_bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_channel_ids": { + "name": "allowed_channel_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slackIntegrations_organizationId_idx": { + "name": "slackIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_createdByUserId_idx": { + "name": "slackIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_teamId_uidx": { + "name": "slackIntegrations_teamId_uidx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_integrations_organization_id_organizations_id_fk": { + "name": "slack_integrations_organization_id_organizations_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_integrations_created_by_user_id_users_id_fk": { + "name": "slack_integrations_created_by_user_id_users_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_social_accounts": { + "name": "tracked_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trackedSocialAccounts_organizationId_idx": { + "name": "trackedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedSocialAccounts_org_provider_account_uidx": { + "name": "trackedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_social_accounts_organization_id_organizations_id_fk": { + "name": "tracked_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "tracked_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hide_personal_data": { + "name": "hide_personal_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_agent_stats": { + "name": "show_agent_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.applicable_platform": { + "name": "applicable_platform", + "schema": "public", + "values": [ + "all", + "twitter", + "linkedin", + "blog" + ] + }, + "public.autonomy_action_status": { + "name": "autonomy_action_status", + "schema": "public", + "values": [ + "pending", + "executing", + "succeeded", + "failed", + "unknown", + "compensated", + "canceled" + ] + }, + "public.autonomy_goal_status": { + "name": "autonomy_goal_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "blocked", + "completed", + "abandoned" + ] + }, + "public.autonomy_mandate_status": { + "name": "autonomy_mandate_status", + "schema": "public", + "values": [ + "active", + "paused", + "revoked" + ] + }, + "public.autonomy_outbox_status": { + "name": "autonomy_outbox_status", + "schema": "public", + "values": [ + "pending", + "attempting", + "delivered", + "failed", + "canceled" + ] + }, + "public.autonomy_run_status": { + "name": "autonomy_run_status", + "schema": "public", + "values": [ + "planning", + "executing", + "completed", + "failed", + "canceled" + ] + }, + "public.autonomy_run_trigger": { + "name": "autonomy_run_trigger", + "schema": "public", + "values": [ + "signal", + "wake", + "manual", + "repair" + ] + }, + "public.autonomy_signal_status": { + "name": "autonomy_signal_status", + "schema": "public", + "values": [ + "pending", + "coalesced", + "processed", + "discarded" + ] + }, + "public.autonomy_task_status": { + "name": "autonomy_task_status", + "schema": "public", + "values": [ + "pending", + "ready", + "running", + "waiting", + "completed", + "failed", + "canceled" + ] + }, + "public.brand_guideline_asset_kind": { + "name": "brand_guideline_asset_kind", + "schema": "public", + "values": [ + "logo", + "wordmark" + ] + }, + "public.brand_guideline_asset_variant": { + "name": "brand_guideline_asset_variant", + "schema": "public", + "values": [ + "light", + "dark" + ] + }, + "public.brand_guideline_color_role": { + "name": "brand_guideline_color_role", + "schema": "public", + "values": [ + "primary", + "secondary", + "accent", + "background", + "foreground", + "neutral", + "custom" + ] + }, + "public.brand_guideline_font_role": { + "name": "brand_guideline_font_role", + "schema": "public", + "values": [ + "heading", + "body", + "button", + "unknown" + ] + }, + "public.brand_guideline_screenshot_kind": { + "name": "brand_guideline_screenshot_kind", + "schema": "public", + "values": [ + "desktop_hero", + "desktop_full_page", + "mobile_hero" + ] + }, + "public.brand_guideline_status": { + "name": "brand_guideline_status", + "schema": "public", + "values": [ + "queued", + "generating", + "ready", + "failed" + ] + }, + "public.brand_guideline_token_type": { + "name": "brand_guideline_token_type", + "schema": "public", + "values": [ + "spacing", + "radius", + "shadow", + "component", + "unknown" + ] + }, + "public.brand_sitemap_page_category": { + "name": "brand_sitemap_page_category", + "schema": "public", + "values": [ + "crawled", + "redirect", + "queued", + "failed" + ] + }, + "public.brand_sitemap_status": { + "name": "brand_sitemap_status", + "schema": "public", + "values": [ + "queued", + "crawling", + "ready", + "failed" + ] + }, + "public.lookback_window": { + "name": "lookback_window", + "schema": "public", + "values": [ + "current_day", + "yesterday", + "last_7_days", + "last_14_days", + "last_30_days" + ] + }, + "public.onboarding_suggestion_type": { + "name": "onboarding_suggestion_type", + "schema": "public", + "values": [ + "schedule_automation", + "event_automation" + ] + }, + "public.post_collection_name_source": { + "name": "post_collection_name_source", + "schema": "public", + "values": [ + "generated", + "user", + "backfill" + ] + }, + "public.post_collection_source": { + "name": "post_collection_source", + "schema": "public", + "values": [ + "manual", + "chat", + "schedule", + "automation", + "api", + "backfill" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.reference_type": { + "name": "reference_type", + "schema": "public", + "values": [ + "twitter_post", + "linkedin_post", + "blog_post", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index a50ae0429..0a225e21b 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -435,6 +435,13 @@ "when": 1785580721087, "tag": "0061_lazy_dreadnoughts", "breakpoints": true + }, + { + "idx": 62, + "version": "7", + "when": 1785669058664, + "tag": "0062_brave_menace", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index c5fccd2da..6d100f919 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1392,6 +1392,36 @@ export const connectedSocialAccounts = pgTable( ] ); +export const trackedSocialAccounts = pgTable( + "tracked_social_accounts", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + providerAccountId: text("provider_account_id").notNull(), + username: text("username").notNull(), + displayName: text("display_name"), + profileImageUrl: text("profile_image_url"), + verified: boolean("verified").notNull().default(false), + verifiedType: text("verified_type"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [ + index("trackedSocialAccounts_organizationId_idx").on(table.organizationId), + uniqueIndex("trackedSocialAccounts_org_provider_account_uidx").on( + table.organizationId, + table.provider, + table.providerAccountId + ), + ] +); + export const organizationNotificationSettings = pgTable( "organization_notification_settings", { @@ -2416,6 +2446,16 @@ export const connectedSocialAccountsRelations = relations( }) ); +export const trackedSocialAccountsRelations = relations( + trackedSocialAccounts, + ({ one }) => ({ + organization: one(organizations, { + fields: [trackedSocialAccounts.organizationId], + references: [organizations.id], + }), + }) +); + export const organizationNotificationSettingsRelations = relations( organizationNotificationSettings, ({ one }) => ({ From 5ea1893b68ab151ee3b16d2053b342d9bd622c5c Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:12:38 +0200 Subject: [PATCH 03/26] feat(experiments): add social A/B tests --- .../[slug]/experiments/loading.tsx | 5 + .../[slug]/experiments/page-client.tsx | 107 + .../(dashboard)/[slug]/experiments/page.tsx | 25 + .../[slug]/experiments/skeleton.tsx | 27 + .../components/command-palette/registry.ts | 9 + .../src/components/dashboard/nav-main.tsx | 7 + .../experiments/create-experiment-dialog.tsx | 202 + .../experiments/experiment-card.tsx | 178 + apps/dashboard/src/constants/experiments.ts | 14 + .../src/lib/hooks/use-experiments.ts | 89 + apps/dashboard/src/lib/orpc/router.ts | 2 + .../src/lib/orpc/routers/experiments.ts | 272 + apps/dashboard/src/schemas/experiments.ts | 23 + apps/dashboard/src/types/experiments.ts | 37 + apps/dashboard/src/utils/experiments.ts | 55 + packages/analytics/src/tinybird/client.ts | 14 + packages/analytics/src/tinybird/endpoints.ts | 67 + packages/db/migrations/0063_slimy_hellcat.sql | 19 + .../db/migrations/meta/0063_snapshot.json | 9126 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 38 + 21 files changed, 10323 insertions(+) create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/experiments/loading.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/experiments/page-client.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/experiments/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/experiments/skeleton.tsx create mode 100644 apps/dashboard/src/components/experiments/create-experiment-dialog.tsx create mode 100644 apps/dashboard/src/components/experiments/experiment-card.tsx create mode 100644 apps/dashboard/src/constants/experiments.ts create mode 100644 apps/dashboard/src/lib/hooks/use-experiments.ts create mode 100644 apps/dashboard/src/lib/orpc/routers/experiments.ts create mode 100644 apps/dashboard/src/schemas/experiments.ts create mode 100644 apps/dashboard/src/types/experiments.ts create mode 100644 apps/dashboard/src/utils/experiments.ts create mode 100644 packages/db/migrations/0063_slimy_hellcat.sql create mode 100644 packages/db/migrations/meta/0063_snapshot.json diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/experiments/loading.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/loading.tsx new file mode 100644 index 000000000..6900ea4be --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/loading.tsx @@ -0,0 +1,5 @@ +import { ExperimentsPageSkeleton } from "./skeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/experiments/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/page-client.tsx new file mode 100644 index 000000000..9b4309577 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/page-client.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Add01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Button } from "@notra/ui/components/ui/button"; +import { useState } from "react"; +import { EmptyState } from "@/components/empty-state"; +import { CreateExperimentDialog } from "@/components/experiments/create-experiment-dialog"; +import { ExperimentCard } from "@/components/experiments/experiment-card"; +import { PageContainer } from "@/components/layout/container"; +import { useOrganizationsContext } from "@/components/providers/organization-provider"; +import { useExperiments } from "@/lib/hooks/use-experiments"; +import { ExperimentsPageSkeleton } from "./skeleton"; + +interface PageClientProps { + organizationSlug: string; +} + +export default function PageClient({ organizationSlug }: PageClientProps) { + const { getOrganization, activeOrganization } = useOrganizationsContext(); + const orgFromList = getOrganization(organizationSlug); + const organization = + activeOrganization?.slug === organizationSlug + ? activeOrganization + : orgFromList; + const organizationId = organization?.id ?? ""; + + const [createOpen, setCreateOpen] = useState(false); + const { data, isPending } = useExperiments(organizationId); + + if (isPending) { + return ; + } + + const experiments = data?.experiments ?? []; + const running = experiments.filter( + (experiment) => experiment.status === "running" + ); + const finished = experiments.filter( + (experiment) => experiment.status !== "running" + ); + + return ( + +
+
+
+

A/B Tests

+

+ {running.length} running · compare two posts on one metric +

+
+ +
+ + {experiments.length === 0 ? ( + setCreateOpen(true)}> + Start your first test + + } + description="Pick two published posts and a metric. Notra tracks both until you declare a winner — so every posting decision is backed by data." + title="No experiments yet" + /> + ) : ( + <> + {running.length > 0 && ( +
+ {running.map((experiment) => ( + + ))} +
+ )} + {finished.length > 0 && ( +
+

+ Finished +

+ {finished.map((experiment) => ( + + ))} +
+ )} + + )} + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/experiments/page.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/page.tsx new file mode 100644 index 000000000..1c4141bdd --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import PageClient from "./page-client"; +import { ExperimentsPageSkeleton } from "./skeleton"; + +export const metadata: Metadata = { + title: "A/B Tests", +}; + +async function Page({ + params, +}: { + params: Promise<{ + slug: string; + }>; +}) { + const { slug } = await params; + + return ( + }> + + + ); +} +export default Page; diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/experiments/skeleton.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/skeleton.tsx new file mode 100644 index 000000000..a7161b73f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/experiments/skeleton.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Skeleton } from "@notra/ui/components/ui/skeleton"; +import { useId } from "react"; +import { PageContainer } from "@/components/layout/container"; + +const CARD_COUNT = 3; + +export function ExperimentsPageSkeleton() { + const id = useId(); + return ( + +
+
+ + +
+ {Array.from({ length: CARD_COUNT }).map((_, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/command-palette/registry.ts b/apps/dashboard/src/components/command-palette/registry.ts index d968286bc..22b82da31 100644 --- a/apps/dashboard/src/components/command-palette/registry.ts +++ b/apps/dashboard/src/components/command-palette/registry.ts @@ -12,6 +12,7 @@ import { Notification03Icon, PlugIcon, Settings01Icon, + TestTube01Icon, UserCircleIcon, UserGroupIcon, Wallet01Icon, @@ -83,6 +84,14 @@ export const COMMAND_ROUTES: CommandRoute[] = [ section: "Automation", path: (slug) => `/${slug}/automation/schedules`, }, + { + id: "experiments", + label: "A/B Tests", + keywords: ["experiments", "ab test", "variants", "testing"], + icon: TestTube01Icon, + section: "Automation", + path: (slug) => `/${slug}/experiments`, + }, { id: "automation-events", label: "Events", diff --git a/apps/dashboard/src/components/dashboard/nav-main.tsx b/apps/dashboard/src/components/dashboard/nav-main.tsx index c1fc48331..3346dd8db 100644 --- a/apps/dashboard/src/components/dashboard/nav-main.tsx +++ b/apps/dashboard/src/components/dashboard/nav-main.tsx @@ -13,6 +13,7 @@ import { PlugIcon, RainbowIcon, SearchIcon, + TestTube01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Badge } from "@notra/ui/components/ui/badge"; @@ -92,6 +93,12 @@ const navMainItems: NavMainItem[] = [ label: "Events", category: "automation", }, + { + link: "/experiments", + icon: TestTube01Icon, + label: "A/B Tests", + category: "automation", + }, { link: "/api-keys", icon: Key01Icon, diff --git a/apps/dashboard/src/components/experiments/create-experiment-dialog.tsx b/apps/dashboard/src/components/experiments/create-experiment-dialog.tsx new file mode 100644 index 000000000..fb41d95a8 --- /dev/null +++ b/apps/dashboard/src/components/experiments/create-experiment-dialog.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { + ResponsiveDialog, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from "@notra/ui/components/shared/responsive-dialog"; +import { Button } from "@notra/ui/components/ui/button"; +import { Input } from "@notra/ui/components/ui/input"; +import { Label } from "@notra/ui/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@notra/ui/components/ui/select"; +import { Loader2Icon } from "lucide-react"; +import { useId, useState } from "react"; +import { + EXPERIMENT_METRIC_LABELS, + EXPERIMENT_METRICS, + EXPERIMENT_POST_PICKER_LIMIT, +} from "@/constants/experiments"; +import { useExperimentCreate } from "@/lib/hooks/use-experiments"; +import { useTopPosts } from "@/lib/hooks/use-social-analytics"; +import type { ExperimentMetric } from "@/types/experiments"; + +interface CreateExperimentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; +} + +const POST_LABEL_LENGTH = 60; + +function postLabel(content: string): string { + const singleLine = content.replace(/\s+/g, " ").trim(); + if (singleLine.length <= POST_LABEL_LENGTH) { + return singleLine; + } + return `${singleLine.slice(0, POST_LABEL_LENGTH)}…`; +} + +export function CreateExperimentDialog({ + open, + onOpenChange, + organizationId, +}: CreateExperimentDialogProps) { + const id = useId(); + const [name, setName] = useState(""); + const [metric, setMetric] = useState("engagement"); + const [variantAPostId, setVariantAPostId] = useState(""); + const [variantBPostId, setVariantBPostId] = useState(""); + const create = useExperimentCreate(organizationId); + const { data: topPosts } = useTopPosts( + organizationId, + EXPERIMENT_POST_PICKER_LIMIT + ); + + const posts = topPosts?.posts ?? []; + const variantAPost = posts.find( + (post) => post.platformPostId === variantAPostId + ); + + const canCreate = + name.trim().length > 0 && + variantAPostId.length > 0 && + variantBPostId.length > 0 && + variantAPostId !== variantBPostId; + + const handleCreate = () => { + if (!canCreate) { + return; + } + create.mutate( + { + name: name.trim(), + provider: variantAPost?.provider ?? "twitter", + variantAPostId, + variantBPostId, + metric, + }, + { + onSuccess: () => { + setName(""); + setVariantAPostId(""); + setVariantBPostId(""); + onOpenChange(false); + }, + } + ); + }; + + return ( + + + + New A/B test + + Compare two published posts on one metric. Metrics update on every + sync until you declare a winner. + + +
+
+ + setName(event.target.value)} + placeholder="Hook style: question vs statement" + value={name} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/experiments/experiment-card.tsx b/apps/dashboard/src/components/experiments/experiment-card.tsx new file mode 100644 index 000000000..22add9dcb --- /dev/null +++ b/apps/dashboard/src/components/experiments/experiment-card.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Badge } from "@notra/ui/components/ui/badge"; +import { Button } from "@notra/ui/components/ui/button"; +import { Card, CardContent, CardHeader } from "@notra/ui/components/ui/card"; +import { EXPERIMENT_METRIC_LABELS } from "@/constants/experiments"; +import { + useExperimentCancel, + useExperimentComplete, + useExperimentRemove, +} from "@/lib/hooks/use-experiments"; +import { cn } from "@/lib/utils"; +import type { + ExperimentItem, + ExperimentVariantStats, +} from "@/types/experiments"; +import { formatMetric } from "@/utils/analytics-charts"; + +interface ExperimentCardProps { + experiment: ExperimentItem; + organizationId: string; +} + +const CONTENT_PREVIEW_LENGTH = 80; + +function previewContent(content: string | null): string { + if (!content) { + return "Post not tracked yet"; + } + const singleLine = content.replace(/\s+/g, " ").trim(); + if (singleLine.length <= CONTENT_PREVIEW_LENGTH) { + return singleLine; + } + return `${singleLine.slice(0, CONTENT_PREVIEW_LENGTH)}…`; +} + +function statusBadge(experiment: ExperimentItem) { + if (experiment.status === "running") { + return Running; + } + if (experiment.status === "cancelled") { + return Cancelled; + } + if (experiment.winner === "tie") { + return Tie; + } + return Winner: {experiment.winner?.toUpperCase() ?? "?"}; +} + +function VariantRow({ + label, + variant, + otherValue, + highlighted, +}: { + label: string; + variant: ExperimentVariantStats; + otherValue: number | null; + highlighted: boolean; +}) { + const value = variant.value ?? 0; + const max = Math.max(value, otherValue ?? 0, 1); + const widthPercent = Math.max(Math.round((value / max) * 100), 4); + + const body = ( +
+
+ + Variant {label} + + + {variant.value === null ? "N/A" : formatMetric(variant.value)} + +
+

{previewContent(variant.content)}

+
+
+
+
+ ); + + if (variant.url) { + return ( + + {body} + + ); + } + return body; +} + +export function ExperimentCard({ + experiment, + organizationId, +}: ExperimentCardProps) { + const complete = useExperimentComplete(organizationId); + const cancel = useExperimentCancel(organizationId); + const remove = useExperimentRemove(organizationId); + + const leader = + experiment.status === "completed" ? experiment.winner : experiment.leader; + + return ( + + +
+

{experiment.name}

+

+ {EXPERIMENT_METRIC_LABELS[experiment.metric] ?? experiment.metric} + {experiment.liftPercent !== null && + experiment.liftPercent > 0 && + ` · ${experiment.liftPercent}% lift`} + {experiment.hypothesis && ` · ${experiment.hypothesis}`} +

+
+ {statusBadge(experiment)} +
+ +
+ + +
+
+ {experiment.status === "running" ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/constants/experiments.ts b/apps/dashboard/src/constants/experiments.ts new file mode 100644 index 000000000..2e925b085 --- /dev/null +++ b/apps/dashboard/src/constants/experiments.ts @@ -0,0 +1,14 @@ +export const EXPERIMENT_METRICS = [ + "engagement", + "impressions", + "likes", +] as const; + +export const EXPERIMENT_METRIC_LABELS: Record = { + engagement: "Engagement", + impressions: "Impressions", + likes: "Likes", +}; + +export const EXPERIMENT_WINNER_LIFT_THRESHOLD = 0.05; +export const EXPERIMENT_POST_PICKER_LIMIT = 30; diff --git a/apps/dashboard/src/lib/hooks/use-experiments.ts b/apps/dashboard/src/lib/hooks/use-experiments.ts new file mode 100644 index 000000000..b35b9c9fe --- /dev/null +++ b/apps/dashboard/src/lib/hooks/use-experiments.ts @@ -0,0 +1,89 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import type { + ExperimentMetric, + ExperimentsResponse, +} from "@/types/experiments"; +import { dashboardOrpc } from "../orpc/query"; + +export function useExperiments(organizationId: string) { + return useQuery({ + ...dashboardOrpc.experiments.list.queryOptions({ + input: { organizationId }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load experiments" }, + }); +} + +function useInvalidateExperiments(organizationId: string) { + const queryClient = useQueryClient(); + return () => + queryClient.invalidateQueries({ + queryKey: dashboardOrpc.experiments.list.queryKey({ + input: { organizationId }, + }), + }); +} + +export function useExperimentCreate(organizationId: string) { + const invalidate = useInvalidateExperiments(organizationId); + return useMutation({ + mutationFn: (input: { + name: string; + hypothesis?: string; + provider: string; + variantAPostId: string; + variantBPostId: string; + metric: ExperimentMetric; + }) => dashboardOrpc.experiments.create.call({ organizationId, ...input }), + onSuccess: async () => { + toast.success("Experiment started"); + await invalidate(); + }, + onError: () => toast.error("Failed to create experiment"), + }); +} + +export function useExperimentComplete(organizationId: string) { + const invalidate = useInvalidateExperiments(organizationId); + return useMutation({ + mutationFn: (experimentId: string) => + dashboardOrpc.experiments.complete.call({ organizationId, experimentId }), + onSuccess: async (result) => { + toast.success( + result.winner === "tie" + ? "Completed: too close to call" + : `Completed: variant ${result.winner?.toUpperCase() ?? "?"} wins` + ); + await invalidate(); + }, + onError: () => toast.error("Failed to complete experiment"), + }); +} + +export function useExperimentCancel(organizationId: string) { + const invalidate = useInvalidateExperiments(organizationId); + return useMutation({ + mutationFn: (experimentId: string) => + dashboardOrpc.experiments.cancel.call({ organizationId, experimentId }), + onSuccess: async () => { + await invalidate(); + }, + onError: () => toast.error("Failed to cancel experiment"), + }); +} + +export function useExperimentRemove(organizationId: string) { + const invalidate = useInvalidateExperiments(organizationId); + return useMutation({ + mutationFn: (experimentId: string) => + dashboardOrpc.experiments.remove.call({ organizationId, experimentId }), + onSuccess: async () => { + await invalidate(); + }, + onError: () => toast.error("Failed to delete experiment"), + }); +} diff --git a/apps/dashboard/src/lib/orpc/router.ts b/apps/dashboard/src/lib/orpc/router.ts index 56a733c7d..d9ebd8f5c 100644 --- a/apps/dashboard/src/lib/orpc/router.ts +++ b/apps/dashboard/src/lib/orpc/router.ts @@ -4,6 +4,7 @@ import { attachmentsRouter } from "./routers/attachments"; import { automationRouter } from "./routers/automation"; import { brandRouter } from "./routers/brand"; import { contentRouter } from "./routers/content"; +import { experimentsRouter } from "./routers/experiments"; import { feedbackRouter } from "./routers/feedback"; import { githubRouter } from "./routers/github"; import { integrationsRouter } from "./routers/integrations"; @@ -24,6 +25,7 @@ export const dashboardRouter = { automation: automationRouter, brand: brandRouter, content: contentRouter, + experiments: experimentsRouter, feedback: feedbackRouter, github: githubRouter, iris: irisRouter, diff --git a/apps/dashboard/src/lib/orpc/routers/experiments.ts b/apps/dashboard/src/lib/orpc/routers/experiments.ts new file mode 100644 index 000000000..b294c32b6 --- /dev/null +++ b/apps/dashboard/src/lib/orpc/routers/experiments.ts @@ -0,0 +1,272 @@ +import { + isTinybirdConfigured, + queryPostMetricsLookup, +} from "@notra/analytics/tinybird/client"; +import { db } from "@notra/db/drizzle"; +import { socialExperiments } from "@notra/db/schema"; +import { and, desc, eq } from "drizzle-orm"; +import { assertOrganizationAccess } from "@/lib/auth/organization"; +import { authorizedProcedure } from "@/lib/orpc/base"; +import { + experimentActionInputSchema, + experimentCreateInputSchema, + experimentMetricSchema, + experimentsOrganizationInputSchema, +} from "@/schemas/experiments"; +import type { + ExperimentItem, + ExperimentStatus, + ExperimentsResponse, + ExperimentVariantStats, + ExperimentWinner, +} from "@/types/experiments"; +import { + computeLeader, + computeLiftPercent, + metricValue, +} from "@/utils/experiments"; +import { notFound } from "../utils/errors"; + +interface PostLookupEntry { + content: string; + url: string | null; + impressions: number | null; + likes: number | null; + replies: number | null; + reposts: number | null; +} + +function parseStatus(value: string): ExperimentStatus { + if (value === "completed" || value === "cancelled") { + return value; + } + return "running"; +} + +function parseWinner(value: string | null): ExperimentWinner | null { + if (value === "a" || value === "b" || value === "tie") { + return value; + } + return null; +} + +function toNullableNumber(value: number | bigint | null): number | null { + if (value === null) { + return null; + } + return Number(value); +} + +function buildVariant( + postId: string, + metric: "engagement" | "impressions" | "likes", + lookup: Map +): ExperimentVariantStats { + const entry = lookup.get(postId) ?? null; + return { + postId, + content: entry?.content ?? null, + url: entry?.url ?? null, + value: metricValue(metric, entry), + impressions: entry?.impressions ?? null, + likes: entry?.likes ?? null, + replies: entry?.replies ?? null, + reposts: entry?.reposts ?? null, + }; +} + +async function loadPostLookup( + organizationId: string, + postIds: string[] +): Promise> { + if (postIds.length === 0 || !isTinybirdConfigured()) { + return new Map(); + } + const result = await queryPostMetricsLookup({ + organization_id: organizationId, + post_ids: postIds, + }).catch((error) => { + console.error("[Experiments] post lookup failed:", error); + return null; + }); + return new Map( + (result?.data ?? []).map((row) => [ + row.platform_post_id, + { + content: row.content, + url: row.url, + impressions: toNullableNumber(row.impressions), + likes: toNullableNumber(row.likes), + replies: toNullableNumber(row.replies), + reposts: toNullableNumber(row.reposts), + }, + ]) + ); +} + +async function findExperiment(organizationId: string, experimentId: string) { + const experiment = await db.query.socialExperiments.findFirst({ + where: and( + eq(socialExperiments.id, experimentId), + eq(socialExperiments.organizationId, organizationId) + ), + }); + if (!experiment) { + throw notFound("Experiment not found"); + } + return experiment; +} + +export const experimentsRouter = { + list: authorizedProcedure + .input(experimentsOrganizationInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const rows = await db.query.socialExperiments.findMany({ + where: eq(socialExperiments.organizationId, input.organizationId), + orderBy: [desc(socialExperiments.createdAt)], + }); + + const postIds = [ + ...new Set( + rows.flatMap((row) => [row.variantAPostId, row.variantBPostId]) + ), + ]; + const lookup = await loadPostLookup(input.organizationId, postIds); + + const experiments: ExperimentItem[] = rows.map((row) => { + const metric = experimentMetricSchema + .catch("engagement") + .parse(row.metric); + const variantA = buildVariant(row.variantAPostId, metric, lookup); + const variantB = buildVariant(row.variantBPostId, metric, lookup); + return { + id: row.id, + name: row.name, + hypothesis: row.hypothesis, + provider: row.provider, + metric, + status: parseStatus(row.status), + winner: parseWinner(row.winner), + startedAt: row.startedAt.toISOString(), + endedAt: row.endedAt?.toISOString() ?? null, + variantA, + variantB, + liftPercent: computeLiftPercent(variantA.value, variantB.value), + leader: computeLeader(variantA.value, variantB.value), + }; + }); + + return { configured: isTinybirdConfigured(), experiments }; + }), + create: authorizedProcedure + .input(experimentCreateInputSchema) + .handler(async ({ context, input }) => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [created] = await db + .insert(socialExperiments) + .values({ + id: crypto.randomUUID(), + organizationId: input.organizationId, + name: input.name, + hypothesis: input.hypothesis ?? null, + provider: input.provider, + variantAPostId: input.variantAPostId, + variantBPostId: input.variantBPostId, + metric: input.metric, + }) + .returning({ id: socialExperiments.id }); + + return { id: created?.id ?? null }; + }), + complete: authorizedProcedure + .input(experimentActionInputSchema) + .handler(async ({ context, input }) => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const experiment = await findExperiment( + input.organizationId, + input.experimentId + ); + const metric = experimentMetricSchema + .catch("engagement") + .parse(experiment.metric); + const lookup = await loadPostLookup(input.organizationId, [ + experiment.variantAPostId, + experiment.variantBPostId, + ]); + const valueA = metricValue( + metric, + lookup.get(experiment.variantAPostId) ?? null + ); + const valueB = metricValue( + metric, + lookup.get(experiment.variantBPostId) ?? null + ); + const winner = computeLeader(valueA, valueB); + + await db + .update(socialExperiments) + .set({ + status: "completed", + winner, + endedAt: new Date(), + }) + .where(eq(socialExperiments.id, experiment.id)); + + return { winner }; + }), + cancel: authorizedProcedure + .input(experimentActionInputSchema) + .handler(async ({ context, input }) => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const experiment = await findExperiment( + input.organizationId, + input.experimentId + ); + await db + .update(socialExperiments) + .set({ status: "cancelled", endedAt: new Date() }) + .where(eq(socialExperiments.id, experiment.id)); + + return { success: true }; + }), + remove: authorizedProcedure + .input(experimentActionInputSchema) + .handler(async ({ context, input }) => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const experiment = await findExperiment( + input.organizationId, + input.experimentId + ); + await db + .delete(socialExperiments) + .where(eq(socialExperiments.id, experiment.id)); + + return { success: true }; + }), +}; diff --git a/apps/dashboard/src/schemas/experiments.ts b/apps/dashboard/src/schemas/experiments.ts new file mode 100644 index 000000000..81c002730 --- /dev/null +++ b/apps/dashboard/src/schemas/experiments.ts @@ -0,0 +1,23 @@ +import { object, string, enum as zodEnum } from "zod"; +import { EXPERIMENT_METRICS } from "@/constants/experiments"; + +export const experimentMetricSchema = zodEnum(EXPERIMENT_METRICS); + +export const experimentsOrganizationInputSchema = object({ + organizationId: string().min(1), +}); + +export const experimentCreateInputSchema = object({ + organizationId: string().min(1), + name: string().min(1).max(120), + hypothesis: string().max(500).optional(), + provider: string().min(1), + variantAPostId: string().min(1), + variantBPostId: string().min(1), + metric: experimentMetricSchema, +}); + +export const experimentActionInputSchema = object({ + organizationId: string().min(1), + experimentId: string().min(1), +}); diff --git a/apps/dashboard/src/types/experiments.ts b/apps/dashboard/src/types/experiments.ts new file mode 100644 index 000000000..3885756bf --- /dev/null +++ b/apps/dashboard/src/types/experiments.ts @@ -0,0 +1,37 @@ +export type ExperimentMetric = "engagement" | "impressions" | "likes"; + +export type ExperimentStatus = "running" | "completed" | "cancelled"; + +export type ExperimentWinner = "a" | "b" | "tie"; + +export interface ExperimentVariantStats { + postId: string; + content: string | null; + url: string | null; + value: number | null; + impressions: number | null; + likes: number | null; + replies: number | null; + reposts: number | null; +} + +export interface ExperimentItem { + id: string; + name: string; + hypothesis: string | null; + provider: string; + metric: ExperimentMetric; + status: ExperimentStatus; + winner: ExperimentWinner | null; + startedAt: string; + endedAt: string | null; + variantA: ExperimentVariantStats; + variantB: ExperimentVariantStats; + liftPercent: number | null; + leader: ExperimentWinner | null; +} + +export interface ExperimentsResponse { + configured: boolean; + experiments: ExperimentItem[]; +} diff --git a/apps/dashboard/src/utils/experiments.ts b/apps/dashboard/src/utils/experiments.ts new file mode 100644 index 000000000..4d8c25c2d --- /dev/null +++ b/apps/dashboard/src/utils/experiments.ts @@ -0,0 +1,55 @@ +import { EXPERIMENT_WINNER_LIFT_THRESHOLD } from "@/constants/experiments"; +import type { ExperimentMetric, ExperimentWinner } from "@/types/experiments"; + +interface VariantMetricInput { + impressions: number | null; + likes: number | null; + replies: number | null; + reposts: number | null; +} + +export function metricValue( + metric: ExperimentMetric, + stats: VariantMetricInput | null +): number | null { + if (!stats) { + return null; + } + if (metric === "impressions") { + return stats.impressions; + } + if (metric === "likes") { + return stats.likes; + } + return (stats.likes ?? 0) + (stats.replies ?? 0) + (stats.reposts ?? 0); +} + +export function computeLiftPercent( + valueA: number | null, + valueB: number | null +): number | null { + if (valueA === null || valueB === null) { + return null; + } + const base = Math.min(valueA, valueB); + const top = Math.max(valueA, valueB); + if (base === 0) { + return top === 0 ? 0 : null; + } + return Math.round(((top - base) / base) * 100); +} + +export function computeLeader( + valueA: number | null, + valueB: number | null +): ExperimentWinner | null { + if (valueA === null || valueB === null) { + return null; + } + const base = Math.max(Math.min(valueA, valueB), 1); + const relativeLift = Math.abs(valueA - valueB) / base; + if (relativeLift < EXPERIMENT_WINNER_LIFT_THRESHOLD) { + return "tie"; + } + return valueA > valueB ? "a" : "b"; +} diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index 6a8ee75b5..2314e2929 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -25,7 +25,9 @@ import { notraAdoption, type PostingPerformanceParams, type PostingPerformanceRow, + type PostMetricsLookupRow, postingPerformance, + postMetricsLookup, type SocialOverviewParams, type SocialOverviewRow, socialOverview, @@ -57,6 +59,7 @@ function createTinybirdClient() { followerGrowth, postingPerformance, notraAdoption, + postMetricsLookup, accountLeaderboard, }, }); @@ -197,3 +200,14 @@ export async function queryAccountLeaderboard( } return await client.accountLeaderboard.query(params); } + +export async function queryPostMetricsLookup(params: { + organization_id: string; + post_ids: string[]; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.postMetricsLookup.query(params); +} diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts index bf302626e..375e53bc5 100644 --- a/packages/analytics/src/tinybird/endpoints.ts +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -477,3 +477,70 @@ export const notraAdoption = defineEndpoint("notra_adoption", { }); export type NotraAdoptionRow = InferOutputRow; + +export const postMetricsLookup = defineEndpoint("post_metrics_lookup", { + description: "Latest metric snapshot for specific posts by platform post id", + params: { + organization_id: p.string().describe("Organization id"), + post_ids: p.array(p.string()).describe("Platform post ids"), + }, + nodes: [ + node({ + name: "stats_lookup", + sql: ` + SELECT + provider, + platform_post_id, + argMax(impressions, captured_at) AS impressions, + argMax(likes, captured_at) AS likes, + argMax(replies, captured_at) AS replies, + argMax(reposts, captured_at) AS reposts, + argMax(bookmarks, captured_at) AS bookmarks, + max(captured_at) AS last_captured_at + FROM social_post_stats + WHERE organization_id = {{String(organization_id)}} + AND platform_post_id IN {{Array(post_ids, 'String')}} + GROUP BY provider, platform_post_id + `, + }), + node({ + name: "lookup", + sql: ` + SELECT + posts.provider AS provider, + posts.platform_post_id AS platform_post_id, + argMax(posts.content, posts.captured_at) AS content, + argMax(posts.url, posts.captured_at) AS url, + min(posts.posted_at) AS first_posted_at, + any(stats.impressions) AS impressions, + any(stats.likes) AS likes, + any(stats.replies) AS replies, + any(stats.reposts) AS reposts, + any(stats.bookmarks) AS bookmarks, + any(stats.last_captured_at) AS last_captured_at + FROM social_posts AS posts + LEFT JOIN stats_lookup AS stats + ON stats.provider = posts.provider + AND stats.platform_post_id = posts.platform_post_id + WHERE posts.organization_id = {{String(organization_id)}} + AND posts.platform_post_id IN {{Array(post_ids, 'String')}} + GROUP BY posts.provider, posts.platform_post_id + `, + }), + ], + output: { + provider: t.string(), + platform_post_id: t.string(), + content: t.string(), + url: t.string().nullable(), + first_posted_at: t.dateTime(), + impressions: t.uint64().nullable(), + likes: t.uint64().nullable(), + replies: t.uint64().nullable(), + reposts: t.uint64().nullable(), + bookmarks: t.uint64().nullable(), + last_captured_at: t.dateTime().nullable(), + }, +}); + +export type PostMetricsLookupRow = InferOutputRow; diff --git a/packages/db/migrations/0063_slimy_hellcat.sql b/packages/db/migrations/0063_slimy_hellcat.sql new file mode 100644 index 000000000..a6029f6df --- /dev/null +++ b/packages/db/migrations/0063_slimy_hellcat.sql @@ -0,0 +1,19 @@ +CREATE TABLE "social_experiments" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "name" text NOT NULL, + "hypothesis" text, + "provider" text NOT NULL, + "variant_a_post_id" text NOT NULL, + "variant_b_post_id" text NOT NULL, + "metric" text NOT NULL, + "status" text DEFAULT 'running' NOT NULL, + "winner" text, + "started_at" timestamp DEFAULT now() NOT NULL, + "ended_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "social_experiments" ADD CONSTRAINT "social_experiments_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "socialExperiments_organizationId_idx" ON "social_experiments" USING btree ("organization_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0063_snapshot.json b/packages/db/migrations/meta/0063_snapshot.json new file mode 100644 index 000000000..f3d80b345 --- /dev/null +++ b/packages/db/migrations/meta/0063_snapshot.json @@ -0,0 +1,9126 @@ +{ + "id": "3e7d6177-419d-4b9f-ac44-8ddda86c56de", + "prevId": "15722568-6fad-4c25-a22b-220cdc45d546", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_userId_idx": { + "name": "accounts_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eve_session_id": { + "name": "eve_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "continuation_token": { + "name": "continuation_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_index": { + "name": "stream_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentSessions_eveSessionId_uidx": { + "name": "agentSessions_eveSessionId_uidx", + "columns": [ + { + "expression": "eve_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_organizationId_idx": { + "name": "agentSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_chatId_idx": { + "name": "agentSessions_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_organization_id_organizations_id_fk": { + "name": "agent_sessions_organization_id_organizations_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_chat_id_chat_sessions_id_fk": { + "name": "agent_sessions_chat_id_chat_sessions_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_actions": { + "name": "autonomy_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "external_ref": { + "name": "external_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyActions_organizationId_idx": { + "name": "autonomyActions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_runId_idx": { + "name": "autonomyActions_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_org_capability_idempotency_uidx": { + "name": "autonomyActions_org_capability_idempotency_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_organizationId_status_idx": { + "name": "autonomyActions_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_actions_organization_id_organizations_id_fk": { + "name": "autonomy_actions_organization_id_organizations_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_run_id_autonomy_runs_id_fk": { + "name": "autonomy_actions_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_actions_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_checkpoints": { + "name": "autonomy_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyCheckpoints_organizationId_idx": { + "name": "autonomyCheckpoints_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyCheckpoints_runId_idx": { + "name": "autonomyCheckpoints_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_checkpoints_organization_id_organizations_id_fk": { + "name": "autonomy_checkpoints_organization_id_organizations_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_run_id_autonomy_runs_id_fk": { + "name": "autonomy_checkpoints_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_checkpoints_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_claims": { + "name": "autonomy_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_key": { + "name": "claim_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyClaims_scope_claimKey_uidx": { + "name": "autonomyClaims_scope_claimKey_uidx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyClaims_expiresAt_idx": { + "name": "autonomyClaims_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_claims_organization_id_organizations_id_fk": { + "name": "autonomy_claims_organization_id_organizations_id_fk", + "tableFrom": "autonomy_claims", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_controller_leases": { + "name": "autonomy_controller_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyControllerLeases_organizationId_idx": { + "name": "autonomyControllerLeases_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_controller_leases_organization_id_organizations_id_fk": { + "name": "autonomy_controller_leases_organization_id_organizations_id_fk", + "tableFrom": "autonomy_controller_leases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_goals": { + "name": "autonomy_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_goal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "origin_signal_ids": { + "name": "origin_signal_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyGoals_organizationId_idx": { + "name": "autonomyGoals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_mandateId_idx": { + "name": "autonomyGoals_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_organizationId_status_idx": { + "name": "autonomyGoals_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_goals_organization_id_organizations_id_fk": { + "name": "autonomy_goals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_goals_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_goals_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_mandates": { + "name": "autonomy_mandates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_mandate_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyMandates_organizationId_idx": { + "name": "autonomyMandates_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyMandates_organizationId_name_uidx": { + "name": "autonomyMandates_organizationId_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_mandates_organization_id_organizations_id_fk": { + "name": "autonomy_mandates_organization_id_organizations_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_mandates_created_by_user_id_users_id_fk": { + "name": "autonomy_mandates_created_by_user_id_users_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_outbox": { + "name": "autonomy_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyOutbox_organizationId_idx": { + "name": "autonomyOutbox_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_org_destination_dedupeKey_uidx": { + "name": "autonomyOutbox_org_destination_dedupeKey_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_status_nextAttemptAt_idx": { + "name": "autonomyOutbox_status_nextAttemptAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_outbox_organization_id_organizations_id_fk": { + "name": "autonomy_outbox_organization_id_organizations_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_outbox_run_id_autonomy_runs_id_fk": { + "name": "autonomy_outbox_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_runs": { + "name": "autonomy_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_version": { + "name": "mandate_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "autonomy_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "planner_input_hash": { + "name": "planner_input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planner_output": { + "name": "planner_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyRuns_organizationId_idx": { + "name": "autonomyRuns_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_mandateId_idx": { + "name": "autonomyRuns_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_goalId_idx": { + "name": "autonomyRuns_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_organizationId_status_idx": { + "name": "autonomyRuns_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_runs_organization_id_organizations_id_fk": { + "name": "autonomy_runs_organization_id_organizations_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_runs_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_runs_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_signals": { + "name": "autonomy_signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_signal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "coalesced_into_signal_id": { + "name": "coalesced_into_signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomySignals_organizationId_idx": { + "name": "autonomySignals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_dedupeHash_uidx": { + "name": "autonomySignals_organizationId_dedupeHash_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_status_occurredAt_idx": { + "name": "autonomySignals_organizationId_status_occurredAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_signals_organization_id_organizations_id_fk": { + "name": "autonomy_signals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_signals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomySignals_coalescedIntoSignalId_fk": { + "name": "autonomySignals_coalescedIntoSignalId_fk", + "tableFrom": "autonomy_signals", + "tableTo": "autonomy_signals", + "columnsFrom": [ + "coalesced_into_signal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_tasks": { + "name": "autonomy_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "depends_on_task_ids": { + "name": "depends_on_task_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "autonomy_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyTasks_organizationId_idx": { + "name": "autonomyTasks_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_goalId_idx": { + "name": "autonomyTasks_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_runId_idx": { + "name": "autonomyTasks_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_organizationId_status_waitUntil_idx": { + "name": "autonomyTasks_organizationId_status_waitUntil_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_tasks_organization_id_organizations_id_fk": { + "name": "autonomy_tasks_organization_id_organizations_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_tasks_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_run_id_autonomy_runs_id_fk": { + "name": "autonomy_tasks_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_assets": { + "name": "brand_guideline_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_asset_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "brand_guideline_asset_variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineAssets_guidelineId_idx": { + "name": "brandGuidelineAssets_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_idx": { + "name": "brandGuidelineAssets_guideline_kind_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_variant_uidx": { + "name": "brandGuidelineAssets_guideline_kind_variant_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_assets_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_assets_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_assets", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_colors": { + "name": "brand_guideline_colors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_color_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "light_value": { + "name": "light_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dark_value": { + "name": "dark_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineColors_guidelineId_idx": { + "name": "brandGuidelineColors_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineColors_guideline_role_idx": { + "name": "brandGuidelineColors_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_colors_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_colors_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_colors", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_fonts": { + "name": "brand_guideline_fonts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_font_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_height": { + "name": "line_height", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineFonts_guidelineId_idx": { + "name": "brandGuidelineFonts_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineFonts_guideline_role_idx": { + "name": "brandGuidelineFonts_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_fonts", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_screenshots": { + "name": "brand_guideline_screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_screenshot_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_page": { + "name": "full_page", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineScreenshots_guidelineId_idx": { + "name": "brandGuidelineScreenshots_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineScreenshots_guideline_kind_uidx": { + "name": "brandGuidelineScreenshots_guideline_kind_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_screenshots", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_tokens": { + "name": "brand_guideline_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "brand_guideline_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineTokens_guidelineId_idx": { + "name": "brandGuidelineTokens_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineTokens_guideline_type_idx": { + "name": "brandGuidelineTokens_guideline_type_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_tokens", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guidelines": { + "name": "brand_guidelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_guideline_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_generated_at": { + "name": "last_generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_generation_error": { + "name": "last_generation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelines_brandSettingsId_uidx": { + "name": "brandGuidelines_brandSettingsId_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelines_status_idx": { + "name": "brandGuidelines_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guidelines_brand_settings_id_brand_settings_id_fk": { + "name": "brand_guidelines_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_guidelines", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_references": { + "name": "brand_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "reference_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_key": { + "name": "source_snapshot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_hash": { + "name": "source_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_captured_at": { + "name": "source_captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_document_id": { + "name": "supermemory_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_memory_id": { + "name": "supermemory_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_synced_at": { + "name": "supermemory_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supermemory_last_sync_error": { + "name": "supermemory_last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicable_to": { + "name": "applicable_to", + "type": "applicable_platform[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['all']::applicable_platform[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandReferences_brandSettingsId_idx": { + "name": "brandReferences_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandReferences_brandSettingsId_sourceUrl_idx": { + "name": "brandReferences_brandSettingsId_sourceUrl_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_references_brand_settings_id_brand_settings_id_fk": { + "name": "brand_references_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_references", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_settings": { + "name": "brand_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tone_profile": { + "name": "tone_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_tone": { + "name": "custom_tone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_instructions": { + "name": "custom_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'English'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSettings_org_name_uidx": { + "name": "brandSettings_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_org_default_uidx": { + "name": "brandSettings_org_default_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brand_settings\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_organizationId_idx": { + "name": "brandSettings_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_settings_organization_id_organizations_id_fk": { + "name": "brand_settings_organization_id_organizations_id_fk", + "tableFrom": "brand_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brandSettings_toneProfile_check": { + "name": "brandSettings_toneProfile_check", + "value": "\"brand_settings\".\"tone_profile\" IS NULL OR \"brand_settings\".\"tone_profile\" IN ('Conversational', 'Professional', 'Casual', 'Formal')" + } + }, + "isRLSEnabled": false + }, + "public.brand_sitemap_pages": { + "name": "brand_sitemap_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sitemap_id": { + "name": "sitemap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "brand_sitemap_page_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_target": { + "name": "redirect_target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text_ratio": { + "name": "text_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "internal_links": { + "name": "internal_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_links": { + "name": "external_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "crawled_at": { + "name": "crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemapPages_sitemapId_idx": { + "name": "brandSitemapPages_sitemapId_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_category_idx": { + "name": "brandSitemapPages_sitemap_category_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_url_uidx": { + "name": "brandSitemapPages_sitemap_url_uidx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk": { + "name": "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk", + "tableFrom": "brand_sitemap_pages", + "tableTo": "brand_sitemaps", + "columnsFrom": [ + "sitemap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_sitemaps": { + "name": "brand_sitemaps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_sitemap_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "total_pages": { + "name": "total_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_pages": { + "name": "indexed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_pages": { + "name": "failed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_crawl_started_at": { + "name": "last_crawl_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawled_at": { + "name": "last_crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawl_error": { + "name": "last_crawl_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemaps_brandSettingsId_idx": { + "name": "brandSitemaps_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemaps_brandSettings_url_uidx": { + "name": "brandSitemaps_brandSettings_url_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemaps_brand_settings_id_brand_settings_id_fk": { + "name": "brand_sitemaps_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_sitemaps", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatAttachments_organizationId_createdAt_idx": { + "name": "chatAttachments_organizationId_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatAttachments_userId_idx": { + "name": "chatAttachments_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_user_id_users_id_fk": { + "name": "chat_attachments_user_id_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_attachments_key_unique": { + "name": "chat_attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_channel_source": { + "name": "external_channel_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_channel_id": { + "name": "external_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatSessions_organizationId_idx": { + "name": "chatSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_organizationId_deletedAt_idx": { + "name": "chatSessions_organizationId_deletedAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_org_externalChannel_uidx": { + "name": "chatSessions_org_externalChannel_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_sessions\".\"external_channel_source\" IN ('discord', 'slack') AND \"chat_sessions\".\"external_channel_id\" IS NOT NULL AND \"chat_sessions\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connected_social_accounts": { + "name": "connected_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connectedSocialAccounts_organizationId_idx": { + "name": "connectedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connectedSocialAccounts_org_provider_account_uidx": { + "name": "connectedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connected_social_accounts_organization_id_organizations_id_fk": { + "name": "connected_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "connected_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_trigger_lookback_windows": { + "name": "content_trigger_lookback_windows", + "schema": "", + "columns": { + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window": { + "name": "window", + "type": "lookback_window", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk": { + "name": "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk", + "tableFrom": "content_trigger_lookback_windows", + "tableTo": "content_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_triggers": { + "name": "content_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Schedule'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_config": { + "name": "source_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_config": { + "name": "output_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_publish": { + "name": "auto_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contentTriggers_organizationId_idx": { + "name": "contentTriggers_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentTriggers_organization_dedupe_uidx": { + "name": "contentTriggers_organization_dedupe_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_triggers_organization_id_organizations_id_fk": { + "name": "content_triggers_organization_id_organizations_id_fk", + "tableFrom": "content_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubAppInstallations_organizationId_idx": { + "name": "githubAppInstallations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_createdByUserId_idx": { + "name": "githubAppInstallations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_organization_installation_uidx": { + "name": "githubAppInstallations_organization_installation_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_organization_id_organizations_id_fk": { + "name": "github_app_installations_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_app_installations_created_by_user_id_users_id_fk": { + "name": "github_app_installations_created_by_user_id_users_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integrations": { + "name": "github_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_installation_id": { + "name": "github_app_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_private": { + "name": "github_repository_private", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_enabled": { + "name": "repository_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubIntegrations_organizationId_idx": { + "name": "githubIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_createdByUserId_idx": { + "name": "githubIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_organization_owner_repo_uidx": { + "name": "githubIntegrations_organization_owner_repo_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_integrations_organization_id_organizations_id_fk": { + "name": "github_integrations_organization_id_organizations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_created_by_user_id_users_id_fk": { + "name": "github_integrations_created_by_user_id_users_id_fk", + "tableFrom": "github_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_github_app_installation_id_github_app_installations_id_fk": { + "name": "github_integrations_github_app_installation_id_github_app_installations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "github_app_installations", + "columnsFrom": [ + "github_app_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.granola_integrations": { + "name": "granola_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "granolaIntegrations_organizationId_idx": { + "name": "granolaIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "granolaIntegrations_createdByUserId_idx": { + "name": "granolaIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "granola_integrations_organization_id_organizations_id_fk": { + "name": "granola_integrations_organization_id_organizations_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "granola_integrations_created_by_user_id_users_id_fk": { + "name": "granola_integrations_created_by_user_id_users_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organizationId_idx": { + "name": "invitations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_integrations": { + "name": "linear_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_name": { + "name": "linear_organization_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_name": { + "name": "linear_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linearIntegrations_organizationId_idx": { + "name": "linearIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_createdByUserId_idx": { + "name": "linearIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_team_uidx": { + "name": "linearIntegrations_org_linearOrg_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_no_team_uidx": { + "name": "linearIntegrations_org_linearOrg_no_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"linear_integrations\".\"linear_team_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_integrations_organization_id_organizations_id_fk": { + "name": "linear_integrations_organization_id_organizations_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "linear_integrations_created_by_user_id_users_id_fk": { + "name": "linear_integrations_created_by_user_id_users_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_credentials": { + "name": "mcp_oauth_credentials", + "schema": "", + "columns": { + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_tokens": { + "name": "encrypted_tokens", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_refresh_at": { + "name": "access_token_refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_lease_id": { + "name": "refresh_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_lease_expires_at": { + "name": "refresh_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthCredentials_organizationId_idx": { + "name": "mcpOAuthCredentials_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthCredentials_connectedByUserId_idx": { + "name": "mcpOAuthCredentials_connectedByUserId_idx", + "columns": [ + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_organization_id_organizations_id_fk": { + "name": "mcp_oauth_credentials_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_connected_by_user_id_users_id_fk": { + "name": "mcp_oauth_credentials_connected_by_user_id_users_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "users", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthCredentials_org_server_fk": { + "name": "mcpOAuthCredentials_org_server_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpOAuthCredentials_status_check": { + "name": "mcpOAuthCredentials_status_check", + "value": "\"mcp_oauth_credentials\".\"status\" IN ('connected', 'refreshing', 'reauth_required')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_oauth_pending_authorizations": { + "name": "mcp_oauth_pending_authorizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_path": { + "name": "callback_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_code_verifier": { + "name": "encrypted_code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthPendingAuthorizations_organizationId_idx": { + "name": "mcpOAuthPendingAuthorizations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_userId_idx": { + "name": "mcpOAuthPendingAuthorizations_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_serverIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_serverIntegrationId_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_expiresAt_idx": { + "name": "mcpOAuthPendingAuthorizations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk": { + "name": "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_user_id_users_id_fk": { + "name": "mcp_oauth_pending_authorizations_user_id_users_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_org_server_fk": { + "name": "mcpOAuthPendingAuthorizations_org_server_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_pending_authorizations_state_hash_unique": { + "name": "mcp_oauth_pending_authorizations_state_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "state_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_integrations": { + "name": "mcp_server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connection'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_light_url": { + "name": "logo_light_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_dark_url": { + "name": "logo_dark_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_featured_at": { + "name": "store_featured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_status": { + "name": "store_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_tool_sync_at": { + "name": "last_tool_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tool_sync_status": { + "name": "tool_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "tool_sync_error": { + "name": "tool_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexed_tool_count": { + "name": "indexed_tool_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpServerIntegrations_resourceType_idx": { + "name": "mcpServerIntegrations_resourceType_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeStatus_idx": { + "name": "mcpServerIntegrations_storeStatus_idx", + "columns": [ + { + "expression": "store_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_organizationId_idx": { + "name": "mcpServerIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_createdByUserId_idx": { + "name": "mcpServerIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeSourceIntegrationId_idx": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_id_uidx": { + "name": "mcpServerIntegrations_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_resourceType_name_uidx": { + "name": "mcpServerIntegrations_org_resourceType_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_storeSource_uidx": { + "name": "mcpServerIntegrations_org_storeSource_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"store_source_integration_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeListing_slug_uidx": { + "name": "mcpServerIntegrations_storeListing_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"resource_type\" = 'store_listing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_integrations_organization_id_organizations_id_fk": { + "name": "mcp_server_integrations_organization_id_organizations_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_integrations_created_by_user_id_users_id_fk": { + "name": "mcp_server_integrations_created_by_user_id_users_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpServerIntegrations_storeSourceIntegrationId_fk": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpServerIntegrations_authType_check": { + "name": "mcpServerIntegrations_authType_check", + "value": "\"mcp_server_integrations\".\"auth_type\" IN ('none', 'headers', 'oauth')" + }, + "mcpServerIntegrations_storeStatus_check": { + "name": "mcpServerIntegrations_storeStatus_check", + "value": "\"mcp_server_integrations\".\"store_status\" IN ('draft', 'pending_review', 'live', 'rejected')" + }, + "mcpServerIntegrations_resourceType_check": { + "name": "mcpServerIntegrations_resourceType_check", + "value": "\"mcp_server_integrations\".\"resource_type\" IN ('connection', 'store_listing')" + }, + "mcpServerIntegrations_category_check": { + "name": "mcpServerIntegrations_category_check", + "value": "\"mcp_server_integrations\".\"category\" IS NULL OR \"mcp_server_integrations\".\"category\" IN ('AI', 'Source control', 'Project management', 'Communication', 'Design', 'Notes', 'Deploys', 'Productivity', 'Marketing', 'Publishing')" + }, + "mcpServerIntegrations_resourceState_check": { + "name": "mcpServerIntegrations_resourceState_check", + "value": "(\n (\"mcp_server_integrations\".\"resource_type\" = 'store_listing' AND \"mcp_server_integrations\".\"store_source_integration_id\" IS NULL)\n OR\n (\"mcp_server_integrations\".\"resource_type\" = 'connection' AND \"mcp_server_integrations\".\"store_status\" = 'draft' AND \"mcp_server_integrations\".\"review_note\" IS NULL AND \"mcp_server_integrations\".\"submitted_at\" IS NULL AND \"mcp_server_integrations\".\"reviewed_at\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.mcp_session_tool_activations": { + "name": "mcp_session_tool_activations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mcp_tool_index_id": { + "name": "mcp_tool_index_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_query": { + "name": "source_query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcpSessionToolActivations_session_tool_uidx": { + "name": "mcpSessionToolActivations_session_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_tool_index_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_session_idx": { + "name": "mcpSessionToolActivations_session_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_expiresAt_idx": { + "name": "mcpSessionToolActivations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_session_tool_activations_organization_id_organizations_id_fk": { + "name": "mcp_session_tool_activations_organization_id_organizations_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk": { + "name": "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "mcp_tool_index_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpSessionToolActivations_org_tool_fk": { + "name": "mcpSessionToolActivations_org_tool_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "organization_id", + "mcp_tool_index_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tool_index": { + "name": "mcp_tool_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_tool_name": { + "name": "server_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_present": { + "name": "action_phrase_present", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_past": { + "name": "action_phrase_past", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_indexed_at": { + "name": "last_indexed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpToolIndex_server_tool_uidx": { + "name": "mcpToolIndex_server_tool_uidx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_id_uidx": { + "name": "mcpToolIndex_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_runtime_tool_uidx": { + "name": "mcpToolIndex_org_runtime_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "runtime_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_organizationId_status_idx": { + "name": "mcpToolIndex_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_serverIntegrationId_status_idx": { + "name": "mcpToolIndex_serverIntegrationId_status_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_searchText_gin_idx": { + "name": "mcpToolIndex_searchText_gin_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "mcp_tool_index_organization_id_organizations_id_fk": { + "name": "mcp_tool_index_organization_id_organizations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpToolIndex_org_server_fk": { + "name": "mcpToolIndex_org_server_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "members_organizationId_idx": { + "name": "members_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_userId_idx": { + "name": "members_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessTokens_clientId_idx": { + "name": "oauthAccessTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_sessionId_idx": { + "name": "oauthAccessTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_userId_idx": { + "name": "oauthAccessTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_refreshId_idx": { + "name": "oauthAccessTokens_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClients_userId_idx": { + "name": "oauthClients_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consents": { + "name": "oauth_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthConsents_clientId_idx": { + "name": "oauthConsents_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsents_userId_idx": { + "name": "oauthConsents_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshTokens_clientId_idx": { + "name": "oauthRefreshTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_sessionId_idx": { + "name": "oauthRefreshTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_userId_idx": { + "name": "oauthRefreshTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_tokens_token_unique": { + "name": "oauth_refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_suggestions": { + "name": "onboarding_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "onboarding_suggestion_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingSuggestions_org_type_idx": { + "name": "onboardingSuggestions_org_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_suggestions_organization_id_organizations_id_fk": { + "name": "onboarding_suggestions_organization_id_organizations_id_fk", + "tableFrom": "onboarding_suggestions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_settings": { + "name": "organization_notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_content_creation": { + "name": "scheduled_content_creation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "scheduled_content_failed": { + "name": "scheduled_content_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scheduled_content_skipped": { + "name": "scheduled_content_skipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketing_emails": { + "name": "marketing_emails", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orgNotificationSettings_organizationId_uidx": { + "name": "orgNotificationSettings_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_notification_settings_organization_id_organizations_id_fk": { + "name": "organization_notification_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_notification_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_source": { + "name": "heard_about_notra_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_other": { + "name": "heard_about_notra_other", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_dismissed": { + "name": "onboarding_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_ran": { + "name": "onboarding_agent_ran", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_started_at": { + "name": "onboarding_agent_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_uidx": { + "name": "organizations_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_collections": { + "name": "post_collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "post_collection_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_source": { + "name": "name_source", + "type": "post_collection_name_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "content_types": { + "name": "content_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expected_post_count": { + "name": "expected_post_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_post_count": { + "name": "completed_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_collections_org_created_at_idx": { + "name": "post_collections_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_source_idx": { + "name": "post_collections_source_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_chat_source_uidx": { + "name": "post_collections_chat_source_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"post_collections\".\"source\" = 'chat' AND \"post_collections\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_collections_organization_id_organizations_id_fk": { + "name": "post_collections_organization_id_organizations_id_fk", + "tableFrom": "post_collections", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recommendations": { + "name": "recommendations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_org_slug_uidx": { + "name": "posts_org_slug_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"posts\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_createdAt_id_idx": { + "name": "posts_org_createdAt_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_collection_id_idx": { + "name": "posts_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_organization_id_organizations_id_fk": { + "name": "posts_organization_id_organizations_id_fk", + "tableFrom": "posts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_collection_id_post_collections_id_fk": { + "name": "posts_collection_id_post_collections_id_fk", + "tableFrom": "posts", + "tableTo": "post_collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_outputs": { + "name": "repository_outputs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositoryOutputs_repositoryId_idx": { + "name": "repositoryOutputs_repositoryId_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositoryOutputs_repository_outputType_uidx": { + "name": "repositoryOutputs_repository_outputType_uidx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "output_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_outputs_repository_id_github_integrations_id_fk": { + "name": "repository_outputs_repository_id_github_integrations_id_fk", + "tableFrom": "repository_outputs", + "tableTo": "github_integrations", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_userId_idx": { + "name": "sessions_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_organizationId_idx": { + "name": "skills_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_org_name_uidx": { + "name": "skills_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_organization_id_organizations_id_fk": { + "name": "skills_organization_id_organizations_id_fk", + "tableFrom": "skills", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_integrations": { + "name": "slack_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_bot_token": { + "name": "encrypted_bot_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_bot_user_id": { + "name": "slack_bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_channel_ids": { + "name": "allowed_channel_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slackIntegrations_organizationId_idx": { + "name": "slackIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_createdByUserId_idx": { + "name": "slackIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_teamId_uidx": { + "name": "slackIntegrations_teamId_uidx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_integrations_organization_id_organizations_id_fk": { + "name": "slack_integrations_organization_id_organizations_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_integrations_created_by_user_id_users_id_fk": { + "name": "slack_integrations_created_by_user_id_users_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_experiments": { + "name": "social_experiments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hypothesis": { + "name": "hypothesis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_a_post_id": { + "name": "variant_a_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_b_post_id": { + "name": "variant_b_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "winner": { + "name": "winner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "socialExperiments_organizationId_idx": { + "name": "socialExperiments_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_experiments_organization_id_organizations_id_fk": { + "name": "social_experiments_organization_id_organizations_id_fk", + "tableFrom": "social_experiments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_social_accounts": { + "name": "tracked_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trackedSocialAccounts_organizationId_idx": { + "name": "trackedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedSocialAccounts_org_provider_account_uidx": { + "name": "trackedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_social_accounts_organization_id_organizations_id_fk": { + "name": "tracked_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "tracked_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hide_personal_data": { + "name": "hide_personal_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_agent_stats": { + "name": "show_agent_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.applicable_platform": { + "name": "applicable_platform", + "schema": "public", + "values": [ + "all", + "twitter", + "linkedin", + "blog" + ] + }, + "public.autonomy_action_status": { + "name": "autonomy_action_status", + "schema": "public", + "values": [ + "pending", + "executing", + "succeeded", + "failed", + "unknown", + "compensated", + "canceled" + ] + }, + "public.autonomy_goal_status": { + "name": "autonomy_goal_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "blocked", + "completed", + "abandoned" + ] + }, + "public.autonomy_mandate_status": { + "name": "autonomy_mandate_status", + "schema": "public", + "values": [ + "active", + "paused", + "revoked" + ] + }, + "public.autonomy_outbox_status": { + "name": "autonomy_outbox_status", + "schema": "public", + "values": [ + "pending", + "attempting", + "delivered", + "failed", + "canceled" + ] + }, + "public.autonomy_run_status": { + "name": "autonomy_run_status", + "schema": "public", + "values": [ + "planning", + "executing", + "completed", + "failed", + "canceled" + ] + }, + "public.autonomy_run_trigger": { + "name": "autonomy_run_trigger", + "schema": "public", + "values": [ + "signal", + "wake", + "manual", + "repair" + ] + }, + "public.autonomy_signal_status": { + "name": "autonomy_signal_status", + "schema": "public", + "values": [ + "pending", + "coalesced", + "processed", + "discarded" + ] + }, + "public.autonomy_task_status": { + "name": "autonomy_task_status", + "schema": "public", + "values": [ + "pending", + "ready", + "running", + "waiting", + "completed", + "failed", + "canceled" + ] + }, + "public.brand_guideline_asset_kind": { + "name": "brand_guideline_asset_kind", + "schema": "public", + "values": [ + "logo", + "wordmark" + ] + }, + "public.brand_guideline_asset_variant": { + "name": "brand_guideline_asset_variant", + "schema": "public", + "values": [ + "light", + "dark" + ] + }, + "public.brand_guideline_color_role": { + "name": "brand_guideline_color_role", + "schema": "public", + "values": [ + "primary", + "secondary", + "accent", + "background", + "foreground", + "neutral", + "custom" + ] + }, + "public.brand_guideline_font_role": { + "name": "brand_guideline_font_role", + "schema": "public", + "values": [ + "heading", + "body", + "button", + "unknown" + ] + }, + "public.brand_guideline_screenshot_kind": { + "name": "brand_guideline_screenshot_kind", + "schema": "public", + "values": [ + "desktop_hero", + "desktop_full_page", + "mobile_hero" + ] + }, + "public.brand_guideline_status": { + "name": "brand_guideline_status", + "schema": "public", + "values": [ + "queued", + "generating", + "ready", + "failed" + ] + }, + "public.brand_guideline_token_type": { + "name": "brand_guideline_token_type", + "schema": "public", + "values": [ + "spacing", + "radius", + "shadow", + "component", + "unknown" + ] + }, + "public.brand_sitemap_page_category": { + "name": "brand_sitemap_page_category", + "schema": "public", + "values": [ + "crawled", + "redirect", + "queued", + "failed" + ] + }, + "public.brand_sitemap_status": { + "name": "brand_sitemap_status", + "schema": "public", + "values": [ + "queued", + "crawling", + "ready", + "failed" + ] + }, + "public.lookback_window": { + "name": "lookback_window", + "schema": "public", + "values": [ + "current_day", + "yesterday", + "last_7_days", + "last_14_days", + "last_30_days" + ] + }, + "public.onboarding_suggestion_type": { + "name": "onboarding_suggestion_type", + "schema": "public", + "values": [ + "schedule_automation", + "event_automation" + ] + }, + "public.post_collection_name_source": { + "name": "post_collection_name_source", + "schema": "public", + "values": [ + "generated", + "user", + "backfill" + ] + }, + "public.post_collection_source": { + "name": "post_collection_source", + "schema": "public", + "values": [ + "manual", + "chat", + "schedule", + "automation", + "api", + "backfill" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.reference_type": { + "name": "reference_type", + "schema": "public", + "values": [ + "twitter_post", + "linkedin_post", + "blog_post", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 0a225e21b..f56613587 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1785669058664, "tag": "0062_brave_menace", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1785669117308, + "tag": "0063_slimy_hellcat", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6d100f919..e1a7c9786 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1452,6 +1452,34 @@ export const organizationNotificationSettings = pgTable( ] ); +export const socialExperiments = pgTable( + "social_experiments", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + hypothesis: text("hypothesis"), + provider: text("provider").notNull(), + variantAPostId: text("variant_a_post_id").notNull(), + variantBPostId: text("variant_b_post_id").notNull(), + metric: text("metric").notNull(), + status: text("status").notNull().default("running"), + winner: text("winner"), + startedAt: timestamp("started_at").defaultNow().notNull(), + endedAt: timestamp("ended_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [ + index("socialExperiments_organizationId_idx").on(table.organizationId), + ] +); + export const postCollections = pgTable( "post_collections", { @@ -2466,6 +2494,16 @@ export const organizationNotificationSettingsRelations = relations( }) ); +export const socialExperimentsRelations = relations( + socialExperiments, + ({ one }) => ({ + organization: one(organizations, { + fields: [socialExperiments.organizationId], + references: [organizations.id], + }), + }) +); + export const postCollectionsRelations = relations( postCollections, ({ one, many }) => ({ From 61d1cdab07e15cbabefd0b660398e995ee719b47 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:13:29 +0200 Subject: [PATCH 04/26] feat(geo): add AI visibility tracking --- .env.example | 5 + apps/dashboard/package.json | 3 + .../[slug]/geo/competitors/loading.tsx | 5 + .../[slug]/geo/competitors/page-client.tsx | 81 + .../[slug]/geo/competitors/page.tsx | 25 + .../app/(dashboard)/[slug]/geo/loading.tsx | 5 + .../(dashboard)/[slug]/geo/page-client.tsx | 135 + .../src/app/(dashboard)/[slug]/geo/page.tsx | 25 + .../[slug]/geo/prompts/loading.tsx | 5 + .../[slug]/geo/prompts/page-client.tsx | 75 + .../(dashboard)/[slug]/geo/prompts/page.tsx | 25 + .../app/(dashboard)/[slug]/geo/skeleton.tsx | 34 + .../src/app/api/workflows/geo-scan/route.ts | 37 + .../components/command-palette/registry.ts | 25 + .../src/components/dashboard/nav-main.tsx | 23 + .../src/components/geo/competitor-manager.tsx | 109 + .../components/geo/competitor-share-card.tsx | 63 + .../components/geo/geo-settings-dialog.tsx | 130 + .../src/components/geo/geo-summary-stats.tsx | 79 + .../src/components/geo/mention-rate-card.tsx | 188 + .../src/components/geo/prompt-manager.tsx | 142 + .../components/geo/prompt-results-card.tsx | 104 + .../components/geo/website-generate-card.tsx | 86 + apps/dashboard/src/constants/geo.ts | 105 + apps/dashboard/src/lib/geo/discover.ts | 209 + apps/dashboard/src/lib/geo/engines.ts | 111 + apps/dashboard/src/lib/geo/errors.ts | 11 + apps/dashboard/src/lib/geo/prompts.ts | 94 + apps/dashboard/src/lib/geo/scan.ts | 314 + apps/dashboard/src/lib/hooks/use-geo.ts | 198 + apps/dashboard/src/lib/orpc/router.ts | 2 + apps/dashboard/src/lib/orpc/routers/geo.ts | 421 + apps/dashboard/src/lib/workflows/start.ts | 10 + apps/dashboard/src/schemas/geo.ts | 80 + apps/dashboard/src/types/components/nav.ts | 7 +- apps/dashboard/src/types/geo.ts | 179 + apps/dashboard/src/utils/geo-charts.ts | 37 + apps/dashboard/src/workflows/geo-scan.ts | 18 + .../src/workflows/steps/geo-scan-steps.ts | 10 + bun.lock | 29 +- packages/analytics/src/tinybird/client.ts | 67 + .../analytics/src/tinybird/datasources.ts | 23 + packages/analytics/src/tinybird/endpoints.ts | 136 + .../db/migrations/0064_exotic_scourge.sql | 24 + .../db/migrations/meta/0064_snapshot.json | 9304 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 59 + turbo.json | 3 + 48 files changed, 12859 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/loading.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page-client.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/loading.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/loading.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page-client.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/[slug]/geo/skeleton.tsx create mode 100644 apps/dashboard/src/app/api/workflows/geo-scan/route.ts create mode 100644 apps/dashboard/src/components/geo/competitor-manager.tsx create mode 100644 apps/dashboard/src/components/geo/competitor-share-card.tsx create mode 100644 apps/dashboard/src/components/geo/geo-settings-dialog.tsx create mode 100644 apps/dashboard/src/components/geo/geo-summary-stats.tsx create mode 100644 apps/dashboard/src/components/geo/mention-rate-card.tsx create mode 100644 apps/dashboard/src/components/geo/prompt-manager.tsx create mode 100644 apps/dashboard/src/components/geo/prompt-results-card.tsx create mode 100644 apps/dashboard/src/components/geo/website-generate-card.tsx create mode 100644 apps/dashboard/src/constants/geo.ts create mode 100644 apps/dashboard/src/lib/geo/discover.ts create mode 100644 apps/dashboard/src/lib/geo/engines.ts create mode 100644 apps/dashboard/src/lib/geo/errors.ts create mode 100644 apps/dashboard/src/lib/geo/prompts.ts create mode 100644 apps/dashboard/src/lib/geo/scan.ts create mode 100644 apps/dashboard/src/lib/hooks/use-geo.ts create mode 100644 apps/dashboard/src/lib/orpc/routers/geo.ts create mode 100644 apps/dashboard/src/schemas/geo.ts create mode 100644 apps/dashboard/src/types/geo.ts create mode 100644 apps/dashboard/src/utils/geo-charts.ts create mode 100644 apps/dashboard/src/workflows/geo-scan.ts create mode 100644 apps/dashboard/src/workflows/steps/geo-scan-steps.ts create mode 100644 packages/db/migrations/0064_exotic_scourge.sql create mode 100644 packages/db/migrations/meta/0064_snapshot.json diff --git a/.env.example b/.env.example index 498798abe..cf0f21b0a 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,11 @@ UNKEY_API_ID= # Twitter TWITTER_BEARER_TOKEN= +# GEO grounded scanning (optional) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +PERPLEXITY_API_KEY= + # Tinybird (social analytics) TINYBIRD_TOKEN= TINYBIRD_BASE_URL=https://api.tinybird.co diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index fe1c9c41c..34cd91179 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -17,6 +17,9 @@ "smoke:autonomy": "bun run scripts/autonomy-smoke.ts" }, "dependencies": { + "@ai-sdk/anthropic": "3.0.84", + "@ai-sdk/openai": "3.0.71", + "@ai-sdk/perplexity": "3.0.35", "@ai-sdk/react": "3.0.208", "@aws-sdk/client-s3": "^3.990.0", "@aws-sdk/s3-request-presigner": "^3.990.0", diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/loading.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/loading.tsx new file mode 100644 index 000000000..6c552f14e --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/loading.tsx @@ -0,0 +1,5 @@ +import { GeoPageSkeleton } from "../skeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page-client.tsx new file mode 100644 index 000000000..9d58ce0fe --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page-client.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Link from "next/link"; +import { EmptyState } from "@/components/empty-state"; +import { CompetitorManager } from "@/components/geo/competitor-manager"; +import { CompetitorShareCard } from "@/components/geo/competitor-share-card"; +import { PageContainer } from "@/components/layout/container"; +import { useOrganizationsContext } from "@/components/providers/organization-provider"; +import { useGeoCompetitorShare, useGeoSettings } from "@/lib/hooks/use-geo"; +import { GeoPageSkeleton } from "../skeleton"; + +interface PageClientProps { + organizationSlug: string; +} + +export default function PageClient({ organizationSlug }: PageClientProps) { + const { getOrganization, activeOrganization } = useOrganizationsContext(); + const orgFromList = getOrganization(organizationSlug); + const organization = + activeOrganization?.slug === organizationSlug + ? activeOrganization + : orgFromList; + const organizationId = organization?.id ?? ""; + + const { data: settingsData, isPending } = useGeoSettings(organizationId); + const { data: competitorShare } = useGeoCompetitorShare(organizationId); + + if (isPending) { + return ; + } + + const settings = settingsData?.settings ?? null; + + if (!settings) { + return ( + +
+
+

Competitors

+

+ Who AI engines recommend instead of you +

+
+ + Set up GEO tracking + + } + description="Set up GEO tracking first, then track which competitors AI engines surface." + title="Not set up yet" + /> +
+
+ ); + } + + return ( + +
+
+

Competitors

+

+ Who AI engines recommend instead of you +

+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page.tsx new file mode 100644 index 000000000..9e2ed824e --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/competitors/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { GeoPageSkeleton } from "../skeleton"; +import PageClient from "./page-client"; + +export const metadata: Metadata = { + title: "GEO Competitors", +}; + +async function Page({ + params, +}: { + params: Promise<{ + slug: string; + }>; +}) { + const { slug } = await params; + + return ( + }> + + + ); +} +export default Page; diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/loading.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/loading.tsx new file mode 100644 index 000000000..e7074334b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/loading.tsx @@ -0,0 +1,5 @@ +import { GeoPageSkeleton } from "./skeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx new file mode 100644 index 000000000..865a3ac3c --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Settings01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Button } from "@notra/ui/components/ui/button"; +import { Loader2Icon } from "lucide-react"; +import { useState } from "react"; +import { EmptyState } from "@/components/empty-state"; +import { GeoSettingsDialog } from "@/components/geo/geo-settings-dialog"; +import { GeoSummaryStats } from "@/components/geo/geo-summary-stats"; +import { MentionRateCard } from "@/components/geo/mention-rate-card"; +import { WebsiteGenerateCard } from "@/components/geo/website-generate-card"; +import { PageContainer } from "@/components/layout/container"; +import { useOrganizationsContext } from "@/components/providers/organization-provider"; +import { + useGeoOverview, + useGeoPrompts, + useGeoSettings, + useGeoStartScan, + useGeoTimeseries, +} from "@/lib/hooks/use-geo"; +import { GeoPageSkeleton } from "./skeleton"; + +interface PageClientProps { + organizationSlug: string; +} + +export default function PageClient({ organizationSlug }: PageClientProps) { + const { getOrganization, activeOrganization } = useOrganizationsContext(); + const orgFromList = getOrganization(organizationSlug); + const organization = + activeOrganization?.slug === organizationSlug + ? activeOrganization + : orgFromList; + const organizationId = organization?.id ?? ""; + + const [settingsOpen, setSettingsOpen] = useState(false); + + const { data: settingsData, isPending: isSettingsPending } = + useGeoSettings(organizationId); + const { data: overview } = useGeoOverview(organizationId); + const { data: timeseries } = useGeoTimeseries(organizationId); + const { data: prompts } = useGeoPrompts(organizationId); + const startScan = useGeoStartScan(organizationId); + + if (isSettingsPending) { + return ; + } + + const settings = settingsData?.settings ?? null; + + if (!settings) { + return ( + +
+
+

GEO

+

+ Track how often AI engines mention your company +

+
+ + setSettingsOpen(true)} variant="outline"> + Set up manually instead + + } + description="Or tell us your company name, aliases, and competitors yourself. We'll ask the major AI engines the questions your customers ask and track whether you come up." + title="Prefer manual setup?" + /> + +
+
+ ); + } + + return ( + +
+
+
+

GEO

+

+ How AI engines talk about {settings.companyName} +

+
+
+ + +
+
+ + + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page.tsx new file mode 100644 index 000000000..9cb6a12e0 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import PageClient from "./page-client"; +import { GeoPageSkeleton } from "./skeleton"; + +export const metadata: Metadata = { + title: "GEO", +}; + +async function Page({ + params, +}: { + params: Promise<{ + slug: string; + }>; +}) { + const { slug } = await params; + + return ( + }> + + + ); +} +export default Page; diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/loading.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/loading.tsx new file mode 100644 index 000000000..6c552f14e --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/loading.tsx @@ -0,0 +1,5 @@ +import { GeoPageSkeleton } from "../skeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page-client.tsx new file mode 100644 index 000000000..8a81486fe --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page-client.tsx @@ -0,0 +1,75 @@ +"use client"; + +import Link from "next/link"; +import { EmptyState } from "@/components/empty-state"; +import { PromptManager } from "@/components/geo/prompt-manager"; +import { PromptResultsCard } from "@/components/geo/prompt-results-card"; +import { WebsiteGenerateCard } from "@/components/geo/website-generate-card"; +import { PageContainer } from "@/components/layout/container"; +import { useOrganizationsContext } from "@/components/providers/organization-provider"; +import { useGeoPromptResults, useGeoSettings } from "@/lib/hooks/use-geo"; +import { GeoPageSkeleton } from "../skeleton"; + +interface PageClientProps { + organizationSlug: string; +} + +export default function PageClient({ organizationSlug }: PageClientProps) { + const { getOrganization, activeOrganization } = useOrganizationsContext(); + const orgFromList = getOrganization(organizationSlug); + const organization = + activeOrganization?.slug === organizationSlug + ? activeOrganization + : orgFromList; + const organizationId = organization?.id ?? ""; + + const { data: settingsData, isPending } = useGeoSettings(organizationId); + const { data: promptResults } = useGeoPromptResults(organizationId); + + if (isPending) { + return ; + } + + if (!settingsData?.settings) { + return ( + +
+
+

Prompts

+

+ The questions we ask AI engines on your behalf +

+
+ + Set up GEO tracking + + } + description="Set up GEO tracking first, then manage the prompts scanned across AI engines." + title="Not set up yet" + /> +
+
+ ); + } + + return ( + +
+
+

Prompts

+

+ The questions we ask AI engines on your behalf +

+
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page.tsx new file mode 100644 index 000000000..670e4c124 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/prompts/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { GeoPageSkeleton } from "../skeleton"; +import PageClient from "./page-client"; + +export const metadata: Metadata = { + title: "GEO Prompts", +}; + +async function Page({ + params, +}: { + params: Promise<{ + slug: string; + }>; +}) { + const { slug } = await params; + + return ( + }> + + + ); +} +export default Page; diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/skeleton.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/skeleton.tsx new file mode 100644 index 000000000..ec73ed466 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/skeleton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Skeleton } from "@notra/ui/components/ui/skeleton"; +import { useId } from "react"; +import { PageContainer } from "@/components/layout/container"; + +const TILE_COUNT = 4; + +export function GeoPageSkeleton() { + const id = useId(); + return ( + +
+
+ + +
+
+ {Array.from({ length: TILE_COUNT }).map((_, index) => ( + + ))} +
+
+ + +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/api/workflows/geo-scan/route.ts b/apps/dashboard/src/app/api/workflows/geo-scan/route.ts new file mode 100644 index 000000000..6a71d7bc3 --- /dev/null +++ b/apps/dashboard/src/app/api/workflows/geo-scan/route.ts @@ -0,0 +1,37 @@ +import { getAppUrl } from "@notra/ai/qstash/triggers"; +import { flattenError } from "zod"; +import { verifyQstashSignature } from "@/lib/workflows/qstash-verify"; +import { startGeoScanRun } from "@/lib/workflows/start"; +import { geoScanPayloadSchema } from "@/schemas/geo"; + +const ROUTE_PATH = "/api/workflows/geo-scan"; + +export async function POST(request: Request) { + const rawBody = await request.text(); + const verified = await verifyQstashSignature({ + request, + rawBody, + url: `${getAppUrl()}${ROUTE_PATH}`, + }); + if (!verified) { + return new Response("Unauthorized", { status: 401 }); + } + + let body: unknown = {}; + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + return new Response("Invalid JSON body", { status: 400 }); + } + } + + const parsed = geoScanPayloadSchema.safeParse(body); + if (!parsed.success) { + console.error("[GEO] Invalid scan payload:", flattenError(parsed.error)); + return new Response("Invalid payload", { status: 400 }); + } + + const { runId } = await startGeoScanRun(parsed.data); + return Response.json({ runId }, { status: 202 }); +} diff --git a/apps/dashboard/src/components/command-palette/registry.ts b/apps/dashboard/src/components/command-palette/registry.ts index 22b82da31..d325c5781 100644 --- a/apps/dashboard/src/components/command-palette/registry.ts +++ b/apps/dashboard/src/components/command-palette/registry.ts @@ -1,4 +1,5 @@ import { + AiBrowserIcon, Analytics01Icon, AnalyticsUpIcon, Calendar03Icon, @@ -52,6 +53,30 @@ export const COMMAND_ROUTES: CommandRoute[] = [ section: "Navigation", path: (slug) => `/${slug}/analytics`, }, + { + id: "geo", + label: "GEO Overview", + keywords: ["ai", "mentions", "visibility", "chatgpt", "share of voice"], + icon: AiBrowserIcon, + section: "Navigation", + path: (slug) => `/${slug}/geo`, + }, + { + id: "geo-prompts", + label: "GEO Prompts", + keywords: ["ai", "prompts", "questions", "tracking"], + icon: AiBrowserIcon, + section: "Navigation", + path: (slug) => `/${slug}/geo/prompts`, + }, + { + id: "geo-competitors", + label: "GEO Competitors", + keywords: ["ai", "competitors", "share of voice", "brands"], + icon: AiBrowserIcon, + section: "Navigation", + path: (slug) => `/${slug}/geo/competitors`, + }, { id: "content", label: "Content", diff --git a/apps/dashboard/src/components/dashboard/nav-main.tsx b/apps/dashboard/src/components/dashboard/nav-main.tsx index 3346dd8db..3cc2376b3 100644 --- a/apps/dashboard/src/components/dashboard/nav-main.tsx +++ b/apps/dashboard/src/components/dashboard/nav-main.tsx @@ -1,9 +1,12 @@ "use client"; import { useFlag } from "@databuddy/sdk/react"; import { + AiBrowserIcon, + AiChat01Icon, Analytics01Icon, AnalyticsUpIcon, Calendar03Icon, + ChartAnalysisIcon, Home01Icon, Key01Icon, MagicWand01Icon, @@ -40,6 +43,7 @@ import { NavBrandIdentity } from "./nav-brand-identity"; const categoryLabels: Record, string> = { workspace: "Workspace", automation: "Automation", + geo: "GEO", manage: "Manage", }; @@ -69,6 +73,24 @@ const navMainItems: NavMainItem[] = [ label: "Analytics", category: "workspace", }, + { + link: "/geo", + icon: AiBrowserIcon, + label: "Overview", + category: "geo", + }, + { + link: "/geo/prompts", + icon: AiChat01Icon, + label: "Prompts", + category: "geo", + }, + { + link: "/geo/competitors", + icon: ChartAnalysisIcon, + label: "Competitors", + category: "geo", + }, { link: "/skills", icon: MagicWand01Icon, @@ -123,6 +145,7 @@ const itemsByCategory: Record = { none: [], workspace: [], automation: [], + geo: [], manage: [], }; for (const item of navMainItems) { diff --git a/apps/dashboard/src/components/geo/competitor-manager.tsx b/apps/dashboard/src/components/geo/competitor-manager.tsx new file mode 100644 index 000000000..2fb19c806 --- /dev/null +++ b/apps/dashboard/src/components/geo/competitor-manager.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Badge } from "@notra/ui/components/ui/badge"; +import { Button } from "@notra/ui/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { Input } from "@notra/ui/components/ui/input"; +import { Loader2Icon } from "lucide-react"; +import { useState } from "react"; +import { useGeoSettingsUpsert } from "@/lib/hooks/use-geo"; +import type { GeoSettings } from "@/types/geo"; + +interface CompetitorManagerProps { + organizationId: string; + settings: GeoSettings; +} + +export function CompetitorManager({ + organizationId, + settings, +}: CompetitorManagerProps) { + const [draft, setDraft] = useState(""); + const upsert = useGeoSettingsUpsert(organizationId); + + const save = (competitors: string[]) => { + upsert.mutate({ + organizationId, + companyName: settings.companyName, + aliases: settings.aliases, + competitors, + enabled: settings.enabled, + }); + }; + + const handleAdd = () => { + const name = draft.trim(); + if (name.length === 0 || settings.competitors.includes(name)) { + return; + } + save([...settings.competitors, name]); + setDraft(""); + }; + + return ( + + + Watched competitors + + Named competitors get called out in scans; unlisted brands are still + detected automatically. + + + +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAdd(); + } + }} + placeholder="Competitor name" + value={draft} + /> + +
+
+ {settings.competitors.length === 0 && ( +

+ No competitors added yet +

+ )} + {settings.competitors.map((competitor) => ( + + {competitor} + + + ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/geo/competitor-share-card.tsx b/apps/dashboard/src/components/geo/competitor-share-card.tsx new file mode 100644 index 000000000..3cfc1a8e0 --- /dev/null +++ b/apps/dashboard/src/components/geo/competitor-share-card.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Bar } from "@notra/ui/components/dither-kit/bar"; +import { BarChart } from "@notra/ui/components/dither-kit/bar-chart"; +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { Grid } from "@notra/ui/components/dither-kit/grid"; +import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; +import { XAxis } from "@notra/ui/components/dither-kit/x-axis"; +import { YAxis } from "@notra/ui/components/dither-kit/y-axis"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import type { GeoCompetitorSharePoint } from "@/types/geo"; + +interface CompetitorShareCardProps { + points: GeoCompetitorSharePoint[]; + companyName: string | null; +} + +const chartConfig: ChartConfig = { + mentions: { label: "Mentions", color: "orange" }, +}; + +export function CompetitorShareCard({ + points, + companyName, +}: CompetitorShareCardProps) { + const rows = points.map((point) => ({ + brand: point.brand, + mentions: point.mentions, + })); + + return ( + + + Share of voice + + Brands AI engines bring up + {companyName ? ` alongside ${companyName}` : ""} + + + + {rows.length === 0 ? ( +

+ No competitor data yet +

+ ) : ( + + + + + + + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/geo/geo-settings-dialog.tsx b/apps/dashboard/src/components/geo/geo-settings-dialog.tsx new file mode 100644 index 000000000..f4f6bd4c9 --- /dev/null +++ b/apps/dashboard/src/components/geo/geo-settings-dialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { + ResponsiveDialog, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from "@notra/ui/components/shared/responsive-dialog"; +import { Button } from "@notra/ui/components/ui/button"; +import { Input } from "@notra/ui/components/ui/input"; +import { Label } from "@notra/ui/components/ui/label"; +import { Switch } from "@notra/ui/components/ui/switch"; +import { Loader2Icon } from "lucide-react"; +import { useEffect, useId, useState } from "react"; +import { useGeoSettingsUpsert } from "@/lib/hooks/use-geo"; +import type { GeoSettings } from "@/types/geo"; + +interface GeoSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + settings: GeoSettings | null; +} + +function parseList(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export function GeoSettingsDialog({ + open, + onOpenChange, + organizationId, + settings, +}: GeoSettingsDialogProps) { + const id = useId(); + const [companyName, setCompanyName] = useState(""); + const [aliases, setAliases] = useState(""); + const [competitors, setCompetitors] = useState(""); + const [enabled, setEnabled] = useState(true); + const upsert = useGeoSettingsUpsert(organizationId); + + useEffect(() => { + if (open) { + setCompanyName(settings?.companyName ?? ""); + setAliases(settings?.aliases.join(", ") ?? ""); + setCompetitors(settings?.competitors.join(", ") ?? ""); + setEnabled(settings?.enabled ?? true); + } + }, [open, settings]); + + const handleSave = () => { + upsert.mutate( + { + organizationId, + companyName: companyName.trim(), + aliases: parseList(aliases), + competitors: parseList(competitors), + enabled, + }, + { onSuccess: () => onOpenChange(false) } + ); + }; + + return ( + + + + GEO tracking settings + + What should AI engines be checked for? Aliases count as mentions + too. + + +
+
+ + setCompanyName(event.target.value)} + placeholder="Notra" + value={companyName} + /> +
+
+ + setAliases(event.target.value)} + placeholder="usenotra, notra.so (comma separated)" + value={aliases} + /> +
+
+ + setCompetitors(event.target.value)} + placeholder="Buffer, Hypefury (comma separated)" + value={competitors} + /> +
+
+ + +
+
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/geo/geo-summary-stats.tsx b/apps/dashboard/src/components/geo/geo-summary-stats.tsx new file mode 100644 index 000000000..d31dea62a --- /dev/null +++ b/apps/dashboard/src/components/geo/geo-summary-stats.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Card, CardContent } from "@notra/ui/components/ui/card"; +import { useMemo } from "react"; +import { GEO_ENGINE_LABELS } from "@/constants/geo"; +import type { GeoOverviewEngine, GeoSettings } from "@/types/geo"; +import { formatMentionRate } from "@/utils/geo-charts"; + +interface GeoSummaryStatsProps { + engines: GeoOverviewEngine[]; + settings: GeoSettings; + promptCount: number; +} + +interface StatTile { + label: string; + value: string; + hint: string; +} + +const GROUNDED_PATTERN = /-grounded$|^perplexity-sonar$/; + +export function GeoSummaryStats({ + engines, + settings, + promptCount, +}: GeoSummaryStatsProps) { + const tiles = useMemo(() => { + const grounded = engines.filter((engine) => + GROUNDED_PATTERN.test(engine.engine) + ); + const pool = grounded.length > 0 ? grounded : engines; + const checks = pool.reduce((total, engine) => total + engine.checks, 0); + const mentions = pool.reduce((total, engine) => total + engine.mentions, 0); + const best = [...engines].sort((a, b) => b.mentionRate - a.mentionRate)[0]; + + return [ + { + label: "AI visibility", + value: checks > 0 ? formatMentionRate(mentions / checks) : "N/A", + hint: + grounded.length > 0 + ? "web-grounded answers mentioning you" + : "answers mentioning you", + }, + { + label: "Best engine", + value: best ? (GEO_ENGINE_LABELS[best.engine] ?? best.engine) : "N/A", + hint: best + ? `${formatMentionRate(best.mentionRate)} mention rate` + : "run a scan", + }, + { + label: "Tracked prompts", + value: String(promptCount), + hint: "asked to every engine per scan", + }, + { + label: "Competitors watched", + value: String(settings.competitors.length), + hint: "named rivals in scans", + }, + ]; + }, [engines, settings.competitors.length, promptCount]); + + return ( +
+ {tiles.map((tile) => ( + + +

{tile.label}

+

{tile.value}

+

{tile.hint}

+
+
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/geo/mention-rate-card.tsx b/apps/dashboard/src/components/geo/mention-rate-card.tsx new file mode 100644 index 000000000..d84b7b25f --- /dev/null +++ b/apps/dashboard/src/components/geo/mention-rate-card.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { Line } from "@notra/ui/components/dither-kit/area"; +import { LineChart } from "@notra/ui/components/dither-kit/area-chart"; +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { Grid } from "@notra/ui/components/dither-kit/grid"; +import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; +import { XAxis } from "@notra/ui/components/dither-kit/x-axis"; +import { YAxis } from "@notra/ui/components/dither-kit/y-axis"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { useMemo } from "react"; +import { ACCOUNT_SERIES_COLORS } from "@/constants/analytics"; +import { GEO_ENGINE_LABELS } from "@/constants/geo"; +import { cn } from "@/lib/utils"; +import type { GeoOverviewEngine, GeoTimeseriesPoint } from "@/types/geo"; +import { buildMentionRateRows, formatMentionRate } from "@/utils/geo-charts"; + +interface MentionRateCardProps { + engines: GeoOverviewEngine[]; + points: GeoTimeseriesPoint[]; +} + +interface EngineFamily { + family: string; + label: string; + web: GeoOverviewEngine | null; + raw: GeoOverviewEngine | null; +} + +const GROUNDED_SUFFIX = /(-direct)?-grounded$/; +const WEB_LABEL_SUFFIX = /\s*\(web\)$/; +const TREND_MIN_DAYS = 5; + +function isGrounded(engine: string): boolean { + return GROUNDED_SUFFIX.test(engine) || engine === "perplexity-sonar"; +} + +function familyOf(engine: string): string { + return engine.replace(GROUNDED_SUFFIX, ""); +} + +function familyLabel(family: string): string { + const label = + GEO_ENGINE_LABELS[family] ?? GEO_ENGINE_LABELS[`${family}-grounded`]; + if (!label) { + return family; + } + return label.replace(WEB_LABEL_SUFFIX, ""); +} + +function groupEngines(engines: GeoOverviewEngine[]): EngineFamily[] { + const families = new Map(); + for (const engine of engines) { + const family = familyOf(engine.engine); + const entry = families.get(family) ?? { + family, + label: familyLabel(family), + web: null, + raw: null, + }; + if (isGrounded(engine.engine)) { + entry.web = engine; + } else { + entry.raw = engine; + } + families.set(family, entry); + } + return [...families.values()].sort( + (a, b) => + Math.max(b.web?.mentionRate ?? 0, b.raw?.mentionRate ?? 0) - + Math.max(a.web?.mentionRate ?? 0, a.raw?.mentionRate ?? 0) + ); +} + +function RateBar({ + variant, + engine, +}: { + variant: "web" | "raw"; + engine: GeoOverviewEngine | null; +}) { + if (!engine) { + return null; + } + const percent = Math.round(engine.mentionRate * 100); + return ( +
+ + {variant} + +
+
+
+ + + {formatMentionRate(engine.mentionRate)} + {" "} + + {engine.mentions}/{engine.checks} + + +
+ ); +} + +export function MentionRateCard({ engines, points }: MentionRateCardProps) { + const families = useMemo(() => groupEngines(engines), [engines]); + const { rows, engines: trendEngines } = useMemo( + () => buildMentionRateRows(points), + [points] + ); + const distinctDays = rows.length; + + const trendConfig = useMemo(() => { + const config: ChartConfig = {}; + trendEngines.forEach((engine, index) => { + config[engine] = { + label: GEO_ENGINE_LABELS[engine] ?? engine, + color: + ACCOUNT_SERIES_COLORS[index % ACCOUNT_SERIES_COLORS.length] ?? + "purple", + }; + }); + return config; + }, [trendEngines]); + + return ( + + + Mention rate + + How often each engine mentions you, with web search (web) and without + (raw) + + + + {families.length === 0 ? ( +

+ No scans yet +

+ ) : ( +
+ {families.map((family) => ( +
+
+ {family.label} + {family.web?.avgPosition !== null && + family.web?.avgPosition !== undefined && ( + + avg position {family.web.avgPosition} + + )} +
+ + +
+ ))} +
+ )} + {distinctDays >= TREND_MIN_DAYS && ( +
+ + + + + {trendEngines.map((engine) => ( + + ))} + `${value}%`} /> + +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/geo/prompt-manager.tsx b/apps/dashboard/src/components/geo/prompt-manager.tsx new file mode 100644 index 000000000..57394e244 --- /dev/null +++ b/apps/dashboard/src/components/geo/prompt-manager.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Badge } from "@notra/ui/components/ui/badge"; +import { Button } from "@notra/ui/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { Input } from "@notra/ui/components/ui/input"; +import { Switch } from "@notra/ui/components/ui/switch"; +import { Loader2Icon } from "lucide-react"; +import { useState } from "react"; +import { + useGeoPromptCreate, + useGeoPromptDelete, + useGeoPrompts, + useGeoPromptToggle, +} from "@/lib/hooks/use-geo"; +import type { GeoTrackedPrompt } from "@/types/geo"; + +interface PromptManagerProps { + organizationId: string; +} + +const MIN_PROMPT_LENGTH = 8; + +function PromptRow({ + prompt, + organizationId, +}: { + prompt: GeoTrackedPrompt; + organizationId: string; +}) { + const toggle = useGeoPromptToggle(organizationId); + const remove = useGeoPromptDelete(organizationId); + + return ( +
+

{prompt.prompt}

+ {prompt.source === "auto" ? ( + + Auto + + ) : ( +
+ + toggle.mutate({ promptId: prompt.id, enabled }) + } + /> + +
+ )} +
+ ); +} + +export function PromptManager({ organizationId }: PromptManagerProps) { + const [draft, setDraft] = useState(""); + const { data } = useGeoPrompts(organizationId); + const create = useGeoPromptCreate(organizationId); + + const prompts = data?.prompts ?? []; + const customPrompts = prompts.filter((prompt) => prompt.source === "custom"); + const autoPrompts = prompts.filter((prompt) => prompt.source === "auto"); + + const handleAdd = () => { + const prompt = draft.trim(); + if (prompt.length < MIN_PROMPT_LENGTH) { + return; + } + create.mutate({ prompt }, { onSuccess: () => setDraft("") }); + }; + + return ( + + + Tracked prompts + + Every scan asks each engine these questions. Add the questions your + customers actually ask. + + + +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAdd(); + } + }} + placeholder="What's the best tool for automating changelogs?" + value={draft} + /> + +
+
+ {customPrompts.map((prompt) => ( + + ))} + {autoPrompts.map((prompt) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/geo/prompt-results-card.tsx b/apps/dashboard/src/components/geo/prompt-results-card.tsx new file mode 100644 index 000000000..7cf01c0c3 --- /dev/null +++ b/apps/dashboard/src/components/geo/prompt-results-card.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Badge } from "@notra/ui/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { useMemo } from "react"; +import { GEO_ENGINE_LABELS } from "@/constants/geo"; +import type { GeoPromptResult } from "@/types/geo"; + +interface PromptResultsCardProps { + results: GeoPromptResult[]; +} + +interface PromptGroup { + promptId: string; + prompt: string; + results: GeoPromptResult[]; +} + +function groupByPrompt(results: GeoPromptResult[]): PromptGroup[] { + const groups = new Map(); + for (const result of results) { + const group = groups.get(result.promptId) ?? { + promptId: result.promptId, + prompt: result.prompt, + results: [], + }; + group.results.push(result); + groups.set(result.promptId, group); + } + return [...groups.values()]; +} + +function ResultBadge({ result }: { result: GeoPromptResult }) { + const label = GEO_ENGINE_LABELS[result.engine] ?? result.engine; + if (!result.mentioned) { + return ( + + {label} · not mentioned + + ); + } + return ( + + {label} + {result.position !== null && ` · #${result.position}`} + {result.sentiment === "positive" && " · positive"} + {result.sentiment === "negative" && " · negative"} + + ); +} + +export function PromptResultsCard({ results }: PromptResultsCardProps) { + const groups = useMemo(() => groupByPrompt(results), [results]); + + return ( + + + Prompt results + + Latest answer per prompt across every engine + + + + {groups.length === 0 ? ( +

+ Run a scan to see how AI engines answer these prompts +

+ ) : ( +
+ {groups.map((group) => { + const mentionedResult = group.results.find( + (result) => result.mentioned && result.excerpt + ); + return ( +
+

{group.prompt}

+
+ {group.results.map((result) => ( + + ))} +
+ {mentionedResult && ( +

+ "{mentionedResult.excerpt}" +

+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/geo/website-generate-card.tsx b/apps/dashboard/src/components/geo/website-generate-card.tsx new file mode 100644 index 000000000..f0ab797cd --- /dev/null +++ b/apps/dashboard/src/components/geo/website-generate-card.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Button } from "@notra/ui/components/ui/button"; +import { Card, CardContent } from "@notra/ui/components/ui/card"; +import { Input } from "@notra/ui/components/ui/input"; +import { Label } from "@notra/ui/components/ui/label"; +import { Loader2Icon } from "lucide-react"; +import { useId, useState } from "react"; +import { useGeoGenerateFromWebsite } from "@/lib/hooks/use-geo"; + +interface WebsiteGenerateCardProps { + organizationId: string; + compact?: boolean; +} + +const URL_PREFIX = /^https?:\/\//i; + +export function WebsiteGenerateCard({ + organizationId, + compact = false, +}: WebsiteGenerateCardProps) { + const id = useId(); + const [url, setUrl] = useState(""); + const generate = useGeoGenerateFromWebsite(organizationId); + + const handleGenerate = () => { + const trimmed = url.trim(); + if (trimmed.length === 0) { + return; + } + const normalized = URL_PREFIX.test(trimmed) + ? trimmed + : `https://${trimmed}`; + generate.mutate({ url: normalized }); + }; + + const form = ( +
+ setUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleGenerate(); + } + }} + placeholder="yourcompany.com" + value={url} + /> + +
+ ); + + if (compact) { + return ( +
+ + {form} +
+ ); + } + + return ( + + +
+

Set up from your website

+

+ We read your site, work out your company, aliases, and competitors, + and write the prompts your buyers actually ask AI assistants. +

+
+ {form} +
+
+ ); +} diff --git a/apps/dashboard/src/constants/geo.ts b/apps/dashboard/src/constants/geo.ts new file mode 100644 index 000000000..d200d6afa --- /dev/null +++ b/apps/dashboard/src/constants/geo.ts @@ -0,0 +1,105 @@ +import type { GeoGroundedEngine } from "@/types/geo"; + +export const GEO_ENGINES = [ + "openai/gpt-5.4", + "anthropic/claude-sonnet-4.6", + "google/gemini-3-flash", +] as const; + +export const GEO_JUDGE_MODEL = "openai/gpt-5.4-nano"; + +export const GEO_OPENAI_API_KEY_ENV = "OPENAI_API_KEY"; +export const GEO_ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"; +export const GEO_PERPLEXITY_API_KEY_ENV = "PERPLEXITY_API_KEY"; + +const hasEnv = (name: string): boolean => { + const value = process.env[name]; + return typeof value === "string" && value.length > 0; +}; + +export const GEO_GROUNDED_ENGINES: readonly GeoGroundedEngine[] = [ + { + key: "openai/gpt-5.4-grounded", + label: "ChatGPT (web)", + model: "openai/gpt-5.4", + provider: "gateway-openai", + envVar: null, + isAvailable: () => true, + }, + { + key: "anthropic/claude-sonnet-4.6-grounded", + label: "Claude (web)", + model: "anthropic/claude-sonnet-4.6", + provider: "gateway-anthropic", + envVar: null, + isAvailable: () => true, + }, + { + key: "google/gemini-3-flash-grounded", + label: "Gemini (web)", + model: "google/gemini-3-flash", + provider: "gateway-google", + envVar: null, + isAvailable: () => true, + }, + { + key: "openai-direct-grounded", + label: "ChatGPT (web)", + model: "gpt-5.4", + provider: "direct-openai", + envVar: GEO_OPENAI_API_KEY_ENV, + isAvailable: () => hasEnv(GEO_OPENAI_API_KEY_ENV), + }, + { + key: "anthropic-direct-grounded", + label: "Claude (web)", + model: "claude-sonnet-4-6", + provider: "direct-anthropic", + envVar: GEO_ANTHROPIC_API_KEY_ENV, + isAvailable: () => hasEnv(GEO_ANTHROPIC_API_KEY_ENV), + }, + { + key: "perplexity-sonar", + label: "Perplexity", + model: "sonar", + provider: "direct-perplexity", + envVar: GEO_PERPLEXITY_API_KEY_ENV, + isAvailable: () => hasEnv(GEO_PERPLEXITY_API_KEY_ENV), + }, +]; + +const groundedEngineLabels = Object.fromEntries( + GEO_GROUNDED_ENGINES.map((engine) => [engine.key, engine.label]) +); + +export const GEO_ENGINE_LABELS: Record = { + "openai/gpt-5.4": "ChatGPT", + "anthropic/claude-sonnet-4.6": "Claude", + "google/gemini-3-flash": "Gemini", + ...groundedEngineLabels, +}; + +export const GEO_MAX_PROMPTS = 8; +export const GEO_GROUNDED_MAX_PROMPTS = 6; +export const GEO_GROUNDED_MAX_SEARCHES = 3; +export const GEO_ANSWER_MAX_TOKENS = 600; +export const GEO_GROUNDED_ANSWER_MAX_TOKENS = 1200; +export const GEO_JUDGE_MAX_TOKENS = 800; +export const GEO_SCAN_CONCURRENCY = 4; +export const GEO_EXCERPT_MAX_LENGTH = 300; +export const GEO_PROMPT_MIN_LENGTH = 8; +export const GEO_PROMPT_MAX_LENGTH = 300; +export const GEO_DISCOVERY_MODEL = "anthropic/claude-sonnet-4.6"; +export const GEO_DISCOVERY_MAX_TOKENS = 4000; +export const GEO_DISCOVERY_MAX_ALIASES = 6; +export const GEO_DISCOVERY_MIN_COMPETITORS = 5; +export const GEO_DISCOVERY_MAX_COMPETITORS = 10; +export const GEO_DISCOVERY_MIN_PROMPTS = 10; +export const GEO_DISCOVERY_MAX_PROMPTS = 14; +export const GEO_DISCOVERY_MAX_BRANDED_PROMPTS = 3; +export const GEO_DISCOVERY_ALIAS_LIMIT = 8; +export const GEO_DISCOVERY_COMPETITOR_LIMIT = 12; +export const GEO_DISCOVERY_SYSTEM_PROMPT = + "You are a search visibility analyst. You read a company's website and derive the brand identity and the buyer questions that decide whether an AI assistant recommends this company. Respond only with the requested structured data."; +export const GEO_ANSWER_SYSTEM_PROMPT = + "You are a helpful AI assistant. Answer the user's question directly and concretely, naming specific products or companies where relevant."; diff --git a/apps/dashboard/src/lib/geo/discover.ts b/apps/dashboard/src/lib/geo/discover.ts new file mode 100644 index 000000000..4035e179e --- /dev/null +++ b/apps/dashboard/src/lib/geo/discover.ts @@ -0,0 +1,209 @@ +import { gateway } from "@notra/ai/gateway"; +import { scrapeWebsiteForBrandAnalysis } from "@notra/ai/utils/context-dev"; +import { db } from "@notra/db/drizzle"; +import { geoPrompts, geoSettings } from "@notra/db/schema"; +import { generateText, Output } from "ai"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; +import { + GEO_DISCOVERY_ALIAS_LIMIT, + GEO_DISCOVERY_COMPETITOR_LIMIT, + GEO_DISCOVERY_MAX_ALIASES, + GEO_DISCOVERY_MAX_BRANDED_PROMPTS, + GEO_DISCOVERY_MAX_COMPETITORS, + GEO_DISCOVERY_MAX_PROMPTS, + GEO_DISCOVERY_MAX_TOKENS, + GEO_DISCOVERY_MIN_COMPETITORS, + GEO_DISCOVERY_MIN_PROMPTS, + GEO_DISCOVERY_MODEL, + GEO_DISCOVERY_SYSTEM_PROMPT, + GEO_PROMPT_MAX_LENGTH, + GEO_PROMPT_MIN_LENGTH, +} from "@/constants/geo"; +import { GeoDiscoveryError } from "@/lib/geo/errors"; +import { geoWebsiteDiscoverySchema } from "@/schemas/geo"; +import type { + GeoGenerateFromWebsiteResult, + GeoWebsiteDiscovery, +} from "@/types/geo"; + +const MIN_PROMPT_LENGTH = GEO_PROMPT_MIN_LENGTH; +const MAX_PROMPT_LENGTH = GEO_PROMPT_MAX_LENGTH; + +function buildDiscoveryPrompt(url: string, content: string): string { + return `Website: ${url} + +Website content: +""" +${content} +""" + +Derive the brand tracking configuration for this company: + +1. companyName: the company or product name exactly as it brands itself. +2. aliases: up to ${GEO_DISCOVERY_MAX_ALIASES} alternative spellings that identify this company - product names, the bare domain, and common misspellings. Never include generic words that could refer to anything else. +3. competitors: between ${GEO_DISCOVERY_MIN_COMPETITORS} and ${GEO_DISCOVERY_MAX_COMPETITORS} real, named companies or products that compete in the same category. +4. prompts: between ${GEO_DISCOVERY_MIN_PROMPTS} and ${GEO_DISCOVERY_MAX_PROMPTS} questions a real buyer would type into an AI assistant while researching this category. At most ${GEO_DISCOVERY_MAX_BRANDED_PROMPTS} of them may contain the company name; every other question must be unbranded and framed around the category, the problem or the buying decision, so the answer reveals whether an assistant recommends this company unprompted. Each question must be between ${MIN_PROMPT_LENGTH} and ${MAX_PROMPT_LENGTH} characters.`; +} + +function normalizeKey(value: string): string { + return value.trim().toLowerCase(); +} + +function unionValues( + existing: string[], + extracted: string[], + limit: number +): string[] { + const seen = new Set(); + const merged: string[] = []; + for (const value of [...existing, ...extracted]) { + const trimmed = value.trim(); + const key = normalizeKey(trimmed); + if (!trimmed || seen.has(key) || merged.length >= limit) { + continue; + } + seen.add(key); + merged.push(trimmed); + } + return merged; +} + +const scrapeWebsite = Effect.fn("geo.discover.scrape")(function* (url: string) { + const result = yield* Effect.tryPromise({ + try: () => scrapeWebsiteForBrandAnalysis(url), + catch: (cause) => + new GeoDiscoveryError({ message: "Failed to scrape the website", cause }), + }); + + if (!result.success) { + return yield* Effect.fail(new GeoDiscoveryError({ message: result.error })); + } + + return result.content; +}); + +const extractDiscovery = Effect.fn("geo.discover.extract")(function* ( + url: string, + content: string +) { + const result = yield* Effect.tryPromise({ + try: () => + generateText({ + model: gateway(GEO_DISCOVERY_MODEL), + output: Output.object({ schema: geoWebsiteDiscoverySchema }), + prompt: buildDiscoveryPrompt(url, content), + system: GEO_DISCOVERY_SYSTEM_PROMPT, + maxOutputTokens: GEO_DISCOVERY_MAX_TOKENS, + }), + catch: (cause) => + new GeoDiscoveryError({ + message: "Failed to analyze the website", + cause, + }), + }); + + const discovery: GeoWebsiteDiscovery = result.output; + return discovery; +}); + +export const generateGeoFromWebsite = Effect.fn("geo.generateFromWebsite")( + function* (organizationId: string, url: string) { + const content = yield* scrapeWebsite(url); + const discovery = yield* extractDiscovery(url, content); + + const existing = yield* Effect.tryPromise({ + try: () => + db.query.geoSettings.findFirst({ + where: eq(geoSettings.organizationId, organizationId), + }), + catch: (cause) => + new GeoDiscoveryError({ + message: "Failed to load GEO settings", + cause, + }), + }); + + const aliases = unionValues( + existing?.aliases ?? [], + discovery.aliases, + GEO_DISCOVERY_ALIAS_LIMIT + ); + const competitors = unionValues( + existing?.competitors ?? [], + discovery.competitors, + GEO_DISCOVERY_COMPETITOR_LIMIT + ); + const companyName = existing?.companyName ?? discovery.companyName; + + yield* Effect.tryPromise({ + try: () => + db + .insert(geoSettings) + .values({ + id: crypto.randomUUID(), + organizationId, + companyName, + aliases, + competitors, + enabled: true, + }) + .onConflictDoUpdate({ + target: geoSettings.organizationId, + set: { companyName, aliases, competitors }, + }), + catch: (cause) => + new GeoDiscoveryError({ + message: "Failed to save GEO settings", + cause, + }), + }); + + const existingPrompts = yield* Effect.tryPromise({ + try: () => + db.query.geoPrompts.findMany({ + columns: { prompt: true }, + where: eq(geoPrompts.organizationId, organizationId), + }), + catch: (cause) => + new GeoDiscoveryError({ message: "Failed to load GEO prompts", cause }), + }); + + const seen = new Set( + existingPrompts.map((row) => normalizeKey(row.prompt)) + ); + const values: { id: string; organizationId: string; prompt: string }[] = []; + for (const prompt of discovery.prompts) { + const trimmed = prompt.trim(); + const key = normalizeKey(trimmed); + if ( + trimmed.length < MIN_PROMPT_LENGTH || + trimmed.length > MAX_PROMPT_LENGTH || + seen.has(key) + ) { + continue; + } + seen.add(key); + values.push({ id: crypto.randomUUID(), organizationId, prompt: trimmed }); + } + + if (values.length > 0) { + yield* Effect.tryPromise({ + try: () => db.insert(geoPrompts).values(values), + catch: (cause) => + new GeoDiscoveryError({ + message: "Failed to save GEO prompts", + cause, + }), + }); + } + + const summary: GeoGenerateFromWebsiteResult = { + companyName, + aliases, + competitors, + promptsAdded: values.length, + }; + return summary; + } +); diff --git a/apps/dashboard/src/lib/geo/engines.ts b/apps/dashboard/src/lib/geo/engines.ts new file mode 100644 index 000000000..7d927b11e --- /dev/null +++ b/apps/dashboard/src/lib/geo/engines.ts @@ -0,0 +1,111 @@ +import { anthropic, createAnthropic } from "@ai-sdk/anthropic"; +import { createOpenAI, openai } from "@ai-sdk/openai"; +import { createPerplexity } from "@ai-sdk/perplexity"; +import { gateway } from "@notra/ai/gateway"; +import { type LanguageModel, type ToolSet, tool } from "ai"; +import { z } from "zod"; +import { + GEO_ANTHROPIC_API_KEY_ENV, + GEO_GROUNDED_ENGINES, + GEO_GROUNDED_MAX_SEARCHES, + GEO_OPENAI_API_KEY_ENV, + GEO_PERPLEXITY_API_KEY_ENV, +} from "@/constants/geo"; +import type { GeoGroundedEngine } from "@/types/geo"; + +const googleSearchTool = tool({ + type: "provider", + id: "google.google_search", + args: {}, + inputSchema: z.object({}), +}); + +const requireApiKey = (name: string): string => { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is not configured`); + } + return value; +}; + +export interface GeoGroundedInvocation { + model: LanguageModel; + tools: ToolSet; +} + +export function buildGroundedInvocation( + engine: GeoGroundedEngine +): GeoGroundedInvocation { + switch (engine.provider) { + case "gateway-openai": + return { + model: gateway(engine.model), + tools: { web_search: openai.tools.webSearch({}) }, + }; + case "gateway-anthropic": + return { + model: gateway(engine.model), + tools: { + web_search: anthropic.tools.webSearch_20250305({ + maxUses: GEO_GROUNDED_MAX_SEARCHES, + }), + }, + }; + case "gateway-google": + return { + model: gateway(engine.model), + tools: { google_search: googleSearchTool }, + }; + case "direct-openai": { + const provider = createOpenAI({ + apiKey: requireApiKey(GEO_OPENAI_API_KEY_ENV), + }); + return { + model: provider.responses(engine.model), + tools: { web_search: provider.tools.webSearch({}) }, + }; + } + case "direct-anthropic": { + const provider = createAnthropic({ + apiKey: requireApiKey(GEO_ANTHROPIC_API_KEY_ENV), + }); + return { + model: provider(engine.model), + tools: { + web_search: provider.tools.webSearch_20250305({ + maxUses: GEO_GROUNDED_MAX_SEARCHES, + }), + }, + }; + } + default: { + const provider = createPerplexity({ + apiKey: requireApiKey(GEO_PERPLEXITY_API_KEY_ENV), + }); + return { model: provider(engine.model), tools: {} }; + } + } +} + +const SUPERSEDED_BY_DIRECT: Partial< + Record +> = { + "direct-openai": "gateway-openai", + "direct-anthropic": "gateway-anthropic", +}; + +export function resolveGroundedEngines(): GeoGroundedEngine[] { + const available = GEO_GROUNDED_ENGINES.filter((engine) => + engine.isAvailable() + ); + + const superseded = new Set(); + for (const engine of available) { + const replaced = SUPERSEDED_BY_DIRECT[engine.provider]; + if (replaced) { + superseded.add(replaced); + } + } + + return available.filter((engine) => !superseded.has(engine.provider)); +} diff --git a/apps/dashboard/src/lib/geo/errors.ts b/apps/dashboard/src/lib/geo/errors.ts new file mode 100644 index 000000000..a3f424a4d --- /dev/null +++ b/apps/dashboard/src/lib/geo/errors.ts @@ -0,0 +1,11 @@ +import { Data } from "effect"; + +export class GeoScanError extends Data.TaggedError("GeoScanError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class GeoDiscoveryError extends Data.TaggedError("GeoDiscoveryError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} diff --git a/apps/dashboard/src/lib/geo/prompts.ts b/apps/dashboard/src/lib/geo/prompts.ts new file mode 100644 index 000000000..85ec9c8c1 --- /dev/null +++ b/apps/dashboard/src/lib/geo/prompts.ts @@ -0,0 +1,94 @@ +import { GEO_MAX_PROMPTS } from "@/constants/geo"; +import type { + GeoBrandContext, + GeoPromptDefinition, + GeoSettings, +} from "@/types/geo"; + +const SENTENCE_SPLIT_REGEX = /[.!?]/; +const LEADING_FILLER_REGEX = + /^(we are|we're|we|it is|it's|the|a|an|our)\s+(the\s+|a\s+|an\s+)?/i; +const PLATFORM_PREFIX_REGEX = + /^(platform|tool|software|solution|service|app|product)\s+(for|to|that)\s+/i; +const WHITESPACE_REGEX = /\s+/; +const TRAILING_PUNCTUATION_REGEX = /[\s,;:.-]+$/; + +const CATEGORY_MAX_WORDS = 12; +const CATEGORY_FALLBACK = "software in this space"; +const AUDIENCE_MAX_WORDS = 14; + +function condense(value: string, maxWords: number): string { + return value + .trim() + .split(WHITESPACE_REGEX) + .slice(0, maxWords) + .join(" ") + .replace(TRAILING_PUNCTUATION_REGEX, ""); +} + +function deriveCategory(companyDescription: string | null): string { + if (!companyDescription) { + return CATEGORY_FALLBACK; + } + const firstSentence = companyDescription.split(SENTENCE_SPLIT_REGEX).at(0); + if (!firstSentence) { + return CATEGORY_FALLBACK; + } + const cleaned = firstSentence + .trim() + .replace(LEADING_FILLER_REGEX, "") + .replace(PLATFORM_PREFIX_REGEX, ""); + const condensed = condense(cleaned, CATEGORY_MAX_WORDS).toLowerCase(); + return condensed.length > 0 ? condensed : CATEGORY_FALLBACK; +} + +function deriveAudience(audience: string | null): string | null { + if (!audience) { + return null; + } + const condensed = condense(audience, AUDIENCE_MAX_WORDS).toLowerCase(); + return condensed.length > 0 ? condensed : null; +} + +export function buildGeoPrompts( + settings: GeoSettings, + brand: GeoBrandContext | null +): GeoPromptDefinition[] { + const companyName = settings.companyName; + const category = deriveCategory(brand?.companyDescription ?? null); + const audience = deriveAudience(brand?.audience ?? null); + + const prompts: GeoPromptDefinition[] = [ + { id: "best-tools", text: `What are the best tools for ${category}?` }, + { + id: "alternatives", + text: `What are good alternatives to ${companyName}?`, + }, + { + id: "recommendation", + text: `Which product would you recommend for ${category}, and why?`, + }, + { + id: "comparison", + text: `How does ${companyName} compare to other options for ${category}?`, + }, + { id: "what-is", text: `What is ${companyName} and who is it for?` }, + { + id: "how-to-choose", + text: `How should I choose a product for ${category}?`, + }, + { + id: "top-list", + text: `Can you list the top 5 companies for ${category} right now?`, + }, + ]; + + if (audience) { + prompts.push({ + id: "audience-specific", + text: `Which tools for ${category} work best for ${audience}?`, + }); + } + + return prompts.slice(0, GEO_MAX_PROMPTS); +} diff --git a/apps/dashboard/src/lib/geo/scan.ts b/apps/dashboard/src/lib/geo/scan.ts new file mode 100644 index 000000000..d478ccc1f --- /dev/null +++ b/apps/dashboard/src/lib/geo/scan.ts @@ -0,0 +1,314 @@ +import { gateway } from "@notra/ai/gateway"; +import { ingestGeoMentionChecks } from "@notra/analytics/tinybird/client"; +import type { GeoMentionCheckRow } from "@notra/analytics/tinybird/datasources"; +import { toClickHouseDateTime } from "@notra/analytics/utils/datetime"; +import { db } from "@notra/db/drizzle"; +import { brandSettings, geoPrompts, geoSettings } from "@notra/db/schema"; +import { generateText, Output, stepCountIs } from "ai"; +import { and, asc, eq } from "drizzle-orm"; +import { Effect } from "effect"; +import { + GEO_ANSWER_MAX_TOKENS, + GEO_ANSWER_SYSTEM_PROMPT, + GEO_ENGINES, + GEO_EXCERPT_MAX_LENGTH, + GEO_GROUNDED_ANSWER_MAX_TOKENS, + GEO_GROUNDED_MAX_PROMPTS, + GEO_JUDGE_MAX_TOKENS, + GEO_JUDGE_MODEL, + GEO_MAX_PROMPTS, + GEO_SCAN_CONCURRENCY, +} from "@/constants/geo"; +import { + buildGroundedInvocation, + resolveGroundedEngines, +} from "@/lib/geo/engines"; +import { GeoScanError } from "@/lib/geo/errors"; +import { buildGeoPrompts } from "@/lib/geo/prompts"; +import { geoJudgeResultSchema } from "@/schemas/geo"; +import type { + GeoGroundedEngine, + GeoJudgeResult, + GeoPromptDefinition, + GeoScanResult, + GeoSettings, +} from "@/types/geo"; + +const MAX_JUDGE_COMPETITORS = 10; +const GROUNDED_MAX_STEPS = 4; + +interface GeoCheckTask { + engine: string; + grounded: GeoGroundedEngine | null; + prompt: GeoPromptDefinition; +} + +interface GeoCheckContext { + organizationId: string; + scanId: string; + capturedAt: string; + companyName: string; + aliases: string[]; +} + +function normalizePosition(position: number | null): number | null { + if (position === null || !Number.isFinite(position)) { + return null; + } + const rounded = Math.round(position); + return rounded >= 1 ? rounded : null; +} + +function buildJudgePrompt( + context: GeoCheckContext, + promptText: string, + answer: string +): string { + const aliasList = + context.aliases.length > 0 ? context.aliases.join(", ") : "none"; + return `Company: ${context.companyName} +Known aliases (any of these counts as a mention): ${aliasList} + +A user asked an AI assistant: +""" +${promptText} +""" + +The assistant answered: +""" +${answer} +""" + +Analyze the answer and report: +- mentioned: true if the company or any alias appears in the answer. +- position: the 1-based rank of the company among the recommended brands if the answer contains an ordered or bulleted list of brands, otherwise null. +- sentiment: the sentiment expressed toward the company ("positive", "neutral" or "negative"), or null if it is not mentioned. +- competitors: up to ${MAX_JUDGE_COMPETITORS} other brand or product names mentioned in the answer, excluding the company and its aliases. +- excerpt: at most ${GEO_EXCERPT_MAX_LENGTH} characters of the answer around the mention, or the first 200 characters of the answer if the company is not mentioned.`; +} + +const askEngine = Effect.fn("geo.askEngine")(function* ( + engine: string, + promptText: string +) { + const result = yield* Effect.tryPromise({ + try: () => + generateText({ + model: gateway(engine), + prompt: promptText, + system: GEO_ANSWER_SYSTEM_PROMPT, + maxOutputTokens: GEO_ANSWER_MAX_TOKENS, + }), + catch: (cause) => + new GeoScanError({ + message: `Engine ${engine} failed to answer`, + cause, + }), + }); + return result.text; +}); + +const askGroundedEngine = Effect.fn("geo.askGroundedEngine")(function* ( + engine: GeoGroundedEngine, + promptText: string +) { + const result = yield* Effect.tryPromise({ + try: () => { + const invocation = buildGroundedInvocation(engine); + return generateText({ + model: invocation.model, + tools: invocation.tools, + stopWhen: stepCountIs(GROUNDED_MAX_STEPS), + prompt: promptText, + system: GEO_ANSWER_SYSTEM_PROMPT, + maxOutputTokens: GEO_GROUNDED_ANSWER_MAX_TOKENS, + }); + }, + catch: (cause) => + new GeoScanError({ + message: `Grounded engine ${engine.key} failed to answer`, + cause, + }), + }); + return result.text; +}); + +const judgeAnswer = Effect.fn("geo.judgeAnswer")(function* ( + context: GeoCheckContext, + promptText: string, + answer: string +) { + const result = yield* Effect.tryPromise({ + try: () => + generateText({ + model: gateway(GEO_JUDGE_MODEL), + output: Output.object({ schema: geoJudgeResultSchema }), + prompt: buildJudgePrompt(context, promptText, answer), + system: + "You analyze AI assistant answers for brand mentions. Respond only with the requested structured data.", + maxOutputTokens: GEO_JUDGE_MAX_TOKENS, + }), + catch: (cause) => + new GeoScanError({ message: "Judge model failed", cause }), + }); + const judged: GeoJudgeResult = result.output; + return judged; +}); + +const runGeoCheck = Effect.fn("geo.runCheck")(function* ( + context: GeoCheckContext, + task: GeoCheckTask +) { + const answer = task.grounded + ? yield* askGroundedEngine(task.grounded, task.prompt.text) + : yield* askEngine(task.engine, task.prompt.text); + const judged = yield* judgeAnswer(context, task.prompt.text, answer); + + const row: GeoMentionCheckRow = { + organization_id: context.organizationId, + scan_id: context.scanId, + engine: task.engine, + prompt_id: task.prompt.id, + prompt: task.prompt.text, + captured_at: context.capturedAt, + mentioned: judged.mentioned, + position: normalizePosition(judged.position), + sentiment: judged.sentiment, + competitors: judged.competitors.slice(0, MAX_JUDGE_COMPETITORS), + excerpt: judged.excerpt.slice(0, GEO_EXCERPT_MAX_LENGTH), + }; + + return row; +}); + +export const runGeoScan = Effect.fn("geo.runScan")(function* ( + organizationId: string +) { + const settingsRow = yield* Effect.tryPromise({ + try: () => + db.query.geoSettings.findFirst({ + where: eq(geoSettings.organizationId, organizationId), + }), + catch: (cause) => + new GeoScanError({ message: "Failed to load GEO settings", cause }), + }); + + if (!settingsRow?.enabled) { + const skipped: GeoScanResult = { status: "skipped" }; + return skipped; + } + + const settings: GeoSettings = { + id: settingsRow.id, + organizationId: settingsRow.organizationId, + companyName: settingsRow.companyName, + aliases: settingsRow.aliases, + competitors: settingsRow.competitors, + enabled: settingsRow.enabled, + createdAt: settingsRow.createdAt.toISOString(), + updatedAt: settingsRow.updatedAt.toISOString(), + }; + + const brand = yield* Effect.tryPromise({ + try: () => + db.query.brandSettings.findFirst({ + columns: { companyDescription: true, audience: true }, + where: and( + eq(brandSettings.organizationId, organizationId), + eq(brandSettings.isDefault, true) + ), + }), + catch: (cause) => + new GeoScanError({ message: "Failed to load brand settings", cause }), + }); + + const customRows = yield* Effect.tryPromise({ + try: () => + db.query.geoPrompts.findMany({ + columns: { id: true, prompt: true }, + where: and( + eq(geoPrompts.organizationId, organizationId), + eq(geoPrompts.enabled, true) + ), + orderBy: [asc(geoPrompts.createdAt)], + }), + catch: (cause) => + new GeoScanError({ message: "Failed to load GEO prompts", cause }), + }); + + const autoPrompts = buildGeoPrompts( + settings, + brand + ? { + companyDescription: brand.companyDescription, + audience: brand.audience, + } + : null + ).slice(0, GEO_MAX_PROMPTS); + + const prompts: GeoPromptDefinition[] = [ + ...autoPrompts, + ...customRows.map((row) => ({ + id: `custom-${row.id}`, + text: row.prompt, + })), + ]; + + const context: GeoCheckContext = { + organizationId, + scanId: crypto.randomUUID(), + capturedAt: toClickHouseDateTime(new Date()), + companyName: settings.companyName, + aliases: settings.aliases, + }; + + const tasks: GeoCheckTask[] = []; + for (const engine of GEO_ENGINES) { + for (const prompt of prompts) { + tasks.push({ engine, grounded: null, prompt }); + } + } + + const groundedEngines = resolveGroundedEngines(); + const groundedPrompts = prompts.slice(0, GEO_GROUNDED_MAX_PROMPTS); + for (const grounded of groundedEngines) { + for (const prompt of groundedPrompts) { + tasks.push({ engine: grounded.key, grounded, prompt }); + } + } + + const results = yield* Effect.forEach( + tasks, + (task) => + runGeoCheck(context, task).pipe( + Effect.catch((error: GeoScanError) => { + console.error( + `[GEO] check failed for ${task.engine}/${task.prompt.id}:`, + error + ); + return Effect.succeed(null); + }) + ), + { concurrency: GEO_SCAN_CONCURRENCY } + ); + + const rows: GeoMentionCheckRow[] = []; + for (const result of results) { + if (result) { + rows.push(result); + } + } + + yield* Effect.tryPromise({ + try: () => ingestGeoMentionChecks(rows), + catch: (cause) => + new GeoScanError({ message: "Failed to ingest GEO checks", cause }), + }); + + const completed: GeoScanResult = { + status: "completed", + checks: rows.length, + mentions: rows.filter((row) => row.mentioned).length, + }; + return completed; +}); diff --git a/apps/dashboard/src/lib/hooks/use-geo.ts b/apps/dashboard/src/lib/hooks/use-geo.ts new file mode 100644 index 000000000..53c4de89c --- /dev/null +++ b/apps/dashboard/src/lib/hooks/use-geo.ts @@ -0,0 +1,198 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import type { + GeoCompetitorShareResponse, + GeoGenerateFromWebsiteInput, + GeoOverviewResponse, + GeoPromptCreateInput, + GeoPromptDeleteInput, + GeoPromptResultsResponse, + GeoPromptToggleInput, + GeoSettingsResponse, + GeoSettingsUpsertInput, + GeoTimeseriesResponse, + GeoTrackedPromptsResponse, +} from "@/types/geo"; +import { dashboardOrpc } from "../orpc/query"; + +const DEFAULT_GEO_DAYS = 30; +const DEFAULT_COMPETITOR_DAYS = 30; + +function toErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +export function useGeoSettings(organizationId: string) { + return useQuery({ + ...dashboardOrpc.geo.settings.queryOptions({ input: { organizationId } }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load AI visibility settings" }, + }); +} + +export function useGeoSettingsUpsert(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: GeoSettingsUpsertInput) => + dashboardOrpc.geo.settingsUpsert.call({ ...input, organizationId }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: dashboardOrpc.geo.settings.queryKey({ + input: { organizationId }, + }), + }); + toast.success("AI visibility settings saved"); + }, + onError: (error) => { + toast.error(toErrorMessage(error, "Failed to save settings")); + }, + }); +} + +export function useGeoOverview(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.geo.overview.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_GEO_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load AI visibility overview" }, + }); +} + +export function useGeoTimeseries(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.geo.timeseries.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_GEO_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load AI visibility trend" }, + }); +} + +export function useGeoPromptResults(organizationId: string) { + return useQuery({ + ...dashboardOrpc.geo.promptResults.queryOptions({ + input: { organizationId }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load prompt results" }, + }); +} + +export function useGeoCompetitorShare(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.geo.competitorShare.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_COMPETITOR_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load competitor share" }, + }); +} + +export function useGeoPrompts(organizationId: string) { + return useQuery({ + ...dashboardOrpc.geo.promptsList.queryOptions({ + input: { organizationId }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load tracked prompts" }, + }); +} + +export function useGeoPromptCreate(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: GeoPromptCreateInput) => + dashboardOrpc.geo.promptsCreate.call({ ...input, organizationId }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: dashboardOrpc.geo.promptsList.queryKey({ + input: { organizationId }, + }), + }); + toast.success("Prompt added"); + }, + onError: (error) => { + toast.error(toErrorMessage(error, "Failed to add prompt")); + }, + }); +} + +export function useGeoPromptDelete(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: GeoPromptDeleteInput) => + dashboardOrpc.geo.promptsDelete.call({ ...input, organizationId }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: dashboardOrpc.geo.promptsList.queryKey({ + input: { organizationId }, + }), + }); + toast.success("Prompt removed"); + }, + onError: (error) => { + toast.error(toErrorMessage(error, "Failed to remove prompt")); + }, + }); +} + +export function useGeoPromptToggle(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: GeoPromptToggleInput) => + dashboardOrpc.geo.promptsToggle.call({ ...input, organizationId }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: dashboardOrpc.geo.promptsList.queryKey({ + input: { organizationId }, + }), + }); + }, + onError: (error) => { + toast.error(toErrorMessage(error, "Failed to update prompt")); + }, + }); +} + +export function useGeoGenerateFromWebsite(organizationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: GeoGenerateFromWebsiteInput) => + dashboardOrpc.geo.generateFromWebsite.call({ ...input, organizationId }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: dashboardOrpc.geo.settings.queryKey({ + input: { organizationId }, + }), + }), + queryClient.invalidateQueries({ + queryKey: dashboardOrpc.geo.promptsList.queryKey({ + input: { organizationId }, + }), + }), + ]); + toast.success("GEO tracking generated from website"); + }, + onError: (error) => { + toast.error( + toErrorMessage(error, "Failed to generate GEO tracking from website") + ); + }, + }); +} + +export function useGeoStartScan(organizationId: string) { + return useMutation({ + mutationFn: () => dashboardOrpc.geo.startScan.call({ organizationId }), + onSuccess: () => { + toast.success("Scan started"); + }, + onError: (error) => { + toast.error(toErrorMessage(error, "Failed to start scan")); + }, + }); +} diff --git a/apps/dashboard/src/lib/orpc/router.ts b/apps/dashboard/src/lib/orpc/router.ts index d9ebd8f5c..949920c7f 100644 --- a/apps/dashboard/src/lib/orpc/router.ts +++ b/apps/dashboard/src/lib/orpc/router.ts @@ -6,6 +6,7 @@ import { brandRouter } from "./routers/brand"; import { contentRouter } from "./routers/content"; import { experimentsRouter } from "./routers/experiments"; import { feedbackRouter } from "./routers/feedback"; +import { geoRouter } from "./routers/geo"; import { githubRouter } from "./routers/github"; import { integrationsRouter } from "./routers/integrations"; import { irisRouter } from "./routers/iris"; @@ -27,6 +28,7 @@ export const dashboardRouter = { content: contentRouter, experiments: experimentsRouter, feedback: feedbackRouter, + geo: geoRouter, github: githubRouter, iris: irisRouter, integrations: integrationsRouter, diff --git a/apps/dashboard/src/lib/orpc/routers/geo.ts b/apps/dashboard/src/lib/orpc/routers/geo.ts new file mode 100644 index 000000000..0fbffcb06 --- /dev/null +++ b/apps/dashboard/src/lib/orpc/routers/geo.ts @@ -0,0 +1,421 @@ +import { + isTinybirdConfigured, + queryGeoCompetitorShare, + queryGeoOverview, + queryGeoPromptResults, + queryGeoTimeseries, +} from "@notra/analytics/tinybird/client"; +import { db } from "@notra/db/drizzle"; +import { brandSettings, geoPrompts, geoSettings } from "@notra/db/schema"; +import { and, asc, eq } from "drizzle-orm"; +import { Effect } from "effect"; +import { assertOrganizationAccess } from "@/lib/auth/organization"; +import { generateGeoFromWebsite } from "@/lib/geo/discover"; +import type { GeoDiscoveryError } from "@/lib/geo/errors"; +import { buildGeoPrompts } from "@/lib/geo/prompts"; +import { authorizedProcedure } from "@/lib/orpc/base"; +import { badRequest, notFound } from "@/lib/orpc/utils/errors"; +import { startGeoScanRun } from "@/lib/workflows/start"; +import { + geoGenerateFromWebsiteInputSchema, + geoOrganizationInputSchema, + geoPromptCreateInputSchema, + geoPromptDeleteInputSchema, + geoPromptToggleInputSchema, + geoSettingsUpsertInputSchema, + geoTimeseriesInputSchema, +} from "@/schemas/geo"; +import type { + GeoCompetitorShareResponse, + GeoGenerateFromWebsiteResult, + GeoOverviewResponse, + GeoPromptResultsResponse, + GeoPromptRow, + GeoSettings, + GeoSettingsResponse, + GeoTimeseriesResponse, + GeoTrackedPrompt, + GeoTrackedPromptsResponse, +} from "@/types/geo"; + +interface GeoSettingsRow { + id: string; + organizationId: string; + companyName: string; + aliases: string[]; + competitors: string[]; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +function toGeoSettings(row: GeoSettingsRow): GeoSettings { + return { + id: row.id, + organizationId: row.organizationId, + companyName: row.companyName, + aliases: row.aliases, + competitors: row.competitors, + enabled: row.enabled, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function toTrackedPrompt(row: GeoPromptRow): GeoTrackedPrompt { + return { + id: row.id, + prompt: row.prompt, + enabled: row.enabled, + source: "custom", + createdAt: row.createdAt.toISOString(), + }; +} + +function toNullableNumber(value: number | bigint | null): number | null { + if (value === null) { + return null; + } + return Number(value); +} + +export const geoRouter = { + settings: authorizedProcedure + .input(geoOrganizationInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const row = await db.query.geoSettings.findFirst({ + where: eq(geoSettings.organizationId, input.organizationId), + }); + + return { + configured: isTinybirdConfigured(), + settings: row ? toGeoSettings(row) : null, + }; + }), + settingsUpsert: authorizedProcedure + .input(geoSettingsUpsertInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [row] = await db + .insert(geoSettings) + .values({ + id: crypto.randomUUID(), + organizationId: input.organizationId, + companyName: input.companyName, + aliases: input.aliases, + competitors: input.competitors, + enabled: input.enabled, + }) + .onConflictDoUpdate({ + target: geoSettings.organizationId, + set: { + companyName: input.companyName, + aliases: input.aliases, + competitors: input.competitors, + enabled: input.enabled, + }, + }) + .returning(); + + return { + configured: isTinybirdConfigured(), + settings: row ? toGeoSettings(row) : null, + }; + }), + overview: authorizedProcedure + .input(geoTimeseriesInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryGeoOverview({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[GEO] overview query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + engines: (result?.data ?? []).map((row) => ({ + engine: row.engine, + checks: Number(row.checks), + mentions: Number(row.mentions), + mentionRate: Number(row.mention_rate), + avgPosition: toNullableNumber(row.avg_position), + lastCheckedAt: row.last_checked_at, + })), + }; + }), + timeseries: authorizedProcedure + .input(geoTimeseriesInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryGeoTimeseries({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[GEO] timeseries query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + points: (result?.data ?? []).map((row) => ({ + day: row.day, + engine: row.engine, + checks: Number(row.checks), + mentions: Number(row.mentions), + })), + }; + }), + promptResults: authorizedProcedure + .input(geoOrganizationInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryGeoPromptResults({ + organization_id: input.organizationId, + }).catch((error) => { + console.error("[GEO] prompt results query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + results: (result?.data ?? []).map((row) => ({ + promptId: row.prompt_id, + engine: row.engine, + prompt: row.prompt, + mentioned: row.mentioned, + position: toNullableNumber(row.position), + sentiment: row.sentiment, + excerpt: row.excerpt, + lastCheckedAt: row.last_checked_at, + })), + }; + }), + competitorShare: authorizedProcedure + .input(geoTimeseriesInputSchema) + .handler( + async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryGeoCompetitorShare({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[GEO] competitor share query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + points: (result?.data ?? []).map((row) => ({ + brand: row.brand, + mentions: Number(row.mentions), + })), + }; + } + ), + promptsList: authorizedProcedure + .input(geoOrganizationInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [customRows, settingsRow] = await Promise.all([ + db.query.geoPrompts.findMany({ + where: eq(geoPrompts.organizationId, input.organizationId), + orderBy: [asc(geoPrompts.createdAt)], + }), + db.query.geoSettings.findFirst({ + where: eq(geoSettings.organizationId, input.organizationId), + }), + ]); + + const prompts: GeoTrackedPrompt[] = customRows.map(toTrackedPrompt); + + if (!settingsRow) { + return { configured: isTinybirdConfigured(), prompts }; + } + + const brand = await db.query.brandSettings.findFirst({ + columns: { companyDescription: true, audience: true }, + where: and( + eq(brandSettings.organizationId, input.organizationId), + eq(brandSettings.isDefault, true) + ), + }); + + const autoPrompts = buildGeoPrompts( + toGeoSettings(settingsRow), + brand + ? { + companyDescription: brand.companyDescription, + audience: brand.audience, + } + : null + ); + + for (const autoPrompt of autoPrompts) { + prompts.push({ + id: autoPrompt.id, + prompt: autoPrompt.text, + enabled: true, + source: "auto", + createdAt: null, + }); + } + + return { configured: isTinybirdConfigured(), prompts }; + }), + promptsCreate: authorizedProcedure + .input(geoPromptCreateInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [row] = await db + .insert(geoPrompts) + .values({ + id: crypto.randomUUID(), + organizationId: input.organizationId, + prompt: input.prompt, + }) + .returning(); + + if (!row) { + throw badRequest("Failed to create prompt"); + } + + return toTrackedPrompt(row); + }), + promptsDelete: authorizedProcedure + .input(geoPromptDeleteInputSchema) + .handler(async ({ context, input }): Promise<{ success: boolean }> => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [row] = await db + .delete(geoPrompts) + .where( + and( + eq(geoPrompts.id, input.promptId), + eq(geoPrompts.organizationId, input.organizationId) + ) + ) + .returning(); + + if (!row) { + throw notFound("Prompt not found"); + } + + return { success: true }; + }), + promptsToggle: authorizedProcedure + .input(geoPromptToggleInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [row] = await db + .update(geoPrompts) + .set({ enabled: input.enabled }) + .where( + and( + eq(geoPrompts.id, input.promptId), + eq(geoPrompts.organizationId, input.organizationId) + ) + ) + .returning(); + + if (!row) { + throw notFound("Prompt not found"); + } + + return toTrackedPrompt(row); + }), + generateFromWebsite: authorizedProcedure + .input(geoGenerateFromWebsiteInputSchema) + .handler( + async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const outcome = await Effect.runPromise( + Effect.result(generateGeoFromWebsite(input.organizationId, input.url)) + ); + + if (outcome._tag === "Failure") { + const failure: GeoDiscoveryError = outcome.failure; + console.error("[GEO] website discovery failed:", failure); + throw badRequest(failure.message); + } + + return outcome.success; + } + ), + startScan: authorizedProcedure + .input(geoOrganizationInputSchema) + .handler(async ({ context, input }): Promise<{ runId: string }> => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const row = await db.query.geoSettings.findFirst({ + columns: { id: true }, + where: eq(geoSettings.organizationId, input.organizationId), + }); + if (!row) { + throw badRequest("Configure your brand tracking settings first"); + } + + return await startGeoScanRun({ organizationId: input.organizationId }); + }), +}; diff --git a/apps/dashboard/src/lib/workflows/start.ts b/apps/dashboard/src/lib/workflows/start.ts index a29693ac8..439912706 100644 --- a/apps/dashboard/src/lib/workflows/start.ts +++ b/apps/dashboard/src/lib/workflows/start.ts @@ -9,6 +9,7 @@ import { } from "@/constants/iris"; import { socialAnalyticsSyncPayloadSchema } from "@/schemas/analytics"; import { brandGuidelinesWorkflowPayloadSchema } from "@/schemas/brand-guidelines"; +import { geoScanPayloadSchema } from "@/schemas/geo"; import { eventWorkflowPayloadSchema, scheduleWorkflowPayloadSchema, @@ -25,6 +26,7 @@ import { } from "@/workflows/brand-analysis"; import { brandGuidelinesWorkflow } from "@/workflows/brand-guidelines"; import { eventContentWorkflow } from "@/workflows/event-content"; +import { geoScanWorkflow } from "@/workflows/geo-scan"; import { irisControllerRun } from "@/workflows/iris-controller"; import { onDemandContentWorkflow } from "@/workflows/on-demand-content"; import { onboardingAgentWorkflow } from "@/workflows/onboarding-agent"; @@ -116,6 +118,14 @@ export async function startSocialAnalyticsSyncRun(payload: { return { runId: run.runId }; } +export async function startGeoScanRun(payload: { + organizationId: string; +}): Promise<{ runId: string }> { + const parsed = geoScanPayloadSchema.parse(payload); + const run = await start(geoScanWorkflow, [parsed]); + return { runId: run.runId }; +} + export async function startOnDemandRun( payload: unknown ): Promise<{ runId: string }> { diff --git a/apps/dashboard/src/schemas/geo.ts b/apps/dashboard/src/schemas/geo.ts new file mode 100644 index 000000000..cde45c0c5 --- /dev/null +++ b/apps/dashboard/src/schemas/geo.ts @@ -0,0 +1,80 @@ +import { array, boolean, enum as enumType, number, object, string } from "zod"; +import { + GEO_DISCOVERY_MAX_ALIASES, + GEO_DISCOVERY_MAX_COMPETITORS, + GEO_DISCOVERY_MAX_PROMPTS, + GEO_DISCOVERY_MIN_COMPETITORS, + GEO_DISCOVERY_MIN_PROMPTS, + GEO_PROMPT_MAX_LENGTH, + GEO_PROMPT_MIN_LENGTH, +} from "@/constants/geo"; +import { publicWebsiteUrlSchema } from "@/schemas/url"; + +const MAX_ALIASES = 10; +const MAX_COMPETITORS = 10; +const MAX_JUDGE_COMPETITORS = 15; +const MAX_EXCERPT_LENGTH = 300; +const MAX_DAYS = 365; +const MIN_PROMPT_LENGTH = GEO_PROMPT_MIN_LENGTH; +const MAX_PROMPT_LENGTH = GEO_PROMPT_MAX_LENGTH; + +export const geoSettingsUpsertInputSchema = object({ + organizationId: string().min(1), + companyName: string().min(1), + aliases: array(string().min(1)).max(MAX_ALIASES), + competitors: array(string().min(1)).max(MAX_COMPETITORS), + enabled: boolean(), +}); + +export const geoOrganizationInputSchema = object({ + organizationId: string().min(1), +}); + +export const geoTimeseriesInputSchema = object({ + organizationId: string().min(1), + days: number().int().min(1).max(MAX_DAYS).optional(), +}); + +export const geoPromptCreateInputSchema = object({ + organizationId: string().min(1), + prompt: string().min(MIN_PROMPT_LENGTH).max(MAX_PROMPT_LENGTH), +}); + +export const geoPromptDeleteInputSchema = object({ + organizationId: string().min(1), + promptId: string().min(1), +}); + +export const geoPromptToggleInputSchema = object({ + organizationId: string().min(1), + promptId: string().min(1), + enabled: boolean(), +}); + +export const geoScanPayloadSchema = object({ + organizationId: string().min(1), +}); + +export const geoGenerateFromWebsiteInputSchema = object({ + organizationId: string().min(1), + url: publicWebsiteUrlSchema, +}); + +export const geoWebsiteDiscoverySchema = object({ + companyName: string().min(1), + aliases: array(string().min(1)).max(GEO_DISCOVERY_MAX_ALIASES), + competitors: array(string().min(1)) + .min(GEO_DISCOVERY_MIN_COMPETITORS) + .max(GEO_DISCOVERY_MAX_COMPETITORS), + prompts: array(string().min(MIN_PROMPT_LENGTH).max(MAX_PROMPT_LENGTH)) + .min(GEO_DISCOVERY_MIN_PROMPTS) + .max(GEO_DISCOVERY_MAX_PROMPTS), +}); + +export const geoJudgeResultSchema = object({ + mentioned: boolean(), + position: number().nullable(), + sentiment: enumType(["positive", "neutral", "negative"]).nullable(), + competitors: array(string()).max(MAX_JUDGE_COMPETITORS), + excerpt: string().max(MAX_EXCERPT_LENGTH), +}); diff --git a/apps/dashboard/src/types/components/nav.ts b/apps/dashboard/src/types/components/nav.ts index 754261d67..fe69c76f7 100644 --- a/apps/dashboard/src/types/components/nav.ts +++ b/apps/dashboard/src/types/components/nav.ts @@ -1,6 +1,11 @@ import type { IconSvgElement } from "@hugeicons/react"; -export type NavMainCategory = "none" | "workspace" | "automation" | "manage"; +export type NavMainCategory = + | "none" + | "workspace" + | "automation" + | "geo" + | "manage"; export interface NavItem { link: string; diff --git a/apps/dashboard/src/types/geo.ts b/apps/dashboard/src/types/geo.ts new file mode 100644 index 000000000..824027c3e --- /dev/null +++ b/apps/dashboard/src/types/geo.ts @@ -0,0 +1,179 @@ +export interface GeoSettings { + id: string; + organizationId: string; + companyName: string; + aliases: string[]; + competitors: string[]; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface GeoSettingsResponse { + configured: boolean; + settings: GeoSettings | null; +} + +export interface GeoOverviewEngine { + engine: string; + checks: number; + mentions: number; + mentionRate: number; + avgPosition: number | null; + lastCheckedAt: string; +} + +export interface GeoOverviewResponse { + configured: boolean; + engines: GeoOverviewEngine[]; +} + +export interface GeoTimeseriesPoint { + day: string; + engine: string; + checks: number; + mentions: number; +} + +export interface GeoTimeseriesResponse { + configured: boolean; + points: GeoTimeseriesPoint[]; +} + +export interface GeoPromptResult { + promptId: string; + engine: string; + prompt: string; + mentioned: boolean; + position: number | null; + sentiment: string | null; + excerpt: string; + lastCheckedAt: string; +} + +export interface GeoPromptResultsResponse { + configured: boolean; + results: GeoPromptResult[]; +} + +export interface GeoCompetitorSharePoint { + brand: string; + mentions: number; +} + +export interface GeoCompetitorShareResponse { + configured: boolean; + points: GeoCompetitorSharePoint[]; +} + +export interface GeoSettingsUpsertInput { + organizationId: string; + companyName: string; + aliases: string[]; + competitors: string[]; + enabled: boolean; +} + +export interface GeoTrackedPrompt { + id: string; + prompt: string; + enabled: boolean; + source: "custom" | "auto"; + createdAt: string | null; +} + +export interface GeoPromptCreateInput { + prompt: string; +} + +export interface GeoPromptDeleteInput { + promptId: string; +} + +export interface GeoPromptToggleInput { + promptId: string; + enabled: boolean; +} + +export interface GeoPromptRow { + id: string; + organizationId: string; + prompt: string; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface GeoTrackedPromptsResponse { + configured: boolean; + prompts: GeoTrackedPrompt[]; +} + +export interface GeoScanPayload { + organizationId: string; +} + +export interface GeoScanResult { + status: "completed" | "skipped" | "invalid_payload"; + checks?: number; + mentions?: number; +} + +export interface GeoPromptDefinition { + id: string; + text: string; +} + +export interface GeoBrandContext { + companyDescription: string | null; + audience: string | null; +} + +export interface MentionRateRow { + day: string; + rawDay: string; + [engine: string]: string | number; +} + +export type GeoGroundedProvider = + | "gateway-openai" + | "gateway-anthropic" + | "gateway-google" + | "direct-openai" + | "direct-anthropic" + | "direct-perplexity"; + +export interface GeoGroundedEngine { + key: string; + label: string; + model: string; + provider: GeoGroundedProvider; + envVar: string | null; + isAvailable: () => boolean; +} + +export interface GeoWebsiteDiscovery { + companyName: string; + aliases: string[]; + competitors: string[]; + prompts: string[]; +} + +export interface GeoGenerateFromWebsiteResult { + companyName: string; + aliases: string[]; + competitors: string[]; + promptsAdded: number; +} + +export interface GeoGenerateFromWebsiteInput { + url: string; +} + +export interface GeoJudgeResult { + mentioned: boolean; + position: number | null; + sentiment: "positive" | "neutral" | "negative" | null; + competitors: string[]; + excerpt: string; +} diff --git a/apps/dashboard/src/utils/geo-charts.ts b/apps/dashboard/src/utils/geo-charts.ts new file mode 100644 index 000000000..522091dbb --- /dev/null +++ b/apps/dashboard/src/utils/geo-charts.ts @@ -0,0 +1,37 @@ +import type { GeoTimeseriesPoint, MentionRateRow } from "@/types/geo"; +import { formatDayLabel } from "@/utils/analytics-charts"; + +const PERCENT = 100; + +export function formatMentionRate(rate: number): string { + return `${Math.round(rate * PERCENT)}%`; +} + +export function buildMentionRateRows(points: GeoTimeseriesPoint[]): { + rows: MentionRateRow[]; + engines: string[]; +} { + const engines = [...new Set(points.map((point) => point.engine))]; + const byDay = new Map>(); + for (const point of points) { + const dayPoints = byDay.get(point.day) ?? new Map(); + dayPoints.set(point.engine, point); + byDay.set(point.day, dayPoints); + } + + const days = [...byDay.keys()].sort(); + const rows = days.map((day) => { + const row: MentionRateRow = { day: formatDayLabel(day), rawDay: day }; + const dayPoints = byDay.get(day); + for (const engine of engines) { + const point = dayPoints?.get(engine); + row[engine] = + point && point.checks > 0 + ? Math.round((point.mentions / point.checks) * PERCENT) + : 0; + } + return row; + }); + + return { rows, engines }; +} diff --git a/apps/dashboard/src/workflows/geo-scan.ts b/apps/dashboard/src/workflows/geo-scan.ts new file mode 100644 index 000000000..7eb50dea4 --- /dev/null +++ b/apps/dashboard/src/workflows/geo-scan.ts @@ -0,0 +1,18 @@ +import { flattenError } from "zod"; +import { geoScanPayloadSchema } from "@/schemas/geo"; +import type { GeoScanPayload, GeoScanResult } from "@/types/geo"; +import { runGeoScanStep } from "./steps/geo-scan-steps"; + +export async function geoScanWorkflow( + payload: GeoScanPayload +): Promise { + "use workflow"; + + const parseResult = geoScanPayloadSchema.safeParse(payload); + if (!parseResult.success) { + console.error("[GEO] Invalid payload:", flattenError(parseResult.error)); + return { status: "invalid_payload" }; + } + + return await runGeoScanStep(parseResult.data.organizationId); +} diff --git a/apps/dashboard/src/workflows/steps/geo-scan-steps.ts b/apps/dashboard/src/workflows/steps/geo-scan-steps.ts new file mode 100644 index 000000000..c95fba38e --- /dev/null +++ b/apps/dashboard/src/workflows/steps/geo-scan-steps.ts @@ -0,0 +1,10 @@ +import { Effect } from "effect"; +import { runGeoScan } from "@/lib/geo/scan"; +import type { GeoScanResult } from "@/types/geo"; + +export async function runGeoScanStep( + organizationId: string +): Promise { + "use step"; + return await Effect.runPromise(runGeoScan(organizationId)); +} diff --git a/bun.lock b/bun.lock index 85af5afd8..64542fca7 100644 --- a/bun.lock +++ b/bun.lock @@ -138,6 +138,9 @@ "name": "dashboard", "version": "0.1.0", "dependencies": { + "@ai-sdk/anthropic": "3.0.84", + "@ai-sdk/openai": "3.0.71", + "@ai-sdk/perplexity": "3.0.35", "@ai-sdk/react": "3.0.208", "@aws-sdk/client-s3": "^3.990.0", "@aws-sdk/s3-request-presigner": "^3.990.0", @@ -558,7 +561,7 @@ "protobufjs@7": "7.6.5", }, "packages": { - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.83", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-R0uwSr9TFuW/lnSE/k9kp/S9U8POXl1/S0IlKgxmAF65P8Jzlr5NwyJER9mlqca0gXbHvx14weob+2zWZfsgrg=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BIDaHmCHs6Sr5VUsEkTbbVlAN4GWjg97X9x/IfXyviLtzsXvffui9XIcZugkAi1Ri6FnvI5T5qDGh5YLnSuzRg=="], "@ai-sdk/devtools": ["@ai-sdk/devtools@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "4.0.1", "@hono/node-server": "^1.19.13", "hono": "^4.12.25" }, "bin": { "devtools": "bin/cli.js" } }, "sha512-Yak6fCfooo+4/DhPGlMlhte89swJtw4OKI7epX3tFs/0/CDDQ/fw2FyaoP2Xv1TQaAqy7lbaVfJbghAI5sJ3hA=="], @@ -566,7 +569,9 @@ "@ai-sdk/mcp": ["@ai-sdk/mcp@1.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-03JEdJtB+K/I7a85GWJjrB+ypYY++ZFDTp2YJ5Wd0e0raZdFpBLvvX9LJIxPF1LRbs7PMtx6TIS1YMP0Dece+w=="], - "@ai-sdk/openai": ["@ai-sdk/openai@2.0.108", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-apM/hmw4ZinxPK/0vfuuiZ6MwoJmyoBjwTErgTqKVOyFhJxoYP9+SD4jV6yrFIzhuTv4X6Cx5jmv62baQefAlQ=="], + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-j6eBAa5oHFZ4U5CxpIV3T4zXNM/BviodNCZCL1qHkA4aqkwK9iQ18TWYz2DZcXpw4BO5pikKzqpXORxb1EnZGA=="], + + "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.29" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TAWWUF2W+qJrjtWhKN61o0dNguqS7NTDpILlsa39TTeCBZghlyLtiEIGjeclWzP/P/InicXza8ebw+dkGN36lg=="], "@ai-sdk/provider": ["@ai-sdk/provider@4.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6p3C/vGqVIjcptBu1DnVd/BZJ2wWmV9TUv9192vT6ZvT9KNED8EwRTqyqFpoQZKgSbMDSvBSq3dqR524Nt/Crw=="], @@ -4860,9 +4865,7 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], - - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JFhJK5ynprll2FR3e+sHagJJIwvIagsNA0FLbLPq2Os4yLUK2/eiaCU0jXsADik73/hhvcPPLmD+Uo8eu5kFaQ=="], + "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -4870,9 +4873,9 @@ "@ai-sdk/mcp/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JFhJK5ynprll2FR3e+sHagJJIwvIagsNA0FLbLPq2Os4yLUK2/eiaCU0jXsADik73/hhvcPPLmD+Uo8eu5kFaQ=="], + "@ai-sdk/perplexity/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -5248,6 +5251,10 @@ "@stoplight/yaml/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="], + "@supermemory/tools/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.83", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-R0uwSr9TFuW/lnSE/k9kp/S9U8POXl1/S0IlKgxmAF65P8Jzlr5NwyJER9mlqca0gXbHvx14weob+2zWZfsgrg=="], + + "@supermemory/tools/@ai-sdk/openai": ["@ai-sdk/openai@2.0.108", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-apM/hmw4ZinxPK/0vfuuiZ6MwoJmyoBjwTErgTqKVOyFhJxoYP9+SD4jV6yrFIzhuTv4X6Cx5jmv62baQefAlQ=="], + "@supermemory/tools/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@supermemory/tools/ai": ["ai@5.0.204", "", { "dependencies": { "@ai-sdk/gateway": "2.0.103", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.27", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vr4bPUe9v5oJpv/fzcLCupSQODqSerP1oHXDq95oLJYw2vidZUtvRP4pLYD26WzQO/PlIxMHcuVKmjPC3dd6PA=="], @@ -6176,6 +6183,14 @@ "@stoplight/spectral-runtime/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "@supermemory/tools/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + + "@supermemory/tools/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JFhJK5ynprll2FR3e+sHagJJIwvIagsNA0FLbLPq2Os4yLUK2/eiaCU0jXsADik73/hhvcPPLmD+Uo8eu5kFaQ=="], + + "@supermemory/tools/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + + "@supermemory/tools/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JFhJK5ynprll2FR3e+sHagJJIwvIagsNA0FLbLPq2Os4yLUK2/eiaCU0jXsADik73/hhvcPPLmD+Uo8eu5kFaQ=="], + "@supermemory/tools/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.103", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.27", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f3NmeCxHOmZTIk/VG3nVrF6LnuU+S/Ls4aUBSKOI1QF+xtkF6FnREsTkSvcbzoJSsO6Jr7OyQXXEWGFDAMobLQ=="], "@supermemory/tools/ai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index 2314e2929..5ba69143f 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -1,5 +1,7 @@ import { type IngestResult, type QueryResult, Tinybird } from "@tinybirdco/sdk"; import { + type GeoMentionCheckRow, + geoMentionChecks, type SocialAccountRow, type SocialAccountStatsRow, type SocialPostRow, @@ -21,6 +23,14 @@ import { type FollowerGrowthParams, type FollowerGrowthRow, followerGrowth, + type GeoCompetitorShareRow, + type GeoOverviewRow, + type GeoPromptResultsRow, + type GeoTimeseriesRow, + geoCompetitorShare, + geoOverview, + geoPromptResults, + geoTimeseries, type NotraAdoptionRow, notraAdoption, type PostingPerformanceParams, @@ -51,6 +61,7 @@ function createTinybirdClient() { socialPosts, socialPostStats, socialPostSources, + geoMentionChecks, }, pipes: { socialOverview, @@ -60,6 +71,10 @@ function createTinybirdClient() { postingPerformance, notraAdoption, postMetricsLookup, + geoOverview, + geoTimeseries, + geoPromptResults, + geoCompetitorShare, accountLeaderboard, }, }); @@ -131,6 +146,14 @@ export function ingestSocialPostSources( ); } +export function ingestGeoMentionChecks( + rows: GeoMentionCheckRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.geoMentionChecks.ingestBatch(batch) + ); +} + export async function querySocialOverview( params: SocialOverviewParams ): Promise | null> { @@ -191,6 +214,50 @@ export async function queryNotraAdoption(params: { return await client.notraAdoption.query(params); } +export async function queryGeoOverview(params: { + organization_id: string; + days?: number; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.geoOverview.query(params); +} + +export async function queryGeoTimeseries(params: { + organization_id: string; + days?: number; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.geoTimeseries.query(params); +} + +export async function queryGeoPromptResults(params: { + organization_id: string; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.geoPromptResults.query(params); +} + +export async function queryGeoCompetitorShare(params: { + organization_id: string; + days?: number; + limit?: number; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.geoCompetitorShare.query(params); +} + export async function queryAccountLeaderboard( params: AccountLeaderboardParams ): Promise | null> { diff --git a/packages/analytics/src/tinybird/datasources.ts b/packages/analytics/src/tinybird/datasources.ts index 79ac06436..275fe3f84 100644 --- a/packages/analytics/src/tinybird/datasources.ts +++ b/packages/analytics/src/tinybird/datasources.ts @@ -110,8 +110,31 @@ export const socialPostSources = defineDatasource("social_post_sources", { }), }); +export const geoMentionChecks = defineDatasource("geo_mention_checks", { + description: + "AI engine mention checks: one row per prompt x engine per scan, with extracted mention data", + schema: { + organization_id: t.string(), + scan_id: t.string(), + engine: t.string().lowCardinality(), + prompt_id: t.string().lowCardinality(), + prompt: t.string(), + captured_at: t.dateTime(), + mentioned: t.bool(), + position: t.uint64().nullable(), + sentiment: t.string().lowCardinality().nullable(), + competitors: t.array(t.string()).jsonPath("$.competitors[:]"), + excerpt: t.string(), + }, + engine: engine.mergeTree({ + sortingKey: ["organization_id", "engine", "prompt_id", "captured_at"], + partitionKey: "toYYYYMM(captured_at)", + }), +}); + export type SocialAccountRow = InferRow; export type SocialAccountStatsRow = InferRow; export type SocialPostRow = InferRow; export type SocialPostStatsRow = InferRow; export type SocialPostSourceRow = InferRow; +export type GeoMentionCheckRow = InferRow; diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts index 375e53bc5..91b354e7c 100644 --- a/packages/analytics/src/tinybird/endpoints.ts +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -476,7 +476,143 @@ export const notraAdoption = defineEndpoint("notra_adoption", { }, }); +export const geoOverview = defineEndpoint("geo_overview", { + description: "AI mention rate per engine over the trailing window", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "per_engine", + sql: ` + SELECT + engine, + count() AS checks, + countIf(mentioned) AS mentions, + round(countIf(mentioned) / count(), 3) AS mention_rate, + round(avgIf(position, mentioned AND position IS NOT NULL), 1) AS avg_position, + max(captured_at) AS last_checked_at + FROM geo_mention_checks + WHERE organization_id = {{String(organization_id)}} + AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + GROUP BY engine + ORDER BY mention_rate DESC + `, + }), + ], + output: { + engine: t.string(), + checks: t.uint64(), + mentions: t.uint64(), + mention_rate: t.float64(), + avg_position: t.float64().nullable(), + last_checked_at: t.dateTime(), + }, +}); + +export const geoTimeseries = defineEndpoint("geo_timeseries", { + description: "Daily AI mention rate per engine", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "daily", + sql: ` + SELECT + toDate(captured_at) AS day, + engine, + count() AS checks, + countIf(mentioned) AS mentions + FROM geo_mention_checks + WHERE organization_id = {{String(organization_id)}} + AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + GROUP BY day, engine + ORDER BY day ASC + `, + }), + ], + output: { + day: t.date(), + engine: t.string(), + checks: t.uint64(), + mentions: t.uint64(), + }, +}); + +export const geoPromptResults = defineEndpoint("geo_prompt_results", { + description: "Latest result per prompt and engine", + params: { + organization_id: p.string().describe("Organization id"), + }, + nodes: [ + node({ + name: "latest", + sql: ` + SELECT + prompt_id, + engine, + argMax(prompt, captured_at) AS prompt, + argMax(mentioned, captured_at) AS mentioned, + argMax(position, captured_at) AS position, + argMax(sentiment, captured_at) AS sentiment, + argMax(excerpt, captured_at) AS excerpt, + max(captured_at) AS last_checked_at + FROM geo_mention_checks + WHERE organization_id = {{String(organization_id)}} + GROUP BY prompt_id, engine + ORDER BY prompt_id ASC, engine ASC + `, + }), + ], + output: { + prompt_id: t.string(), + engine: t.string(), + prompt: t.string(), + mentioned: t.bool(), + position: t.uint64().nullable(), + sentiment: t.string().nullable(), + excerpt: t.string(), + last_checked_at: t.dateTime(), + }, +}); + +export const geoCompetitorShare = defineEndpoint("geo_competitor_share", { + description: "Competitor brands surfaced by AI engines, by mention count", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + limit: p.int32().optional(10).describe("Max competitors"), + }, + nodes: [ + node({ + name: "share", + sql: ` + SELECT + arrayJoin(competitors) AS brand, + count() AS mentions + FROM geo_mention_checks + WHERE organization_id = {{String(organization_id)}} + AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + GROUP BY brand + ORDER BY mentions DESC + LIMIT {{Int32(limit, 10)}} + `, + }), + ], + output: { + brand: t.string(), + mentions: t.uint64(), + }, +}); + export type NotraAdoptionRow = InferOutputRow; +export type GeoOverviewRow = InferOutputRow; +export type GeoTimeseriesRow = InferOutputRow; +export type GeoPromptResultsRow = InferOutputRow; +export type GeoCompetitorShareRow = InferOutputRow; export const postMetricsLookup = defineEndpoint("post_metrics_lookup", { description: "Latest metric snapshot for specific posts by platform post id", diff --git a/packages/db/migrations/0064_exotic_scourge.sql b/packages/db/migrations/0064_exotic_scourge.sql new file mode 100644 index 000000000..b809a0f09 --- /dev/null +++ b/packages/db/migrations/0064_exotic_scourge.sql @@ -0,0 +1,24 @@ +CREATE TABLE "geo_prompts" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "prompt" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "geo_settings" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "company_name" text NOT NULL, + "aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "competitors" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "geo_prompts" ADD CONSTRAINT "geo_prompts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "geo_settings" ADD CONSTRAINT "geo_settings_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "geoPrompts_organizationId_idx" ON "geo_prompts" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "geoSettings_organizationId_uidx" ON "geo_settings" USING btree ("organization_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0064_snapshot.json b/packages/db/migrations/meta/0064_snapshot.json new file mode 100644 index 000000000..33aecd97a --- /dev/null +++ b/packages/db/migrations/meta/0064_snapshot.json @@ -0,0 +1,9304 @@ +{ + "id": "5565d305-8e7a-47db-8672-c03fa92c9ebf", + "prevId": "3e7d6177-419d-4b9f-ac44-8ddda86c56de", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_userId_idx": { + "name": "accounts_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eve_session_id": { + "name": "eve_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "continuation_token": { + "name": "continuation_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_index": { + "name": "stream_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentSessions_eveSessionId_uidx": { + "name": "agentSessions_eveSessionId_uidx", + "columns": [ + { + "expression": "eve_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_organizationId_idx": { + "name": "agentSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_chatId_idx": { + "name": "agentSessions_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_organization_id_organizations_id_fk": { + "name": "agent_sessions_organization_id_organizations_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_chat_id_chat_sessions_id_fk": { + "name": "agent_sessions_chat_id_chat_sessions_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_actions": { + "name": "autonomy_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "external_ref": { + "name": "external_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyActions_organizationId_idx": { + "name": "autonomyActions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_runId_idx": { + "name": "autonomyActions_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_org_capability_idempotency_uidx": { + "name": "autonomyActions_org_capability_idempotency_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_organizationId_status_idx": { + "name": "autonomyActions_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_actions_organization_id_organizations_id_fk": { + "name": "autonomy_actions_organization_id_organizations_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_run_id_autonomy_runs_id_fk": { + "name": "autonomy_actions_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_actions_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_checkpoints": { + "name": "autonomy_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyCheckpoints_organizationId_idx": { + "name": "autonomyCheckpoints_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyCheckpoints_runId_idx": { + "name": "autonomyCheckpoints_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_checkpoints_organization_id_organizations_id_fk": { + "name": "autonomy_checkpoints_organization_id_organizations_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_run_id_autonomy_runs_id_fk": { + "name": "autonomy_checkpoints_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_checkpoints_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_claims": { + "name": "autonomy_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_key": { + "name": "claim_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyClaims_scope_claimKey_uidx": { + "name": "autonomyClaims_scope_claimKey_uidx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyClaims_expiresAt_idx": { + "name": "autonomyClaims_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_claims_organization_id_organizations_id_fk": { + "name": "autonomy_claims_organization_id_organizations_id_fk", + "tableFrom": "autonomy_claims", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_controller_leases": { + "name": "autonomy_controller_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyControllerLeases_organizationId_idx": { + "name": "autonomyControllerLeases_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_controller_leases_organization_id_organizations_id_fk": { + "name": "autonomy_controller_leases_organization_id_organizations_id_fk", + "tableFrom": "autonomy_controller_leases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_goals": { + "name": "autonomy_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_goal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "origin_signal_ids": { + "name": "origin_signal_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyGoals_organizationId_idx": { + "name": "autonomyGoals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_mandateId_idx": { + "name": "autonomyGoals_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_organizationId_status_idx": { + "name": "autonomyGoals_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_goals_organization_id_organizations_id_fk": { + "name": "autonomy_goals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_goals_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_goals_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_mandates": { + "name": "autonomy_mandates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_mandate_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyMandates_organizationId_idx": { + "name": "autonomyMandates_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyMandates_organizationId_name_uidx": { + "name": "autonomyMandates_organizationId_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_mandates_organization_id_organizations_id_fk": { + "name": "autonomy_mandates_organization_id_organizations_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_mandates_created_by_user_id_users_id_fk": { + "name": "autonomy_mandates_created_by_user_id_users_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_outbox": { + "name": "autonomy_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyOutbox_organizationId_idx": { + "name": "autonomyOutbox_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_org_destination_dedupeKey_uidx": { + "name": "autonomyOutbox_org_destination_dedupeKey_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_status_nextAttemptAt_idx": { + "name": "autonomyOutbox_status_nextAttemptAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_outbox_organization_id_organizations_id_fk": { + "name": "autonomy_outbox_organization_id_organizations_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_outbox_run_id_autonomy_runs_id_fk": { + "name": "autonomy_outbox_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_runs": { + "name": "autonomy_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_version": { + "name": "mandate_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "autonomy_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "planner_input_hash": { + "name": "planner_input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planner_output": { + "name": "planner_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyRuns_organizationId_idx": { + "name": "autonomyRuns_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_mandateId_idx": { + "name": "autonomyRuns_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_goalId_idx": { + "name": "autonomyRuns_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_organizationId_status_idx": { + "name": "autonomyRuns_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_runs_organization_id_organizations_id_fk": { + "name": "autonomy_runs_organization_id_organizations_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_runs_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_runs_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_signals": { + "name": "autonomy_signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_signal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "coalesced_into_signal_id": { + "name": "coalesced_into_signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomySignals_organizationId_idx": { + "name": "autonomySignals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_dedupeHash_uidx": { + "name": "autonomySignals_organizationId_dedupeHash_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_status_occurredAt_idx": { + "name": "autonomySignals_organizationId_status_occurredAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_signals_organization_id_organizations_id_fk": { + "name": "autonomy_signals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_signals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomySignals_coalescedIntoSignalId_fk": { + "name": "autonomySignals_coalescedIntoSignalId_fk", + "tableFrom": "autonomy_signals", + "tableTo": "autonomy_signals", + "columnsFrom": [ + "coalesced_into_signal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_tasks": { + "name": "autonomy_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "depends_on_task_ids": { + "name": "depends_on_task_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "autonomy_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyTasks_organizationId_idx": { + "name": "autonomyTasks_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_goalId_idx": { + "name": "autonomyTasks_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_runId_idx": { + "name": "autonomyTasks_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_organizationId_status_waitUntil_idx": { + "name": "autonomyTasks_organizationId_status_waitUntil_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_tasks_organization_id_organizations_id_fk": { + "name": "autonomy_tasks_organization_id_organizations_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_tasks_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_run_id_autonomy_runs_id_fk": { + "name": "autonomy_tasks_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_assets": { + "name": "brand_guideline_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_asset_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "brand_guideline_asset_variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineAssets_guidelineId_idx": { + "name": "brandGuidelineAssets_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_idx": { + "name": "brandGuidelineAssets_guideline_kind_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_variant_uidx": { + "name": "brandGuidelineAssets_guideline_kind_variant_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_assets_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_assets_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_assets", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_colors": { + "name": "brand_guideline_colors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_color_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "light_value": { + "name": "light_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dark_value": { + "name": "dark_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineColors_guidelineId_idx": { + "name": "brandGuidelineColors_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineColors_guideline_role_idx": { + "name": "brandGuidelineColors_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_colors_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_colors_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_colors", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_fonts": { + "name": "brand_guideline_fonts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_font_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_height": { + "name": "line_height", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineFonts_guidelineId_idx": { + "name": "brandGuidelineFonts_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineFonts_guideline_role_idx": { + "name": "brandGuidelineFonts_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_fonts", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_screenshots": { + "name": "brand_guideline_screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_screenshot_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_page": { + "name": "full_page", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineScreenshots_guidelineId_idx": { + "name": "brandGuidelineScreenshots_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineScreenshots_guideline_kind_uidx": { + "name": "brandGuidelineScreenshots_guideline_kind_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_screenshots", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_tokens": { + "name": "brand_guideline_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "brand_guideline_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineTokens_guidelineId_idx": { + "name": "brandGuidelineTokens_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineTokens_guideline_type_idx": { + "name": "brandGuidelineTokens_guideline_type_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_tokens", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guidelines": { + "name": "brand_guidelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_guideline_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_generated_at": { + "name": "last_generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_generation_error": { + "name": "last_generation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelines_brandSettingsId_uidx": { + "name": "brandGuidelines_brandSettingsId_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelines_status_idx": { + "name": "brandGuidelines_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guidelines_brand_settings_id_brand_settings_id_fk": { + "name": "brand_guidelines_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_guidelines", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_references": { + "name": "brand_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "reference_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_key": { + "name": "source_snapshot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_hash": { + "name": "source_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_captured_at": { + "name": "source_captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_document_id": { + "name": "supermemory_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_memory_id": { + "name": "supermemory_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_synced_at": { + "name": "supermemory_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supermemory_last_sync_error": { + "name": "supermemory_last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicable_to": { + "name": "applicable_to", + "type": "applicable_platform[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['all']::applicable_platform[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandReferences_brandSettingsId_idx": { + "name": "brandReferences_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandReferences_brandSettingsId_sourceUrl_idx": { + "name": "brandReferences_brandSettingsId_sourceUrl_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_references_brand_settings_id_brand_settings_id_fk": { + "name": "brand_references_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_references", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_settings": { + "name": "brand_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tone_profile": { + "name": "tone_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_tone": { + "name": "custom_tone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_instructions": { + "name": "custom_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'English'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSettings_org_name_uidx": { + "name": "brandSettings_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_org_default_uidx": { + "name": "brandSettings_org_default_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brand_settings\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_organizationId_idx": { + "name": "brandSettings_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_settings_organization_id_organizations_id_fk": { + "name": "brand_settings_organization_id_organizations_id_fk", + "tableFrom": "brand_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brandSettings_toneProfile_check": { + "name": "brandSettings_toneProfile_check", + "value": "\"brand_settings\".\"tone_profile\" IS NULL OR \"brand_settings\".\"tone_profile\" IN ('Conversational', 'Professional', 'Casual', 'Formal')" + } + }, + "isRLSEnabled": false + }, + "public.brand_sitemap_pages": { + "name": "brand_sitemap_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sitemap_id": { + "name": "sitemap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "brand_sitemap_page_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_target": { + "name": "redirect_target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text_ratio": { + "name": "text_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "internal_links": { + "name": "internal_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_links": { + "name": "external_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "crawled_at": { + "name": "crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemapPages_sitemapId_idx": { + "name": "brandSitemapPages_sitemapId_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_category_idx": { + "name": "brandSitemapPages_sitemap_category_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_url_uidx": { + "name": "brandSitemapPages_sitemap_url_uidx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk": { + "name": "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk", + "tableFrom": "brand_sitemap_pages", + "tableTo": "brand_sitemaps", + "columnsFrom": [ + "sitemap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_sitemaps": { + "name": "brand_sitemaps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_sitemap_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "total_pages": { + "name": "total_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_pages": { + "name": "indexed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_pages": { + "name": "failed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_crawl_started_at": { + "name": "last_crawl_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawled_at": { + "name": "last_crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawl_error": { + "name": "last_crawl_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemaps_brandSettingsId_idx": { + "name": "brandSitemaps_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemaps_brandSettings_url_uidx": { + "name": "brandSitemaps_brandSettings_url_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemaps_brand_settings_id_brand_settings_id_fk": { + "name": "brand_sitemaps_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_sitemaps", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatAttachments_organizationId_createdAt_idx": { + "name": "chatAttachments_organizationId_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatAttachments_userId_idx": { + "name": "chatAttachments_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_user_id_users_id_fk": { + "name": "chat_attachments_user_id_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_attachments_key_unique": { + "name": "chat_attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_channel_source": { + "name": "external_channel_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_channel_id": { + "name": "external_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatSessions_organizationId_idx": { + "name": "chatSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_organizationId_deletedAt_idx": { + "name": "chatSessions_organizationId_deletedAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_org_externalChannel_uidx": { + "name": "chatSessions_org_externalChannel_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_sessions\".\"external_channel_source\" IN ('discord', 'slack') AND \"chat_sessions\".\"external_channel_id\" IS NOT NULL AND \"chat_sessions\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connected_social_accounts": { + "name": "connected_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connectedSocialAccounts_organizationId_idx": { + "name": "connectedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connectedSocialAccounts_org_provider_account_uidx": { + "name": "connectedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connected_social_accounts_organization_id_organizations_id_fk": { + "name": "connected_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "connected_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_trigger_lookback_windows": { + "name": "content_trigger_lookback_windows", + "schema": "", + "columns": { + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window": { + "name": "window", + "type": "lookback_window", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk": { + "name": "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk", + "tableFrom": "content_trigger_lookback_windows", + "tableTo": "content_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_triggers": { + "name": "content_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Schedule'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_config": { + "name": "source_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_config": { + "name": "output_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_publish": { + "name": "auto_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contentTriggers_organizationId_idx": { + "name": "contentTriggers_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentTriggers_organization_dedupe_uidx": { + "name": "contentTriggers_organization_dedupe_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_triggers_organization_id_organizations_id_fk": { + "name": "content_triggers_organization_id_organizations_id_fk", + "tableFrom": "content_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_prompts": { + "name": "geo_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoPrompts_organizationId_idx": { + "name": "geoPrompts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_prompts_organization_id_organizations_id_fk": { + "name": "geo_prompts_organization_id_organizations_id_fk", + "tableFrom": "geo_prompts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_settings": { + "name": "geo_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "competitors": { + "name": "competitors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoSettings_organizationId_uidx": { + "name": "geoSettings_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_settings_organization_id_organizations_id_fk": { + "name": "geo_settings_organization_id_organizations_id_fk", + "tableFrom": "geo_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubAppInstallations_organizationId_idx": { + "name": "githubAppInstallations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_createdByUserId_idx": { + "name": "githubAppInstallations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_organization_installation_uidx": { + "name": "githubAppInstallations_organization_installation_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_organization_id_organizations_id_fk": { + "name": "github_app_installations_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_app_installations_created_by_user_id_users_id_fk": { + "name": "github_app_installations_created_by_user_id_users_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integrations": { + "name": "github_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_installation_id": { + "name": "github_app_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_private": { + "name": "github_repository_private", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_enabled": { + "name": "repository_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubIntegrations_organizationId_idx": { + "name": "githubIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_createdByUserId_idx": { + "name": "githubIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_organization_owner_repo_uidx": { + "name": "githubIntegrations_organization_owner_repo_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_integrations_organization_id_organizations_id_fk": { + "name": "github_integrations_organization_id_organizations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_created_by_user_id_users_id_fk": { + "name": "github_integrations_created_by_user_id_users_id_fk", + "tableFrom": "github_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_github_app_installation_id_github_app_installations_id_fk": { + "name": "github_integrations_github_app_installation_id_github_app_installations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "github_app_installations", + "columnsFrom": [ + "github_app_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.granola_integrations": { + "name": "granola_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "granolaIntegrations_organizationId_idx": { + "name": "granolaIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "granolaIntegrations_createdByUserId_idx": { + "name": "granolaIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "granola_integrations_organization_id_organizations_id_fk": { + "name": "granola_integrations_organization_id_organizations_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "granola_integrations_created_by_user_id_users_id_fk": { + "name": "granola_integrations_created_by_user_id_users_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organizationId_idx": { + "name": "invitations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_integrations": { + "name": "linear_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_name": { + "name": "linear_organization_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_name": { + "name": "linear_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linearIntegrations_organizationId_idx": { + "name": "linearIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_createdByUserId_idx": { + "name": "linearIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_team_uidx": { + "name": "linearIntegrations_org_linearOrg_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_no_team_uidx": { + "name": "linearIntegrations_org_linearOrg_no_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"linear_integrations\".\"linear_team_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_integrations_organization_id_organizations_id_fk": { + "name": "linear_integrations_organization_id_organizations_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "linear_integrations_created_by_user_id_users_id_fk": { + "name": "linear_integrations_created_by_user_id_users_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_credentials": { + "name": "mcp_oauth_credentials", + "schema": "", + "columns": { + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_tokens": { + "name": "encrypted_tokens", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_refresh_at": { + "name": "access_token_refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_lease_id": { + "name": "refresh_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_lease_expires_at": { + "name": "refresh_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthCredentials_organizationId_idx": { + "name": "mcpOAuthCredentials_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthCredentials_connectedByUserId_idx": { + "name": "mcpOAuthCredentials_connectedByUserId_idx", + "columns": [ + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_organization_id_organizations_id_fk": { + "name": "mcp_oauth_credentials_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_connected_by_user_id_users_id_fk": { + "name": "mcp_oauth_credentials_connected_by_user_id_users_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "users", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthCredentials_org_server_fk": { + "name": "mcpOAuthCredentials_org_server_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpOAuthCredentials_status_check": { + "name": "mcpOAuthCredentials_status_check", + "value": "\"mcp_oauth_credentials\".\"status\" IN ('connected', 'refreshing', 'reauth_required')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_oauth_pending_authorizations": { + "name": "mcp_oauth_pending_authorizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_path": { + "name": "callback_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_code_verifier": { + "name": "encrypted_code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthPendingAuthorizations_organizationId_idx": { + "name": "mcpOAuthPendingAuthorizations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_userId_idx": { + "name": "mcpOAuthPendingAuthorizations_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_serverIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_serverIntegrationId_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_expiresAt_idx": { + "name": "mcpOAuthPendingAuthorizations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk": { + "name": "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_user_id_users_id_fk": { + "name": "mcp_oauth_pending_authorizations_user_id_users_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_org_server_fk": { + "name": "mcpOAuthPendingAuthorizations_org_server_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_pending_authorizations_state_hash_unique": { + "name": "mcp_oauth_pending_authorizations_state_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "state_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_integrations": { + "name": "mcp_server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connection'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_light_url": { + "name": "logo_light_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_dark_url": { + "name": "logo_dark_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_featured_at": { + "name": "store_featured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_status": { + "name": "store_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_tool_sync_at": { + "name": "last_tool_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tool_sync_status": { + "name": "tool_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "tool_sync_error": { + "name": "tool_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexed_tool_count": { + "name": "indexed_tool_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpServerIntegrations_resourceType_idx": { + "name": "mcpServerIntegrations_resourceType_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeStatus_idx": { + "name": "mcpServerIntegrations_storeStatus_idx", + "columns": [ + { + "expression": "store_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_organizationId_idx": { + "name": "mcpServerIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_createdByUserId_idx": { + "name": "mcpServerIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeSourceIntegrationId_idx": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_id_uidx": { + "name": "mcpServerIntegrations_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_resourceType_name_uidx": { + "name": "mcpServerIntegrations_org_resourceType_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_storeSource_uidx": { + "name": "mcpServerIntegrations_org_storeSource_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"store_source_integration_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeListing_slug_uidx": { + "name": "mcpServerIntegrations_storeListing_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"resource_type\" = 'store_listing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_integrations_organization_id_organizations_id_fk": { + "name": "mcp_server_integrations_organization_id_organizations_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_integrations_created_by_user_id_users_id_fk": { + "name": "mcp_server_integrations_created_by_user_id_users_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpServerIntegrations_storeSourceIntegrationId_fk": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpServerIntegrations_authType_check": { + "name": "mcpServerIntegrations_authType_check", + "value": "\"mcp_server_integrations\".\"auth_type\" IN ('none', 'headers', 'oauth')" + }, + "mcpServerIntegrations_storeStatus_check": { + "name": "mcpServerIntegrations_storeStatus_check", + "value": "\"mcp_server_integrations\".\"store_status\" IN ('draft', 'pending_review', 'live', 'rejected')" + }, + "mcpServerIntegrations_resourceType_check": { + "name": "mcpServerIntegrations_resourceType_check", + "value": "\"mcp_server_integrations\".\"resource_type\" IN ('connection', 'store_listing')" + }, + "mcpServerIntegrations_category_check": { + "name": "mcpServerIntegrations_category_check", + "value": "\"mcp_server_integrations\".\"category\" IS NULL OR \"mcp_server_integrations\".\"category\" IN ('AI', 'Source control', 'Project management', 'Communication', 'Design', 'Notes', 'Deploys', 'Productivity', 'Marketing', 'Publishing')" + }, + "mcpServerIntegrations_resourceState_check": { + "name": "mcpServerIntegrations_resourceState_check", + "value": "(\n (\"mcp_server_integrations\".\"resource_type\" = 'store_listing' AND \"mcp_server_integrations\".\"store_source_integration_id\" IS NULL)\n OR\n (\"mcp_server_integrations\".\"resource_type\" = 'connection' AND \"mcp_server_integrations\".\"store_status\" = 'draft' AND \"mcp_server_integrations\".\"review_note\" IS NULL AND \"mcp_server_integrations\".\"submitted_at\" IS NULL AND \"mcp_server_integrations\".\"reviewed_at\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.mcp_session_tool_activations": { + "name": "mcp_session_tool_activations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mcp_tool_index_id": { + "name": "mcp_tool_index_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_query": { + "name": "source_query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcpSessionToolActivations_session_tool_uidx": { + "name": "mcpSessionToolActivations_session_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_tool_index_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_session_idx": { + "name": "mcpSessionToolActivations_session_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_expiresAt_idx": { + "name": "mcpSessionToolActivations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_session_tool_activations_organization_id_organizations_id_fk": { + "name": "mcp_session_tool_activations_organization_id_organizations_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk": { + "name": "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "mcp_tool_index_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpSessionToolActivations_org_tool_fk": { + "name": "mcpSessionToolActivations_org_tool_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "organization_id", + "mcp_tool_index_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tool_index": { + "name": "mcp_tool_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_tool_name": { + "name": "server_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_present": { + "name": "action_phrase_present", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_past": { + "name": "action_phrase_past", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_indexed_at": { + "name": "last_indexed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpToolIndex_server_tool_uidx": { + "name": "mcpToolIndex_server_tool_uidx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_id_uidx": { + "name": "mcpToolIndex_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_runtime_tool_uidx": { + "name": "mcpToolIndex_org_runtime_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "runtime_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_organizationId_status_idx": { + "name": "mcpToolIndex_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_serverIntegrationId_status_idx": { + "name": "mcpToolIndex_serverIntegrationId_status_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_searchText_gin_idx": { + "name": "mcpToolIndex_searchText_gin_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "mcp_tool_index_organization_id_organizations_id_fk": { + "name": "mcp_tool_index_organization_id_organizations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpToolIndex_org_server_fk": { + "name": "mcpToolIndex_org_server_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "members_organizationId_idx": { + "name": "members_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_userId_idx": { + "name": "members_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessTokens_clientId_idx": { + "name": "oauthAccessTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_sessionId_idx": { + "name": "oauthAccessTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_userId_idx": { + "name": "oauthAccessTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_refreshId_idx": { + "name": "oauthAccessTokens_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClients_userId_idx": { + "name": "oauthClients_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consents": { + "name": "oauth_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthConsents_clientId_idx": { + "name": "oauthConsents_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsents_userId_idx": { + "name": "oauthConsents_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshTokens_clientId_idx": { + "name": "oauthRefreshTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_sessionId_idx": { + "name": "oauthRefreshTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_userId_idx": { + "name": "oauthRefreshTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_tokens_token_unique": { + "name": "oauth_refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_suggestions": { + "name": "onboarding_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "onboarding_suggestion_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingSuggestions_org_type_idx": { + "name": "onboardingSuggestions_org_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_suggestions_organization_id_organizations_id_fk": { + "name": "onboarding_suggestions_organization_id_organizations_id_fk", + "tableFrom": "onboarding_suggestions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_settings": { + "name": "organization_notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_content_creation": { + "name": "scheduled_content_creation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "scheduled_content_failed": { + "name": "scheduled_content_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scheduled_content_skipped": { + "name": "scheduled_content_skipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketing_emails": { + "name": "marketing_emails", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orgNotificationSettings_organizationId_uidx": { + "name": "orgNotificationSettings_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_notification_settings_organization_id_organizations_id_fk": { + "name": "organization_notification_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_notification_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_source": { + "name": "heard_about_notra_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_other": { + "name": "heard_about_notra_other", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_dismissed": { + "name": "onboarding_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_ran": { + "name": "onboarding_agent_ran", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_started_at": { + "name": "onboarding_agent_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_uidx": { + "name": "organizations_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_collections": { + "name": "post_collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "post_collection_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_source": { + "name": "name_source", + "type": "post_collection_name_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "content_types": { + "name": "content_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expected_post_count": { + "name": "expected_post_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_post_count": { + "name": "completed_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_collections_org_created_at_idx": { + "name": "post_collections_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_source_idx": { + "name": "post_collections_source_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_chat_source_uidx": { + "name": "post_collections_chat_source_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"post_collections\".\"source\" = 'chat' AND \"post_collections\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_collections_organization_id_organizations_id_fk": { + "name": "post_collections_organization_id_organizations_id_fk", + "tableFrom": "post_collections", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recommendations": { + "name": "recommendations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_org_slug_uidx": { + "name": "posts_org_slug_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"posts\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_createdAt_id_idx": { + "name": "posts_org_createdAt_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_collection_id_idx": { + "name": "posts_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_organization_id_organizations_id_fk": { + "name": "posts_organization_id_organizations_id_fk", + "tableFrom": "posts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_collection_id_post_collections_id_fk": { + "name": "posts_collection_id_post_collections_id_fk", + "tableFrom": "posts", + "tableTo": "post_collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_outputs": { + "name": "repository_outputs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositoryOutputs_repositoryId_idx": { + "name": "repositoryOutputs_repositoryId_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositoryOutputs_repository_outputType_uidx": { + "name": "repositoryOutputs_repository_outputType_uidx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "output_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_outputs_repository_id_github_integrations_id_fk": { + "name": "repository_outputs_repository_id_github_integrations_id_fk", + "tableFrom": "repository_outputs", + "tableTo": "github_integrations", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_userId_idx": { + "name": "sessions_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_organizationId_idx": { + "name": "skills_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_org_name_uidx": { + "name": "skills_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_organization_id_organizations_id_fk": { + "name": "skills_organization_id_organizations_id_fk", + "tableFrom": "skills", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_integrations": { + "name": "slack_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_bot_token": { + "name": "encrypted_bot_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_bot_user_id": { + "name": "slack_bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_channel_ids": { + "name": "allowed_channel_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slackIntegrations_organizationId_idx": { + "name": "slackIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_createdByUserId_idx": { + "name": "slackIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_teamId_uidx": { + "name": "slackIntegrations_teamId_uidx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_integrations_organization_id_organizations_id_fk": { + "name": "slack_integrations_organization_id_organizations_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_integrations_created_by_user_id_users_id_fk": { + "name": "slack_integrations_created_by_user_id_users_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_experiments": { + "name": "social_experiments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hypothesis": { + "name": "hypothesis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_a_post_id": { + "name": "variant_a_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_b_post_id": { + "name": "variant_b_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "winner": { + "name": "winner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "socialExperiments_organizationId_idx": { + "name": "socialExperiments_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_experiments_organization_id_organizations_id_fk": { + "name": "social_experiments_organization_id_organizations_id_fk", + "tableFrom": "social_experiments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_social_accounts": { + "name": "tracked_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trackedSocialAccounts_organizationId_idx": { + "name": "trackedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedSocialAccounts_org_provider_account_uidx": { + "name": "trackedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_social_accounts_organization_id_organizations_id_fk": { + "name": "tracked_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "tracked_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hide_personal_data": { + "name": "hide_personal_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_agent_stats": { + "name": "show_agent_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.applicable_platform": { + "name": "applicable_platform", + "schema": "public", + "values": [ + "all", + "twitter", + "linkedin", + "blog" + ] + }, + "public.autonomy_action_status": { + "name": "autonomy_action_status", + "schema": "public", + "values": [ + "pending", + "executing", + "succeeded", + "failed", + "unknown", + "compensated", + "canceled" + ] + }, + "public.autonomy_goal_status": { + "name": "autonomy_goal_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "blocked", + "completed", + "abandoned" + ] + }, + "public.autonomy_mandate_status": { + "name": "autonomy_mandate_status", + "schema": "public", + "values": [ + "active", + "paused", + "revoked" + ] + }, + "public.autonomy_outbox_status": { + "name": "autonomy_outbox_status", + "schema": "public", + "values": [ + "pending", + "attempting", + "delivered", + "failed", + "canceled" + ] + }, + "public.autonomy_run_status": { + "name": "autonomy_run_status", + "schema": "public", + "values": [ + "planning", + "executing", + "completed", + "failed", + "canceled" + ] + }, + "public.autonomy_run_trigger": { + "name": "autonomy_run_trigger", + "schema": "public", + "values": [ + "signal", + "wake", + "manual", + "repair" + ] + }, + "public.autonomy_signal_status": { + "name": "autonomy_signal_status", + "schema": "public", + "values": [ + "pending", + "coalesced", + "processed", + "discarded" + ] + }, + "public.autonomy_task_status": { + "name": "autonomy_task_status", + "schema": "public", + "values": [ + "pending", + "ready", + "running", + "waiting", + "completed", + "failed", + "canceled" + ] + }, + "public.brand_guideline_asset_kind": { + "name": "brand_guideline_asset_kind", + "schema": "public", + "values": [ + "logo", + "wordmark" + ] + }, + "public.brand_guideline_asset_variant": { + "name": "brand_guideline_asset_variant", + "schema": "public", + "values": [ + "light", + "dark" + ] + }, + "public.brand_guideline_color_role": { + "name": "brand_guideline_color_role", + "schema": "public", + "values": [ + "primary", + "secondary", + "accent", + "background", + "foreground", + "neutral", + "custom" + ] + }, + "public.brand_guideline_font_role": { + "name": "brand_guideline_font_role", + "schema": "public", + "values": [ + "heading", + "body", + "button", + "unknown" + ] + }, + "public.brand_guideline_screenshot_kind": { + "name": "brand_guideline_screenshot_kind", + "schema": "public", + "values": [ + "desktop_hero", + "desktop_full_page", + "mobile_hero" + ] + }, + "public.brand_guideline_status": { + "name": "brand_guideline_status", + "schema": "public", + "values": [ + "queued", + "generating", + "ready", + "failed" + ] + }, + "public.brand_guideline_token_type": { + "name": "brand_guideline_token_type", + "schema": "public", + "values": [ + "spacing", + "radius", + "shadow", + "component", + "unknown" + ] + }, + "public.brand_sitemap_page_category": { + "name": "brand_sitemap_page_category", + "schema": "public", + "values": [ + "crawled", + "redirect", + "queued", + "failed" + ] + }, + "public.brand_sitemap_status": { + "name": "brand_sitemap_status", + "schema": "public", + "values": [ + "queued", + "crawling", + "ready", + "failed" + ] + }, + "public.lookback_window": { + "name": "lookback_window", + "schema": "public", + "values": [ + "current_day", + "yesterday", + "last_7_days", + "last_14_days", + "last_30_days" + ] + }, + "public.onboarding_suggestion_type": { + "name": "onboarding_suggestion_type", + "schema": "public", + "values": [ + "schedule_automation", + "event_automation" + ] + }, + "public.post_collection_name_source": { + "name": "post_collection_name_source", + "schema": "public", + "values": [ + "generated", + "user", + "backfill" + ] + }, + "public.post_collection_source": { + "name": "post_collection_source", + "schema": "public", + "values": [ + "manual", + "chat", + "schedule", + "automation", + "api", + "backfill" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.reference_type": { + "name": "reference_type", + "schema": "public", + "values": [ + "twitter_post", + "linkedin_post", + "blog_post", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f56613587..dc3b155df 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -449,6 +449,13 @@ "when": 1785669117308, "tag": "0063_slimy_hellcat", "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1785669176828, + "tag": "0064_exotic_scourge", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e1a7c9786..e06a055fd 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1452,6 +1452,49 @@ export const organizationNotificationSettings = pgTable( ] ); +export const geoSettings = pgTable( + "geo_settings", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + companyName: text("company_name").notNull(), + aliases: text("aliases").array().notNull().default(sql`ARRAY[]::text[]`), + competitors: text("competitors") + .array() + .notNull() + .default(sql`ARRAY[]::text[]`), + enabled: boolean("enabled").notNull().default(true), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [ + uniqueIndex("geoSettings_organizationId_uidx").on(table.organizationId), + ] +); + +export const geoPrompts = pgTable( + "geo_prompts", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + prompt: text("prompt").notNull(), + enabled: boolean("enabled").notNull().default(true), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("geoPrompts_organizationId_idx").on(table.organizationId)] +); + export const socialExperiments = pgTable( "social_experiments", { @@ -2114,6 +2157,8 @@ export const organizationsRelations = relations( mcpSessionToolActivations: many(mcpSessionToolActivations), brandSettings: many(brandSettings), notificationSettings: one(organizationNotificationSettings), + geoSettings: one(geoSettings), + geoPrompts: many(geoPrompts), connectedSocialAccounts: many(connectedSocialAccounts), postCollections: many(postCollections), posts: many(posts), @@ -2494,6 +2539,13 @@ export const organizationNotificationSettingsRelations = relations( }) ); +export const geoSettingsRelations = relations(geoSettings, ({ one }) => ({ + organization: one(organizations, { + fields: [geoSettings.organizationId], + references: [organizations.id], + }), +})); + export const socialExperimentsRelations = relations( socialExperiments, ({ one }) => ({ @@ -2504,6 +2556,13 @@ export const socialExperimentsRelations = relations( }) ); +export const geoPromptsRelations = relations(geoPrompts, ({ one }) => ({ + organization: one(organizations, { + fields: [geoPrompts.organizationId], + references: [organizations.id], + }), +})); + export const postCollectionsRelations = relations( postCollections, ({ one, many }) => ({ diff --git a/turbo.json b/turbo.json index 7d0302790..add1f073c 100644 --- a/turbo.json +++ b/turbo.json @@ -44,6 +44,9 @@ "UNKEY_ROOT_KEY", "UNKEY_API_ID", "TWITTER_BEARER_TOKEN", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "PERPLEXITY_API_KEY", "TINYBIRD_TOKEN", "TINYBIRD_BASE_URL", "POST_FOR_ME_API_KEY", From e5d0fce206f6a789697a329b2db22e1fcef3ff9d Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:18:53 +0200 Subject: [PATCH 05/26] fix(analytics): render verification badges and window label in leaderboard --- .../src/components/analytics/leaderboard-card.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/components/analytics/leaderboard-card.tsx b/apps/dashboard/src/components/analytics/leaderboard-card.tsx index 27667d696..e593150d7 100644 --- a/apps/dashboard/src/components/analytics/leaderboard-card.tsx +++ b/apps/dashboard/src/components/analytics/leaderboard-card.tsx @@ -125,8 +125,15 @@ function LeaderboardRow({
-

- {entry.displayName ?? entry.username} +

+ + {entry.displayName ?? entry.username} + +

@{entry.username} @@ -228,7 +235,7 @@ export function LeaderboardCard({ value={String(days)} > - + {`Last ${days}d`} {LEADERBOARD_WINDOWS.map((window) => ( From 2f5df2e29a2c1fb85412f098c75c838f920d28ed Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:20:52 +0200 Subject: [PATCH 06/26] fix(analytics): show hovered day heading in chart tooltips --- .../src/components/analytics/account-series-chart-card.tsx | 2 +- .../src/components/analytics/posting-performance-card.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/components/analytics/account-series-chart-card.tsx b/apps/dashboard/src/components/analytics/account-series-chart-card.tsx index 2cbf4b477..d62f01311 100644 --- a/apps/dashboard/src/components/analytics/account-series-chart-card.tsx +++ b/apps/dashboard/src/components/analytics/account-series-chart-card.tsx @@ -103,7 +103,7 @@ export function AccountSeriesChartCard({ label={marker.label} /> ))} - + ) : (

diff --git a/apps/dashboard/src/components/analytics/posting-performance-card.tsx b/apps/dashboard/src/components/analytics/posting-performance-card.tsx index 67b43f658..70ed91bb0 100644 --- a/apps/dashboard/src/components/analytics/posting-performance-card.tsx +++ b/apps/dashboard/src/components/analytics/posting-performance-card.tsx @@ -63,7 +63,7 @@ export function PostingPerformanceCard({ rows }: PostingPerformanceCardProps) { {visibleKeys.map((key) => ( ))} - + ) : (

From de1fe9b3e9f09ba140d4be9ae948d0c5441198fb Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:20:55 +0200 Subject: [PATCH 07/26] fix(geo): show hovered day heading in mention rate tooltip --- apps/dashboard/src/components/geo/mention-rate-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/geo/mention-rate-card.tsx b/apps/dashboard/src/components/geo/mention-rate-card.tsx index d84b7b25f..e9122ca45 100644 --- a/apps/dashboard/src/components/geo/mention-rate-card.tsx +++ b/apps/dashboard/src/components/geo/mention-rate-card.tsx @@ -178,7 +178,7 @@ export function MentionRateCard({ engines, points }: MentionRateCardProps) { {trendEngines.map((engine) => ( ))} - `${value}%`} /> + `${value}%`} />

)} From c25ac977fd9021f5e99f2c1fe84d33d764305b33 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 13:29:04 +0200 Subject: [PATCH 08/26] fix(analytics): square business avatars, header connect buttons --- .../[slug]/analytics/page-client.tsx | 17 +++--- .../analytics/connect-accounts-buttons.tsx | 53 +++++++++++++++++++ .../components/analytics/leaderboard-card.tsx | 11 +++- 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 apps/dashboard/src/components/analytics/connect-accounts-buttons.tsx diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx index 16d52cda3..e1ef10df5 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx @@ -4,6 +4,7 @@ import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context" import Link from "next/link"; import { useMemo, useState } from "react"; import { AccountFilter } from "@/components/analytics/account-filter"; +import { ConnectAccountsButtons } from "@/components/analytics/connect-accounts-buttons"; import { AccountSeriesChartCard } from "@/components/analytics/account-series-chart-card"; import { FollowersCard } from "@/components/analytics/followers-card"; import { LeaderboardCard } from "@/components/analytics/leaderboard-card"; @@ -202,12 +203,16 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
-
-

Analytics

-

- {accounts.length} {accounts.length === 1 ? "account" : "accounts"}{" "} - connected · hover any chart to compare the same day everywhere -

+
+
+

Analytics

+

+ {accounts.length}{" "} + {accounts.length === 1 ? "account" : "accounts"} connected on X + and LinkedIn +

+
+
+ + Connect + + + +
+ ); +} diff --git a/apps/dashboard/src/components/analytics/leaderboard-card.tsx b/apps/dashboard/src/components/analytics/leaderboard-card.tsx index e593150d7..cba9c4d2e 100644 --- a/apps/dashboard/src/components/analytics/leaderboard-card.tsx +++ b/apps/dashboard/src/components/analytics/leaderboard-card.tsx @@ -30,6 +30,7 @@ import { useUntrackAccount, } from "@/lib/hooks/use-social-analytics"; import { cn } from "@/lib/utils"; +import { isSquareTwitterAvatar } from "@/utils/twitter"; import type { LeaderboardEntry, LeaderboardWindow, @@ -113,10 +114,18 @@ function LeaderboardRow({ - + {entry.profileImageUrl && ( )} From 9e00afef898fd1b7887ad43d80b6acfbaf8ef1fe Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 14:12:02 +0200 Subject: [PATCH 09/26] fix(analytics): gate account tracking behind lookup and confirm dialog --- .../[slug]/analytics/page-client.tsx | 2 +- .../components/analytics/leaderboard-card.tsx | 42 ++-- .../analytics/track-account-dialog.tsx | 190 ++++++++++++++++++ .../src/lib/analytics/tracked-accounts.ts | 1 + .../src/lib/hooks/use-social-analytics.ts | 12 ++ .../src/lib/orpc/routers/analytics.ts | 18 ++ apps/dashboard/src/types/analytics.ts | 5 + 7 files changed, 239 insertions(+), 31 deletions(-) create mode 100644 apps/dashboard/src/components/analytics/track-account-dialog.tsx diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx index e1ef10df5..1f3187b83 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx @@ -4,8 +4,8 @@ import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context" import Link from "next/link"; import { useMemo, useState } from "react"; import { AccountFilter } from "@/components/analytics/account-filter"; -import { ConnectAccountsButtons } from "@/components/analytics/connect-accounts-buttons"; import { AccountSeriesChartCard } from "@/components/analytics/account-series-chart-card"; +import { ConnectAccountsButtons } from "@/components/analytics/connect-accounts-buttons"; import { FollowersCard } from "@/components/analytics/followers-card"; import { LeaderboardCard } from "@/components/analytics/leaderboard-card"; import { PostingPerformanceCard } from "@/components/analytics/posting-performance-card"; diff --git a/apps/dashboard/src/components/analytics/leaderboard-card.tsx b/apps/dashboard/src/components/analytics/leaderboard-card.tsx index cba9c4d2e..49bc92f34 100644 --- a/apps/dashboard/src/components/analytics/leaderboard-card.tsx +++ b/apps/dashboard/src/components/analytics/leaderboard-card.tsx @@ -9,7 +9,6 @@ import { } from "@notra/ui/components/ui/avatar"; import { Button } from "@notra/ui/components/ui/button"; import { Card, CardContent, CardHeader } from "@notra/ui/components/ui/card"; -import { Input } from "@notra/ui/components/ui/input"; import { Select, SelectContent, @@ -19,24 +18,22 @@ import { } from "@notra/ui/components/ui/select"; import { Loader2Icon } from "lucide-react"; import { useState } from "react"; +import { TrackAccountDialog } from "@/components/analytics/track-account-dialog"; import { XVerificationBadge } from "@/components/icons/x-verification-badge"; import { LEADERBOARD_WINDOWS } from "@/constants/analytics"; -const LEADING_AT = /^@/; - import { useLeaderboard, - useTrackAccount, useUntrackAccount, } from "@/lib/hooks/use-social-analytics"; import { cn } from "@/lib/utils"; -import { isSquareTwitterAvatar } from "@/utils/twitter"; import type { LeaderboardEntry, LeaderboardWindow, SocialOverviewAccount, } from "@/types/analytics"; import { formatMetric } from "@/utils/analytics-charts"; +import { isSquareTwitterAvatar } from "@/utils/twitter"; interface LeaderboardCardProps { organizationId: string; @@ -203,24 +200,15 @@ export function LeaderboardCard({ accountDetails, }: LeaderboardCardProps) { const [days, setDays] = useState(7); - const [handle, setHandle] = useState(""); + const [trackOpen, setTrackOpen] = useState(false); const [expandedKey, setExpandedKey] = useState(null); const detailsByUsername = new Map( accountDetails.map((account) => [account.username.toLowerCase(), account]) ); const { data } = useLeaderboard(organizationId, days); - const track = useTrackAccount(organizationId); const entries = data?.entries ?? []; - const handleTrack = () => { - const username = handle.trim().replace(LEADING_AT, ""); - if (username.length === 0) { - return; - } - track.mutate(username, { onSuccess: () => setHandle("") }); - }; - return ( @@ -295,26 +283,20 @@ export function LeaderboardCard({ ))}
)} -
- setHandle(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleTrack(); - } - }} - placeholder="@handle to track (teammates, affiliates)" - value={handle} - /> +
+ ); diff --git a/apps/dashboard/src/components/analytics/track-account-dialog.tsx b/apps/dashboard/src/components/analytics/track-account-dialog.tsx new file mode 100644 index 000000000..46a775fa2 --- /dev/null +++ b/apps/dashboard/src/components/analytics/track-account-dialog.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { + ResponsiveDialog, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from "@notra/ui/components/shared/responsive-dialog"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@notra/ui/components/ui/avatar"; +import { Button } from "@notra/ui/components/ui/button"; +import { Input } from "@notra/ui/components/ui/input"; +import { Label } from "@notra/ui/components/ui/label"; +import { Loader2Icon } from "lucide-react"; +import { useId, useState } from "react"; +import { XVerificationBadge } from "@/components/icons/x-verification-badge"; +import { + useTrackAccount, + useTrackAccountPreview, +} from "@/lib/hooks/use-social-analytics"; +import { cn } from "@/lib/utils"; +import type { ResolvedTwitterAccount } from "@/types/analytics"; +import { formatMetric } from "@/utils/analytics-charts"; +import { isSquareTwitterAvatar } from "@/utils/twitter"; + +interface TrackAccountDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; +} + +const LEADING_AT = /^@/; + +export function TrackAccountDialog({ + open, + onOpenChange, + organizationId, +}: TrackAccountDialogProps) { + const id = useId(); + const [handle, setHandle] = useState(""); + const [account, setAccount] = useState(null); + const [notFound, setNotFound] = useState(false); + const preview = useTrackAccountPreview(organizationId); + const track = useTrackAccount(organizationId); + + const reset = () => { + setHandle(""); + setAccount(null); + setNotFound(false); + }; + + const handleLookup = () => { + const username = handle.trim().replace(LEADING_AT, ""); + if (username.length === 0) { + return; + } + setAccount(null); + setNotFound(false); + preview.mutate(username, { + onSuccess: (result) => { + setAccount(result.account); + setNotFound(result.account === null); + }, + }); + }; + + const handleConfirm = () => { + if (!account) { + return; + } + track.mutate(account.username, { + onSuccess: () => { + reset(); + onOpenChange(false); + }, + }); + }; + + return ( + { + if (!next) { + reset(); + } + onOpenChange(next); + }} + open={open} + > + + + Track an account + + Follow a public X account on your leaderboard — teammates, + affiliates, or competitors. Their public posts sync hourly and are + visible to everyone in this workspace. + + +
+
+ +
+ setHandle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleLookup(); + } + }} + placeholder="@handle" + value={handle} + /> + +
+
+ {notFound && ( +

+ No account found with that handle. Check the spelling and try + again. +

+ )} + {account && ( +
+ + {account.profileImageUrl && ( + + )} + + {account.username.slice(0, 2).toUpperCase()} + + +
+

+ + {account.displayName ?? account.username} + + +

+

+ @{account.username} + {account.followersCount !== null && + ` · ${formatMetric(account.followersCount)} followers`} +

+
+
+ )} +
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/lib/analytics/tracked-accounts.ts b/apps/dashboard/src/lib/analytics/tracked-accounts.ts index d5926bbff..e54ec8687 100644 --- a/apps/dashboard/src/lib/analytics/tracked-accounts.ts +++ b/apps/dashboard/src/lib/analytics/tracked-accounts.ts @@ -43,5 +43,6 @@ export async function resolveTwitterAccount( : null, verified: user.verified === true || verifiedType !== "none", verifiedType, + followersCount: user.public_metrics?.followers_count ?? null, }; } diff --git a/apps/dashboard/src/lib/hooks/use-social-analytics.ts b/apps/dashboard/src/lib/hooks/use-social-analytics.ts index 5735924df..9b16ac8ea 100644 --- a/apps/dashboard/src/lib/hooks/use-social-analytics.ts +++ b/apps/dashboard/src/lib/hooks/use-social-analytics.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import type { + TrackAccountPreviewResponse, EngagementTimeseriesResponse, FollowerGrowthResponse, LeaderboardResponse, @@ -135,3 +136,14 @@ export function useNotraAdoption(organizationId: string) { meta: { errorMessage: "Failed to load adoption data" }, }); } + +export function useTrackAccountPreview(organizationId: string) { + return useMutation({ + mutationFn: (username: string): Promise => + dashboardOrpc.analytics.previewTrackAccount.call({ + organizationId, + username, + }), + onError: () => toast.error("Failed to look up account"), + }); +} diff --git a/apps/dashboard/src/lib/orpc/routers/analytics.ts b/apps/dashboard/src/lib/orpc/routers/analytics.ts index 941b3eb74..1e6ffdf71 100644 --- a/apps/dashboard/src/lib/orpc/routers/analytics.ts +++ b/apps/dashboard/src/lib/orpc/routers/analytics.ts @@ -36,6 +36,7 @@ import { } from "@/schemas/analytics"; import type { EngagementTimeseriesResponse, + TrackAccountPreviewResponse, FollowerGrowthResponse, LeaderboardAccount, LeaderboardResponse, @@ -414,6 +415,23 @@ export const analyticsRouter = { entries: buildLeaderboardEntries(accounts, totals), }; }), + previewTrackAccount: authorizedProcedure + .input(trackAccountInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const account = await resolveTwitterAccount(input.username).catch( + (error) => { + console.error("[Analytics] account preview failed:", error); + return null; + } + ); + return { account }; + }), trackAccount: authorizedProcedure .input(trackAccountInputSchema) .handler(async ({ context, input }) => { diff --git a/apps/dashboard/src/types/analytics.ts b/apps/dashboard/src/types/analytics.ts index 13114cf9b..c33a0b018 100644 --- a/apps/dashboard/src/types/analytics.ts +++ b/apps/dashboard/src/types/analytics.ts @@ -60,6 +60,7 @@ export interface ResolvedTwitterAccount { profileImageUrl: string | null; verified: boolean; verifiedType: string | null; + followersCount: number | null; } export interface TwitterTimelineTweet { @@ -259,3 +260,7 @@ export interface TimelineMarker { index: number | null; label: string; } + +export interface TrackAccountPreviewResponse { + account: ResolvedTwitterAccount | null; +} From 0470d533d3a030912a00664351d6642fd56630be Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 15:08:35 +0200 Subject: [PATCH 10/26] feat(geo): per-model engines and model usage market share --- .../(dashboard)/[slug]/geo/page-client.tsx | 5 + .../src/components/geo/model-usage-card.tsx | 105 +++++++++++ apps/dashboard/src/constants/geo.ts | 21 +++ apps/dashboard/src/lib/geo/model-usage.ts | 175 ++++++++++++++++++ apps/dashboard/src/lib/geo/scan.ts | 8 + apps/dashboard/src/lib/hooks/use-geo.ts | 11 ++ apps/dashboard/src/lib/orpc/routers/geo.ts | 94 ++++++++++ apps/dashboard/src/schemas/geo.ts | 31 ++++ apps/dashboard/src/types/geo.ts | 30 +++ apps/dashboard/src/utils/geo-charts.ts | 18 ++ packages/analytics/src/tinybird/client.ts | 39 ++++ .../analytics/src/tinybird/datasources.ts | 18 ++ packages/analytics/src/tinybird/endpoints.ts | 91 +++++++++ 13 files changed, 646 insertions(+) create mode 100644 apps/dashboard/src/components/geo/model-usage-card.tsx create mode 100644 apps/dashboard/src/lib/geo/model-usage.ts diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx index 865a3ac3c..9db625222 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx @@ -9,6 +9,7 @@ import { EmptyState } from "@/components/empty-state"; import { GeoSettingsDialog } from "@/components/geo/geo-settings-dialog"; import { GeoSummaryStats } from "@/components/geo/geo-summary-stats"; import { MentionRateCard } from "@/components/geo/mention-rate-card"; +import { ModelUsageCard } from "@/components/geo/model-usage-card"; import { WebsiteGenerateCard } from "@/components/geo/website-generate-card"; import { PageContainer } from "@/components/layout/container"; import { useOrganizationsContext } from "@/components/providers/organization-provider"; @@ -18,6 +19,7 @@ import { useGeoSettings, useGeoStartScan, useGeoTimeseries, + useModelUsage, } from "@/lib/hooks/use-geo"; import { GeoPageSkeleton } from "./skeleton"; @@ -41,6 +43,7 @@ export default function PageClient({ organizationSlug }: PageClientProps) { const { data: overview } = useGeoOverview(organizationId); const { data: timeseries } = useGeoTimeseries(organizationId); const { data: prompts } = useGeoPrompts(organizationId); + const { data: modelUsage } = useModelUsage(organizationId); const startScan = useGeoStartScan(organizationId); if (isSettingsPending) { @@ -123,6 +126,8 @@ export default function PageClient({ organizationSlug }: PageClientProps) { points={timeseries?.points ?? []} /> + + +
+ + {model.label} + + + {model.scanned && model.mentionRate !== null ? ( + + {formatMentionRate(model.mentionRate)} mention rate + + ) : ( + not scanned + )} + +
+
+
+
+
+ + {formatUsageShare(model.share)} + +
+
+ ); +} + +export function ModelUsageCard({ usage }: ModelUsageCardProps) { + const models = usage?.models ?? []; + const maxShare = useMemo( + () => models.reduce((max, model) => Math.max(max, model.share), 0), + [models] + ); + const scannedCount = useMemo( + () => models.filter((model) => model.scanned).length, + [models] + ); + + return ( + + + Where AI usage actually happens + + {models.length > 0 + ? `Share of industry token volume per model. We scan ${scannedCount} of the top ${models.length}. ${usage?.attribution ?? ""}` + : "Share of industry token volume per model, matched against the engines we scan"} + + + + {models.length === 0 ? ( +

+ Run a scan to capture model usage share +

+ ) : ( +
+ {models.map((model) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/constants/geo.ts b/apps/dashboard/src/constants/geo.ts index d200d6afa..46f3b4a78 100644 --- a/apps/dashboard/src/constants/geo.ts +++ b/apps/dashboard/src/constants/geo.ts @@ -4,6 +4,9 @@ export const GEO_ENGINES = [ "openai/gpt-5.4", "anthropic/claude-sonnet-4.6", "google/gemini-3-flash", + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + "openai/gpt-5.4-mini", ] as const; export const GEO_JUDGE_MODEL = "openai/gpt-5.4-nano"; @@ -76,9 +79,27 @@ export const GEO_ENGINE_LABELS: Record = { "openai/gpt-5.4": "ChatGPT", "anthropic/claude-sonnet-4.6": "Claude", "google/gemini-3-flash": "Gemini", + "anthropic/claude-opus-5": "Claude Opus", + "anthropic/claude-haiku-4.5": "Claude Haiku", + "openai/gpt-5.4-mini": "GPT-5.4 mini", ...groundedEngineLabels, }; +export const GEO_MODEL_USAGE_SOURCE = "openrouter"; +export const GEO_MODEL_USAGE_ATTRIBUTION = + "Source: OpenRouter (openrouter.ai/rankings)"; +export const GEO_MODEL_USAGE_API_KEY_ENV = "OPENROUTER_API_KEY"; +export const GEO_MODEL_USAGE_ENDPOINT = + "https://openrouter.ai/api/v1/datasets/rankings-daily"; +export const GEO_MODEL_USAGE_PERIOD = "week"; +export const GEO_MODEL_USAGE_OTHER_KEY = "other"; +export const GEO_MODEL_USAGE_MODELS_ENDPOINT = + "https://openrouter.ai/api/v1/models"; +export const GEO_MODEL_USAGE_INGEST_LIMIT = 40; +export const GEO_MODEL_USAGE_FETCH_TIMEOUT_MS = 20_000; +export const GEO_MODEL_USAGE_DEFAULT_LIMIT = 12; +export const GEO_MODEL_USAGE_DEFAULT_WEEKS = 8; + export const GEO_MAX_PROMPTS = 8; export const GEO_GROUNDED_MAX_PROMPTS = 6; export const GEO_GROUNDED_MAX_SEARCHES = 3; diff --git a/apps/dashboard/src/lib/geo/model-usage.ts b/apps/dashboard/src/lib/geo/model-usage.ts new file mode 100644 index 000000000..d800f76d0 --- /dev/null +++ b/apps/dashboard/src/lib/geo/model-usage.ts @@ -0,0 +1,175 @@ +import { ingestModelUsageShare } from "@notra/analytics/tinybird/client"; +import type { ModelUsageShareRow } from "@notra/analytics/tinybird/datasources"; +import { Effect } from "effect"; +import { + GEO_MODEL_USAGE_API_KEY_ENV, + GEO_MODEL_USAGE_ENDPOINT, + GEO_MODEL_USAGE_FETCH_TIMEOUT_MS, + GEO_MODEL_USAGE_INGEST_LIMIT, + GEO_MODEL_USAGE_MODELS_ENDPOINT, + GEO_MODEL_USAGE_OTHER_KEY, + GEO_MODEL_USAGE_PERIOD, + GEO_MODEL_USAGE_SOURCE, +} from "@/constants/geo"; +import { GeoScanError } from "@/lib/geo/errors"; +import { + openRouterModelsResponseSchema, + openRouterRankingsResponseSchema, +} from "@/schemas/geo"; +import type { GeoModelUsageSnapshot } from "@/types/geo"; + +const VARIANT_SUFFIX = /:[a-z0-9-]+$/; +const PREVIEW_SUFFIX = /-preview$/; +const GROUNDED_SUFFIX = /(-direct)?-grounded$/; + +interface WeeklyTotal { + model: string; + tokens: number; +} + +export function normalizeModelId(value: string): string { + return value + .toLowerCase() + .replace(GROUNDED_SUFFIX, "") + .replace(VARIANT_SUFFIX, "") + .replace(PREVIEW_SUFFIX, ""); +} + +function toCapturedAt(date: string): string { + return `${date.slice(0, 10)} 00:00:00`; +} + +const fetchJson = Effect.fn("geo.modelUsage.fetchJson")(function* ( + url: string, + apiKey: string | null +) { + const payload = yield* Effect.tryPromise({ + try: async () => { + const response = await fetch(url, { + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {}, + signal: AbortSignal.timeout(GEO_MODEL_USAGE_FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`${url} responded with ${response.status}`); + } + return await response.json(); + }, + catch: (cause) => + new GeoScanError({ message: `Failed to fetch ${url}`, cause }), + }); + return payload; +}); + +const fetchSlugMap = Effect.fn("geo.modelUsage.fetchSlugMap")(function* () { + const payload = yield* fetchJson(GEO_MODEL_USAGE_MODELS_ENDPOINT, null); + const parsed = openRouterModelsResponseSchema.safeParse(payload); + const slugs = new Map(); + if (!parsed.success) { + return slugs; + } + for (const model of parsed.data.data) { + if (model.canonical_slug) { + slugs.set(model.canonical_slug.toLowerCase(), model.id); + } + } + return slugs; +}); + +function resolveModelId(permaslug: string, slugs: Map): string { + const base = permaslug.toLowerCase().replace(VARIANT_SUFFIX, ""); + return normalizeModelId(slugs.get(base) ?? base); +} + +function buildRows( + weeks: Map, + slugs: Map +): ModelUsageShareRow[] { + const rows: ModelUsageShareRow[] = []; + for (const [date, entries] of weeks) { + const total = entries.reduce((sum, entry) => sum + entry.tokens, 0); + if (total <= 0) { + continue; + } + + const byModel = new Map(); + for (const entry of entries) { + if (entry.model === GEO_MODEL_USAGE_OTHER_KEY) { + continue; + } + const model = resolveModelId(entry.model, slugs); + byModel.set(model, (byModel.get(model) ?? 0) + entry.tokens); + } + + const ranked = [...byModel.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, GEO_MODEL_USAGE_INGEST_LIMIT); + + ranked.forEach(([model, tokens], index) => { + rows.push({ + captured_at: toCapturedAt(date), + source: GEO_MODEL_USAGE_SOURCE, + model, + rank: index + 1, + share: tokens / total, + raw_tokens: tokens, + }); + }); + } + return rows; +} + +export const captureModelUsageShare = Effect.fn("geo.captureModelUsageShare")( + function* () { + const apiKey = process.env[GEO_MODEL_USAGE_API_KEY_ENV]; + if (!apiKey) { + const skipped: GeoModelUsageSnapshot = { status: "skipped" }; + return skipped; + } + + const payload = yield* fetchJson( + `${GEO_MODEL_USAGE_ENDPOINT}?period=${GEO_MODEL_USAGE_PERIOD}`, + apiKey + ); + const parsed = openRouterRankingsResponseSchema.safeParse(payload); + if (!parsed.success) { + return yield* Effect.fail( + new GeoScanError({ + message: "Model usage rankings response did not match the schema", + cause: parsed.error, + }) + ); + } + + const weeks = new Map(); + for (const entry of parsed.data.data) { + const tokens = Number(entry.total_tokens); + if (!Number.isFinite(tokens)) { + continue; + } + const bucket = weeks.get(entry.date) ?? []; + bucket.push({ model: entry.model_permaslug, tokens }); + weeks.set(entry.date, bucket); + } + + const slugs = yield* fetchSlugMap(); + const rows = buildRows(weeks, slugs); + + if (rows.length === 0) { + const skipped: GeoModelUsageSnapshot = { status: "skipped" }; + return skipped; + } + + yield* Effect.tryPromise({ + try: () => ingestModelUsageShare(rows), + catch: (cause) => + new GeoScanError({ message: "Failed to ingest model usage", cause }), + }); + + const captured: GeoModelUsageSnapshot = { + status: "captured", + models: rows.length, + capturedAt: parsed.data.meta.as_of, + }; + return captured; + } +); diff --git a/apps/dashboard/src/lib/geo/scan.ts b/apps/dashboard/src/lib/geo/scan.ts index d478ccc1f..ff06326ff 100644 --- a/apps/dashboard/src/lib/geo/scan.ts +++ b/apps/dashboard/src/lib/geo/scan.ts @@ -24,6 +24,7 @@ import { resolveGroundedEngines, } from "@/lib/geo/engines"; import { GeoScanError } from "@/lib/geo/errors"; +import { captureModelUsageShare } from "@/lib/geo/model-usage"; import { buildGeoPrompts } from "@/lib/geo/prompts"; import { geoJudgeResultSchema } from "@/schemas/geo"; import type { @@ -305,6 +306,13 @@ export const runGeoScan = Effect.fn("geo.runScan")(function* ( new GeoScanError({ message: "Failed to ingest GEO checks", cause }), }); + yield* captureModelUsageShare().pipe( + Effect.catch((error: GeoScanError) => { + console.error("[GEO] model usage snapshot failed:", error); + return Effect.succeed(null); + }) + ); + const completed: GeoScanResult = { status: "completed", checks: rows.length, diff --git a/apps/dashboard/src/lib/hooks/use-geo.ts b/apps/dashboard/src/lib/hooks/use-geo.ts index 53c4de89c..a5fbdefe2 100644 --- a/apps/dashboard/src/lib/hooks/use-geo.ts +++ b/apps/dashboard/src/lib/hooks/use-geo.ts @@ -5,6 +5,7 @@ import { toast } from "sonner"; import type { GeoCompetitorShareResponse, GeoGenerateFromWebsiteInput, + GeoModelUsageResponse, GeoOverviewResponse, GeoPromptCreateInput, GeoPromptDeleteInput, @@ -91,6 +92,16 @@ export function useGeoCompetitorShare(organizationId: string, days?: number) { }); } +export function useModelUsage(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.geo.modelUsage.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_GEO_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load model usage share" }, + }); +} + export function useGeoPrompts(organizationId: string) { return useQuery({ ...dashboardOrpc.geo.promptsList.queryOptions({ diff --git a/apps/dashboard/src/lib/orpc/routers/geo.ts b/apps/dashboard/src/lib/orpc/routers/geo.ts index 0fbffcb06..c6f49beef 100644 --- a/apps/dashboard/src/lib/orpc/routers/geo.ts +++ b/apps/dashboard/src/lib/orpc/routers/geo.ts @@ -4,20 +4,29 @@ import { queryGeoOverview, queryGeoPromptResults, queryGeoTimeseries, + queryModelUsageLatest, } from "@notra/analytics/tinybird/client"; import { db } from "@notra/db/drizzle"; import { brandSettings, geoPrompts, geoSettings } from "@notra/db/schema"; import { and, asc, eq } from "drizzle-orm"; import { Effect } from "effect"; +import { + GEO_ENGINE_LABELS, + GEO_MODEL_USAGE_ATTRIBUTION, + GEO_MODEL_USAGE_DEFAULT_LIMIT, + GEO_MODEL_USAGE_SOURCE, +} from "@/constants/geo"; import { assertOrganizationAccess } from "@/lib/auth/organization"; import { generateGeoFromWebsite } from "@/lib/geo/discover"; import type { GeoDiscoveryError } from "@/lib/geo/errors"; +import { normalizeModelId } from "@/lib/geo/model-usage"; import { buildGeoPrompts } from "@/lib/geo/prompts"; import { authorizedProcedure } from "@/lib/orpc/base"; import { badRequest, notFound } from "@/lib/orpc/utils/errors"; import { startGeoScanRun } from "@/lib/workflows/start"; import { geoGenerateFromWebsiteInputSchema, + geoModelUsageInputSchema, geoOrganizationInputSchema, geoPromptCreateInputSchema, geoPromptDeleteInputSchema, @@ -28,6 +37,8 @@ import { import type { GeoCompetitorShareResponse, GeoGenerateFromWebsiteResult, + GeoModelUsageResponse, + GeoModelUsageRow, GeoOverviewResponse, GeoPromptResultsResponse, GeoPromptRow, @@ -79,6 +90,44 @@ function toNullableNumber(value: number | bigint | null): number | null { return Number(value); } +interface EngineCoverage { + mentions: number; + checks: number; +} + +function buildCoverageByModel( + rows: { engine: string; mentions: number | bigint; checks: number | bigint }[] +): Map { + const coverage = new Map(); + for (const row of rows) { + const model = normalizeModelId(row.engine); + const entry = coverage.get(model) ?? { mentions: 0, checks: 0 }; + entry.mentions += Number(row.mentions); + entry.checks += Number(row.checks); + coverage.set(model, entry); + } + return coverage; +} + +function toModelUsageRow( + model: string, + rank: number | bigint, + share: number, + rawTokens: number | bigint | null, + coverage: EngineCoverage | undefined +): GeoModelUsageRow { + const checks = coverage?.checks ?? 0; + return { + model, + label: GEO_ENGINE_LABELS[model] ?? model, + rank: Number(rank), + share, + rawTokens: rawTokens === null ? null : Number(rawTokens), + scanned: checks > 0, + mentionRate: checks > 0 ? (coverage?.mentions ?? 0) / checks : null, + checks, + }; +} export const geoRouter = { settings: authorizedProcedure .input(geoOrganizationInputSchema) @@ -246,6 +295,51 @@ export const geoRouter = { }; } ), + modelUsage: authorizedProcedure + .input(geoModelUsageInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const [usage, overview] = await Promise.all([ + queryModelUsageLatest({ + source: GEO_MODEL_USAGE_SOURCE, + limit: input.limit ?? GEO_MODEL_USAGE_DEFAULT_LIMIT, + }).catch((error) => { + console.error("[GEO] model usage query failed:", error); + return null; + }), + queryGeoOverview({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[GEO] overview query failed:", error); + return null; + }), + ]); + + const coverage = buildCoverageByModel(overview?.data ?? []); + const rows = usage?.data ?? []; + + return { + configured: isTinybirdConfigured(), + source: GEO_MODEL_USAGE_SOURCE, + attribution: GEO_MODEL_USAGE_ATTRIBUTION, + capturedAt: rows[0]?.captured_at ?? null, + models: rows.map((row) => + toModelUsageRow( + row.model, + row.rank, + Number(row.share), + row.raw_tokens, + coverage.get(row.model) + ) + ), + }; + }), promptsList: authorizedProcedure .input(geoOrganizationInputSchema) .handler(async ({ context, input }): Promise => { diff --git a/apps/dashboard/src/schemas/geo.ts b/apps/dashboard/src/schemas/geo.ts index cde45c0c5..4a4a2aa24 100644 --- a/apps/dashboard/src/schemas/geo.ts +++ b/apps/dashboard/src/schemas/geo.ts @@ -15,6 +15,7 @@ const MAX_COMPETITORS = 10; const MAX_JUDGE_COMPETITORS = 15; const MAX_EXCERPT_LENGTH = 300; const MAX_DAYS = 365; +const MAX_MODEL_USAGE_LIMIT = 50; const MIN_PROMPT_LENGTH = GEO_PROMPT_MIN_LENGTH; const MAX_PROMPT_LENGTH = GEO_PROMPT_MAX_LENGTH; @@ -71,6 +72,36 @@ export const geoWebsiteDiscoverySchema = object({ .max(GEO_DISCOVERY_MAX_PROMPTS), }); +export const geoModelUsageInputSchema = object({ + organizationId: string().min(1), + days: number().int().min(1).max(MAX_DAYS).optional(), + limit: number().int().min(1).max(MAX_MODEL_USAGE_LIMIT).optional(), +}); + +export const openRouterRankingsResponseSchema = object({ + meta: object({ + as_of: string().min(1), + start_date: string().min(1), + end_date: string().min(1), + }), + data: array( + object({ + date: string().min(1), + model_permaslug: string().min(1), + total_tokens: string().min(1), + }) + ), +}); + +export const openRouterModelsResponseSchema = object({ + data: array( + object({ + id: string().min(1), + canonical_slug: string().min(1).nullable().optional(), + }) + ), +}); + export const geoJudgeResultSchema = object({ mentioned: boolean(), position: number().nullable(), diff --git a/apps/dashboard/src/types/geo.ts b/apps/dashboard/src/types/geo.ts index 824027c3e..e5e879506 100644 --- a/apps/dashboard/src/types/geo.ts +++ b/apps/dashboard/src/types/geo.ts @@ -170,6 +170,36 @@ export interface GeoGenerateFromWebsiteInput { url: string; } +export interface GeoModelUsageRow { + model: string; + label: string; + rank: number; + share: number; + rawTokens: number | null; + scanned: boolean; + mentionRate: number | null; + checks: number; +} + +export interface GeoModelUsageResponse { + configured: boolean; + source: string; + attribution: string; + capturedAt: string | null; + models: GeoModelUsageRow[]; +} + +export interface GeoModelUsageInput { + days?: number; + limit?: number; +} + +export interface GeoModelUsageSnapshot { + status: "captured" | "skipped"; + models?: number; + capturedAt?: string; +} + export interface GeoJudgeResult { mentioned: boolean; position: number | null; diff --git a/apps/dashboard/src/utils/geo-charts.ts b/apps/dashboard/src/utils/geo-charts.ts index 522091dbb..288e79233 100644 --- a/apps/dashboard/src/utils/geo-charts.ts +++ b/apps/dashboard/src/utils/geo-charts.ts @@ -7,6 +7,24 @@ export function formatMentionRate(rate: number): string { return `${Math.round(rate * PERCENT)}%`; } +const SHARE_DECIMALS = 10; +const MIN_SHARE_PERCENT = 0.1; + +export function formatUsageShare(share: number): string { + const percent = share * PERCENT; + if (percent > 0 && percent < MIN_SHARE_PERCENT) { + return `<${MIN_SHARE_PERCENT}%`; + } + return `${Math.round(percent * SHARE_DECIMALS) / SHARE_DECIMALS}%`; +} + +export function usageBarWidth(share: number, maxShare: number): number { + if (maxShare <= 0) { + return 0; + } + return Math.max((share / maxShare) * PERCENT, 2); +} + export function buildMentionRateRows(points: GeoTimeseriesPoint[]): { rows: MentionRateRow[]; engines: string[]; diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index 5ba69143f..b3d2692a3 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -2,6 +2,8 @@ import { type IngestResult, type QueryResult, Tinybird } from "@tinybirdco/sdk"; import { type GeoMentionCheckRow, geoMentionChecks, + type ModelUsageShareRow, + modelUsageShare, type SocialAccountRow, type SocialAccountStatsRow, type SocialPostRow, @@ -31,6 +33,12 @@ import { geoOverview, geoPromptResults, geoTimeseries, + type ModelUsageLatestParams, + type ModelUsageLatestRow, + type ModelUsageTrendParams, + type ModelUsageTrendRow, + modelUsageLatest, + modelUsageTrend, type NotraAdoptionRow, notraAdoption, type PostingPerformanceParams, @@ -62,6 +70,7 @@ function createTinybirdClient() { socialPostStats, socialPostSources, geoMentionChecks, + modelUsageShare, }, pipes: { socialOverview, @@ -76,6 +85,8 @@ function createTinybirdClient() { geoPromptResults, geoCompetitorShare, accountLeaderboard, + modelUsageLatest, + modelUsageTrend, }, }); } @@ -154,6 +165,14 @@ export function ingestGeoMentionChecks( ); } +export function ingestModelUsageShare( + rows: ModelUsageShareRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.modelUsageShare.ingestBatch(batch) + ); +} + export async function querySocialOverview( params: SocialOverviewParams ): Promise | null> { @@ -278,3 +297,23 @@ export async function queryPostMetricsLookup(params: { } return await client.postMetricsLookup.query(params); } + +export async function queryModelUsageLatest( + params: ModelUsageLatestParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.modelUsageLatest.query(params); +} + +export async function queryModelUsageTrend( + params: ModelUsageTrendParams +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.modelUsageTrend.query(params); +} diff --git a/packages/analytics/src/tinybird/datasources.ts b/packages/analytics/src/tinybird/datasources.ts index 275fe3f84..252a4b414 100644 --- a/packages/analytics/src/tinybird/datasources.ts +++ b/packages/analytics/src/tinybird/datasources.ts @@ -132,9 +132,27 @@ export const geoMentionChecks = defineDatasource("geo_mention_checks", { }), }); +export const modelUsageShare = defineDatasource("model_usage_share", { + description: + "Industry-wide AI model usage snapshots. Intentionally has no organization_id: model usage share is global market data, identical for every organization", + schema: { + captured_at: t.dateTime(), + source: t.string().lowCardinality(), + model: t.string(), + rank: t.uint64(), + share: t.float64(), + raw_tokens: t.uint64().nullable(), + }, + engine: engine.mergeTree({ + sortingKey: ["source", "captured_at", "model"], + partitionKey: "toYYYYMM(captured_at)", + }), +}); + export type SocialAccountRow = InferRow; export type SocialAccountStatsRow = InferRow; export type SocialPostRow = InferRow; export type SocialPostStatsRow = InferRow; export type SocialPostSourceRow = InferRow; export type GeoMentionCheckRow = InferRow; +export type ModelUsageShareRow = InferRow; diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts index 91b354e7c..8b7ab9b1e 100644 --- a/packages/analytics/src/tinybird/endpoints.ts +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -608,6 +608,97 @@ export const geoCompetitorShare = defineEndpoint("geo_competitor_share", { }, }); +export const modelUsageLatest = defineEndpoint("model_usage_latest", { + description: + "Most recent industry-wide usage share snapshot per model, ranked by share", + params: { + source: p.string().optional("openrouter").describe("Snapshot source"), + limit: p.int32().optional(15).describe("Max models"), + }, + nodes: [ + node({ + name: "latest_capture", + sql: ` + SELECT max(captured_at) AS latest_captured_at + FROM model_usage_share + WHERE source = {{String(source, 'openrouter')}} + `, + }), + node({ + name: "latest_models", + sql: ` + SELECT + model, + any(captured_at) AS captured_value, + any(rank) AS rank_value, + any(share) AS share_value, + any(raw_tokens) AS tokens_value + FROM model_usage_share + WHERE source = {{String(source, 'openrouter')}} + AND captured_at = (SELECT latest_captured_at FROM latest_capture) + GROUP BY model + `, + }), + node({ + name: "ranked_models", + sql: ` + SELECT + model, + captured_value AS captured_at, + rank_value AS rank, + share_value AS share, + tokens_value AS raw_tokens + FROM latest_models + ORDER BY share DESC, model ASC + LIMIT {{Int32(limit, 15)}} + `, + }), + ], + output: { + model: t.string(), + captured_at: t.dateTime(), + rank: t.uint64(), + share: t.float64(), + raw_tokens: t.uint64().nullable(), + }, +}); + +export const modelUsageTrend = defineEndpoint("model_usage_trend", { + description: "Weekly usage share per model over the trailing window", + params: { + source: p.string().optional("openrouter").describe("Snapshot source"), + weeks: p.int32().optional(8).describe("Number of trailing weeks"), + }, + nodes: [ + node({ + name: "weekly_share", + sql: ` + SELECT + toMonday(captured_at) AS week, + model, + avg(share) AS avg_share, + max(raw_tokens) AS peak_tokens + FROM model_usage_share + WHERE source = {{String(source, 'openrouter')}} + AND captured_at >= toMonday(now()) - toIntervalWeek({{Int32(weeks, 8)}}) + GROUP BY week, model + ORDER BY week ASC, avg_share DESC + `, + }), + ], + output: { + week: t.date(), + model: t.string(), + avg_share: t.float64(), + peak_tokens: t.uint64().nullable(), + }, +}); + +export type ModelUsageLatestParams = InferParams; +export type ModelUsageLatestRow = InferOutputRow; +export type ModelUsageTrendParams = InferParams; +export type ModelUsageTrendRow = InferOutputRow; + export type NotraAdoptionRow = InferOutputRow; export type GeoOverviewRow = InferOutputRow; export type GeoTimeseriesRow = InferOutputRow; From 204b64c70edec448e00ee66cfa991679ff04d8c1 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 15:25:40 +0200 Subject: [PATCH 11/26] perf(analytics): latest-state materialized views and array param fix --- packages/analytics/src/tinybird/client.ts | 8 +- .../analytics/src/tinybird/datasources.ts | 91 ++++++++ packages/analytics/src/tinybird/endpoints.ts | 208 ++++++++++++------ 3 files changed, 243 insertions(+), 64 deletions(-) diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index b3d2692a3..ce87cc743 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -295,7 +295,13 @@ export async function queryPostMetricsLookup(params: { if (!client) { return null; } - return await client.postMetricsLookup.query(params); + // The SDK serializes arrays as repeated query keys (post_ids=a&post_ids=b), + // but Tinybird's Array() template reads a single comma-separated value, so + // repeated keys silently collapse to one id. Send one pre-joined value. + return await client.postMetricsLookup.query({ + organization_id: params.organization_id, + post_ids: [params.post_ids.join(",")], + }); } export async function queryModelUsageLatest( diff --git a/packages/analytics/src/tinybird/datasources.ts b/packages/analytics/src/tinybird/datasources.ts index 252a4b414..a3ce6fd82 100644 --- a/packages/analytics/src/tinybird/datasources.ts +++ b/packages/analytics/src/tinybird/datasources.ts @@ -94,6 +94,97 @@ export const socialPostStats = defineDatasource("social_post_stats", { }), }); +export const socialPostStatsLatest = defineDatasource( + "social_post_stats_latest", + { + description: + "Materialized latest-per-post metric states; read with argMaxMerge/maxMerge instead of scanning social_post_stats", + schema: { + organization_id: t.string(), + provider: t.string().lowCardinality(), + provider_account_id: t.string(), + platform_post_id: t.string(), + impressions_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + likes_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + replies_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + reposts_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + quotes_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + bookmarks_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + last_captured_at_state: t.aggregateFunction("max", t.dateTime()), + }, + engine: engine.aggregatingMergeTree({ + sortingKey: [ + "organization_id", + "provider", + "platform_post_id", + "provider_account_id", + ], + }), + } +); + +export const socialAccountStatsLatest = defineDatasource( + "social_account_stats_latest", + { + description: + "Materialized latest-per-account stat states; read with argMaxMerge/maxMerge instead of scanning social_account_stats", + schema: { + organization_id: t.string(), + provider: t.string().lowCardinality(), + provider_account_id: t.string(), + account_id_state: t.aggregateFunction("argMax", t.string(), t.dateTime()), + followers_count_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + following_count_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + posts_count_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + listed_count_state: t.aggregateFunction( + "argMax", + t.uint64().nullable(), + t.dateTime() + ), + last_captured_at_state: t.aggregateFunction("max", t.dateTime()), + }, + engine: engine.aggregatingMergeTree({ + sortingKey: ["organization_id", "provider", "provider_account_id"], + }), + } +); + export const socialPostSources = defineDatasource("social_post_sources", { description: "Append-only ledger marking posts that were published through Notra", diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts index 8b7ab9b1e..0e12730ec 100644 --- a/packages/analytics/src/tinybird/endpoints.ts +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -1,11 +1,93 @@ import { + defineCopyPipe, defineEndpoint, + defineMaterializedView, type InferOutputRow, type InferParams, node, p, t, } from "@tinybirdco/sdk"; +import { socialAccountStatsLatest, socialPostStatsLatest } from "./datasources"; + +const POST_STATS_LATEST_SQL = ` + SELECT + organization_id, + provider, + provider_account_id, + platform_post_id, + argMaxState(impressions, captured_at) AS impressions_state, + argMaxState(likes, captured_at) AS likes_state, + argMaxState(replies, captured_at) AS replies_state, + argMaxState(reposts, captured_at) AS reposts_state, + argMaxState(quotes, captured_at) AS quotes_state, + argMaxState(bookmarks, captured_at) AS bookmarks_state, + maxState(captured_at) AS last_captured_at_state + FROM social_post_stats + GROUP BY organization_id, provider, provider_account_id, platform_post_id +`; + +const ACCOUNT_STATS_LATEST_SQL = ` + SELECT + organization_id, + provider, + provider_account_id, + argMaxState(account_id, captured_at) AS account_id_state, + argMaxState(followers_count, captured_at) AS followers_count_state, + argMaxState(following_count, captured_at) AS following_count_state, + argMaxState(posts_count, captured_at) AS posts_count_state, + argMaxState(listed_count, captured_at) AS listed_count_state, + maxState(captured_at) AS last_captured_at_state + FROM social_account_stats + GROUP BY organization_id, provider, provider_account_id +`; + +export const socialPostStatsLatestMv = defineMaterializedView( + "social_post_stats_latest_mv", + { + description: "Keeps social_post_stats_latest current on every ingest batch", + datasource: socialPostStatsLatest, + nodes: [node({ name: "post_stats_latest", sql: POST_STATS_LATEST_SQL })], + } +); + +export const socialAccountStatsLatestMv = defineMaterializedView( + "social_account_stats_latest_mv", + { + description: + "Keeps social_account_stats_latest current on every ingest batch", + datasource: socialAccountStatsLatest, + nodes: [ + node({ name: "account_stats_latest", sql: ACCOUNT_STATS_LATEST_SQL }), + ], + } +); + +export const socialPostStatsLatestBackfill = defineCopyPipe( + "social_post_stats_latest_backfill", + { + description: + "One-off backfill of social_post_stats_latest from history predating the materialized view; argMax states are idempotent so reruns are safe", + datasource: socialPostStatsLatest, + copy_mode: "append", + copy_schedule: "@on-demand", + nodes: [node({ name: "post_stats_backfill", sql: POST_STATS_LATEST_SQL })], + } +); + +export const socialAccountStatsLatestBackfill = defineCopyPipe( + "social_account_stats_latest_backfill", + { + description: + "One-off backfill of social_account_stats_latest from history predating the materialized view; argMax states are idempotent so reruns are safe", + datasource: socialAccountStatsLatest, + copy_mode: "append", + copy_schedule: "@on-demand", + nodes: [ + node({ name: "account_stats_backfill", sql: ACCOUNT_STATS_LATEST_SQL }), + ], + } +); export const socialOverview = defineEndpoint("social_overview", { description: @@ -20,12 +102,12 @@ export const socialOverview = defineEndpoint("social_overview", { SELECT provider, provider_account_id, - argMax(account_id, captured_at) AS account_id, - argMax(followers_count, captured_at) AS followers_count, - argMax(following_count, captured_at) AS following_count, - argMax(posts_count, captured_at) AS posts_count, - max(captured_at) AS stats_captured_at - FROM social_account_stats + argMaxMerge(account_id_state) AS account_id, + argMaxMerge(followers_count_state) AS followers_count, + argMaxMerge(following_count_state) AS following_count, + argMaxMerge(posts_count_state) AS posts_count, + maxMerge(last_captured_at_state) AS stats_captured_at + FROM social_account_stats_latest WHERE organization_id = {{String(organization_id)}} GROUP BY provider, provider_account_id `, @@ -48,13 +130,13 @@ export const socialOverview = defineEndpoint("social_overview", { provider, provider_account_id, platform_post_id, - argMax(impressions, captured_at) AS impressions, - argMax(likes, captured_at) AS likes, - argMax(replies, captured_at) AS replies, - argMax(reposts, captured_at) AS reposts, - argMax(quotes, captured_at) AS quotes, - argMax(bookmarks, captured_at) AS bookmarks - FROM social_post_stats + argMaxMerge(impressions_state) AS impressions, + argMaxMerge(likes_state) AS likes, + argMaxMerge(replies_state) AS replies, + argMaxMerge(reposts_state) AS reposts, + argMaxMerge(quotes_state) AS quotes, + argMaxMerge(bookmarks_state) AS bookmarks + FROM social_post_stats_latest WHERE organization_id = {{String(organization_id)}} GROUP BY provider, provider_account_id, platform_post_id ) @@ -127,31 +209,31 @@ export const engagementTimeseries = defineEndpoint("engagement_timeseries", { }, nodes: [ node({ - name: "latest_post_metrics", + name: "post_days", sql: ` SELECT provider, platform_post_id, - argMax(impressions, captured_at) AS impressions, - argMax(likes, captured_at) AS likes, - argMax(replies, captured_at) AS replies, - argMax(reposts, captured_at) AS reposts - FROM social_post_stats + argMax(provider_account_id, captured_at) AS provider_account_id, + min(posted_at) AS first_posted_at + FROM social_posts WHERE organization_id = {{String(organization_id)}} + AND posted_at >= now() - toIntervalDay({{Int32(days, 30)}}) GROUP BY provider, platform_post_id `, }), node({ - name: "post_days", + name: "latest_post_metrics", sql: ` SELECT provider, platform_post_id, - argMax(provider_account_id, captured_at) AS provider_account_id, - min(posted_at) AS first_posted_at - FROM social_posts + argMaxMerge(impressions_state) AS impressions, + argMaxMerge(likes_state) AS likes, + argMaxMerge(replies_state) AS replies, + argMaxMerge(reposts_state) AS reposts + FROM social_post_stats_latest WHERE organization_id = {{String(organization_id)}} - AND posted_at >= now() - INTERVAL {{Int32(days, 30)}} DAY GROUP BY provider, platform_post_id `, }), @@ -197,32 +279,32 @@ export const accountLeaderboard = defineEndpoint("account_leaderboard", { }, nodes: [ node({ - name: "leaderboard_post_metrics", + name: "leaderboard_window_posts", sql: ` SELECT provider, platform_post_id, - argMax(impressions, captured_at) AS impressions, - argMax(likes, captured_at) AS likes, - argMax(replies, captured_at) AS replies, - argMax(reposts, captured_at) AS reposts - FROM social_post_stats + argMax(provider_account_id, captured_at) AS provider_account_id, + min(posted_at) AS first_posted_at + FROM social_posts WHERE organization_id = {{String(organization_id)}} + AND posted_at >= now() - toIntervalDay({{Int32(days, 7)}} * 2) + AND posted_at <= now() GROUP BY provider, platform_post_id `, }), node({ - name: "leaderboard_window_posts", + name: "leaderboard_post_metrics", sql: ` SELECT provider, platform_post_id, - argMax(provider_account_id, captured_at) AS provider_account_id, - min(posted_at) AS first_posted_at - FROM social_posts + argMaxMerge(impressions_state) AS impressions, + argMaxMerge(likes_state) AS likes, + argMaxMerge(replies_state) AS replies, + argMaxMerge(reposts_state) AS reposts + FROM social_post_stats_latest WHERE organization_id = {{String(organization_id)}} - AND posted_at >= now() - toIntervalDay({{Int32(days, 7)}} * 2) - AND posted_at <= now() GROUP BY provider, platform_post_id `, }), @@ -287,12 +369,12 @@ export const topPosts = defineEndpoint("top_posts", { SELECT provider, platform_post_id, - argMax(impressions, captured_at) AS impressions, - argMax(likes, captured_at) AS likes, - argMax(replies, captured_at) AS replies, - argMax(reposts, captured_at) AS reposts, - argMax(bookmarks, captured_at) AS bookmarks - FROM social_post_stats + argMaxMerge(impressions_state) AS impressions, + argMaxMerge(likes_state) AS likes, + argMaxMerge(replies_state) AS replies, + argMaxMerge(reposts_state) AS reposts, + argMaxMerge(bookmarks_state) AS bookmarks + FROM social_post_stats_latest WHERE organization_id = {{String(organization_id)}} GROUP BY provider, platform_post_id `, @@ -351,30 +433,30 @@ export const postingPerformance = defineEndpoint("posting_performance", { }, nodes: [ node({ - name: "post_metrics", + name: "post_weekdays", sql: ` SELECT provider, platform_post_id, - argMax(likes, captured_at) AS likes, - argMax(replies, captured_at) AS replies, - argMax(reposts, captured_at) AS reposts, - argMax(impressions, captured_at) AS impressions - FROM social_post_stats + toDayOfWeek(min(posted_at)) AS weekday + FROM social_posts WHERE organization_id = {{String(organization_id)}} + AND posted_at >= now() - toIntervalDay({{Int32(days, 90)}}) GROUP BY provider, platform_post_id `, }), node({ - name: "post_weekdays", + name: "post_metrics", sql: ` SELECT provider, platform_post_id, - toDayOfWeek(min(posted_at)) AS weekday - FROM social_posts + argMaxMerge(likes_state) AS likes, + argMaxMerge(replies_state) AS replies, + argMaxMerge(reposts_state) AS reposts, + argMaxMerge(impressions_state) AS impressions + FROM social_post_stats_latest WHERE organization_id = {{String(organization_id)}} - AND posted_at >= now() - INTERVAL {{Int32(days, 90)}} DAY GROUP BY provider, platform_post_id `, }), @@ -425,7 +507,7 @@ export const followerGrowth = defineEndpoint("follower_growth", { argMax(followers_count, captured_at) AS followers_count FROM social_account_stats WHERE organization_id = {{String(organization_id)}} - AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) GROUP BY day, provider, provider_account_id ORDER BY day ASC `, @@ -495,7 +577,7 @@ export const geoOverview = defineEndpoint("geo_overview", { max(captured_at) AS last_checked_at FROM geo_mention_checks WHERE organization_id = {{String(organization_id)}} - AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) GROUP BY engine ORDER BY mention_rate DESC `, @@ -528,7 +610,7 @@ export const geoTimeseries = defineEndpoint("geo_timeseries", { countIf(mentioned) AS mentions FROM geo_mention_checks WHERE organization_id = {{String(organization_id)}} - AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) GROUP BY day, engine ORDER BY day ASC `, @@ -595,7 +677,7 @@ export const geoCompetitorShare = defineEndpoint("geo_competitor_share", { count() AS mentions FROM geo_mention_checks WHERE organization_id = {{String(organization_id)}} - AND captured_at >= now() - INTERVAL {{Int32(days, 30)}} DAY + AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) GROUP BY brand ORDER BY mentions DESC LIMIT {{Int32(limit, 10)}} @@ -718,13 +800,13 @@ export const postMetricsLookup = defineEndpoint("post_metrics_lookup", { SELECT provider, platform_post_id, - argMax(impressions, captured_at) AS impressions, - argMax(likes, captured_at) AS likes, - argMax(replies, captured_at) AS replies, - argMax(reposts, captured_at) AS reposts, - argMax(bookmarks, captured_at) AS bookmarks, - max(captured_at) AS last_captured_at - FROM social_post_stats + argMaxMerge(impressions_state) AS impressions, + argMaxMerge(likes_state) AS likes, + argMaxMerge(replies_state) AS replies, + argMaxMerge(reposts_state) AS reposts, + argMaxMerge(bookmarks_state) AS bookmarks, + maxMerge(last_captured_at_state) AS last_captured_at + FROM social_post_stats_latest WHERE organization_id = {{String(organization_id)}} AND platform_post_id IN {{Array(post_ids, 'String')}} GROUP BY provider, platform_post_id From 3986aa2d94a437d2190c60e731ae8aa208351d9f Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 17:37:13 +0200 Subject: [PATCH 12/26] feat(beacon): add AI traffic middleware package with signature classification --- .env.example | 6 + .../(dashboard)/[slug]/geo/page-client.tsx | 7 + apps/dashboard/src/app/api/beacon/route.ts | 58 +++ .../src/components/geo/ai-traffic-card.tsx | 192 ++++++++++ apps/dashboard/src/constants/geo.ts | 24 ++ apps/dashboard/src/lib/beacon/snippet.ts | 33 ++ apps/dashboard/src/lib/beacon/token.ts | 30 ++ apps/dashboard/src/lib/hooks/use-geo.ts | 22 ++ .../src/lib/hooks/use-social-analytics.ts | 2 +- .../src/lib/orpc/routers/analytics.ts | 32 +- apps/dashboard/src/lib/orpc/routers/geo.ts | 89 +++++ apps/dashboard/src/schemas/geo.ts | 23 ++ apps/dashboard/src/types/geo.ts | 48 +++ apps/dashboard/src/utils/ai-traffic.ts | 27 ++ apps/dashboard/src/utils/ratelimit.ts | 6 + apps/web/package.json | 1 + apps/web/src/proxy.ts | 32 ++ bun.lock | 11 + packages/analytics/src/tinybird/client.ts | 53 +++ .../analytics/src/tinybird/datasources.ts | 21 ++ packages/analytics/src/tinybird/endpoints.ts | 104 ++++++ packages/beacon/README.md | 84 +++++ packages/beacon/SOURCES.md | 261 ++++++++++++++ packages/beacon/package.json | 33 ++ packages/beacon/src/classify.ts | 31 ++ packages/beacon/src/middleware.ts | 88 +++++ packages/beacon/src/report.ts | 34 ++ packages/beacon/src/signatures.ts | 339 ++++++++++++++++++ packages/beacon/src/types.ts | 55 +++ packages/beacon/tsconfig.json | 11 + turbo.json | 6 +- 31 files changed, 1746 insertions(+), 17 deletions(-) create mode 100644 apps/dashboard/src/app/api/beacon/route.ts create mode 100644 apps/dashboard/src/components/geo/ai-traffic-card.tsx create mode 100644 apps/dashboard/src/lib/beacon/snippet.ts create mode 100644 apps/dashboard/src/lib/beacon/token.ts create mode 100644 apps/dashboard/src/utils/ai-traffic.ts create mode 100644 packages/beacon/README.md create mode 100644 packages/beacon/SOURCES.md create mode 100644 packages/beacon/package.json create mode 100644 packages/beacon/src/classify.ts create mode 100644 packages/beacon/src/middleware.ts create mode 100644 packages/beacon/src/report.ts create mode 100644 packages/beacon/src/signatures.ts create mode 100644 packages/beacon/src/types.ts create mode 100644 packages/beacon/tsconfig.json diff --git a/.env.example b/.env.example index cf0f21b0a..eb2a3cd7f 100644 --- a/.env.example +++ b/.env.example @@ -113,3 +113,9 @@ AXIOM_ORG_ID="" # Notra NOTRA_API_KEY="" + +# Beacon (AI traffic detection) +BEACON_INGEST_SECRET= +BEACON_INGEST_URL="http://localhost:3002/api/beacon" +BEACON_ORG_TOKEN= +BEACON_ORG_ID= diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx index 9db625222..74dd5d54b 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx @@ -6,6 +6,7 @@ import { Button } from "@notra/ui/components/ui/button"; import { Loader2Icon } from "lucide-react"; import { useState } from "react"; import { EmptyState } from "@/components/empty-state"; +import { AiTrafficCard } from "@/components/geo/ai-traffic-card"; import { GeoSettingsDialog } from "@/components/geo/geo-settings-dialog"; import { GeoSummaryStats } from "@/components/geo/geo-summary-stats"; import { MentionRateCard } from "@/components/geo/mention-rate-card"; @@ -14,6 +15,8 @@ import { WebsiteGenerateCard } from "@/components/geo/website-generate-card"; import { PageContainer } from "@/components/layout/container"; import { useOrganizationsContext } from "@/components/providers/organization-provider"; import { + useAiTraffic, + useBeaconSetup, useGeoOverview, useGeoPrompts, useGeoSettings, @@ -44,6 +47,8 @@ export default function PageClient({ organizationSlug }: PageClientProps) { const { data: timeseries } = useGeoTimeseries(organizationId); const { data: prompts } = useGeoPrompts(organizationId); const { data: modelUsage } = useModelUsage(organizationId); + const { data: aiTraffic } = useAiTraffic(organizationId); + const { data: beaconSetup } = useBeaconSetup(organizationId); const startScan = useGeoStartScan(organizationId); if (isSettingsPending) { @@ -128,6 +133,8 @@ export default function PageClient({ organizationSlug }: PageClientProps) { + + null); + const parsed = beaconEventSchema.safeParse(payload); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid payload" }, + { status: 400, headers: NO_STORE } + ); + } + + const event = parsed.data; + if (!verifyBeaconToken(event.organizationId, event.token)) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401, headers: NO_STORE } + ); + } + + await ingestAiTrafficEvents([ + { + organization_id: event.organizationId, + agent: event.agent, + category: event.category, + confidence: event.confidence, + path: event.path, + host: event.host, + method: event.method.toUpperCase(), + referer: event.referer, + captured_at: toCapturedAt(event.ts), + }, + ]); + + return NextResponse.json({ ok: true }, { status: 202, headers: NO_STORE }); +} diff --git a/apps/dashboard/src/components/geo/ai-traffic-card.tsx b/apps/dashboard/src/components/geo/ai-traffic-card.tsx new file mode 100644 index 000000000..17246bf34 --- /dev/null +++ b/apps/dashboard/src/components/geo/ai-traffic-card.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { Badge } from "@notra/ui/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@notra/ui/components/ui/table"; +import { useMemo } from "react"; +import { + AI_TRAFFIC_PURPOSE_DESCRIPTIONS, + AI_TRAFFIC_PURPOSE_LABELS, +} from "@/constants/geo"; +import { cn } from "@/lib/utils"; +import type { + AiTrafficAgent, + AiTrafficLogEntry, + AiTrafficResponse, + BeaconSetupResponse, +} from "@/types/geo"; +import { formatAiTrafficTimestamp, hitBarWidth } from "@/utils/ai-traffic"; + +interface AiTrafficCardProps { + traffic: AiTrafficResponse | undefined; + setup: BeaconSetupResponse | undefined; +} + +function PurposeBadge({ category }: { category: string }) { + return ( + + {AI_TRAFFIC_PURPOSE_LABELS[category] ?? category} + + ); +} + +function AgentRow({ + agent, + maxHits, +}: { + agent: AiTrafficAgent; + maxHits: number; +}) { + return ( +
+
+ + + {agent.agent} + + + + + last seen {formatAiTrafficTimestamp(agent.lastSeenAt)} + +
+
+
+
+
+ + {agent.hits} hits + +
+
+ ); +} + +function LogRow({ entry }: { entry: AiTrafficLogEntry }) { + return ( + + + {formatAiTrafficTimestamp(entry.capturedAt)} + + {entry.agent} + + {entry.path} + + + + + + {entry.method} + + + ); +} + +function BeaconSetup({ setup }: { setup: BeaconSetupResponse | undefined }) { + return ( +
+

+ No AI agent has been seen on your site yet. Install the beacon to start + recording hits. +

+
+        
+          {setup?.snippet ??
+            "Set BEACON_INGEST_SECRET to generate your install snippet"}
+        
+      
+ {setup?.token ? ( +

+ BEACON_ORG_TOKEN{" "} + {setup.token} +

+ ) : null} +
+ ); +} + +export function AiTrafficCard({ traffic, setup }: AiTrafficCardProps) { + const agents = traffic?.agents ?? []; + const log = traffic?.log ?? []; + const maxHits = useMemo( + () => agents.reduce((max, agent) => Math.max(max, agent.hits), 0), + [agents] + ); + const totalHits = useMemo( + () => agents.reduce((sum, agent) => sum + agent.hits, 0), + [agents] + ); + + return ( + + + AI traffic to your site + + {agents.length > 0 + ? `${totalHits} requests from ${agents.length} AI agents in the last 30 days` + : "Which AI crawlers and assistants fetch your pages, and what they came for"} + + + 0 && "space-y-6")}> + {agents.length === 0 ? ( + + ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} + + {log.length > 0 && ( +
+

Recent requests

+
+ + + + When + Provider + Path + Purpose + Method + + + + {log.map((entry) => ( + + ))} + +
+
+
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/constants/geo.ts b/apps/dashboard/src/constants/geo.ts index 46f3b4a78..4784e5845 100644 --- a/apps/dashboard/src/constants/geo.ts +++ b/apps/dashboard/src/constants/geo.ts @@ -124,3 +124,27 @@ export const GEO_DISCOVERY_SYSTEM_PROMPT = "You are a search visibility analyst. You read a company's website and derive the brand identity and the buyer questions that decide whether an AI assistant recommends this company. Respond only with the requested structured data."; export const GEO_ANSWER_SYSTEM_PROMPT = "You are a helpful AI assistant. Answer the user's question directly and concretely, naming specific products or companies where relevant."; + +export const AI_TRAFFIC_DEFAULT_DAYS = 30; +export const AI_TRAFFIC_DEFAULT_LOG_LIMIT = 50; +export const BEACON_INGEST_PATH = "/api/beacon"; +export const BEACON_INGEST_SECRET_ENV = "BEACON_INGEST_SECRET"; + +export const AI_TRAFFIC_PURPOSE_LABELS: Record = { + "training-crawler": "Training data", + "search-index": "Search index", + "assistant-browse": "Used in answer", +}; + +export const AI_TRAFFIC_PURPOSE_DESCRIPTIONS: Record = { + "training-crawler": "Collects pages for model training corpora", + "search-index": "Builds the index an AI answer engine searches", + "assistant-browse": + "Fetched while an assistant was answering someone. A fetch is not proof of a citation", +}; + +export const AI_TRAFFIC_CONFIDENCE_LABELS: Record = { + verified: "Verified", + reported: "Reported", + heuristic: "Heuristic", +}; diff --git a/apps/dashboard/src/lib/beacon/snippet.ts b/apps/dashboard/src/lib/beacon/snippet.ts new file mode 100644 index 000000000..c8336b5ed --- /dev/null +++ b/apps/dashboard/src/lib/beacon/snippet.ts @@ -0,0 +1,33 @@ +import { BEACON_INGEST_PATH } from "@/constants/geo"; + +const FALLBACK_APP_URL = "https://app.usenotra.com"; + +export function buildBeaconIngestUrl(): string { + const base = + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL ?? + FALLBACK_APP_URL; + return new URL(BEACON_INGEST_PATH, base).toString(); +} + +export function buildBeaconSnippet( + ingestUrl: string, + organizationId: string +): string { + return [ + "// middleware.ts (proxy.ts on Next.js 16)", + 'import { createBeaconMiddleware } from "@notra/beacon/middleware";', + 'import { NextResponse } from "next/server";', + "", + "const beacon = createBeaconMiddleware({", + ` ingestUrl: "${ingestUrl}",`, + ' token: process.env.BEACON_ORG_TOKEN ?? "",', + ` organizationId: "${organizationId}",`, + "});", + "", + "export function middleware(request: Request) {", + " beacon(request);", + " return NextResponse.next();", + "}", + ].join("\n"); +} diff --git a/apps/dashboard/src/lib/beacon/token.ts b/apps/dashboard/src/lib/beacon/token.ts new file mode 100644 index 000000000..290c06866 --- /dev/null +++ b/apps/dashboard/src/lib/beacon/token.ts @@ -0,0 +1,30 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { BEACON_INGEST_SECRET_ENV } from "@/constants/geo"; + +function getSecret(): string | null { + const secret = process.env[BEACON_INGEST_SECRET_ENV]; + return secret && secret.length > 0 ? secret : null; +} + +function isBeaconConfigured(): boolean { + return getSecret() !== null; +} + +export function deriveBeaconToken(organizationId: string): string | null { + const secret = getSecret(); + if (!secret) { + return null; + } + return createHmac("sha256", secret).update(organizationId).digest("hex"); +} + +export function verifyBeaconToken( + organizationId: string, + token: string +): boolean { + const expected = deriveBeaconToken(organizationId); + if (!expected || expected.length !== token.length) { + return false; + } + return timingSafeEqual(Buffer.from(expected), Buffer.from(token)); +} diff --git a/apps/dashboard/src/lib/hooks/use-geo.ts b/apps/dashboard/src/lib/hooks/use-geo.ts index a5fbdefe2..a9e5fb5b4 100644 --- a/apps/dashboard/src/lib/hooks/use-geo.ts +++ b/apps/dashboard/src/lib/hooks/use-geo.ts @@ -3,6 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import type { + AiTrafficResponse, + BeaconSetupResponse, GeoCompetitorShareResponse, GeoGenerateFromWebsiteInput, GeoModelUsageResponse, @@ -207,3 +209,23 @@ export function useGeoStartScan(organizationId: string) { }, }); } + +export function useAiTraffic(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.geo.aiTraffic.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_GEO_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load AI traffic" }, + }); +} + +export function useBeaconSetup(organizationId: string) { + return useQuery({ + ...dashboardOrpc.geo.beaconSetup.queryOptions({ + input: { organizationId }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load beacon setup" }, + }); +} diff --git a/apps/dashboard/src/lib/hooks/use-social-analytics.ts b/apps/dashboard/src/lib/hooks/use-social-analytics.ts index 9b16ac8ea..8500d193f 100644 --- a/apps/dashboard/src/lib/hooks/use-social-analytics.ts +++ b/apps/dashboard/src/lib/hooks/use-social-analytics.ts @@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import type { - TrackAccountPreviewResponse, EngagementTimeseriesResponse, FollowerGrowthResponse, LeaderboardResponse, @@ -12,6 +11,7 @@ import type { PostingPerformanceResponse, SocialOverviewResponse, TopPostsResponse, + TrackAccountPreviewResponse, } from "@/types/analytics"; import { dashboardOrpc } from "../orpc/query"; diff --git a/apps/dashboard/src/lib/orpc/routers/analytics.ts b/apps/dashboard/src/lib/orpc/routers/analytics.ts index 1e6ffdf71..cc8174143 100644 --- a/apps/dashboard/src/lib/orpc/routers/analytics.ts +++ b/apps/dashboard/src/lib/orpc/routers/analytics.ts @@ -36,7 +36,6 @@ import { } from "@/schemas/analytics"; import type { EngagementTimeseriesResponse, - TrackAccountPreviewResponse, FollowerGrowthResponse, LeaderboardAccount, LeaderboardResponse, @@ -46,6 +45,7 @@ import type { SocialOverviewResponse, SyncableSocialAccount, TopPostsResponse, + TrackAccountPreviewResponse, } from "@/types/analytics"; import { badRequest, notFound } from "../utils/errors"; @@ -417,21 +417,23 @@ export const analyticsRouter = { }), previewTrackAccount: authorizedProcedure .input(trackAccountInputSchema) - .handler(async ({ context, input }): Promise => { - await assertOrganizationAccess({ - headers: context.headers, - organizationId: input.organizationId, - user: context.user, - }); + .handler( + async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); - const account = await resolveTwitterAccount(input.username).catch( - (error) => { - console.error("[Analytics] account preview failed:", error); - return null; - } - ); - return { account }; - }), + const account = await resolveTwitterAccount(input.username).catch( + (error) => { + console.error("[Analytics] account preview failed:", error); + return null; + } + ); + return { account }; + } + ), trackAccount: authorizedProcedure .input(trackAccountInputSchema) .handler(async ({ context, input }) => { diff --git a/apps/dashboard/src/lib/orpc/routers/geo.ts b/apps/dashboard/src/lib/orpc/routers/geo.ts index c6f49beef..13bf6bbd5 100644 --- a/apps/dashboard/src/lib/orpc/routers/geo.ts +++ b/apps/dashboard/src/lib/orpc/routers/geo.ts @@ -1,5 +1,8 @@ import { isTinybirdConfigured, + queryAiTrafficLog, + queryAiTrafficOverview, + queryAiTrafficTimeseries, queryGeoCompetitorShare, queryGeoOverview, queryGeoPromptResults, @@ -11,12 +14,16 @@ import { brandSettings, geoPrompts, geoSettings } from "@notra/db/schema"; import { and, asc, eq } from "drizzle-orm"; import { Effect } from "effect"; import { + AI_TRAFFIC_DEFAULT_DAYS, + AI_TRAFFIC_DEFAULT_LOG_LIMIT, GEO_ENGINE_LABELS, GEO_MODEL_USAGE_ATTRIBUTION, GEO_MODEL_USAGE_DEFAULT_LIMIT, GEO_MODEL_USAGE_SOURCE, } from "@/constants/geo"; import { assertOrganizationAccess } from "@/lib/auth/organization"; +import { buildBeaconIngestUrl, buildBeaconSnippet } from "@/lib/beacon/snippet"; +import { deriveBeaconToken } from "@/lib/beacon/token"; import { generateGeoFromWebsite } from "@/lib/geo/discover"; import type { GeoDiscoveryError } from "@/lib/geo/errors"; import { normalizeModelId } from "@/lib/geo/model-usage"; @@ -25,6 +32,7 @@ import { authorizedProcedure } from "@/lib/orpc/base"; import { badRequest, notFound } from "@/lib/orpc/utils/errors"; import { startGeoScanRun } from "@/lib/workflows/start"; import { + aiTrafficInputSchema, geoGenerateFromWebsiteInputSchema, geoModelUsageInputSchema, geoOrganizationInputSchema, @@ -35,6 +43,8 @@ import { geoTimeseriesInputSchema, } from "@/schemas/geo"; import type { + AiTrafficResponse, + BeaconSetupResponse, GeoCompetitorShareResponse, GeoGenerateFromWebsiteResult, GeoModelUsageResponse, @@ -340,6 +350,85 @@ export const geoRouter = { ), }; }), + aiTraffic: authorizedProcedure + .input(aiTrafficInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const days = input.days ?? AI_TRAFFIC_DEFAULT_DAYS; + const limit = input.limit ?? AI_TRAFFIC_DEFAULT_LOG_LIMIT; + + const [overview, timeseries, log] = await Promise.all([ + queryAiTrafficOverview({ + organization_id: input.organizationId, + days, + }).catch((error) => { + console.error("[BEACON] ai traffic overview query failed:", error); + return null; + }), + queryAiTrafficTimeseries({ + organization_id: input.organizationId, + days, + }).catch((error) => { + console.error("[BEACON] ai traffic timeseries query failed:", error); + return null; + }), + queryAiTrafficLog({ + organization_id: input.organizationId, + limit, + }).catch((error) => { + console.error("[BEACON] ai traffic log query failed:", error); + return null; + }), + ]); + + return { + configured: isTinybirdConfigured(), + agents: (overview?.data ?? []).map((row) => ({ + agent: row.agent, + category: row.category, + confidence: row.confidence, + hits: Number(row.hits), + paths: Number(row.paths), + lastSeenAt: row.last_seen_at, + })), + points: (timeseries?.data ?? []).map((row) => ({ + day: row.day, + category: row.category, + hits: Number(row.hits), + })), + log: (log?.data ?? []).map((row) => ({ + capturedAt: row.captured_at, + agent: row.agent, + category: row.category, + confidence: row.confidence, + path: row.path, + method: row.method, + referer: row.referer, + })), + }; + }), + beaconSetup: authorizedProcedure + .input(geoOrganizationInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const ingestUrl = buildBeaconIngestUrl(); + + return { + ingestUrl, + token: deriveBeaconToken(input.organizationId) ?? "", + snippet: buildBeaconSnippet(ingestUrl, input.organizationId), + }; + }), promptsList: authorizedProcedure .input(geoOrganizationInputSchema) .handler(async ({ context, input }): Promise => { diff --git a/apps/dashboard/src/schemas/geo.ts b/apps/dashboard/src/schemas/geo.ts index 4a4a2aa24..855ed1c7b 100644 --- a/apps/dashboard/src/schemas/geo.ts +++ b/apps/dashboard/src/schemas/geo.ts @@ -16,6 +16,9 @@ const MAX_JUDGE_COMPETITORS = 15; const MAX_EXCERPT_LENGTH = 300; const MAX_DAYS = 365; const MAX_MODEL_USAGE_LIMIT = 50; +const MAX_AI_TRAFFIC_LOG_LIMIT = 200; +const MAX_BEACON_FIELD_LENGTH = 512; +const MAX_BEACON_METHOD_LENGTH = 16; const MIN_PROMPT_LENGTH = GEO_PROMPT_MIN_LENGTH; const MAX_PROMPT_LENGTH = GEO_PROMPT_MAX_LENGTH; @@ -109,3 +112,23 @@ export const geoJudgeResultSchema = object({ competitors: array(string()).max(MAX_JUDGE_COMPETITORS), excerpt: string().max(MAX_EXCERPT_LENGTH), }); + +export const aiTrafficInputSchema = object({ + organizationId: string().min(1), + days: number().int().min(1).max(MAX_DAYS).optional(), + limit: number().int().min(1).max(MAX_AI_TRAFFIC_LOG_LIMIT).optional(), +}); + +export const beaconEventSchema = object({ + token: string().min(1).max(MAX_BEACON_FIELD_LENGTH), + organizationId: string().min(1).max(MAX_BEACON_FIELD_LENGTH), + agent: string().min(1).max(MAX_BEACON_FIELD_LENGTH), + category: enumType(["training-crawler", "search-index", "assistant-browse"]), + confidence: enumType(["verified", "reported", "heuristic"]), + path: string().min(1).max(MAX_BEACON_FIELD_LENGTH), + host: string().min(1).max(MAX_BEACON_FIELD_LENGTH), + method: string().min(1).max(MAX_BEACON_METHOD_LENGTH), + referer: string().max(MAX_BEACON_FIELD_LENGTH).nullable(), + ua: string().max(MAX_BEACON_FIELD_LENGTH), + ts: string().min(1).max(MAX_BEACON_FIELD_LENGTH), +}); diff --git a/apps/dashboard/src/types/geo.ts b/apps/dashboard/src/types/geo.ts index e5e879506..ffef3b2c4 100644 --- a/apps/dashboard/src/types/geo.ts +++ b/apps/dashboard/src/types/geo.ts @@ -207,3 +207,51 @@ export interface GeoJudgeResult { competitors: string[]; excerpt: string; } + +export type AiTrafficCategory = + | "training-crawler" + | "search-index" + | "assistant-browse"; + +export interface AiTrafficAgent { + agent: string; + category: string; + confidence: string; + hits: number; + paths: number; + lastSeenAt: string; +} + +export interface AiTrafficPoint { + day: string; + category: string; + hits: number; +} + +export interface AiTrafficLogEntry { + capturedAt: string; + agent: string; + category: string; + confidence: string; + path: string; + method: string; + referer: string | null; +} + +export interface AiTrafficResponse { + configured: boolean; + agents: AiTrafficAgent[]; + points: AiTrafficPoint[]; + log: AiTrafficLogEntry[]; +} + +export interface AiTrafficInput { + days?: number; + limit?: number; +} + +export interface BeaconSetupResponse { + ingestUrl: string; + token: string; + snippet: string; +} diff --git a/apps/dashboard/src/utils/ai-traffic.ts b/apps/dashboard/src/utils/ai-traffic.ts new file mode 100644 index 000000000..d0c722087 --- /dev/null +++ b/apps/dashboard/src/utils/ai-traffic.ts @@ -0,0 +1,27 @@ +const PERCENT = 100; +const MIN_BAR_PERCENT = 2; + +export function hitBarWidth(hits: number, maxHits: number): number { + if (maxHits <= 0) { + return 0; + } + return Math.max((hits / maxHits) * PERCENT, MIN_BAR_PERCENT); +} + +const timestampFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +export function formatAiTrafficTimestamp(value: string): string { + const normalized = value.includes("T") + ? value + : `${value.replace(" ", "T")}Z`; + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) { + return value; + } + return timestampFormatter.format(date); +} diff --git a/apps/dashboard/src/utils/ratelimit.ts b/apps/dashboard/src/utils/ratelimit.ts index b11050204..2538717d1 100644 --- a/apps/dashboard/src/utils/ratelimit.ts +++ b/apps/dashboard/src/utils/ratelimit.ts @@ -95,6 +95,12 @@ export const ratelimit = { prefix: "ratelimit:chat-relay", limiter: Ratelimit.slidingWindow(20, "1m"), }), + beaconIngest: new Ratelimit({ + redis, + analytics: true, + prefix: "ratelimit:beacon-ingest", + limiter: Ratelimit.slidingWindow(300, "1m"), + }), slackOAuth: new Ratelimit({ redis, analytics: true, diff --git a/apps/web/package.json b/apps/web/package.json index 53f70dfa9..32154728b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.5", "@neoconfetti/react": "^1.0.0", + "@notra/beacon": "workspace:*", "@notra/email": "workspace:*", "@notra/kiwi": "workspace:*", "@notra/ui": "workspace:*", diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 410055342..2ed958aa1 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,8 +1,21 @@ import { Tracker } from "@bydefault/vercel"; import { createDualmarkMiddleware } from "@dualmark/nextjs"; +import { createBeaconMiddleware } from "@notra/beacon/middleware"; import { after, type NextRequest, NextResponse } from "next/server"; import { HOMEPAGE_LINK_HEADER, SITE_URL } from "@/utils/urls"; +const beaconIngestUrl = process.env.BEACON_INGEST_URL; +const beaconToken = process.env.BEACON_ORG_TOKEN; +const beaconOrganizationId = process.env.BEACON_ORG_ID; +const beacon = + beaconIngestUrl && beaconToken && beaconOrganizationId + ? createBeaconMiddleware({ + ingestUrl: beaconIngestUrl, + token: beaconToken, + organizationId: beaconOrganizationId, + }) + : null; + const bydefaultToken = process.env.BYDEFAULT_TOKEN; const tracker = bydefaultToken ? new Tracker({ @@ -41,12 +54,31 @@ const dualmarkProxy = createDualmarkMiddleware({ }, }); +function trackAiTraffic(request: NextRequest) { + if (!beacon) { + return; + } + + const pending: Promise[] = []; + beacon(request, { + waitUntil: (promise) => { + pending.push(promise); + }, + }); + + if (pending.length > 0) { + after(Promise.all(pending)); + } +} + function appendLinkHeader(headers: Headers, value: string) { const existing = headers.get("Link"); headers.set("Link", existing ? `${existing}, ${value}` : value); } export async function proxy(request: NextRequest) { + trackAiTraffic(request); + if ( request.nextUrl.pathname === "/" && request.nextUrl.searchParams.get("mode") === "agent" diff --git a/bun.lock b/bun.lock index 64542fca7..5214e98c6 100644 --- a/bun.lock +++ b/bun.lock @@ -291,6 +291,7 @@ "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.5", "@neoconfetti/react": "^1.0.0", + "@notra/beacon": "workspace:*", "@notra/email": "workspace:*", "@notra/kiwi": "workspace:*", "@notra/ui": "workspace:*", @@ -397,6 +398,14 @@ "typescript": "5.9.2", }, }, + "packages/beacon": { + "name": "@notra/beacon", + "version": "0.0.1", + "devDependencies": { + "@notra/typescript-config": "workspace:*", + "typescript": "5.9.2", + }, + }, "packages/content-generation": { "name": "@notra/content-generation", "version": "0.0.1", @@ -1269,6 +1278,8 @@ "@notra/analytics": ["@notra/analytics@workspace:packages/analytics"], + "@notra/beacon": ["@notra/beacon@workspace:packages/beacon"], + "@notra/content-generation": ["@notra/content-generation@workspace:packages/content-generation"], "@notra/db": ["@notra/db@workspace:packages/db"], diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index ce87cc743..94241e247 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -1,5 +1,7 @@ import { type IngestResult, type QueryResult, Tinybird } from "@tinybirdco/sdk"; import { + type AiTrafficEventRow, + aiTrafficEvents, type GeoMentionCheckRow, geoMentionChecks, type ModelUsageShareRow, @@ -18,7 +20,13 @@ import { import { type AccountLeaderboardParams, type AccountLeaderboardRow, + type AiTrafficLogRow, + type AiTrafficOverviewRow, + type AiTrafficTimeseriesRow, accountLeaderboard, + aiTrafficLog, + aiTrafficOverview, + aiTrafficTimeseries, type EngagementTimeseriesParams, type EngagementTimeseriesRow, engagementTimeseries, @@ -71,6 +79,7 @@ function createTinybirdClient() { socialPostSources, geoMentionChecks, modelUsageShare, + aiTrafficEvents, }, pipes: { socialOverview, @@ -87,6 +96,9 @@ function createTinybirdClient() { accountLeaderboard, modelUsageLatest, modelUsageTrend, + aiTrafficOverview, + aiTrafficTimeseries, + aiTrafficLog, }, }); } @@ -323,3 +335,44 @@ export async function queryModelUsageTrend( } return await client.modelUsageTrend.query(params); } + +export function ingestAiTrafficEvents( + rows: AiTrafficEventRow[] +): Promise { + return ingestRows(rows, (client, batch) => + client.aiTrafficEvents.ingestBatch(batch) + ); +} + +export async function queryAiTrafficOverview(params: { + organization_id: string; + days?: number; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.aiTrafficOverview.query(params); +} + +export async function queryAiTrafficTimeseries(params: { + organization_id: string; + days?: number; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.aiTrafficTimeseries.query(params); +} + +export async function queryAiTrafficLog(params: { + organization_id: string; + limit?: number; +}): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return null; + } + return await client.aiTrafficLog.query(params); +} diff --git a/packages/analytics/src/tinybird/datasources.ts b/packages/analytics/src/tinybird/datasources.ts index a3ce6fd82..e16f1b59c 100644 --- a/packages/analytics/src/tinybird/datasources.ts +++ b/packages/analytics/src/tinybird/datasources.ts @@ -240,6 +240,26 @@ export const modelUsageShare = defineDatasource("model_usage_share", { }), }); +export const aiTrafficEvents = defineDatasource("ai_traffic_events", { + description: + "Append-only log of AI agent requests to an organization's site, one row per detected hit", + schema: { + organization_id: t.string(), + agent: t.string().lowCardinality(), + category: t.string().lowCardinality(), + confidence: t.string().lowCardinality(), + path: t.string(), + host: t.string(), + method: t.string().lowCardinality(), + referer: t.string().nullable(), + captured_at: t.dateTime(), + }, + engine: engine.mergeTree({ + sortingKey: ["organization_id", "captured_at"], + partitionKey: "toYYYYMM(captured_at)", + }), +}); + export type SocialAccountRow = InferRow; export type SocialAccountStatsRow = InferRow; export type SocialPostRow = InferRow; @@ -247,3 +267,4 @@ export type SocialPostStatsRow = InferRow; export type SocialPostSourceRow = InferRow; export type GeoMentionCheckRow = InferRow; export type ModelUsageShareRow = InferRow; +export type AiTrafficEventRow = InferRow; diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts index 0e12730ec..4949fd20c 100644 --- a/packages/analytics/src/tinybird/endpoints.ts +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -853,3 +853,107 @@ export const postMetricsLookup = defineEndpoint("post_metrics_lookup", { }); export type PostMetricsLookupRow = InferOutputRow; + +export const aiTrafficOverview = defineEndpoint("ai_traffic_overview", { + description: "AI agent hits per agent over the trailing window", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "per_agent", + sql: ` + SELECT + agent, + any(category) AS category, + any(confidence) AS confidence, + count() AS hits, + uniqExact(path) AS paths, + max(captured_at) AS last_seen_at + FROM ai_traffic_events + WHERE organization_id = {{String(organization_id)}} + AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) + GROUP BY agent + ORDER BY hits DESC, agent ASC + `, + }), + ], + output: { + agent: t.string(), + category: t.string(), + confidence: t.string(), + hits: t.uint64(), + paths: t.uint64(), + last_seen_at: t.dateTime(), + }, +}); + +export const aiTrafficTimeseries = defineEndpoint("ai_traffic_timeseries", { + description: "Daily AI agent hits per category", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "daily", + sql: ` + SELECT + toDate(captured_at) AS day, + category, + count() AS hits + FROM ai_traffic_events + WHERE organization_id = {{String(organization_id)}} + AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) + GROUP BY day, category + ORDER BY day ASC, category ASC + `, + }), + ], + output: { + day: t.date(), + category: t.string(), + hits: t.uint64(), + }, +}); + +export const aiTrafficLog = defineEndpoint("ai_traffic_log", { + description: "Most recent individual AI agent requests, newest first", + params: { + organization_id: p.string().describe("Organization id"), + limit: p.int32().optional(50).describe("Max events"), + }, + nodes: [ + node({ + name: "recent", + sql: ` + SELECT + captured_at, + agent, + category, + confidence, + path, + method, + referer + FROM ai_traffic_events + WHERE organization_id = {{String(organization_id)}} + ORDER BY captured_at DESC + LIMIT {{Int32(limit, 50)}} + `, + }), + ], + output: { + captured_at: t.dateTime(), + agent: t.string(), + category: t.string(), + confidence: t.string(), + path: t.string(), + method: t.string(), + referer: t.string().nullable(), + }, +}); + +export type AiTrafficOverviewRow = InferOutputRow; +export type AiTrafficTimeseriesRow = InferOutputRow; +export type AiTrafficLogRow = InferOutputRow; diff --git a/packages/beacon/README.md b/packages/beacon/README.md new file mode 100644 index 000000000..b3963da46 --- /dev/null +++ b/packages/beacon/README.md @@ -0,0 +1,84 @@ +# @notra/beacon + +Edge-safe detection of AI-agent traffic for Next.js. Classifies incoming requests +against a sourced signature table and reports matches to an ingest endpoint without +ever blocking or failing the request. + +Zero dependencies. No Node APIs, so it runs in middleware on the edge runtime. + +## Install + +```bash +bun add @notra/beacon +``` + +## Usage + +```ts +// middleware.ts (proxy.ts on Next.js 16) +import { createBeaconMiddleware } from "@notra/beacon/middleware"; +import { NextResponse } from "next/server"; + +const beacon = createBeaconMiddleware({ + ingestUrl: "https://app.usenotra.com/api/beacon", + token: process.env.BEACON_ORG_TOKEN ?? "", + organizationId: process.env.BEACON_ORG_ID ?? "", +}); + +export function middleware(request: Request) { + beacon(request); + return NextResponse.next(); +} +``` + +`beacon(request)` returns the `BeaconMatch` it detected (or `null`) and schedules the +report as a side effect. Pass a context with `waitUntil` as the second argument when +you have one, so the report survives the response. + +## API + +### `classifyRequest(headers: Headers, ip?: string): BeaconMatch | null` + +Pure. Case-insensitive substring match of the `User-Agent` header against +`BEACON_SIGNATURES`. Returns `{ agent, vendor, category, confidence }` or `null`. The +`ip` argument is accepted for forward compatibility with IP-range verification and is +currently unused. + +### `classifyUserAgent(userAgent: string): BeaconMatch | null` + +The same match against a raw string. + +### `createBeaconMiddleware(config): (request, context?) => BeaconMatch | null` + +Config: + +| Field | Required | Meaning | +| --- | --- | --- | +| `ingestUrl` | yes | Where to POST the event | +| `token` | yes | Per-organization ingest token | +| `organizationId` | yes | Organization the hit belongs to | +| `sample` | no | 0..1 sample rate, default 1 | +| `fetchImpl` | no | Injectable `fetch`, for tests | + +### `reportAiHit(config, event, context?): void` + +Fire-and-forget POST for manual use. Never throws. + +### Categories + +- `training-crawler` — collects pages for model training corpora +- `search-index` — builds the index an AI answer engine searches +- `assistant-browse` — fetched while an assistant was answering someone + +An `assistant-browse` hit means a page was fetched during an answer. It is not proof +the page was cited. + +## Signature provenance + +Every signature carries a `source` URL and a `confidence` of `verified`, `reported`, or +`heuristic`. See [SOURCES.md](./SOURCES.md), which also documents the agents that are +deliberately **not** in the table because they cannot be honestly detected by +user-agent, including Cursor, ChatGPT Atlas, `Google-Extended` and `Applebot-Extended`. + +User-agent matching is spoofable. The `verification` field on each signature points at +the operator's published IP-range list or reverse-DNS method where one exists. diff --git a/packages/beacon/SOURCES.md b/packages/beacon/SOURCES.md new file mode 100644 index 000000000..98dc8c591 --- /dev/null +++ b/packages/beacon/SOURCES.md @@ -0,0 +1,261 @@ +# Signature sources + +Every entry in `src/signatures.ts` is listed here with the source it came from and a +confidence level. Nothing in the table is invented. If a vendor does not publish a +user-agent, the agent is either absent from the table or documented below as +unmatchable. + +Confidence levels: + +- **verified** — the operator publishes the token in its own documentation. +- **reported** — the token appears only in credible third-party catalogs, controlled + header tests, or server-log studies. Spoofable and unconfirmed by the vendor. +- **heuristic** — the token is real but our category assignment is an inference. + +Matching is a case-insensitive substring test on the `User-Agent` header only. UA +matching is trivially spoofable; the `verification` field points at the published IP +range list or reverse-DNS method that can prove a hit is genuine. Nothing in this +package performs that verification yet. + +## OpenAI + +| Agent | Category | Token | Confidence | +| --- | --- | --- | --- | +| GPTBot | training-crawler | `GPTBot` | verified | +| OAI-SearchBot | search-index | `OAI-SearchBot` | verified | +| ChatGPT-User | assistant-browse | `ChatGPT-User` | verified | +| OAI-AdsBot | search-index | `OAI-AdsBot` | verified | + +Source: . IP ranges: +, , +, . Each file has +the shape `{"creationTime": ISO8601, "prefixes": [{"ipv4Prefix": "..."}]}`. + +OAI-AdsBot is filed under `search-index` because our category enum has no ad-safety +bucket; OpenAI describes it as a landing-page checker, not a training crawler. + +There is **no** `ChatGPT-Agent` or `Operator` user-agent token. ChatGPT Atlas and the +ChatGPT agent send an ordinary Chrome user-agent and identify themselves with Web Bot +Auth HTTP message signatures instead +(). They cannot be +detected by user-agent and are deliberately absent from the table. + +## Anthropic + +| Agent | Category | Token | Confidence | +| --- | --- | --- | --- | +| ClaudeBot | training-crawler | `ClaudeBot` | verified | +| Claude-User | assistant-browse | `Claude-User` | verified | +| Claude-SearchBot | search-index | `Claude-SearchBot` | verified | +| Claude Code | assistant-browse | `claude-code/` | reported | +| anthropic-ai | training-crawler | `anthropic-ai` | reported (legacy) | +| claude-web | assistant-browse | `claude-web` | reported (legacy) | + +Source: . IP ranges: +. + +Anthropic documents the three tokens but publishes **no** full user-agent strings, so +we match on the token only. `anthropic-ai` and `claude-web` are legacy names that +appear only in ; that catalog itself +notes there is no official documentation for `Claude-Web`. + +Claude Code sends `Claude-User (claude-code/; +https://support.anthropic.com/)` +in newer builds (); older builds sent +a bare `axios/1.8.4`, which is not attributable. The `claude-code/` entry is ordered +before `Claude-User` so Claude Code hits are labelled specifically. + +## Perplexity + +| Agent | Category | Token | Confidence | +| --- | --- | --- | --- | +| PerplexityBot | search-index | `PerplexityBot` | verified | +| Perplexity-User | assistant-browse | `Perplexity-User` | verified | + +Source: . IP ranges: +, +. + +Perplexity has been documented fetching from undeclared user-agents and IPs, so a UA +miss does not mean Perplexity did not visit. + +## Google + +| Agent | Category | Token | Confidence | +| --- | --- | --- | --- | +| Google-CloudVertexBot | training-crawler | `Google-CloudVertexBot` | verified | +| GoogleOther-Image | training-crawler | `GoogleOther-Image` | verified | +| GoogleOther-Video | training-crawler | `GoogleOther-Video` | verified | +| GoogleOther | training-crawler | `GoogleOther` | heuristic | +| GoogleAgent-URLContext | assistant-browse | `GoogleAgent-URLContext`, `Google-Gemini-CLI` | reported | + +Sources: +, +. +IP ranges: `https://developers.google.com/static/search/apis/ipranges/special-crawlers.json` +plus reverse DNS in `*.googlebot.com` / `*.google.com`. + +**`Google-Extended` is deliberately absent.** Google states it "doesn't have a separate +HTTP request user agent string" — it is a robots.txt control token for Gemini and Vertex +AI grounding only. Any request whose UA literally contains `Google-Extended` is spoofed, +so matching it would produce fake data. + +`GoogleOther` is marked heuristic: the token is verified, but Google describes it as a +shared one-off crawl pool for internal R&D rather than a dedicated AI training crawler, +so the `training-crawler` category is our inference. + +`GoogleAgent-URLContext` is the Gemini CLI / URL-context fetcher, observed in a +controlled header test () +rather than documented by Google. + +Plain `Googlebot` is not in the table: it is classic search indexing, not AI traffic. + +## Apple + +| Agent | Category | Token | Confidence | +| --- | --- | --- | --- | +| Applebot | search-index | `Applebot/` | verified | + +Source: . IP ranges: +, plus forward-confirmed reverse DNS +in `*.applebot.apple.com`. + +**`Applebot-Extended` is deliberately absent.** Apple states it "does not crawl +webpages... only used to determine how to use the data crawled by the Applebot user +agent". It is a robots.txt directive, not a fetcher. The token is matched with a +trailing slash (`Applebot/`) because Apple's real UA reads `(Applebot/0.1; +...)`. + +## Meta + +| Agent | Category | Token | Confidence | +| --- | --- | --- | --- | +| meta-externalagent | training-crawler | `meta-externalagent` | verified | +| meta-externalfetcher | assistant-browse | `meta-externalfetcher` | verified | +| FacebookBot | training-crawler | `FacebookBot` | reported | + +Source: . Meta +publishes no IP list, so none of these can be verified. `FacebookBot` is not on Meta's +crawler page and comes from ai.robots.txt only. + +## Amazon, DuckDuckGo, Liner, You.com, Mistral, Common Crawl + +| Agent | Vendor | Category | Token | Confidence | Verification | +| --- | --- | --- | --- | --- | --- | +| Amazonbot | Amazon | training-crawler | `Amazonbot` | verified | | +| DuckAssistBot | DuckDuckGo | assistant-browse | `DuckAssistBot` | verified | | +| LinerBot | Liner | search-index | `LinerBot` | verified | | +| YouBot | You.com | search-index | `YouBot` | verified | rDNS `*.search.you.com`, `68.67.112.0/24` | +| MistralAI-User | Mistral | assistant-browse | `MistralAI-User` | verified | | +| CCBot | Common Crawl | training-crawler | `CCBot` | verified | | + +Docs: , +, +, , +, . + +## Unverifiable but widely observed + +| Agent | Vendor | Category | Token | Confidence | +| --- | --- | --- | --- | --- | +| Bytespider | ByteDance | training-crawler | `Bytespider` | reported | +| cohere-training-data-crawler | Cohere | training-crawler | `cohere-training-data-crawler` | reported | +| cohere-ai | Cohere | assistant-browse | `cohere-ai` | reported | +| Timpibot | Timpi | training-crawler | `Timpibot` | reported | +| Diffbot | Diffbot | training-crawler | `Diffbot` | reported | +| omgili | Webz.io | training-crawler | `omgilibot`, `omgili` | verified token | +| Devin | Cognition | assistant-browse | `Devin/` | reported | +| Trae-Agent | ByteDance | assistant-browse | `Trae-Agent` | reported | + +ByteDance, Cohere and Timpi publish no crawler documentation at all; the tokens come +from . Cohere's `docs.cohere.com/docs/crawling` +returns 404. Diffbot confirms the `Diffbot` and `Diffbot-User` robots.txt tokens +() but publishes no UA string. +Webz.io documents `omgili`/`omgilibot` +(). +Devin appears in Cloudflare's verified-bots directory +() but not in Cognition's own docs. +Trae comes from . + +Bytespider is widely reported to ignore robots.txt. None of these can be verified by +IP, so a UA match is a hint, not proof. + +## Cursor: not fingerprintable by user-agent + +**Cursor is deliberately absent from `signatures.ts`.** We could not find a Cursor +user-agent token that would be honest to ship. The findings, with confidence: + +- **Cursor sends a stock Chrome user-agent** with no vendor token. A controlled + header-echo test of Cursor 2.4.28's native WebFetch against `httpbin.org/headers` + recorded `Mozilla/5.0 ... Chrome/139.0.0.0 Safari/537.36` plus Chrome client hints + (`Sec-Ch-Ua`, `Sec-Fetch-*`) and + `Accept: text/markdown,text/html;q=0.9,...` + () — **reported**. + Independently corroborated by + . +- **Cursor also routes some fetches through generic clients.** The same study found + Cursor falling back to Python `urllib` and `curl` for larger payloads, so some hits + arrive as `python-urllib/x.y` or `curl/x.y` — **reported**. Those are shared by + thousands of unrelated scripts and are useless as an attribution signal. +- **Cursor publishes no user-agent.** Cursor staff confirmed no public server + identifiers exist + () + — **verified absence**. +- **Do not trust the catalog entry.** lists + Cursor with the user-agent `IntelFeed-People/0.1 (+https://github.com/bertchen321wehc/Cursor)` + and operator "xAI". That is a corrupted submission pointing at an unrelated personal + GitHub repository, and it has propagated verbatim into `ai-robots-txt/ai.robots.txt`. + Shipping it would produce false positives. +- **Most Cursor `@Web` traffic never reaches your origin.** Cursor's own security page + names Exa and SerpApi as its search providers, so page content is usually served from + Exa's index rather than crawled live + () — + **verified**. Exa publishes no crawler user-agent or IP list. + +The closest usable signals, none of which we implement: + +1. **Cloud Agent IP ranges — verified.** lists 416 + `/32` egress addresses plus three git-egress proxy IPs, all inside AWS us-east-1 / + us-east-2 / us-west-2 prefixes (docs: + ). This is the only + Cursor-attributable network signal, and it covers Cloud Agents only — not the + desktop IDE. There is no Anysphere ASN; the address space is Amazon's. +2. **Header heuristic — heuristic, not Cursor-specific.** "Chrome desktop UA + + `Accept: text/markdown` with a q-factor chain + no JavaScript execution" flags + markdown-negotiating coding agents as a class. OpenCode matches the same shape, so + it identifies "a coding agent", not "Cursor". +3. **`x-cursor-client-version` / `x-cursor-client-type` headers — reported.** These are + sent only to Cursor's own API and never reach third-party origins. + +**Bottom line: there is no reliable way to detect Cursor from a web request today.** A +44-day Cloudflare log study reached the same conclusion, finding markdown-requesting +"standard Chrome" traffic from headless pools that could not be attributed to any +vendor (). If Cursor ships +a real token we will add it; until then the table stays honest and leaves it out. + +## Other coding agents we checked and left out + +- **GitHub Copilot** — sends a VS Code Electron UA (`Code/1.109.3 ... Electron/39.3.0`) + or `curl/8.7.1`; no documented fetch token. There *is* an official IP signal: the + `copilot` key in (17 CIDRs) — **verified**. UA-only + detection is impossible. +- **Windsurf / Codeium** — observed sending + `colly - https://github.com/gocolly/colly`, a generic Go scraping library UA shared + by unrelated scrapers — **reported**, too generic to ship. +- **Cline** — open source; its SDK web-fetch defaults to + `Mozilla/5.0 (compatible; AgentBot/1.0)`, link previews use + `Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)`, and Puppeteer + browsing spoofs Chrome 128 () — **verified from + source**, but the tokens are generic enough to cause false positives. +- **Replit** — no published user-agent or IP list found in any catalog or in Replit's + docs — **verified absence**. +- **OpenAI Codex** — reuses `ChatGPT-User`, already covered. + +## Catalog caveats + +- `darkvisitors.com/agents` now redirects to `knownagents.com`. Its data quality is + mixed (see the Cursor entry above); treat it as a lead, not a source. +- `ai-robots-txt/ai.robots.txt` contains robots.txt tokens, not user-agent strings, and + re-imports knownagents entries verbatim including bad ones. +- Cloudflare lists three accepted bot-verification mechanisms: Web Bot Auth, published + IP ranges with stable user-agents, and reverse DNS + (). User-agent + matching alone is none of them. diff --git a/packages/beacon/package.json b/packages/beacon/package.json new file mode 100644 index 000000000..7401fca25 --- /dev/null +++ b/packages/beacon/package.json @@ -0,0 +1,33 @@ +{ + "name": "@notra/beacon", + "version": "0.0.1", + "private": false, + "type": "module", + "description": "Edge-safe Next.js middleware that detects and reports AI agent traffic", + "license": "AGPL-3.0", + "sideEffects": false, + "files": [ + "src", + "SOURCES.md", + "README.md" + ], + "exports": { + "./*": "./src/*.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + } + }, + "scripts": { + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@notra/typescript-config": "workspace:*", + "typescript": "5.9.2" + } +} diff --git a/packages/beacon/src/classify.ts b/packages/beacon/src/classify.ts new file mode 100644 index 000000000..92b9f85b3 --- /dev/null +++ b/packages/beacon/src/classify.ts @@ -0,0 +1,31 @@ +import { BEACON_SIGNATURES } from "./signatures"; +import type { BeaconMatch } from "./types"; + +export function classifyUserAgent(userAgent: string): BeaconMatch | null { + const haystack = userAgent.toLowerCase(); + if (!haystack) { + return null; + } + + for (const signature of BEACON_SIGNATURES) { + for (const token of signature.userAgents) { + if (haystack.includes(token.toLowerCase())) { + return { + agent: signature.agent, + vendor: signature.vendor, + category: signature.category, + confidence: signature.confidence, + }; + } + } + } + + return null; +} + +export function classifyRequest( + headers: Headers, + _ip?: string +): BeaconMatch | null { + return classifyUserAgent(headers.get("user-agent") ?? ""); +} diff --git a/packages/beacon/src/middleware.ts b/packages/beacon/src/middleware.ts new file mode 100644 index 000000000..28ce314e2 --- /dev/null +++ b/packages/beacon/src/middleware.ts @@ -0,0 +1,88 @@ +import { classifyRequest } from "./classify"; +import { reportAiHit } from "./report"; +import type { + BeaconConfig, + BeaconEvent, + BeaconEventContext, + BeaconMatch, + BeaconRequestLike, +} from "./types"; + +const MAX_UA_LENGTH = 512; +const MAX_PATH_LENGTH = 512; +const MAX_REFERER_LENGTH = 512; + +function truncate(value: string, max: number): string { + return value.length > max ? value.slice(0, max) : value; +} + +function shouldSample(sample: number | undefined): boolean { + if (sample === undefined || sample >= 1) { + return true; + } + if (sample <= 0) { + return false; + } + return Math.random() < sample; +} + +function buildEvent( + config: BeaconConfig, + request: BeaconRequestLike, + match: BeaconMatch +): BeaconEvent | null { + let url: URL; + try { + url = new URL(request.url); + } catch { + return null; + } + + const referer = request.headers.get("referer"); + + return { + token: config.token, + organizationId: config.organizationId, + agent: match.agent, + category: match.category, + confidence: match.confidence, + path: truncate(url.pathname, MAX_PATH_LENGTH), + host: request.headers.get("host") ?? url.host, + method: request.method, + referer: referer ? truncate(referer, MAX_REFERER_LENGTH) : null, + ua: truncate(request.headers.get("user-agent") ?? "", MAX_UA_LENGTH), + ts: new Date().toISOString(), + }; +} + +export function trackAiRequest( + config: BeaconConfig, + request: BeaconRequestLike, + context?: BeaconEventContext +): BeaconMatch | null { + try { + const match = classifyRequest(request.headers); + if (!match) { + return null; + } + if (!shouldSample(config.sample)) { + return match; + } + + const event = buildEvent(config, request, match); + if (event) { + reportAiHit(config, event, context); + } + + return match; + } catch { + return null; + } +} + +export function createBeaconMiddleware(config: BeaconConfig) { + return ( + request: BeaconRequestLike, + context?: BeaconEventContext + ): BeaconMatch | null => trackAiRequest(config, request, context); +} diff --git a/packages/beacon/src/report.ts b/packages/beacon/src/report.ts new file mode 100644 index 000000000..69fac3896 --- /dev/null +++ b/packages/beacon/src/report.ts @@ -0,0 +1,34 @@ +import type { BeaconConfig, BeaconEvent, BeaconEventContext } from "./types"; + +const INGEST_TIMEOUT_MS = 2000; + +function schedule( + promise: Promise, + context: BeaconEventContext | undefined +): void { + const settled = promise.catch(() => undefined); + context?.waitUntil?.(settled); +} + +export function reportAiHit( + config: BeaconConfig, + event: BeaconEvent, + context?: BeaconEventContext +): void { + const send = config.fetchImpl ?? fetch; + + try { + schedule( + send(config.ingestUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(event), + keepalive: true, + signal: AbortSignal.timeout(INGEST_TIMEOUT_MS), + }), + context + ); + } catch { + return; + } +} diff --git a/packages/beacon/src/signatures.ts b/packages/beacon/src/signatures.ts new file mode 100644 index 000000000..69f6fa248 --- /dev/null +++ b/packages/beacon/src/signatures.ts @@ -0,0 +1,339 @@ +import type { BeaconSignature } from "./types"; + +export const BEACON_SIGNATURES: readonly BeaconSignature[] = [ + { + agent: "GPTBot", + vendor: "OpenAI", + category: "training-crawler", + userAgents: ["GPTBot"], + confidence: "verified", + verification: "https://openai.com/gptbot.json", + source: "https://developers.openai.com/api/docs/bots", + }, + { + agent: "OAI-SearchBot", + vendor: "OpenAI", + category: "search-index", + userAgents: ["OAI-SearchBot"], + confidence: "verified", + verification: "https://openai.com/searchbot.json", + source: "https://developers.openai.com/api/docs/bots", + }, + { + agent: "ChatGPT-User", + vendor: "OpenAI", + category: "assistant-browse", + userAgents: ["ChatGPT-User"], + confidence: "verified", + verification: "https://openai.com/chatgpt-user.json", + source: "https://developers.openai.com/api/docs/bots", + }, + { + agent: "OAI-AdsBot", + vendor: "OpenAI", + category: "search-index", + userAgents: ["OAI-AdsBot"], + confidence: "verified", + verification: "https://openai.com/adsbot.json", + source: "https://developers.openai.com/api/docs/bots", + }, + { + agent: "Claude Code", + vendor: "Anthropic", + category: "assistant-browse", + userAgents: ["claude-code/"], + confidence: "reported", + verification: null, + source: "https://github.com/monperrus/crawler-user-agents", + }, + { + agent: "Claude-SearchBot", + vendor: "Anthropic", + category: "search-index", + userAgents: ["Claude-SearchBot"], + confidence: "verified", + verification: "https://claude.com/crawling/bots.json", + source: "https://support.claude.com/en/articles/8896518", + }, + { + agent: "Claude-User", + vendor: "Anthropic", + category: "assistant-browse", + userAgents: ["Claude-User"], + confidence: "verified", + verification: "https://claude.com/crawling/bots.json", + source: "https://support.claude.com/en/articles/8896518", + }, + { + agent: "ClaudeBot", + vendor: "Anthropic", + category: "training-crawler", + userAgents: ["ClaudeBot"], + confidence: "verified", + verification: "https://claude.com/crawling/bots.json", + source: "https://support.claude.com/en/articles/8896518", + }, + { + agent: "anthropic-ai", + vendor: "Anthropic", + category: "training-crawler", + userAgents: ["anthropic-ai"], + confidence: "reported", + verification: null, + source: + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json", + }, + { + agent: "claude-web", + vendor: "Anthropic", + category: "assistant-browse", + userAgents: ["claude-web"], + confidence: "reported", + verification: null, + source: + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json", + }, + { + agent: "PerplexityBot", + vendor: "Perplexity", + category: "search-index", + userAgents: ["PerplexityBot"], + confidence: "verified", + verification: "https://www.perplexity.ai/perplexitybot.json", + source: "https://docs.perplexity.ai/guides/bots", + }, + { + agent: "Perplexity-User", + vendor: "Perplexity", + category: "assistant-browse", + userAgents: ["Perplexity-User"], + confidence: "verified", + verification: "https://www.perplexity.ai/perplexity-user.json", + source: "https://docs.perplexity.ai/guides/bots", + }, + { + agent: "Google-CloudVertexBot", + vendor: "Google", + category: "training-crawler", + userAgents: ["Google-CloudVertexBot"], + confidence: "verified", + verification: + "https://developers.google.com/static/search/apis/ipranges/special-crawlers.json", + source: + "https://developers.google.com/search/docs/crawling-indexing/google-special-case-crawlers", + }, + { + agent: "GoogleOther-Image", + vendor: "Google", + category: "training-crawler", + userAgents: ["GoogleOther-Image"], + confidence: "verified", + verification: + "https://developers.google.com/static/search/apis/ipranges/special-crawlers.json", + source: + "https://developers.google.com/search/docs/crawling-indexing/google-special-case-crawlers", + }, + { + agent: "GoogleOther-Video", + vendor: "Google", + category: "training-crawler", + userAgents: ["GoogleOther-Video"], + confidence: "verified", + verification: + "https://developers.google.com/static/search/apis/ipranges/special-crawlers.json", + source: + "https://developers.google.com/search/docs/crawling-indexing/google-special-case-crawlers", + }, + { + agent: "GoogleOther", + vendor: "Google", + category: "training-crawler", + userAgents: ["GoogleOther"], + confidence: "heuristic", + verification: + "https://developers.google.com/static/search/apis/ipranges/special-crawlers.json", + source: + "https://developers.google.com/search/docs/crawling-indexing/google-special-case-crawlers", + }, + { + agent: "Bytespider", + vendor: "ByteDance", + category: "training-crawler", + userAgents: ["Bytespider"], + confidence: "reported", + verification: null, + source: + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json", + }, + { + agent: "Amazonbot", + vendor: "Amazon", + category: "training-crawler", + userAgents: ["Amazonbot"], + confidence: "verified", + verification: "https://developer.amazon.com/amazonbot/ip-addresses/", + source: "https://developer.amazon.com/amazonbot", + }, + { + agent: "Applebot", + vendor: "Apple", + category: "search-index", + userAgents: ["Applebot/"], + confidence: "verified", + verification: "https://search.developer.apple.com/applebot.json", + source: "https://support.apple.com/en-us/119829", + }, + { + agent: "cohere-training-data-crawler", + vendor: "Cohere", + category: "training-crawler", + userAgents: ["cohere-training-data-crawler"], + confidence: "reported", + verification: null, + source: + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json", + }, + { + agent: "cohere-ai", + vendor: "Cohere", + category: "assistant-browse", + userAgents: ["cohere-ai"], + confidence: "reported", + verification: null, + source: + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json", + }, + { + agent: "meta-externalagent", + vendor: "Meta", + category: "training-crawler", + userAgents: ["meta-externalagent"], + confidence: "verified", + verification: null, + source: + "https://developers.facebook.com/docs/sharing/webmasters/web-crawlers/", + }, + { + agent: "meta-externalfetcher", + vendor: "Meta", + category: "assistant-browse", + userAgents: ["meta-externalfetcher"], + confidence: "verified", + verification: null, + source: + "https://developers.facebook.com/docs/sharing/webmasters/web-crawlers/", + }, + { + agent: "FacebookBot", + vendor: "Meta", + category: "training-crawler", + userAgents: ["FacebookBot"], + confidence: "reported", + verification: null, + source: + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json", + }, + { + agent: "DuckAssistBot", + vendor: "DuckDuckGo", + category: "assistant-browse", + userAgents: ["DuckAssistBot"], + confidence: "verified", + verification: "https://duckduckgo.com/duckassistbot.json", + source: + "https://duckduckgo.com/duckduckgo-help-pages/results/duckassistbot/", + }, + { + agent: "LinerBot", + vendor: "Liner", + category: "search-index", + userAgents: ["LinerBot"], + confidence: "verified", + verification: "https://docs.getliner.com/linerbot.json", + source: "https://docs.getliner.com/docs/linerbot", + }, + { + agent: "YouBot", + vendor: "You.com", + category: "search-index", + userAgents: ["YouBot"], + confidence: "verified", + verification: "https://you.com/docs/youbot", + source: "https://you.com/docs/youbot", + }, + { + agent: "MistralAI-User", + vendor: "Mistral", + category: "assistant-browse", + userAgents: ["MistralAI-User"], + confidence: "verified", + verification: "https://mistral.ai/mistralai-user-ips.json", + source: "https://docs.mistral.ai/robots", + }, + { + agent: "CCBot", + vendor: "Common Crawl", + category: "training-crawler", + userAgents: ["CCBot"], + confidence: "verified", + verification: "https://index.commoncrawl.org/ccbot.json", + source: "https://commoncrawl.org/ccbot", + }, + { + agent: "Diffbot", + vendor: "Diffbot", + category: "training-crawler", + userAgents: ["Diffbot"], + confidence: "reported", + verification: null, + source: "https://www.diffbot.com/docs/crawl/faq/robots-txt", + }, + { + agent: "Timpibot", + vendor: "Timpi", + category: "training-crawler", + userAgents: ["Timpibot"], + confidence: "reported", + verification: null, + source: + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.json", + }, + { + agent: "Devin", + vendor: "Cognition", + category: "assistant-browse", + userAgents: ["Devin/"], + confidence: "reported", + verification: null, + source: "https://radar.cloudflare.com/traffic/verified-bots", + }, + { + agent: "GoogleAgent-URLContext", + vendor: "Google", + category: "assistant-browse", + userAgents: ["GoogleAgent-URLContext", "Google-Gemini-CLI"], + confidence: "reported", + verification: null, + source: + "https://www.checklyhq.com/blog/state-of-ai-agent-content-negotation/", + }, + { + agent: "Trae-Agent", + vendor: "ByteDance", + category: "assistant-browse", + userAgents: ["Trae-Agent"], + confidence: "reported", + verification: null, + source: "https://knownagents.com/agents/trae", + }, + { + agent: "omgili", + vendor: "Webz.io", + category: "training-crawler", + userAgents: ["omgilibot", "omgili"], + confidence: "verified", + verification: null, + source: + "https://webz.io/blog/web-data/what-is-the-omgili-bot-and-why-is-it-crawling-your-website/", + }, +]; diff --git a/packages/beacon/src/types.ts b/packages/beacon/src/types.ts new file mode 100644 index 000000000..40bd60c86 --- /dev/null +++ b/packages/beacon/src/types.ts @@ -0,0 +1,55 @@ +export type BeaconCategory = + | "training-crawler" + | "search-index" + | "assistant-browse"; + +export type BeaconConfidence = "verified" | "reported" | "heuristic"; + +export interface BeaconSignature { + agent: string; + vendor: string; + category: BeaconCategory; + userAgents: string[]; + confidence: BeaconConfidence; + verification: string | null; + source: string; +} + +export interface BeaconMatch { + agent: string; + vendor: string; + category: BeaconCategory; + confidence: BeaconConfidence; +} + +export interface BeaconEvent { + token: string; + organizationId: string; + agent: string; + category: BeaconCategory; + confidence: BeaconConfidence; + path: string; + host: string; + method: string; + referer: string | null; + ua: string; + ts: string; +} + +export interface BeaconConfig { + ingestUrl: string; + token: string; + organizationId: string; + sample?: number; + fetchImpl?: typeof fetch; +} + +export interface BeaconRequestLike { + headers: Headers; + method: string; + url: string; +} + +export interface BeaconEventContext { + waitUntil?: (promise: Promise) => void; +} diff --git a/packages/beacon/tsconfig.json b/packages/beacon/tsconfig.json new file mode 100644 index 000000000..454564884 --- /dev/null +++ b/packages/beacon/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@notra/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "strictNullChecks": true, + "types": [] + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/turbo.json b/turbo.json index add1f073c..9b519f65f 100644 --- a/turbo.json +++ b/turbo.json @@ -87,7 +87,11 @@ "EVE_NOTRA_AGENT_PASSWORD", "NOTRA_AGENT_CHAT", "NOTRA_AGENT_CONTENT", - "API_VERCEL_PROJECT_NAME" + "API_VERCEL_PROJECT_NAME", + "BEACON_INGEST_SECRET", + "BEACON_INGEST_URL", + "BEACON_ORG_TOKEN", + "BEACON_ORG_ID" ], "envMode": "strict", "tasks": { From ea9c0eb233e5aaff7604e00a2c9433bccd9e125d Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 18:26:28 +0200 Subject: [PATCH 13/26] feat(geo): restructure overview into sectioned dashboard --- .../(dashboard)/[slug]/geo/page-client.tsx | 61 +++++++++++-- .../components/geo/competitor-share-card.tsx | 2 +- .../components/geo/prompt-results-preview.tsx | 88 +++++++++++++++++++ .../ui/src/components/dither-kit/tooltip.tsx | 11 ++- 4 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 apps/dashboard/src/components/geo/prompt-results-preview.tsx diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx index 74dd5d54b..2cf679c5a 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx @@ -5,16 +5,22 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { Button } from "@notra/ui/components/ui/button"; import { Loader2Icon } from "lucide-react"; import { useState } from "react"; +import Link from "next/link"; import { EmptyState } from "@/components/empty-state"; import { AiTrafficCard } from "@/components/geo/ai-traffic-card"; +import { CompetitorShareCard } from "@/components/geo/competitor-share-card"; import { GeoSettingsDialog } from "@/components/geo/geo-settings-dialog"; import { GeoSummaryStats } from "@/components/geo/geo-summary-stats"; import { MentionRateCard } from "@/components/geo/mention-rate-card"; import { ModelUsageCard } from "@/components/geo/model-usage-card"; +import { PromptResultsPreview } from "@/components/geo/prompt-results-preview"; import { WebsiteGenerateCard } from "@/components/geo/website-generate-card"; import { PageContainer } from "@/components/layout/container"; +import { SectionHeader } from "@/components/layout/section-header"; import { useOrganizationsContext } from "@/components/providers/organization-provider"; import { + useGeoPromptResults, + useGeoCompetitorShare, useAiTraffic, useBeaconSetup, useGeoOverview, @@ -46,6 +52,8 @@ export default function PageClient({ organizationSlug }: PageClientProps) { const { data: overview } = useGeoOverview(organizationId); const { data: timeseries } = useGeoTimeseries(organizationId); const { data: prompts } = useGeoPrompts(organizationId); + const { data: promptResults } = useGeoPromptResults(organizationId); + const { data: competitorShare } = useGeoCompetitorShare(organizationId); const { data: modelUsage } = useModelUsage(organizationId); const { data: aiTraffic } = useAiTraffic(organizationId); const { data: beaconSetup } = useBeaconSetup(organizationId); @@ -126,14 +134,55 @@ export default function PageClient({ organizationSlug }: PageClientProps) { settings={settings} /> - +
+ + All competitors + + } + description="How often engines mention you, and who they name instead" + title="Visibility" + /> +
+ + +
+
- +
+ + All prompts + + } + description="The buyer questions where you surface most" + title="Winning prompts" + /> + +
- +
+ + + +
- + )} diff --git a/apps/dashboard/src/components/geo/prompt-results-preview.tsx b/apps/dashboard/src/components/geo/prompt-results-preview.tsx new file mode 100644 index 000000000..ea5877b3b --- /dev/null +++ b/apps/dashboard/src/components/geo/prompt-results-preview.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Badge } from "@notra/ui/components/ui/badge"; +import { Card, CardContent } from "@notra/ui/components/ui/card"; +import { useMemo } from "react"; +import type { GeoPromptResult } from "@/types/geo"; + +interface PromptResultsPreviewProps { + results: GeoPromptResult[]; + limit?: number; +} + +interface PromptSummary { + promptId: string; + prompt: string; + mentioned: number; + total: number; + bestPosition: number | null; +} + +const DEFAULT_LIMIT = 3; + +function summarize(results: GeoPromptResult[]): PromptSummary[] { + const groups = new Map(); + for (const result of results) { + const group = groups.get(result.promptId) ?? { + promptId: result.promptId, + prompt: result.prompt, + mentioned: 0, + total: 0, + bestPosition: null, + }; + group.total += 1; + if (result.mentioned) { + group.mentioned += 1; + } + if ( + result.position !== null && + (group.bestPosition === null || result.position < group.bestPosition) + ) { + group.bestPosition = result.position; + } + groups.set(result.promptId, group); + } + return [...groups.values()].sort( + (a, b) => b.mentioned / b.total - a.mentioned / a.total + ); +} + +export function PromptResultsPreview({ + results, + limit = DEFAULT_LIMIT, +}: PromptResultsPreviewProps) { + const summaries = useMemo(() => summarize(results), [results]); + + if (summaries.length === 0) { + return ( + + +

+ Run a scan to see which prompts surface you +

+
+
+ ); + } + + return ( + + + {summaries.slice(0, limit).map((summary) => ( +
+

{summary.prompt}

+ {summary.bestPosition !== null && ( + #{summary.bestPosition} + )} + + {summary.mentioned}/{summary.total} engines + +
+ ))} +
+
+ ); +} diff --git a/packages/ui/src/components/dither-kit/tooltip.tsx b/packages/ui/src/components/dither-kit/tooltip.tsx index 03736301a..02f6ecf96 100644 --- a/packages/ui/src/components/dither-kit/tooltip.tsx +++ b/packages/ui/src/components/dither-kit/tooltip.tsx @@ -23,6 +23,7 @@ export function Tooltip({ valueFormatter, variant = "default", sortItems = "desc", + inlineHeading = false, }: { labelKey?: string valueFormatter?: (value: number, name: string) => string @@ -30,6 +31,10 @@ export function Tooltip({ /** Row ordering: highest value first ("desc"), lowest first ("asc"), or * series registration order ("none"). */ sortItems?: "desc" | "asc" | "none" + /** Single-series categorical charts: drop the separate heading line and use + * the heading value as each row's label ("Monday.com 16" instead of a muted + * heading plus a redundant series label). */ + inlineHeading?: boolean }) { const chart = useCommonChart() const show = chart.ready && chart.hoverIndex != null @@ -82,7 +87,7 @@ export function Tooltip({ VARIANT[variant] )} > - {heading && ( + {heading && !inlineHeading && (
{heading}
@@ -98,7 +103,9 @@ export function Tooltip({ className="size-2 rounded-[1px]" style={{ backgroundColor: rgb(item.seed.fill) }} /> - {item.label} + + {inlineHeading && heading ? heading : item.label} + {valueFormatter ? valueFormatter(item.value, item.name) From 6deb21fd2ccc06fcc367ab3ecb0fff658b9e1e98 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 18:30:55 +0200 Subject: [PATCH 14/26] fix(geo): contain request log table in bordered wrapper --- .../src/app/(dashboard)/[slug]/geo/page-client.tsx | 6 +++--- apps/dashboard/src/components/geo/ai-traffic-card.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx index 2cf679c5a..504a1ccbb 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx @@ -4,8 +4,8 @@ import { Settings01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Button } from "@notra/ui/components/ui/button"; import { Loader2Icon } from "lucide-react"; -import { useState } from "react"; import Link from "next/link"; +import { useState } from "react"; import { EmptyState } from "@/components/empty-state"; import { AiTrafficCard } from "@/components/geo/ai-traffic-card"; import { CompetitorShareCard } from "@/components/geo/competitor-share-card"; @@ -19,11 +19,11 @@ import { PageContainer } from "@/components/layout/container"; import { SectionHeader } from "@/components/layout/section-header"; import { useOrganizationsContext } from "@/components/providers/organization-provider"; import { - useGeoPromptResults, - useGeoCompetitorShare, useAiTraffic, useBeaconSetup, + useGeoCompetitorShare, useGeoOverview, + useGeoPromptResults, useGeoPrompts, useGeoSettings, useGeoStartScan, diff --git a/apps/dashboard/src/components/geo/ai-traffic-card.tsx b/apps/dashboard/src/components/geo/ai-traffic-card.tsx index 17246bf34..694cc3195 100644 --- a/apps/dashboard/src/components/geo/ai-traffic-card.tsx +++ b/apps/dashboard/src/components/geo/ai-traffic-card.tsx @@ -163,7 +163,7 @@ export function AiTrafficCard({ traffic, setup }: AiTrafficCardProps) { {log.length > 0 && (

Recent requests

-
+
From 4017bf4d8c301f219e16ceddc5dc253c3ac726a6 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 18:34:05 +0200 Subject: [PATCH 15/26] fix(geo): remove body corner notch in request log table --- apps/dashboard/src/components/geo/ai-traffic-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/geo/ai-traffic-card.tsx b/apps/dashboard/src/components/geo/ai-traffic-card.tsx index 694cc3195..1ac030c63 100644 --- a/apps/dashboard/src/components/geo/ai-traffic-card.tsx +++ b/apps/dashboard/src/components/geo/ai-traffic-card.tsx @@ -174,7 +174,7 @@ export function AiTrafficCard({ traffic, setup }: AiTrafficCardProps) { Method - + {log.map((entry) => ( Date: Sun, 2 Aug 2026 18:57:31 +0200 Subject: [PATCH 16/26] feat(geo): classify prompt presence as training data, retrieval, or invisible --- .../src/components/geo/presence-badge.tsx | 39 +++++++++++++++++++ .../components/geo/prompt-results-card.tsx | 9 ++++- .../components/geo/prompt-results-preview.tsx | 16 +++++++- apps/dashboard/src/constants/geo.ts | 12 ++++++ apps/dashboard/src/types/geo.ts | 5 +++ apps/dashboard/src/utils/geo-presence.ts | 34 ++++++++++++++++ 6 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/src/components/geo/presence-badge.tsx create mode 100644 apps/dashboard/src/utils/geo-presence.ts diff --git a/apps/dashboard/src/components/geo/presence-badge.tsx b/apps/dashboard/src/components/geo/presence-badge.tsx new file mode 100644 index 000000000..20257f933 --- /dev/null +++ b/apps/dashboard/src/components/geo/presence-badge.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Badge } from "@notra/ui/components/ui/badge"; +import { GEO_PRESENCE_DOT_CLASSES, GEO_PRESENCE_LABELS } from "@/constants/geo"; +import { cn } from "@/lib/utils"; +import type { GeoPresenceStatus } from "@/types/geo"; + +interface PresenceBadgeProps { + status: GeoPresenceStatus | null; +} + +const PRESENCE_TITLES: Record = { + "training-data": + "Mentioned even without web access: you're in the model's training data", + "retrieval-only": + "Only mentioned when engines search the web: indexed, not memorized", + invisible: "No engine mentions you on this prompt yet", +}; + +export function PresenceBadge({ status }: PresenceBadgeProps) { + if (!status) { + return null; + } + return ( + + + {GEO_PRESENCE_LABELS[status]} + + ); +} diff --git a/apps/dashboard/src/components/geo/prompt-results-card.tsx b/apps/dashboard/src/components/geo/prompt-results-card.tsx index 7cf01c0c3..9763781c9 100644 --- a/apps/dashboard/src/components/geo/prompt-results-card.tsx +++ b/apps/dashboard/src/components/geo/prompt-results-card.tsx @@ -9,8 +9,10 @@ import { CardTitle, } from "@notra/ui/components/ui/card"; import { useMemo } from "react"; +import { PresenceBadge } from "@/components/geo/presence-badge"; import { GEO_ENGINE_LABELS } from "@/constants/geo"; import type { GeoPromptResult } from "@/types/geo"; +import { classifyPromptPresence } from "@/utils/geo-presence"; interface PromptResultsCardProps { results: GeoPromptResult[]; @@ -79,7 +81,12 @@ export function PromptResultsCard({ results }: PromptResultsCardProps) { ); return (
-

{group.prompt}

+
+

{group.prompt}

+ +
{group.results.map((result) => ( ({ + ...group, + presence: classifyPromptPresence(group.results), + })); + return summaries.sort( (a, b) => b.mentioned / b.total - a.mentioned / a.total ); } @@ -74,6 +85,7 @@ export function PromptResultsPreview({ key={summary.promptId} >

{summary.prompt}

+ {summary.bestPosition !== null && ( #{summary.bestPosition} )} diff --git a/apps/dashboard/src/constants/geo.ts b/apps/dashboard/src/constants/geo.ts index 4784e5845..9158f3667 100644 --- a/apps/dashboard/src/constants/geo.ts +++ b/apps/dashboard/src/constants/geo.ts @@ -148,3 +148,15 @@ export const AI_TRAFFIC_CONFIDENCE_LABELS: Record = { reported: "Reported", heuristic: "Heuristic", }; + +export const GEO_PRESENCE_LABELS: Record = { + "training-data": "Training data", + "retrieval-only": "Retrieval only", + invisible: "Invisible", +}; + +export const GEO_PRESENCE_DOT_CLASSES: Record = { + "training-data": "bg-emerald-500", + "retrieval-only": "bg-amber-500", + invisible: "bg-muted-foreground/50", +}; diff --git a/apps/dashboard/src/types/geo.ts b/apps/dashboard/src/types/geo.ts index ffef3b2c4..5624a0dd5 100644 --- a/apps/dashboard/src/types/geo.ts +++ b/apps/dashboard/src/types/geo.ts @@ -255,3 +255,8 @@ export interface BeaconSetupResponse { token: string; snippet: string; } + +export type GeoPresenceStatus = + | "training-data" + | "retrieval-only" + | "invisible"; diff --git a/apps/dashboard/src/utils/geo-presence.ts b/apps/dashboard/src/utils/geo-presence.ts new file mode 100644 index 000000000..3502222b1 --- /dev/null +++ b/apps/dashboard/src/utils/geo-presence.ts @@ -0,0 +1,34 @@ +import type { GeoPresenceStatus, GeoPromptResult } from "@/types/geo"; + +const GROUNDED_ENGINE_PATTERN = /(-direct)?-grounded$|^perplexity-sonar$/; + +export function isGroundedEngine(engine: string): boolean { + return GROUNDED_ENGINE_PATTERN.test(engine); +} + +export function classifyPromptPresence( + results: GeoPromptResult[] +): GeoPresenceStatus | null { + if (results.length === 0) { + return null; + } + let mentionedRaw = false; + let mentionedWeb = false; + for (const result of results) { + if (!result.mentioned) { + continue; + } + if (isGroundedEngine(result.engine)) { + mentionedWeb = true; + } else { + mentionedRaw = true; + } + } + if (mentionedRaw) { + return "training-data"; + } + if (mentionedWeb) { + return "retrieval-only"; + } + return "invisible"; +} From a69c06f37fd474d7c108b17c3a5d1d1a3a564bfc Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 19:15:10 +0200 Subject: [PATCH 17/26] feat(analytics): add impressions share donut and pie radar chart primitives --- .../[slug]/analytics/page-client.tsx | 14 +- .../analytics/impressions-share-card.tsx | 115 +++++++++ packages/ui/dither-kit.json | 40 +++ .../src/components/dither-kit/pie-canvas.tsx | 224 ++++++++++++++++ .../src/components/dither-kit/pie-chart.tsx | 35 +++ packages/ui/src/components/dither-kit/pie.tsx | 26 ++ .../components/dither-kit/radar-canvas.tsx | 241 ++++++++++++++++++ .../src/components/dither-kit/radar-chart.tsx | 42 +++ .../src/components/dither-kit/radar-frame.tsx | 72 ++++++ .../ui/src/components/dither-kit/radar.tsx | 32 +++ .../ui/src/components/dither-kit/tooltip.tsx | 3 +- 11 files changed, 838 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/src/components/analytics/impressions-share-card.tsx create mode 100644 packages/ui/src/components/dither-kit/pie-canvas.tsx create mode 100644 packages/ui/src/components/dither-kit/pie-chart.tsx create mode 100644 packages/ui/src/components/dither-kit/pie.tsx create mode 100644 packages/ui/src/components/dither-kit/radar-canvas.tsx create mode 100644 packages/ui/src/components/dither-kit/radar-chart.tsx create mode 100644 packages/ui/src/components/dither-kit/radar-frame.tsx create mode 100644 packages/ui/src/components/dither-kit/radar.tsx diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx index 1f3187b83..53135f2cf 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx @@ -7,6 +7,7 @@ import { AccountFilter } from "@/components/analytics/account-filter"; import { AccountSeriesChartCard } from "@/components/analytics/account-series-chart-card"; import { ConnectAccountsButtons } from "@/components/analytics/connect-accounts-buttons"; import { FollowersCard } from "@/components/analytics/followers-card"; +import { ImpressionsShareCard } from "@/components/analytics/impressions-share-card"; import { LeaderboardCard } from "@/components/analytics/leaderboard-card"; import { PostingPerformanceCard } from "@/components/analytics/posting-performance-card"; import { SummaryStats } from "@/components/analytics/summary-stats"; @@ -235,10 +236,15 @@ export default function PageClient({ organizationSlug }: PageClientProps) { description="Every account you post from or track, ranked by interactions" title="Accounts" /> - +
+
+ +
+ +
diff --git a/apps/dashboard/src/components/analytics/impressions-share-card.tsx b/apps/dashboard/src/components/analytics/impressions-share-card.tsx new file mode 100644 index 000000000..6620a8323 --- /dev/null +++ b/apps/dashboard/src/components/analytics/impressions-share-card.tsx @@ -0,0 +1,115 @@ +"use client"; + +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { PALETTE, rgb } from "@notra/ui/components/dither-kit/palette"; +import { Pie } from "@notra/ui/components/dither-kit/pie"; +import { PieChart } from "@notra/ui/components/dither-kit/pie-chart"; +import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { useMemo } from "react"; +import { ACCOUNT_SERIES_COLORS } from "@/constants/analytics"; +import { useLeaderboard } from "@/lib/hooks/use-social-analytics"; + +interface ImpressionsShareCardProps { + organizationId: string; +} + +interface ShareRow { + account: string; + impressions: number; +} + +const DONUT_INNER_RADIUS = 0.55; +const WINDOW_DAYS = 30; +const PERCENT = 100; + +export function ImpressionsShareCard({ + organizationId, +}: ImpressionsShareCardProps) { + const { data } = useLeaderboard(organizationId, WINDOW_DAYS); + + const { rows, config, total } = useMemo(() => { + const shareRows: ShareRow[] = (data?.entries ?? []) + .filter((entry) => (entry.impressions ?? 0) > 0) + .map((entry) => ({ + account: `@${entry.username}`, + impressions: entry.impressions ?? 0, + })); + const shareConfig: ChartConfig = {}; + shareRows.forEach((row, index) => { + shareConfig[row.account] = { + label: row.account, + color: + ACCOUNT_SERIES_COLORS[index % ACCOUNT_SERIES_COLORS.length] ?? "blue", + }; + }); + return { + rows: shareRows, + config: shareConfig, + total: shareRows.reduce((sum, row) => sum + row.impressions, 0), + }; + }, [data?.entries]); + + return ( + + + Impressions share + + Who pulls the reach across every account, last 30 days + + + + {rows.length === 0 ? ( +

+ No impression data yet +

+ ) : ( +
+ + + + +
+ {rows.map((row) => ( +
+ + + {row.account} + + + {total > 0 + ? `${Math.round((row.impressions / total) * PERCENT)}%` + : "0%"} + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/packages/ui/dither-kit.json b/packages/ui/dither-kit.json index 4a11f8143..ff7a52320 100644 --- a/packages/ui/dither-kit.json +++ b/packages/ui/dither-kit.json @@ -151,6 +151,46 @@ "hash": "sha256:96d8cad3d7ca87a2a6f092399a60ba2f32612413a190f2885da67854229cdeff" } ] + }, + "pie-chart": { + "version": "0.1.0", + "hash": "sha256:2c08ce4e2ebe3b522541b38f3e37d2b124ab7692c6501225a3558dbf3ea964ca", + "files": [ + { + "path": "components/dither-kit/pie-canvas.tsx", + "hash": "sha256:bcf6f1d8024448b55a3a198afad0ee5db002b2748c5282bb27679422c6a6c2bf" + }, + { + "path": "components/dither-kit/pie-chart.tsx", + "hash": "sha256:2dae2f8e6193e0bca31164ba7d7b829cc5295533cdb207783d6bdb4e346d2c13" + }, + { + "path": "components/dither-kit/pie.tsx", + "hash": "sha256:1e44525a3119913b042b660be50c9c2c0e34a265ee8ed0aa32c65ce4ce9fd655" + } + ] + }, + "radar-chart": { + "version": "0.1.0", + "hash": "sha256:78c9b966e31f80224947f417a7b7f29cc2e367bad865b97c3b5e18fa1f053b0f", + "files": [ + { + "path": "components/dither-kit/radar-canvas.tsx", + "hash": "sha256:be08e9d801c1dc866a3d8eb74fe70208c888a1f6857b584e5f9de631863535ae" + }, + { + "path": "components/dither-kit/radar-chart.tsx", + "hash": "sha256:b62686351465de7d14cf4d919938e005691e8363d45ac8e625cb863a4ad421db" + }, + { + "path": "components/dither-kit/radar-frame.tsx", + "hash": "sha256:a516ed03eb4f5e864532df1f49f265498f1d2c89a676eb93251fad73a6914a67" + }, + { + "path": "components/dither-kit/radar.tsx", + "hash": "sha256:f55dae75073cef3eab55ede93a5fa394711a9c5736046236ebd11623d02849e9" + } + ] } } } diff --git a/packages/ui/src/components/dither-kit/pie-canvas.tsx b/packages/ui/src/components/dither-kit/pie-canvas.tsx new file mode 100644 index 000000000..8b0dfd97c --- /dev/null +++ b/packages/ui/src/components/dither-kit/pie-canvas.tsx @@ -0,0 +1,224 @@ +"use client" + +import { useEffect, useRef } from "react" +import { + BAYER, + backingSize, + bloomLayerStyle, + easeInOutCubic, + OFF_TIER, + prefersReducedMotion, +} from "./dither-paint" +import { rgb } from "./palette" +import { sliceAtAngle } from "./polar" +import { usePolarChart } from "./polar-context" + +const TOP = -Math.PI / 2 +const TAU = Math.PI * 2 +const POP = 6 // px the hovered slice bulges outward + +/** + * Dither canvas for pie / donut charts. Each backing pixel is mapped back to + * plot space, tested for its slice by angle, and filled with the ordered-dither + * scatter — dense at the outer edge, thinning toward the centre — capped by a + * bright arc on the rim. The pie sweeps in clockwise on mount; the hovered slice + * bulges outward with a brighter rim while the rest dim. + */ +export function PieCanvas() { + const ctx = usePolarChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + + // The RAF loop reads the latest ctx through a ref; written in an effect + // (never during render) — mutating a ref mid-render tears under Strict Mode / + // concurrent rendering. + const state = useRef(ctx) + useEffect(() => { + state.current = ctx + }) + + useEffect(() => { + const canvas = canvasRef.current + const c = canvas?.getContext("2d") + if (!(canvas && c) || cols <= 0 || rows <= 0) return + canvas.width = cols + canvas.height = rows + + const bloomCanvas = bloomRef.current + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + let raf = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let intensity = 0 + let popEase = 0 // eases the hovered slice's outward bulge + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + let lastHover: number | null | undefined = Symbol() as never + + const paint = (prog: number) => { + const s = state.current + const slices = s.pie + if (!slices) return + c.clearRect(0, 0, cols, rows) + const cx = s.center.x + const cy = s.center.y + const outerR = s.outerRadius + const innerR = s.innerRadius + const revealAngle = TOP + easeInOutCubic(prog) * TAU + + for (let y = 0; y < rows; y++) { + const py = ((y + 0.5) * height) / rows + for (let x = 0; x < cols; x++) { + const px = ((x + 0.5) * width) / cols + const dx = px - cx + const dy = py - cy + const r = Math.hypot(dx, dy) + if (r < innerR) continue + const angle = Math.atan2(dy, dx) + let na = angle + while (na < TOP) na += TAU + while (na >= TOP + TAU) na -= TAU + if (na > revealAngle) continue // clockwise sweep-in + const si = sliceAtAngle(slices, angle) + if (si < 0) continue + const slice = slices[si] + if (!slice) continue + const active = s.hoverIndex === si + const localOuter = active ? outerR + POP * popEase : outerR + if (r > localOuter) continue + + const seed = s.seedOf(slice.name) + const variant = s.variantOf(slice.name) + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== slice.name ? 0.3 : 1 + const it = intensity + (active ? 0.4 * popEase : 0) + + // Bright rim on the outer edge — thicker on the hovered slice. + if (localOuter - r < (active ? 1.4 + popEase : 1.4)) { + c.fillStyle = rgb(seed.fill, 1, selDim) + c.fillRect(x, y, 1, 1) + continue + } + const density = (r - innerR) / Math.max(localOuter - innerR, 1) + const bias = variant === "dotted" ? 0.12 : 0 + if (variant === "hatched" && ((x + y) & 3) >= 2) continue + const lit = + variant === "solid" || + density > (BAYER[y & 3]?.[x & 3] ?? 0) - 0.1 * it - bias + if (variant === "dotted" && !lit) continue + // Density → opacity (see the colour-vs-opacity note in dither-paint); + // off cells drop to a faint tier, never a hole to the background. + const k = (0.35 + density * 0.65) * (1 + 0.22 * it) + const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim) + c.fillStyle = rgb(seed.fill, 1, alpha) + c.fillRect(x, y, 1, 1) + } + } + } + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready || !s.pie) return + if (bloomCtx) { + const on = s.bloom !== "off" && (!s.bloomOnHover || s.isMouseInChart) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 + lastProg = -1 + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + if (s.hoverIndex !== lastHover) { + lastHover = s.hoverIndex + popEase = 0 // a freshly-hovered slice bulges out from rest + needsFill = true + } + const itTarget = s.isMouseInChart ? 1 : 0 + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * (reduce ? 1 : 0.16) + needsFill = true + } else intensity = itTarget + // Ease the hovered slice's bulge in (and back out when nothing's hovered). + const popTarget = s.hoverIndex != null ? 1 : 0 + if (Math.abs(popEase - popTarget) > 0.001) { + popEase += (popTarget - popEase) * (reduce ? 1 : 0.22) + needsFill = true + } else popEase = popTarget + if (prog !== lastProg) { + lastProg = prog + needsFill = true + } + + // Live tweak repaint (variant, donut inner radius) without re-sweeping. + const paintSig = `${s.innerRadius}|${s.pie + .map((sl) => s.variantOf(sl.name)) + .join(",")}` + if (paintSig !== lastPaintSig) { + lastPaintSig = paintSig + needsFill = true + } + + if (!needsFill) return + paint(prog) + needsFill = false + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, [cols, rows, width, height]) + + const bloom = bloomLayerStyle( + ctx.bloom, + ctx.bloomOnHover ? ctx.isMouseInChart : true + ) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/packages/ui/src/components/dither-kit/pie-chart.tsx b/packages/ui/src/components/dither-kit/pie-chart.tsx new file mode 100644 index 000000000..b359586c6 --- /dev/null +++ b/packages/ui/src/components/dither-kit/pie-chart.tsx @@ -0,0 +1,35 @@ +"use client" + +import type { ReactNode } from "react" +import type { ChartConfig, Margins } from "./chart-context" +import type { BloomInput } from "./dither-paint" +import { PieCanvas } from "./pie-canvas" +import { PolarRoot } from "./polar-root" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +export type PieChartProps = { + data: TData[] + config: ChartConfig + children: ReactNode + dataKey: string // value field + nameKey: string // slice-name field (looked up in config for colour) + innerRadius?: number // 0–1 ratio for a donut + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +/** Composable dither **pie / donut** chart. Compose ``, ``, … inside. */ +export function PieChart(props: PieChartProps) { + return +} diff --git a/packages/ui/src/components/dither-kit/pie.tsx b/packages/ui/src/components/dither-kit/pie.tsx new file mode 100644 index 000000000..0ef9eb6e2 --- /dev/null +++ b/packages/ui/src/components/dither-kit/pie.tsx @@ -0,0 +1,26 @@ +"use client" + +import { useEffect } from "react" +import type { AreaVariant } from "./chart-context" +import { usePolarPart } from "./polar-context" + +export type PieProps = { + /** Fill texture applied to every slice. */ + variant?: AreaVariant +} + +/** + * The pie/donut ring. Slices come from the chart `data` (one per row); this part + * sets the shared fill variant. The dithered wedges are painted on the canvas. + */ +export function Pie({ variant = "gradient" }: PieProps) { + const ctx = usePolarPart("Pie", "pie") + const { registerVariant, unregisterVariant } = ctx + + useEffect(() => { + registerVariant("*", variant) + return () => unregisterVariant("*") + }, [variant, registerVariant, unregisterVariant]) + + return null +} diff --git a/packages/ui/src/components/dither-kit/radar-canvas.tsx b/packages/ui/src/components/dither-kit/radar-canvas.tsx new file mode 100644 index 000000000..364c63731 --- /dev/null +++ b/packages/ui/src/components/dither-kit/radar-canvas.tsx @@ -0,0 +1,241 @@ +"use client" + +import { useEffect, useRef } from "react" +import { + BAYER, + backingSize, + bloomLayerStyle, + easeInOutCubic, + OFF_TIER, + prefersReducedMotion, +} from "./dither-paint" +import { rgb } from "./palette" +import { distToPolygonEdge, pointInPolygon, polarX, polarY } from "./polar" +import { usePolarChart } from "./polar-context" + +/** + * Dither canvas for radar charts. Each series is a closed polygon over the + * spokes (value → radius per axis). Backing pixels inside a polygon are filled + * with the ordered-dither scatter — dense near the polygon edge, thinning toward + * the centre — and each vertex is marked with a bright dot (larger on the hovered + * axis). The polygons scale in from the centre on mount. + */ +export function RadarCanvas() { + const ctx = usePolarChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + + // The RAF loop reads the latest ctx through a ref; written in an effect + // (never during render) — mutating a ref mid-render tears under Strict Mode / + // concurrent rendering. + const state = useRef(ctx) + useEffect(() => { + state.current = ctx + }) + + useEffect(() => { + const canvas = canvasRef.current + const c = canvas?.getContext("2d") + if (!(canvas && c) || cols <= 0 || rows <= 0) return + canvas.width = cols + canvas.height = rows + + const bloomCanvas = bloomRef.current + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + let raf = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let intensity = 0 + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + let lastHover: number | null | undefined = Symbol() as never + + const fx = cols / Math.max(width, 1) + const fy = rows / Math.max(height, 1) + + // Build each series polygon in plot coords, scaled by `prog`. + const buildPolys = (prog: number) => { + const s = state.current + const radar = s.radar + if (!radar) return [] + return s.configKeys.map((key) => { + const poly: number[] = [] + const pts: { x: number; y: number }[] = [] + radar.axes.forEach((ax, i) => { + const v = Number(s.data[i]?.[key]) || 0 + const r = (v / radar.max) * s.outerRadius * prog + const x = polarX(s.center.x, r, ax.angle) + const y = polarY(s.center.y, r, ax.angle) + poly.push(x, y) + pts.push({ x, y }) + }) + return { key, poly, pts } + }) + } + + const paint = (prog: number) => { + const s = state.current + if (!s.radar) return + c.clearRect(0, 0, cols, rows) + const polys = buildPolys(easeInOutCubic(prog)) + const band = Math.max(s.outerRadius * 0.45, 1) + + for (let y = 0; y < rows; y++) { + const py = ((y + 0.5) * height) / rows + for (let x = 0; x < cols; x++) { + const px = ((x + 0.5) * width) / cols + // Whether a layer behind already coloured this pixel — front layers + // then keep true gaps in their dither so the back layer shows + // through, instead of tinting the overlap into a muddy blend. + let covered = false + for (let pi = 0; pi < polys.length; pi++) { + const entry = polys[pi] + if (!entry) continue + const { key, poly } = entry + if (!pointInPolygon(px, py, poly)) continue + const seed = s.seedOf(key) + const variant = s.variantOf(key) + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1 + const dist = distToPolygonEdge(px, py, poly) + if (dist < 1.4) { + c.fillStyle = rgb(seed.fill, 1, selDim) + c.fillRect(x, y, 1, 1) + covered = true + continue + } + const density = 1 - Math.min(1, dist / band) + const bias = variant === "dotted" ? 0.12 : 0 + // Thin each successive (front) layer so overlapping polygons + // read as distinct layers, not a muddy blend. + const sparse = pi * 0.2 + if (variant === "hatched" && ((x + y) & 3) >= 2) continue + const lit = + variant === "solid" || + density > (BAYER[y & 3]?.[x & 3] ?? 0) - 0.1 * intensity - bias + sparse + // Unlit cells: over another layer, stay a real gap (back layer + // shows through); over bare background, paint the faint tier so + // the page never bleeds in. + if (!lit && (variant === "dotted" || covered)) continue + const k = (0.32 + density * 0.68) * (1 + 0.22 * intensity) + const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim) + c.fillStyle = rgb(seed.fill, 1, alpha) + c.fillRect(x, y, 1, 1) + covered = true + } + } + } + + // Vertex markers — larger on the hovered axis. + for (const { key, pts } of polys) { + const seed = s.seedOf(key) + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1 + pts.forEach((p, i) => { + const bx = Math.round(p.x * fx) + const by = Math.round(p.y * fy) + const big = s.hoverIndex === i + c.fillStyle = rgb(seed.fill, 1, selDim) + const sz = big ? 2 : 1 + c.fillRect(bx - (sz - 1), by - (sz - 1), sz * 2 - 1, sz * 2 - 1) + }) + } + } + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready || !s.radar) return + if (bloomCtx) { + const on = s.bloom !== "off" && (!s.bloomOnHover || s.isMouseInChart) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 + lastProg = -1 + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + if (s.hoverIndex !== lastHover) { + lastHover = s.hoverIndex + needsFill = true + } + const itTarget = s.isMouseInChart ? 1 : 0 + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * (reduce ? 1 : 0.16) + needsFill = true + } else intensity = itTarget + if (prog !== lastProg) { + lastProg = prog + needsFill = true + } + + // Live tweak repaint (variant) without replaying the scale-in. + const paintSig = s.configKeys.map((k) => s.variantOf(k)).join(",") + if (paintSig !== lastPaintSig) { + lastPaintSig = paintSig + needsFill = true + } + + if (!needsFill) return + paint(prog) + needsFill = false + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, [cols, rows, width, height]) + + const bloom = bloomLayerStyle( + ctx.bloom, + ctx.bloomOnHover ? ctx.isMouseInChart : true + ) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/packages/ui/src/components/dither-kit/radar-chart.tsx b/packages/ui/src/components/dither-kit/radar-chart.tsx new file mode 100644 index 000000000..0561ccf3b --- /dev/null +++ b/packages/ui/src/components/dither-kit/radar-chart.tsx @@ -0,0 +1,42 @@ +"use client" + +import type { ReactNode } from "react" +import type { ChartConfig, Margins } from "./chart-context" +import type { BloomInput } from "./dither-paint" +import { PolarRoot } from "./polar-root" +import { RadarCanvas } from "./radar-canvas" +import { RadarFrame } from "./radar-frame" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +export type RadarChartProps = { + data: TData[] + config: ChartConfig + children: ReactNode + nameKey: string // axis-label field + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +/** Composable dither **radar** chart. Compose `` series, ``, … inside. */ +export function RadarChart(props: RadarChartProps) { + return ( + } + dataKey="" + {...props} + /> + ) +} diff --git a/packages/ui/src/components/dither-kit/radar-frame.tsx b/packages/ui/src/components/dither-kit/radar-frame.tsx new file mode 100644 index 000000000..d27349354 --- /dev/null +++ b/packages/ui/src/components/dither-kit/radar-frame.tsx @@ -0,0 +1,72 @@ +"use client" + +import { polarX, polarY } from "./polar" +import { usePolarChart } from "./polar-context" + +const LEVELS = 4 + +/** Built-in radar chrome: concentric polygon rings, spokes, and axis labels. + * Rendered behind the dither canvas by the radar root. */ +export function RadarFrame() { + const ctx = usePolarChart() + if (!ctx.ready || !ctx.radar) return null + const { axes } = ctx.radar + const { x: cx, y: cy } = ctx.center + const R = ctx.outerRadius + + const ring = (radius: number) => + `${axes + .map( + (ax, i) => + `${i === 0 ? "M" : "L"}${polarX(cx, radius, ax.angle).toFixed(1)},${polarY(cy, radius, ax.angle).toFixed(1)}` + ) + .join(" ")} Z` + + return ( + + + {Array.from({ length: LEVELS }, (_, l) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: fixed ring levels + + ))} + {axes.map((ax, i) => ( + + ))} + + + {axes.map((ax, i) => { + const lx = polarX(cx, R + 10, ax.angle) + const ly = polarY(cy, R + 10, ax.angle) + const anchor = + Math.abs(Math.cos(ax.angle)) < 0.3 + ? "middle" + : Math.cos(ax.angle) > 0 + ? "start" + : "end" + const hot = ctx.hoverIndex === i + return ( + + {ax.label} + + ) + })} + + + ) +} + +RadarFrame.chartLayer = "back" as const diff --git a/packages/ui/src/components/dither-kit/radar.tsx b/packages/ui/src/components/dither-kit/radar.tsx new file mode 100644 index 000000000..990c6026d --- /dev/null +++ b/packages/ui/src/components/dither-kit/radar.tsx @@ -0,0 +1,32 @@ +"use client" + +import { useEffect } from "react" +import type { AreaVariant } from "./chart-context" +import { usePolarPart } from "./polar-context" + +export type RadarProps = { + dataKey: string + variant?: AreaVariant +} + +/** + * One radar series — a closed polygon over the spokes. Sets this series' fill + * variant; the dithered polygon is painted on the canvas. + */ +export function Radar({ dataKey, variant = "gradient" }: RadarProps) { + const ctx = usePolarPart("Radar", "radar") + const { registerVariant, unregisterVariant } = ctx + + if (process.env.NODE_ENV !== "production" && !ctx.config[dataKey]) { + console.warn( + `: "${dataKey}" is not in the chart \`config\`. Add it so the series has a colour and label.` + ) + } + + useEffect(() => { + registerVariant(dataKey, variant) + return () => unregisterVariant(dataKey) + }, [dataKey, variant, registerVariant, unregisterVariant]) + + return null +} diff --git a/packages/ui/src/components/dither-kit/tooltip.tsx b/packages/ui/src/components/dither-kit/tooltip.tsx index 02f6ecf96..2edf93e46 100644 --- a/packages/ui/src/components/dither-kit/tooltip.tsx +++ b/packages/ui/src/components/dither-kit/tooltip.tsx @@ -32,8 +32,7 @@ export function Tooltip({ * series registration order ("none"). */ sortItems?: "desc" | "asc" | "none" /** Single-series categorical charts: drop the separate heading line and use - * the heading value as each row's label ("Monday.com 16" instead of a muted - * heading plus a redundant series label). */ + * the heading value as each row's label. */ inlineHeading?: boolean }) { const chart = useCommonChart() From 336d728f84c14d68e9bf106a4108088039271675 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 19:15:12 +0200 Subject: [PATCH 18/26] feat(geo): add engine radar, share of voice donut, traffic purpose donut --- .../(dashboard)/[slug]/geo/page-client.tsx | 8 +- .../src/components/geo/ai-traffic-card.tsx | 56 +++++++- .../src/components/geo/engine-radar-card.tsx | 110 ++++++++++++++++ .../components/geo/share-of-voice-donut.tsx | 122 ++++++++++++++++++ apps/dashboard/src/types/geo.ts | 6 + apps/dashboard/src/utils/geo-charts.ts | 39 +++++- 6 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard/src/components/geo/engine-radar-card.tsx create mode 100644 apps/dashboard/src/components/geo/share-of-voice-donut.tsx diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx index 504a1ccbb..c5f6c38b7 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx @@ -8,12 +8,13 @@ import Link from "next/link"; import { useState } from "react"; import { EmptyState } from "@/components/empty-state"; import { AiTrafficCard } from "@/components/geo/ai-traffic-card"; -import { CompetitorShareCard } from "@/components/geo/competitor-share-card"; +import { EngineRadarCard } from "@/components/geo/engine-radar-card"; import { GeoSettingsDialog } from "@/components/geo/geo-settings-dialog"; import { GeoSummaryStats } from "@/components/geo/geo-summary-stats"; import { MentionRateCard } from "@/components/geo/mention-rate-card"; import { ModelUsageCard } from "@/components/geo/model-usage-card"; import { PromptResultsPreview } from "@/components/geo/prompt-results-preview"; +import { ShareOfVoiceDonut } from "@/components/geo/share-of-voice-donut"; import { WebsiteGenerateCard } from "@/components/geo/website-generate-card"; import { PageContainer } from "@/components/layout/container"; import { SectionHeader } from "@/components/layout/section-header"; @@ -147,12 +148,13 @@ export default function PageClient({ organizationSlug }: PageClientProps) { description="How often engines mention you, and who they name instead" title="Visibility" /> -
+
- + diff --git a/apps/dashboard/src/components/geo/ai-traffic-card.tsx b/apps/dashboard/src/components/geo/ai-traffic-card.tsx index 1ac030c63..abcd8cd38 100644 --- a/apps/dashboard/src/components/geo/ai-traffic-card.tsx +++ b/apps/dashboard/src/components/geo/ai-traffic-card.tsx @@ -1,5 +1,9 @@ "use client"; +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { Pie } from "@notra/ui/components/dither-kit/pie"; +import { PieChart } from "@notra/ui/components/dither-kit/pie-chart"; +import { Tooltip as DitherTooltip } from "@notra/ui/components/dither-kit/tooltip"; import { Badge } from "@notra/ui/components/ui/badge"; import { Card, @@ -127,6 +131,47 @@ function BeaconSetup({ setup }: { setup: BeaconSetupResponse | undefined }) { ); } +const PURPOSE_DONUT_CONFIG: ChartConfig = { + "Training data": { label: "Training data", color: "green" }, + "Search index": { label: "Search index", color: "blue" }, + "Used in answer": { label: "Used in answer", color: "purple" }, +}; + +function PurposeDonut({ agents }: { agents: AiTrafficAgent[] }) { + const rows = useMemo(() => { + const byPurpose = new Map(); + for (const agent of agents) { + const label = AI_TRAFFIC_PURPOSE_LABELS[agent.category] ?? agent.category; + byPurpose.set(label, (byPurpose.get(label) ?? 0) + agent.hits); + } + return [...byPurpose.entries()].map(([purpose, hits]) => ({ + purpose, + hits, + })); + }, [agents]); + + if (rows.length === 0) { + return null; + } + + return ( +
+

Requests by purpose

+ + + + +
+ ); +} + export function AiTrafficCard({ traffic, setup }: AiTrafficCardProps) { const agents = traffic?.agents ?? []; const log = traffic?.log ?? []; @@ -153,10 +198,13 @@ export function AiTrafficCard({ traffic, setup }: AiTrafficCardProps) { {agents.length === 0 ? ( ) : ( -
- {agents.map((agent) => ( - - ))} +
+
+ {agents.map((agent) => ( + + ))} +
+
)} diff --git a/apps/dashboard/src/components/geo/engine-radar-card.tsx b/apps/dashboard/src/components/geo/engine-radar-card.tsx new file mode 100644 index 000000000..7b58490c2 --- /dev/null +++ b/apps/dashboard/src/components/geo/engine-radar-card.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { Radar } from "@notra/ui/components/dither-kit/radar"; +import { RadarChart } from "@notra/ui/components/dither-kit/radar-chart"; +import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { useMemo, useState } from "react"; +import { ChartSeriesLegend } from "@/components/analytics/chart-legend"; +import { GEO_ENGINE_LABELS } from "@/constants/geo"; +import type { GeoOverviewEngine } from "@/types/geo"; +import { groupEngineFamilies } from "@/utils/geo-charts"; + +interface EngineRadarCardProps { + engines: GeoOverviewEngine[]; +} + +interface RadarRow { + engine: string; + web: number; + raw: number; +} + +const chartConfig: ChartConfig = { + web: { label: "With web search", color: "purple" }, + raw: { label: "Model only", color: "grey" }, +}; + +const seriesKeys = Object.keys(chartConfig); +const MIN_AXES = 3; +const WEB_LABEL_SUFFIX = /\s*\(web\)$/; +const PERCENT = 100; + +function familyLabel(family: string): string { + const label = + GEO_ENGINE_LABELS[family] ?? GEO_ENGINE_LABELS[`${family}-grounded`]; + if (!label) { + return family; + } + return label.replace(WEB_LABEL_SUFFIX, ""); +} + +export function EngineRadarCard({ engines }: EngineRadarCardProps) { + const [hiddenKeys, setHiddenKeys] = useState>(new Set()); + const rows = useMemo( + () => + groupEngineFamilies(engines).map((family) => ({ + engine: familyLabel(family.family), + web: Math.round((family.web?.mentionRate ?? 0) * PERCENT), + raw: Math.round((family.raw?.mentionRate ?? 0) * PERCENT), + })), + [engines] + ); + + const visibleKeys = seriesKeys.filter((key) => !hiddenKeys.has(key)); + + const toggle = (key: string) => { + setHiddenKeys((previous) => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + return ( + + + The grounding gap + + Mention rate per engine, with and without web access + + + + {rows.length < MIN_AXES || visibleKeys.length === 0 ? ( +

+ Needs scans across at least three engines +

+ ) : ( + + {visibleKeys.map((key) => ( + + ))} + `${value}%`} /> + + )} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/geo/share-of-voice-donut.tsx b/apps/dashboard/src/components/geo/share-of-voice-donut.tsx new file mode 100644 index 000000000..a39f3e4ed --- /dev/null +++ b/apps/dashboard/src/components/geo/share-of-voice-donut.tsx @@ -0,0 +1,122 @@ +"use client"; + +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { PALETTE, rgb } from "@notra/ui/components/dither-kit/palette"; +import { Pie } from "@notra/ui/components/dither-kit/pie"; +import { PieChart } from "@notra/ui/components/dither-kit/pie-chart"; +import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { useMemo } from "react"; +import { ACCOUNT_SERIES_COLORS } from "@/constants/analytics"; +import type { GeoCompetitorSharePoint } from "@/types/geo"; + +interface ShareOfVoiceDonutProps { + points: GeoCompetitorSharePoint[]; + companyName: string | null; +} + +interface SliceRow { + brand: string; + mentions: number; +} + +const TOP_SLICES = 5; +const DONUT_INNER_RADIUS = 0.55; + +export function ShareOfVoiceDonut({ + points, + companyName, +}: ShareOfVoiceDonutProps) { + const { rows, config, total } = useMemo(() => { + const top = points.slice(0, TOP_SLICES); + const rest = points.slice(TOP_SLICES); + const sliceRows: SliceRow[] = top.map((point) => ({ + brand: point.brand, + mentions: point.mentions, + })); + const otherTotal = rest.reduce((sum, point) => sum + point.mentions, 0); + if (otherTotal > 0) { + sliceRows.push({ brand: "Other", mentions: otherTotal }); + } + const sliceConfig: ChartConfig = {}; + sliceRows.forEach((row, index) => { + sliceConfig[row.brand] = { + label: row.brand, + color: + row.brand === "Other" + ? "grey" + : (ACCOUNT_SERIES_COLORS[index % ACCOUNT_SERIES_COLORS.length] ?? + "blue"), + }; + }); + return { + rows: sliceRows, + config: sliceConfig, + total: sliceRows.reduce((sum, row) => sum + row.mentions, 0), + }; + }, [points]); + + return ( + + + Share of voice + + Brands AI engines bring up + {companyName ? ` alongside ${companyName}` : ""} + + + + {rows.length === 0 ? ( +

+ No competitor data yet +

+ ) : ( +
+ + + + +
+ {rows.map((row) => ( +
+ + + {row.brand} + + + {total > 0 + ? `${Math.round((row.mentions / total) * 100)}%` + : "0%"} + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/types/geo.ts b/apps/dashboard/src/types/geo.ts index 5624a0dd5..c20a85ffa 100644 --- a/apps/dashboard/src/types/geo.ts +++ b/apps/dashboard/src/types/geo.ts @@ -260,3 +260,9 @@ export type GeoPresenceStatus = | "training-data" | "retrieval-only" | "invisible"; + +export interface GeoEngineFamily { + family: string; + web: GeoOverviewEngine | null; + raw: GeoOverviewEngine | null; +} diff --git a/apps/dashboard/src/utils/geo-charts.ts b/apps/dashboard/src/utils/geo-charts.ts index 288e79233..84ce630c7 100644 --- a/apps/dashboard/src/utils/geo-charts.ts +++ b/apps/dashboard/src/utils/geo-charts.ts @@ -1,5 +1,11 @@ -import type { GeoTimeseriesPoint, MentionRateRow } from "@/types/geo"; +import type { + GeoEngineFamily, + GeoOverviewEngine, + GeoTimeseriesPoint, + MentionRateRow, +} from "@/types/geo"; import { formatDayLabel } from "@/utils/analytics-charts"; +import { isGroundedEngine } from "@/utils/geo-presence"; const PERCENT = 100; @@ -53,3 +59,34 @@ export function buildMentionRateRows(points: GeoTimeseriesPoint[]): { return { rows, engines }; } + +const GROUNDED_SUFFIX_PATTERN = /(-direct)?-grounded$/; + +export function engineFamilyOf(engine: string): string { + return engine.replace(GROUNDED_SUFFIX_PATTERN, ""); +} + +export function groupEngineFamilies( + engines: GeoOverviewEngine[] +): GeoEngineFamily[] { + const families = new Map(); + for (const engine of engines) { + const family = engineFamilyOf(engine.engine); + const entry = families.get(family) ?? { + family, + web: null, + raw: null, + }; + if (isGroundedEngine(engine.engine)) { + entry.web = engine; + } else { + entry.raw = engine; + } + families.set(family, entry); + } + return [...families.values()].sort( + (a, b) => + Math.max(b.web?.mentionRate ?? 0, b.raw?.mentionRate ?? 0) - + Math.max(a.web?.mentionRate ?? 0, a.raw?.mentionRate ?? 0) + ); +} From b22c73d1900ff339e1415654cb6a4b60ec5b08f6 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 20:05:43 +0200 Subject: [PATCH 19/26] style(analytics): instrument panel synthesis from design tournament --- .../[slug]/analytics/page-client.tsx | 208 +++++++++++++----- .../(dashboard)/[slug]/analytics/skeleton.tsx | 27 ++- .../components/analytics/account-filter.tsx | 4 +- .../analytics/account-series-chart-card.tsx | 121 ++++------ .../components/analytics/followers-card.tsx | 140 ++++++------ .../analytics/impressions-share-card.tsx | 62 +++--- .../components/analytics/leaderboard-card.tsx | 196 +++++++++-------- .../analytics/posting-performance-card.tsx | 65 +++--- .../components/analytics/summary-stats.tsx | 94 ++++---- .../components/analytics/top-posts-card.tsx | 63 +++--- .../components/instrument/instrument-grid.tsx | 15 ++ .../instrument/instrument-module.tsx | 54 +++++ .../instrument/instrument-reveal.tsx | 51 +++++ apps/dashboard/src/types/analytics.ts | 38 ++++ apps/dashboard/src/types/instrument.ts | 28 +++ apps/dashboard/src/utils/analytics-charts.ts | 22 ++ apps/dashboard/src/utils/instrument.ts | 11 + 17 files changed, 733 insertions(+), 466 deletions(-) create mode 100644 apps/dashboard/src/components/instrument/instrument-grid.tsx create mode 100644 apps/dashboard/src/components/instrument/instrument-module.tsx create mode 100644 apps/dashboard/src/components/instrument/instrument-reveal.tsx create mode 100644 apps/dashboard/src/types/instrument.ts create mode 100644 apps/dashboard/src/utils/instrument.ts diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx index 53135f2cf..66b380f1e 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx @@ -1,8 +1,9 @@ "use client"; import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; +import { useReducedMotion } from "motion/react"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AccountFilter } from "@/components/analytics/account-filter"; import { AccountSeriesChartCard } from "@/components/analytics/account-series-chart-card"; import { ConnectAccountsButtons } from "@/components/analytics/connect-accounts-buttons"; @@ -13,8 +14,9 @@ import { PostingPerformanceCard } from "@/components/analytics/posting-performan import { SummaryStats } from "@/components/analytics/summary-stats"; import { TopPostsCard } from "@/components/analytics/top-posts-card"; import { EmptyState } from "@/components/empty-state"; +import { InstrumentGrid } from "@/components/instrument/instrument-grid"; +import { InstrumentReveal } from "@/components/instrument/instrument-reveal"; import { PageContainer } from "@/components/layout/container"; -import { SectionHeader } from "@/components/layout/section-header"; import { useOrganizationsContext } from "@/components/providers/organization-provider"; import { ACCOUNT_SERIES_COLORS, @@ -28,6 +30,7 @@ import { useSocialOverview, useTopPosts, } from "@/lib/hooks/use-social-analytics"; +import { cn } from "@/lib/utils"; import type { TimelineMarker } from "@/types/analytics"; import { accountSeriesKey, @@ -36,8 +39,33 @@ import { buildTimelineDays, markerIndexForDate, } from "@/utils/analytics-charts"; +import { formatSyncClock } from "@/utils/instrument"; import { AnalyticsPageSkeleton } from "./skeleton"; +/* ───────────────────────────────────────────────────────── + * ANIMATION STORYBOARD — Analytics instrument panel + * + * Read top-to-bottom. Each `at` value is ms after data mount. + * + * 0ms header + account switches render statically + * 60ms master readout rail powers on, + * sync dot starts pulsing + * 150ms modules materialize over the grid substrate + * (staggered 45ms in reading order) + * + * Reduced motion: everything appears at once, no offsets. + * ───────────────────────────────────────────────────────── */ + +const TIMING = { + readoutRail: 60, // master readout rail powers on + modules: 150, // grid modules start staggering in +}; + +const STAGE = { + rail: 1, // readout rail visible + modules: 2, // grid modules visible +}; + interface PageClientProps { organizationSlug: string; } @@ -51,8 +79,11 @@ export default function PageClient({ organizationSlug }: PageClientProps) { : orgFromList; const organizationId = organization?.id ?? ""; - const { data: overview, isPending: isOverviewPending } = - useSocialOverview(organizationId); + const { + data: overview, + isPending: isOverviewPending, + dataUpdatedAt, + } = useSocialOverview(organizationId); const { data: engagement } = useEngagementTimeseries(organizationId); const { data: followerGrowth } = useFollowerGrowth(organizationId); const { data: topPosts } = useTopPosts(organizationId); @@ -62,6 +93,29 @@ export default function PageClient({ organizationSlug }: PageClientProps) { const [hoverIndex, setHoverIndex] = useState(null); const [hiddenKeys, setHiddenKeys] = useState>(new Set()); + const reduceMotion = useReducedMotion(); + const [stage, setStage] = useState(0); + const ready = !isOverviewPending; + + useEffect(() => { + if (!ready) { + setStage(0); + return; + } + if (reduceMotion) { + setStage(STAGE.modules); + return; + } + const timers: ReturnType[] = []; + timers.push(setTimeout(() => setStage(STAGE.rail), TIMING.readoutRail)); + timers.push(setTimeout(() => setStage(STAGE.modules), TIMING.modules)); + return () => { + for (const timer of timers) { + clearTimeout(timer); + } + }; + }, [ready, reduceMotion]); + const accounts = useMemo( () => overview?.accounts ?? [], [overview?.accounts] @@ -178,10 +232,12 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
-

Analytics

-

- Performance of your connected X and LinkedIn accounts +

+ X + LinkedIn instrument panel

+

+ Nothing to report yet. +

-
+
-

Analytics

-

- {accounts.length}{" "} - {accounts.length === 1 ? "account" : "accounts"} connected on X - and LinkedIn +

+ Analytics +

+

+ + {accounts.length}{" "} + {accounts.length === 1 ? "account" : "accounts"} · X + + LinkedIn · {ANALYTICS_TIMESERIES_DAYS}D window · Sync{" "} + {formatSyncClock(dataUpdatedAt || null)} + +

@@ -223,54 +292,75 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
{overview?.configured === false && ( -

+

Analytics ingestion is not configured yet. Connected accounts are shown, but stats will appear once the analytics backend is set up.

)} - + = STAGE.rail}> + + -
- -
-
- -
- -
-
- -
- -
+ + = STAGE.modules} + className="lg:col-span-8" + order={0} + > + + = STAGE.modules} + className="lg:col-span-4" + order={1} + > + accountConfig[key]?.color ?? "blue"} + hiddenKeys={hiddenKeys} + points={followerGrowth?.points ?? []} + /> + + = STAGE.modules} + className="lg:col-span-8" + order={2} + > + + + = STAGE.modules} + className="lg:col-span-4" + order={3} + > + + + = STAGE.modules} + className="lg:col-span-6" + order={4} + > + + = STAGE.modules} + className="lg:col-span-6" + order={5} + > - accountConfig[key]?.color ?? "blue"} - hiddenKeys={hiddenKeys} - points={followerGrowth?.points ?? []} - /> -
-
- -
- -
+ + = STAGE.modules} + className="lg:col-span-6" + order={6} + > + + = STAGE.modules} + className="lg:col-span-6" + order={7} + > -
-
+ +
); diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx index c60f8ca10..9c6972963 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/analytics/skeleton.tsx @@ -4,35 +4,34 @@ import { Skeleton } from "@notra/ui/components/ui/skeleton"; import { useId } from "react"; import { PageContainer } from "@/components/layout/container"; -const ACCOUNT_CARD_COUNT = 2; -const CHART_CARD_COUNT = 2; +const RAIL_TILE_COUNT = 4; +const MODULE_COUNT = 4; export function AnalyticsPageSkeleton() { const id = useId(); return ( -
+
- - + +
-
- {Array.from({ length: ACCOUNT_CARD_COUNT }).map((_, index) => ( +
+ {Array.from({ length: RAIL_TILE_COUNT }).map((_, index) => ( ))}
-
- {Array.from({ length: CHART_CARD_COUNT }).map((_, index) => ( +
+ {Array.from({ length: MODULE_COUNT }).map((_, index) => ( ))}
-
); diff --git a/apps/dashboard/src/components/analytics/account-filter.tsx b/apps/dashboard/src/components/analytics/account-filter.tsx index 5bd48ad42..0b6398677 100644 --- a/apps/dashboard/src/components/analytics/account-filter.tsx +++ b/apps/dashboard/src/components/analytics/account-filter.tsx @@ -34,9 +34,9 @@ export function AccountFilter({
- + {formatMetric(entry.interactions)} - + {formatMetric(entry.impressions)} - + {entry.posts} @@ -175,19 +178,19 @@ function LeaderboardRow({
{expanded && detail && ( -
+
{detailMetrics(detail).map((metric) => ( -
-
{metric.label}
-
- {metric.value} -
+
+
+ {metric.label} +
+
{metric.value}
))}
)} {expanded && !detail && ( -

+

Lifetime stats appear after this account's first sync

)} @@ -210,94 +213,93 @@ export function LeaderboardCard({ const entries = data?.entries ?? []; return ( - - -
-

- Leaderboard -

-

- Connected and tracked accounts ranked by interactions -

-
- -
- -
- Rank - +
- +
+ Rk + + Δ + + + Account + Interact + Impress + Posts + +
+ {entries.length === 0 ? ( + -
-
+ ) : ( +
+ {entries.map((entry) => ( + + setExpandedKey((previous) => + previous === entry.key ? null : entry.key + ) + } + organizationId={organizationId} + /> + ))} +
+ )} + + ); } diff --git a/apps/dashboard/src/components/analytics/posting-performance-card.tsx b/apps/dashboard/src/components/analytics/posting-performance-card.tsx index 70ed91bb0..2b0385039 100644 --- a/apps/dashboard/src/components/analytics/posting-performance-card.tsx +++ b/apps/dashboard/src/components/analytics/posting-performance-card.tsx @@ -7,15 +7,12 @@ import { Grid } from "@notra/ui/components/dither-kit/grid"; import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; import { XAxis } from "@notra/ui/components/dither-kit/x-axis"; import { YAxis } from "@notra/ui/components/dither-kit/y-axis"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@notra/ui/components/ui/card"; import { useState } from "react"; import { ChartSeriesLegend } from "@/components/analytics/chart-legend"; +import { + InstrumentEmpty, + InstrumentModule, +} from "@/components/instrument/instrument-module"; import type { PostingPerformanceChartRow } from "@/types/analytics"; interface PostingPerformanceCardProps { @@ -47,36 +44,30 @@ export function PostingPerformanceCard({ rows }: PostingPerformanceCardProps) { }; return ( - - - Best days to post - - Average engagement per post and post volume by weekday, last 90 days - - - - {hasData && visibleKeys.length > 0 ? ( - - - - - {visibleKeys.map((key) => ( - - ))} - - - ) : ( -

- No posting data yet -

- )} - + {hasData && visibleKeys.length > 0 ? ( + + + + + {visibleKeys.map((key) => ( + + ))} + + + ) : ( + -
-
+ )} + + ); } diff --git a/apps/dashboard/src/components/analytics/summary-stats.tsx b/apps/dashboard/src/components/analytics/summary-stats.tsx index b9ff4c197..7b320e795 100644 --- a/apps/dashboard/src/components/analytics/summary-stats.tsx +++ b/apps/dashboard/src/components/analytics/summary-stats.tsx @@ -1,77 +1,69 @@ "use client"; -import { Card, CardContent } from "@notra/ui/components/ui/card"; import { useMemo } from "react"; -import type { - EngagementTimeseriesPoint, - SocialOverviewAccount, -} from "@/types/analytics"; -import { formatMetric, sumMetric } from "@/utils/analytics-charts"; - -interface SummaryStatsProps { - accounts: SocialOverviewAccount[]; - points: EngagementTimeseriesPoint[]; -} - -interface StatTile { - label: string; - value: string; - hint: string; -} - -const PERCENT = 100; +import { InstrumentGrid } from "@/components/instrument/instrument-grid"; +import { cn } from "@/lib/utils"; +import type { AnalyticsStatTile, SummaryStatsProps } from "@/types/analytics"; +import { + buildAnalyticsHeroSummary, + formatMetric, +} from "@/utils/analytics-charts"; export function SummaryStats({ accounts, points }: SummaryStatsProps) { - const tiles = useMemo(() => { - const followers = sumMetric(accounts, (account) => account.followersCount); - let impressions = 0; - let interactions = 0; - let posts = 0; - for (const point of points) { - impressions += point.impressions ?? 0; - interactions += - (point.likes ?? 0) + (point.replies ?? 0) + (point.reposts ?? 0); - posts += point.posts; - } - const engagementRate = - impressions > 0 ? (interactions / impressions) * PERCENT : null; + const tiles = useMemo(() => { + const summary = buildAnalyticsHeroSummary(accounts, points); return [ + { + label: "Engagement rate", + value: + summary.engagementRate === null + ? "N/A" + : `${summary.engagementRate.toFixed(1)}%`, + hint: `${formatMetric(summary.interactions)} interactions / ${formatMetric(summary.impressions)} impressions`, + accent: true, + }, { label: "Followers", - value: formatMetric(followers), + value: formatMetric(summary.followers), hint: "across connected accounts", + accent: false, }, { label: "Impressions", - value: formatMetric(impressions), + value: formatMetric(summary.impressions), hint: "posts from the last 30 days", + accent: false, }, { label: "Interactions", - value: formatMetric(interactions), - hint: `${posts} posts, last 30 days`, - }, - { - label: "Engagement rate", - value: - engagementRate === null ? "N/A" : `${engagementRate.toFixed(1)}%`, - hint: "interactions per impression", + value: formatMetric(summary.interactions), + hint: `${summary.posts} posts, last 30 days`, + accent: false, }, ]; }, [accounts, points]); return ( -
+ {tiles.map((tile) => ( - - -

{tile.label}

-

{tile.value}

-

{tile.hint}

-
-
+
+

+ {tile.label} +

+

+ {tile.value} +

+

+ {tile.hint} +

+
))} -
+ ); } diff --git a/apps/dashboard/src/components/analytics/top-posts-card.tsx b/apps/dashboard/src/components/analytics/top-posts-card.tsx index 618cb0537..2f87c7d7f 100644 --- a/apps/dashboard/src/components/analytics/top-posts-card.tsx +++ b/apps/dashboard/src/components/analytics/top-posts-card.tsx @@ -8,12 +8,9 @@ import { AvatarImage, } from "@notra/ui/components/ui/avatar"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@notra/ui/components/ui/card"; + InstrumentEmpty, + InstrumentModule, +} from "@/components/instrument/instrument-module"; import { TOP_POST_CONTENT_PREVIEW_LENGTH } from "@/constants/analytics"; import type { TopPostItem } from "@/types/analytics"; import { formatDayLabel, formatMetric } from "@/utils/analytics-charts"; @@ -33,7 +30,7 @@ function previewContent(content: string): string { function PostAvatar({ post }: { post: TopPostItem }) { const name = post.username ?? post.providerAccountId; return ( - + {post.profileImageUrl && ( )} @@ -49,7 +46,7 @@ function PostRow({ post }: { post: TopPostItem }) {
-

+

{previewContent(post.content)}

-

+

{formatMetric(post.likes)} likes · {formatMetric(post.replies)}{" "} replies · {formatMetric(post.reposts)} reposts {post.impressions !== null && ` · ${formatMetric(post.impressions)} impressions`}

- + {formatMetric(post.engagement)}
@@ -77,7 +74,7 @@ function PostRow({ post }: { post: TopPostItem }) { if (post.url) { return ( ); } - return
{body}
; + return
{body}
; } export function TopPostsCard({ posts }: TopPostsCardProps) { return ( - - - Top posts - - Ranked by likes, replies, and reposts from the latest sync - - - - {posts.length === 0 ? ( -

- No tracked posts yet -

- ) : ( -
- {posts.map((post) => ( - - ))} -
- )} -
-
+ + {posts.length === 0 ? ( + + ) : ( +
+ {posts.map((post) => ( + + ))} +
+ )} +
); } diff --git a/apps/dashboard/src/components/instrument/instrument-grid.tsx b/apps/dashboard/src/components/instrument/instrument-grid.tsx new file mode 100644 index 000000000..579ca94c3 --- /dev/null +++ b/apps/dashboard/src/components/instrument/instrument-grid.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import type { InstrumentGridProps } from "@/types/instrument"; + +export function InstrumentGrid({ children, className }: InstrumentGridProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/components/instrument/instrument-module.tsx b/apps/dashboard/src/components/instrument/instrument-module.tsx new file mode 100644 index 000000000..b13b7c6b4 --- /dev/null +++ b/apps/dashboard/src/components/instrument/instrument-module.tsx @@ -0,0 +1,54 @@ +import { DitherAvatar } from "@notra/ui/components/dither-kit/avatar"; +import { cn } from "@/lib/utils"; +import type { + InstrumentEmptyProps, + InstrumentModuleProps, +} from "@/types/instrument"; + +export function InstrumentModule({ + eyebrow, + readout, + action, + children, + className, + bodyClassName, +}: InstrumentModuleProps) { + return ( +
+
+

+ {eyebrow} +

+
+ {readout && ( + + {readout} + + )} + {action} +
+
+
{children}
+
+ ); +} + +export function InstrumentEmpty({ + seed, + message, + className, +}: InstrumentEmptyProps) { + return ( +
+ +

+ {message} +

+
+ ); +} diff --git a/apps/dashboard/src/components/instrument/instrument-reveal.tsx b/apps/dashboard/src/components/instrument/instrument-reveal.tsx new file mode 100644 index 000000000..8f6e183b7 --- /dev/null +++ b/apps/dashboard/src/components/instrument/instrument-reveal.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { Transition } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; +import { cn } from "@/lib/utils"; +import type { InstrumentRevealProps } from "@/types/instrument"; + +/* ───────────────────────────────────────────────────────── + * ANIMATION STORYBOARD — module reveal + * + * Each module powers on once its page stage goes active: + * + * 0ms dark cell (opacity 0, y +0.375rem) + * +n opacity 0 → 1, y +0.375rem → 0 (45ms per order step) + * + * Reduced motion: modules appear instantly, no offset. + * ───────────────────────────────────────────────────────── */ + +const REVEAL = { + offsetY: 6, // px each module rises while powering on + stagger: 0.045, // seconds between neighboring modules + spring: { type: "spring", stiffness: 380, damping: 34 }, +} satisfies { offsetY: number; stagger: number; spring: Transition }; + +const INSTANT: Transition = { duration: 0 }; + +export function InstrumentReveal({ + active, + order = 0, + children, + className, +}: InstrumentRevealProps) { + const reduceMotion = useReducedMotion(); + const transition: Transition = reduceMotion + ? INSTANT + : { ...REVEAL.spring, delay: order * REVEAL.stagger }; + + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/types/analytics.ts b/apps/dashboard/src/types/analytics.ts index c33a0b018..0ab4faf58 100644 --- a/apps/dashboard/src/types/analytics.ts +++ b/apps/dashboard/src/types/analytics.ts @@ -1,3 +1,5 @@ +import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context"; + export interface SocialAnalyticsSyncPayload { organizationId?: string; } @@ -264,3 +266,39 @@ export interface TimelineMarker { export interface TrackAccountPreviewResponse { account: ResolvedTwitterAccount | null; } + +export interface AnalyticsHeroSummary { + followers: number | null; + impressions: number; + interactions: number; + posts: number; + engagementRate: number | null; +} + +export interface SummaryStatsProps { + accounts: SocialOverviewAccount[]; + points: EngagementTimeseriesPoint[]; +} + +export interface AnalyticsStatTile { + label: string; + value: string; + hint: string; + accent: boolean; +} + +export interface AccountSeriesChartCardProps { + hero?: boolean; + title: string; + readout: string; + kind: "area" | "line" | "bar"; + rows: AccountSeriesRow[]; + config: ChartConfig; + allKeys: string[]; + hiddenKeys: ReadonlySet; + onToggleSeries: (key: string) => void; + hoverIndex: number | null; + onHoverChange: (index: number | null) => void; + markers: TimelineMarker[]; + emptyMessage: string; +} diff --git a/apps/dashboard/src/types/instrument.ts b/apps/dashboard/src/types/instrument.ts new file mode 100644 index 000000000..8c6b79489 --- /dev/null +++ b/apps/dashboard/src/types/instrument.ts @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +export interface InstrumentGridProps { + children: ReactNode; + className?: string; +} + +export interface InstrumentModuleProps { + eyebrow: string; + readout?: ReactNode; + action?: ReactNode; + children: ReactNode; + className?: string; + bodyClassName?: string; +} + +export interface InstrumentRevealProps { + active: boolean; + order?: number; + children: ReactNode; + className?: string; +} + +export interface InstrumentEmptyProps { + seed: string; + message: string; + className?: string; +} diff --git a/apps/dashboard/src/utils/analytics-charts.ts b/apps/dashboard/src/utils/analytics-charts.ts index 58d829fa3..023d55b59 100644 --- a/apps/dashboard/src/utils/analytics-charts.ts +++ b/apps/dashboard/src/utils/analytics-charts.ts @@ -1,5 +1,6 @@ import type { AccountSeriesRow, + AnalyticsHeroSummary, EngagementTimeseriesPoint, FollowerGrowthPoint, PostingPerformanceChartRow, @@ -123,3 +124,24 @@ export function buildPostingPerformanceRows( }; }); } + +const PERCENT = 100; + +export function buildAnalyticsHeroSummary( + accounts: SocialOverviewAccount[], + points: EngagementTimeseriesPoint[] +): AnalyticsHeroSummary { + const followers = sumMetric(accounts, (account) => account.followersCount); + let impressions = 0; + let interactions = 0; + let posts = 0; + for (const point of points) { + impressions += point.impressions ?? 0; + interactions += + (point.likes ?? 0) + (point.replies ?? 0) + (point.reposts ?? 0); + posts += point.posts; + } + const engagementRate = + impressions > 0 ? (interactions / impressions) * PERCENT : null; + return { followers, impressions, interactions, posts, engagementRate }; +} diff --git a/apps/dashboard/src/utils/instrument.ts b/apps/dashboard/src/utils/instrument.ts new file mode 100644 index 000000000..7accd5d0a --- /dev/null +++ b/apps/dashboard/src/utils/instrument.ts @@ -0,0 +1,11 @@ +const CLOCK_PAD = 2; + +export function formatSyncClock(timestamp: number | null): string { + if (!timestamp) { + return "--:--"; + } + const date = new Date(timestamp); + return [date.getHours(), date.getMinutes()] + .map((part) => String(part).padStart(CLOCK_PAD, "0")) + .join(":"); +} From d1302d1799914d462034cf1197542a419cb9c0b5 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 20:05:52 +0200 Subject: [PATCH 20/26] style(geo): instrument panel synthesis from design tournament --- .../(dashboard)/[slug]/geo/page-client.tsx | 204 +++++++++++++----- .../app/(dashboard)/[slug]/geo/skeleton.tsx | 29 +-- .../src/components/geo/ai-traffic-card.tsx | 125 ++++++----- .../src/components/geo/engine-radar-card.tsx | 69 +++--- .../src/components/geo/geo-summary-stats.tsx | 94 ++++---- .../src/components/geo/mention-rate-card.tsx | 122 +++++------ .../src/components/geo/model-usage-card.tsx | 68 +++--- .../src/components/geo/presence-badge.tsx | 2 +- .../components/geo/prompt-results-preview.tsx | 77 ++++--- .../components/geo/share-of-voice-donut.tsx | 76 ++++--- .../src/components/layout/section-header.tsx | 23 -- apps/dashboard/src/types/geo.ts | 25 +++ apps/dashboard/src/utils/geo-charts.ts | 45 ++++ 13 files changed, 553 insertions(+), 406 deletions(-) delete mode 100644 apps/dashboard/src/components/layout/section-header.tsx diff --git a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx index c5f6c38b7..065cd870b 100644 --- a/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx +++ b/apps/dashboard/src/app/(dashboard)/[slug]/geo/page-client.tsx @@ -4,8 +4,9 @@ import { Settings01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Button } from "@notra/ui/components/ui/button"; import { Loader2Icon } from "lucide-react"; +import { useReducedMotion } from "motion/react"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { EmptyState } from "@/components/empty-state"; import { AiTrafficCard } from "@/components/geo/ai-traffic-card"; import { EngineRadarCard } from "@/components/geo/engine-radar-card"; @@ -16,8 +17,9 @@ import { ModelUsageCard } from "@/components/geo/model-usage-card"; import { PromptResultsPreview } from "@/components/geo/prompt-results-preview"; import { ShareOfVoiceDonut } from "@/components/geo/share-of-voice-donut"; import { WebsiteGenerateCard } from "@/components/geo/website-generate-card"; +import { InstrumentGrid } from "@/components/instrument/instrument-grid"; +import { InstrumentReveal } from "@/components/instrument/instrument-reveal"; import { PageContainer } from "@/components/layout/container"; -import { SectionHeader } from "@/components/layout/section-header"; import { useOrganizationsContext } from "@/components/providers/organization-provider"; import { useAiTraffic, @@ -31,8 +33,34 @@ import { useGeoTimeseries, useModelUsage, } from "@/lib/hooks/use-geo"; +import { cn } from "@/lib/utils"; +import { formatSyncClock } from "@/utils/instrument"; import { GeoPageSkeleton } from "./skeleton"; +/* ───────────────────────────────────────────────────────── + * ANIMATION STORYBOARD — GEO instrument panel + * + * Read top-to-bottom. Each `at` value is ms after data mount. + * + * 0ms header + scan controls render statically + * 60ms AI visibility master gauge powers on, + * sync dot starts pulsing + * 150ms modules materialize over the grid substrate + * (staggered 45ms in reading order) + * + * Reduced motion: everything appears at once, no offsets. + * ───────────────────────────────────────────────────────── */ + +const TIMING = { + masterGauge: 60, // AI visibility gauge powers on + modules: 150, // grid modules start staggering in +}; + +const STAGE = { + gauge: 1, // master gauge visible + modules: 2, // grid modules visible +}; + interface PageClientProps { organizationSlug: string; } @@ -50,7 +78,7 @@ export default function PageClient({ organizationSlug }: PageClientProps) { const { data: settingsData, isPending: isSettingsPending } = useGeoSettings(organizationId); - const { data: overview } = useGeoOverview(organizationId); + const { data: overview, dataUpdatedAt } = useGeoOverview(organizationId); const { data: timeseries } = useGeoTimeseries(organizationId); const { data: prompts } = useGeoPrompts(organizationId); const { data: promptResults } = useGeoPromptResults(organizationId); @@ -60,6 +88,29 @@ export default function PageClient({ organizationSlug }: PageClientProps) { const { data: beaconSetup } = useBeaconSetup(organizationId); const startScan = useGeoStartScan(organizationId); + const reduceMotion = useReducedMotion(); + const [stage, setStage] = useState(0); + const ready = !isSettingsPending; + + useEffect(() => { + if (!ready) { + setStage(0); + return; + } + if (reduceMotion) { + setStage(STAGE.modules); + return; + } + const timers: ReturnType[] = []; + timers.push(setTimeout(() => setStage(STAGE.gauge), TIMING.masterGauge)); + timers.push(setTimeout(() => setStage(STAGE.modules), TIMING.modules)); + return () => { + for (const timer of timers) { + clearTimeout(timer); + } + }; + }, [ready, reduceMotion]); + if (isSettingsPending) { return ; } @@ -71,10 +122,12 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
-

GEO

-

- Track how often AI engines mention your company +

+ AI engine visibility instrument

+

+ How often do AI engines mention you? +

-
+
-

GEO

-

- How AI engines talk about {settings.companyName} +

GEO

+

+ + How AI engines talk about {settings.companyName} ·{" "} + {startScan.isPending + ? "Scanning" + : `Sync ${formatSyncClock(dataUpdatedAt || null)}`} + +

@@ -129,62 +199,82 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
- - -
- - All competitors - - } - description="How often engines mention you, and who they name instead" - title="Visibility" + = STAGE.gauge}> + -
+ + + + = STAGE.modules} + className="lg:col-span-5" + order={0} + > + + = STAGE.modules} + className="lg:col-span-3" + order={1} + > + + = STAGE.modules} + className="lg:col-span-4" + order={2} + > + All competitors + + } points={competitorShare?.points ?? []} /> -
-
- -
- - All prompts - - } - description="The buyer questions where you surface most" - title="Winning prompts" - /> - -
- -
- - - -
+ + = STAGE.modules} + className="lg:col-span-12" + order={3} + > + + All prompts + + } + results={promptResults?.results ?? []} + /> + + = STAGE.modules} + className="lg:col-span-4" + order={4} + > + + + = STAGE.modules} + className="lg:col-span-8" + order={5} + > + + + -
+
- - + +
-
- {Array.from({ length: TILE_COUNT }).map((_, index) => ( +
+ {Array.from({ length: RAIL_TILE_COUNT }).map((_, index) => ( ))}
-
- - +
+ {Array.from({ length: MODULE_COUNT }).map((_, index) => ( + + ))}
- +
); diff --git a/apps/dashboard/src/components/geo/ai-traffic-card.tsx b/apps/dashboard/src/components/geo/ai-traffic-card.tsx index abcd8cd38..6146047cb 100644 --- a/apps/dashboard/src/components/geo/ai-traffic-card.tsx +++ b/apps/dashboard/src/components/geo/ai-traffic-card.tsx @@ -5,13 +5,6 @@ import { Pie } from "@notra/ui/components/dither-kit/pie"; import { PieChart } from "@notra/ui/components/dither-kit/pie-chart"; import { Tooltip as DitherTooltip } from "@notra/ui/components/dither-kit/tooltip"; import { Badge } from "@notra/ui/components/ui/badge"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@notra/ui/components/ui/card"; import { Table, TableBody, @@ -21,6 +14,7 @@ import { TableRow, } from "@notra/ui/components/ui/table"; import { useMemo } from "react"; +import { InstrumentModule } from "@/components/instrument/instrument-module"; import { AI_TRAFFIC_PURPOSE_DESCRIPTIONS, AI_TRAFFIC_PURPOSE_LABELS, @@ -42,6 +36,7 @@ interface AiTrafficCardProps { function PurposeBadge({ category }: { category: string }) { return ( @@ -58,7 +53,7 @@ function AgentRow({ maxHits: number; }) { return ( -
+
@@ -66,18 +61,18 @@ function AgentRow({ - + last seen {formatAiTrafficTimestamp(agent.lastSeenAt)}
-
+
- + {agent.hits} hits
@@ -88,7 +83,7 @@ function AgentRow({ function LogRow({ entry }: { entry: AiTrafficLogEntry }) { return ( - + {formatAiTrafficTimestamp(entry.capturedAt)} {entry.agent} @@ -101,7 +96,7 @@ function LogRow({ entry }: { entry: AiTrafficLogEntry }) { - + {entry.method} @@ -115,7 +110,7 @@ function BeaconSetup({ setup }: { setup: BeaconSetupResponse | undefined }) { No AI agent has been seen on your site yet. Install the beacon to start recording hits.

-
+      
         
           {setup?.snippet ??
             "Set BEACON_INGEST_SECRET to generate your install snippet"}
@@ -156,7 +151,9 @@ function PurposeDonut({ agents }: { agents: AiTrafficAgent[] }) {
 
   return (
     
-

Requests by purpose

+

+ Requests by purpose +

- - AI traffic to your site - - {agents.length > 0 - ? `${totalHits} requests from ${agents.length} AI agents in the last 30 days` - : "Which AI crawlers and assistants fetch your pages, and what they came for"} - - - 0 && "space-y-6")}> - {agents.length === 0 ? ( - - ) : ( -
-
- {agents.map((agent) => ( - - ))} -
- + 0 && "space-y-5")} + eyebrow="AI traffic to your site" + readout={ + agents.length > 0 + ? `${totalHits} requests · ${agents.length} agents · 30D` + : "crawlers and assistants fetching your pages" + } + > + {agents.length === 0 ? ( + + ) : ( +
+
+ {agents.map((agent) => ( + + ))}
- )} + +
+ )} - {log.length > 0 && ( -
-

Recent requests

-
-
- - - When - Provider - Path - Purpose - Method - - - - {log.map((entry) => ( - - ))} - -
-
+ {log.length > 0 && ( +
+

+ Recent requests +

+
+ + + + When + Provider + Path + Purpose + Method + + + + {log.map((entry) => ( + + ))} + +
- )} - - +
+ )} + ); } diff --git a/apps/dashboard/src/components/geo/engine-radar-card.tsx b/apps/dashboard/src/components/geo/engine-radar-card.tsx index 7b58490c2..8f29a3165 100644 --- a/apps/dashboard/src/components/geo/engine-radar-card.tsx +++ b/apps/dashboard/src/components/geo/engine-radar-card.tsx @@ -4,15 +4,12 @@ import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context" import { Radar } from "@notra/ui/components/dither-kit/radar"; import { RadarChart } from "@notra/ui/components/dither-kit/radar-chart"; import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@notra/ui/components/ui/card"; import { useMemo, useState } from "react"; import { ChartSeriesLegend } from "@/components/analytics/chart-legend"; +import { + InstrumentEmpty, + InstrumentModule, +} from "@/components/instrument/instrument-module"; import { GEO_ENGINE_LABELS } from "@/constants/geo"; import type { GeoOverviewEngine } from "@/types/geo"; import { groupEngineFamilies } from "@/utils/geo-charts"; @@ -73,38 +70,32 @@ export function EngineRadarCard({ engines }: EngineRadarCardProps) { }; return ( - - - The grounding gap - - Mention rate per engine, with and without web access - - - - {rows.length < MIN_AXES || visibleKeys.length === 0 ? ( -

- Needs scans across at least three engines -

- ) : ( - - {visibleKeys.map((key) => ( - - ))} - `${value}%`} /> - - )} - + {rows.length < MIN_AXES || visibleKeys.length === 0 ? ( + -
-
+ ) : ( + + {visibleKeys.map((key) => ( + + ))} + `${value}%`} /> + + )} + + ); } diff --git a/apps/dashboard/src/components/geo/geo-summary-stats.tsx b/apps/dashboard/src/components/geo/geo-summary-stats.tsx index d31dea62a..467500c8f 100644 --- a/apps/dashboard/src/components/geo/geo-summary-stats.tsx +++ b/apps/dashboard/src/components/geo/geo-summary-stats.tsx @@ -1,48 +1,26 @@ "use client"; -import { Card, CardContent } from "@notra/ui/components/ui/card"; import { useMemo } from "react"; +import { InstrumentGrid } from "@/components/instrument/instrument-grid"; import { GEO_ENGINE_LABELS } from "@/constants/geo"; -import type { GeoOverviewEngine, GeoSettings } from "@/types/geo"; -import { formatMentionRate } from "@/utils/geo-charts"; - -interface GeoSummaryStatsProps { - engines: GeoOverviewEngine[]; - settings: GeoSettings; - promptCount: number; -} - -interface StatTile { - label: string; - value: string; - hint: string; -} - -const GROUNDED_PATTERN = /-grounded$|^perplexity-sonar$/; +import { cn } from "@/lib/utils"; +import type { GeoStatTile, GeoSummaryStatsProps } from "@/types/geo"; +import { + buildGeoHeroSummary, + formatMentionRate, + gapInsight, +} from "@/utils/geo-charts"; export function GeoSummaryStats({ engines, settings, promptCount, }: GeoSummaryStatsProps) { - const tiles = useMemo(() => { - const grounded = engines.filter((engine) => - GROUNDED_PATTERN.test(engine.engine) - ); - const pool = grounded.length > 0 ? grounded : engines; - const checks = pool.reduce((total, engine) => total + engine.checks, 0); - const mentions = pool.reduce((total, engine) => total + engine.mentions, 0); - const best = [...engines].sort((a, b) => b.mentionRate - a.mentionRate)[0]; + const { visibility, visibilityHint, tiles } = useMemo(() => { + const summary = buildGeoHeroSummary(engines); + const best = summary.bestEngine; - return [ - { - label: "AI visibility", - value: checks > 0 ? formatMentionRate(mentions / checks) : "N/A", - hint: - grounded.length > 0 - ? "web-grounded answers mentioning you" - : "answers mentioning you", - }, + const sideTiles: GeoStatTile[] = [ { label: "Best engine", value: best ? (GEO_ENGINE_LABELS[best.engine] ?? best.engine) : "N/A", @@ -61,19 +39,47 @@ export function GeoSummaryStats({ hint: "named rivals in scans", }, ]; + + return { + visibility: + summary.visibilityRate === null + ? "N/A" + : formatMentionRate(summary.visibilityRate), + visibilityHint: gapInsight(summary.gapPoints), + tiles: sideTiles, + }; }, [engines, settings.competitors.length, promptCount]); return ( -
- {tiles.map((tile) => ( - - -

{tile.label}

-

{tile.value}

-

{tile.hint}

-
-
+ +
+

+ AI visibility +

+

+ {visibility} +

+

+ {visibilityHint} +

+
+ {tiles.map((tile, index) => ( +
+

+ {tile.label} +

+

+ {tile.value} +

+

{tile.hint}

+
))} -
+ ); } diff --git a/apps/dashboard/src/components/geo/mention-rate-card.tsx b/apps/dashboard/src/components/geo/mention-rate-card.tsx index e9122ca45..cffbdc9ff 100644 --- a/apps/dashboard/src/components/geo/mention-rate-card.tsx +++ b/apps/dashboard/src/components/geo/mention-rate-card.tsx @@ -7,25 +7,17 @@ import { Grid } from "@notra/ui/components/dither-kit/grid"; import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; import { XAxis } from "@notra/ui/components/dither-kit/x-axis"; import { YAxis } from "@notra/ui/components/dither-kit/y-axis"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@notra/ui/components/ui/card"; import { useMemo } from "react"; +import { + InstrumentEmpty, + InstrumentModule, +} from "@/components/instrument/instrument-module"; import { ACCOUNT_SERIES_COLORS } from "@/constants/analytics"; import { GEO_ENGINE_LABELS } from "@/constants/geo"; import { cn } from "@/lib/utils"; -import type { GeoOverviewEngine, GeoTimeseriesPoint } from "@/types/geo"; +import type { GeoOverviewEngine, MentionRateCardProps } from "@/types/geo"; import { buildMentionRateRows, formatMentionRate } from "@/utils/geo-charts"; -interface MentionRateCardProps { - engines: GeoOverviewEngine[]; - points: GeoTimeseriesPoint[]; -} - interface EngineFamily { family: string; label: string; @@ -91,20 +83,20 @@ function RateBar({ const percent = Math.round(engine.mentionRate * 100); return (
- + {variant} -
+
- - + + {formatMentionRate(engine.mentionRate)} {" "} @@ -115,7 +107,11 @@ function RateBar({ ); } -export function MentionRateCard({ engines, points }: MentionRateCardProps) { +export function MentionRateCard({ + hero = false, + engines, + points, +}: MentionRateCardProps) { const families = useMemo(() => groupEngines(engines), [engines]); const { rows, engines: trendEngines } = useMemo( () => buildMentionRateRows(points), @@ -137,52 +133,50 @@ export function MentionRateCard({ engines, points }: MentionRateCardProps) { }, [trendEngines]); return ( - - - Mention rate - - How often each engine mentions you, with web search (web) and without - (raw) - - - - {families.length === 0 ? ( -

- No scans yet -

- ) : ( -
- {families.map((family) => ( -
-
- {family.label} - {family.web?.avgPosition !== null && - family.web?.avgPosition !== undefined && ( - - avg position {family.web.avgPosition} - - )} -
- - + + {families.length === 0 ? ( + + ) : ( +
+ {families.map((family) => ( +
+
+ {family.label} + {family.web?.avgPosition !== null && + family.web?.avgPosition !== undefined && ( + + avg position {family.web.avgPosition} + + )}
+ + +
+ ))} +
+ )} + {distinctDays >= TREND_MIN_DAYS && ( +
+ + + + + {trendEngines.map((engine) => ( + ))} -
- )} - {distinctDays >= TREND_MIN_DAYS && ( -
- - - - - {trendEngines.map((engine) => ( - - ))} - `${value}%`} /> - -
- )} - - + `${value}%`} /> + +
+ )} + ); } diff --git a/apps/dashboard/src/components/geo/model-usage-card.tsx b/apps/dashboard/src/components/geo/model-usage-card.tsx index 5f8f95dd4..7e5ac235c 100644 --- a/apps/dashboard/src/components/geo/model-usage-card.tsx +++ b/apps/dashboard/src/components/geo/model-usage-card.tsx @@ -1,13 +1,10 @@ "use client"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@notra/ui/components/ui/card"; import { useMemo } from "react"; +import { + InstrumentEmpty, + InstrumentModule, +} from "@/components/instrument/instrument-module"; import { cn } from "@/lib/utils"; import type { GeoModelUsageResponse, GeoModelUsageRow } from "@/types/geo"; import { @@ -28,7 +25,7 @@ function UsageRow({ maxShare: number; }) { return ( -
+
{model.label} - + {model.scanned && model.mentionRate !== null ? ( - + {formatMentionRate(model.mentionRate)} mention rate ) : ( @@ -49,16 +46,16 @@ function UsageRow({
-
+
- + {formatUsageShare(model.share)}
@@ -78,28 +75,27 @@ export function ModelUsageCard({ usage }: ModelUsageCardProps) { ); return ( - - - Where AI usage actually happens - - {models.length > 0 - ? `Share of industry token volume per model. We scan ${scannedCount} of the top ${models.length}. ${usage?.attribution ?? ""}` - : "Share of industry token volume per model, matched against the engines we scan"} - - - - {models.length === 0 ? ( -

- Run a scan to capture model usage share -

- ) : ( -
- {models.map((model) => ( - - ))} -
- )} -
-
+ 0 + ? `we scan ${scannedCount} of the top ${models.length} · ${usage?.attribution ?? ""}` + : "industry token share per model" + } + > + {models.length === 0 ? ( + + ) : ( +
+ {models.map((model) => ( + + ))} +
+ )} +
); } diff --git a/apps/dashboard/src/components/geo/presence-badge.tsx b/apps/dashboard/src/components/geo/presence-badge.tsx index 20257f933..99e19e1cd 100644 --- a/apps/dashboard/src/components/geo/presence-badge.tsx +++ b/apps/dashboard/src/components/geo/presence-badge.tsx @@ -23,7 +23,7 @@ export function PresenceBadge({ status }: PresenceBadgeProps) { } return ( diff --git a/apps/dashboard/src/components/geo/prompt-results-preview.tsx b/apps/dashboard/src/components/geo/prompt-results-preview.tsx index c65ab5957..9d5e6ce3c 100644 --- a/apps/dashboard/src/components/geo/prompt-results-preview.tsx +++ b/apps/dashboard/src/components/geo/prompt-results-preview.tsx @@ -1,15 +1,20 @@ "use client"; import { Badge } from "@notra/ui/components/ui/badge"; -import { Card, CardContent } from "@notra/ui/components/ui/card"; +import type { ReactNode } from "react"; import { useMemo } from "react"; import { PresenceBadge } from "@/components/geo/presence-badge"; +import { + InstrumentEmpty, + InstrumentModule, +} from "@/components/instrument/instrument-module"; import type { GeoPresenceStatus, GeoPromptResult } from "@/types/geo"; import { classifyPromptPresence } from "@/utils/geo-presence"; interface PromptResultsPreviewProps { results: GeoPromptResult[]; limit?: number; + action?: ReactNode; } interface PromptSummary { @@ -61,40 +66,48 @@ function summarize(results: GeoPromptResult[]): PromptSummary[] { export function PromptResultsPreview({ results, limit = DEFAULT_LIMIT, + action, }: PromptResultsPreviewProps) { const summaries = useMemo(() => summarize(results), [results]); - if (summaries.length === 0) { - return ( - - -

- Run a scan to see which prompts surface you -

-
-
- ); - } - return ( - - - {summaries.slice(0, limit).map((summary) => ( -
-

{summary.prompt}

- - {summary.bestPosition !== null && ( - #{summary.bestPosition} - )} - - {summary.mentioned}/{summary.total} engines - -
- ))} -
-
+ + {summaries.length === 0 ? ( + + ) : ( +
+ {summaries.slice(0, limit).map((summary) => ( +
+

+ {summary.prompt} +

+ + {summary.bestPosition !== null && ( + + #{summary.bestPosition} + + )} + + {summary.mentioned}/{summary.total} engines + +
+ ))} +
+ )} +
); } diff --git a/apps/dashboard/src/components/geo/share-of-voice-donut.tsx b/apps/dashboard/src/components/geo/share-of-voice-donut.tsx index a39f3e4ed..a31cf3d3d 100644 --- a/apps/dashboard/src/components/geo/share-of-voice-donut.tsx +++ b/apps/dashboard/src/components/geo/share-of-voice-donut.tsx @@ -5,20 +5,18 @@ import { PALETTE, rgb } from "@notra/ui/components/dither-kit/palette"; import { Pie } from "@notra/ui/components/dither-kit/pie"; import { PieChart } from "@notra/ui/components/dither-kit/pie-chart"; import { Tooltip } from "@notra/ui/components/dither-kit/tooltip"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@notra/ui/components/ui/card"; +import type { ReactNode } from "react"; import { useMemo } from "react"; +import { + InstrumentEmpty, + InstrumentModule, +} from "@/components/instrument/instrument-module"; import { ACCOUNT_SERIES_COLORS } from "@/constants/analytics"; import type { GeoCompetitorSharePoint } from "@/types/geo"; interface ShareOfVoiceDonutProps { points: GeoCompetitorSharePoint[]; - companyName: string | null; + action?: ReactNode; } interface SliceRow { @@ -27,13 +25,11 @@ interface SliceRow { } const TOP_SLICES = 5; +const PERCENT = 100; const DONUT_INNER_RADIUS = 0.55; -export function ShareOfVoiceDonut({ - points, - companyName, -}: ShareOfVoiceDonutProps) { - const { rows, config, total } = useMemo(() => { +export function ShareOfVoiceDonut({ points, action }: ShareOfVoiceDonutProps) { + const { rows, config, total, caption } = useMemo(() => { const top = points.slice(0, TOP_SLICES); const rest = points.slice(TOP_SLICES); const sliceRows: SliceRow[] = top.map((point) => ({ @@ -55,29 +51,36 @@ export function ShareOfVoiceDonut({ "blue"), }; }); + const sliceTotal = sliceRows.reduce((sum, row) => sum + row.mentions, 0); + const topSlice = sliceRows.reduce( + (best, row) => + row.brand !== "Other" && (best === null || row.mentions > best.mentions) + ? row + : best, + null + ); return { rows: sliceRows, config: sliceConfig, - total: sliceRows.reduce((sum, row) => sum + row.mentions, 0), + total: sliceTotal, + caption: + topSlice && sliceTotal > 0 + ? `${topSlice.brand} · ${Math.round((topSlice.mentions / sliceTotal) * PERCENT)}% of mentions` + : null, }; }, [points]); return ( - - - Share of voice - - Brands AI engines bring up - {companyName ? ` alongside ${companyName}` : ""} - - - - {rows.length === 0 ? ( -

- No competitor data yet -

- ) : ( -
+ + {rows.length === 0 ? ( + + ) : ( +
+
{rows.map((row) => (
{total > 0 - ? `${Math.round((row.mentions / total) * 100)}%` + ? `${Math.round((row.mentions / total) * PERCENT)}%` : "0%"}
))}
- )} - - + {caption && ( +

+ {caption} +

+ )} +
+ )} + ); } diff --git a/apps/dashboard/src/components/layout/section-header.tsx b/apps/dashboard/src/components/layout/section-header.tsx deleted file mode 100644 index 3b555f205..000000000 --- a/apps/dashboard/src/components/layout/section-header.tsx +++ /dev/null @@ -1,23 +0,0 @@ -interface SectionHeaderProps { - title: string; - description?: string; - action?: React.ReactNode; -} - -export function SectionHeader({ - title, - description, - action, -}: SectionHeaderProps) { - return ( -
-
-

{title}

- {description && ( -

{description}

- )} -
- {action} -
- ); -} diff --git a/apps/dashboard/src/types/geo.ts b/apps/dashboard/src/types/geo.ts index c20a85ffa..8b920fac5 100644 --- a/apps/dashboard/src/types/geo.ts +++ b/apps/dashboard/src/types/geo.ts @@ -266,3 +266,28 @@ export interface GeoEngineFamily { web: GeoOverviewEngine | null; raw: GeoOverviewEngine | null; } + +export interface GeoHeroSummary { + visibilityRate: number | null; + grounded: boolean; + gapPoints: number | null; + bestEngine: GeoOverviewEngine | null; +} + +export interface GeoSummaryStatsProps { + engines: GeoOverviewEngine[]; + settings: GeoSettings; + promptCount: number; +} + +export interface GeoStatTile { + label: string; + value: string; + hint: string; +} + +export interface MentionRateCardProps { + hero?: boolean; + engines: GeoOverviewEngine[]; + points: GeoTimeseriesPoint[]; +} diff --git a/apps/dashboard/src/utils/geo-charts.ts b/apps/dashboard/src/utils/geo-charts.ts index 84ce630c7..1288a59cc 100644 --- a/apps/dashboard/src/utils/geo-charts.ts +++ b/apps/dashboard/src/utils/geo-charts.ts @@ -1,5 +1,6 @@ import type { GeoEngineFamily, + GeoHeroSummary, GeoOverviewEngine, GeoTimeseriesPoint, MentionRateRow, @@ -60,6 +61,50 @@ export function buildMentionRateRows(points: GeoTimeseriesPoint[]): { return { rows, engines }; } +function poolRate(pool: GeoOverviewEngine[]): number | null { + const checks = pool.reduce((total, engine) => total + engine.checks, 0); + if (checks === 0) { + return null; + } + const mentions = pool.reduce((total, engine) => total + engine.mentions, 0); + return mentions / checks; +} + +export function buildGeoHeroSummary( + engines: GeoOverviewEngine[] +): GeoHeroSummary { + const grounded = engines.filter((engine) => isGroundedEngine(engine.engine)); + const raw = engines.filter((engine) => !isGroundedEngine(engine.engine)); + const groundedRate = poolRate(grounded); + const rawRate = poolRate(raw); + const visibilityRate = groundedRate ?? poolRate(engines); + const gapPoints = + groundedRate !== null && rawRate !== null + ? Math.round((groundedRate - rawRate) * PERCENT) + : null; + const bestEngine = + [...engines].sort((a, b) => b.mentionRate - a.mentionRate)[0] ?? null; + return { + visibilityRate, + grounded: groundedRate !== null, + gapPoints, + bestEngine, + }; +} + +export function gapInsight(gapPoints: number | null): string { + if (gapPoints === null) { + return "Run scans with and without web search to measure your grounding gap."; + } + if (gapPoints > 0) { + return `Engines mention you ${gapPoints} points more often with web search than from memory alone: your visibility lives in retrieval, not training data.`; + } + if (gapPoints < 0) { + return `Engines mention you ${Math.abs(gapPoints)} points more often from memory than with web search: you are already part of the training data.`; + } + return "Engines mention you as often from memory as with web search: your visibility is evenly grounded."; +} + const GROUNDED_SUFFIX_PATTERN = /(-direct)?-grounded$/; export function engineFamilyOf(engine: string): string { From 73af75fe5316121f3e8f6bac5ecb0b31c30c9a74 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 22:41:53 +0200 Subject: [PATCH 21/26] feat(analytics): add upstash redis cache over tinybird queries --- bun.lock | 2 + packages/analytics/package.json | 4 +- packages/analytics/src/cache/query-cache.ts | 96 ++++++ packages/analytics/src/cache/redis.ts | 15 + packages/analytics/src/constants/cache.ts | 5 + packages/analytics/src/tinybird/client.ts | 338 ++++++++++++-------- packages/analytics/src/types/cache.ts | 9 + 7 files changed, 339 insertions(+), 130 deletions(-) create mode 100644 packages/analytics/src/cache/query-cache.ts create mode 100644 packages/analytics/src/cache/redis.ts create mode 100644 packages/analytics/src/constants/cache.ts create mode 100644 packages/analytics/src/types/cache.ts diff --git a/bun.lock b/bun.lock index 5214e98c6..6346ace8f 100644 --- a/bun.lock +++ b/bun.lock @@ -391,6 +391,8 @@ "version": "0.0.1", "dependencies": { "@tinybirdco/sdk": "^0.0.82", + "@upstash/redis": "^1.35.8", + "effect": "4.0.0-beta.93", }, "devDependencies": { "@notra/typescript-config": "workspace:*", diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 4e8143e95..bac6a1819 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -13,7 +13,9 @@ "./*": "./src/*.ts" }, "dependencies": { - "@tinybirdco/sdk": "^0.0.82" + "@tinybirdco/sdk": "^0.0.82", + "@upstash/redis": "^1.35.8", + "effect": "4.0.0-beta.93" }, "devDependencies": { "@notra/typescript-config": "workspace:*", diff --git a/packages/analytics/src/cache/query-cache.ts b/packages/analytics/src/cache/query-cache.ts new file mode 100644 index 000000000..afc0fd93a --- /dev/null +++ b/packages/analytics/src/cache/query-cache.ts @@ -0,0 +1,96 @@ +import type { Redis } from "@upstash/redis"; +import { Effect } from "effect"; +import { + GLOBAL_SCOPE_ID, + INITIAL_CACHE_VERSION, + QUERY_CACHE_KEY_PREFIX, + QUERY_CACHE_TTL_SECONDS, + VERSION_KEY_PREFIX, +} from "../constants/cache"; +import type { AnalyticsCacheScope, CachedQueryOptions } from "../types/cache"; +import { getAnalyticsRedis } from "./redis"; + +function versionKey( + scope: AnalyticsCacheScope, + organizationId: string | null +): string { + return `${VERSION_KEY_PREFIX}:${scope}:${organizationId ?? GLOBAL_SCOPE_ID}`; +} + +function toJsonSafe(value: unknown): unknown { + return JSON.parse( + JSON.stringify(value, (_key, item) => + typeof item === "bigint" ? Number(item) : item + ) + ); +} + +function stableParams(params: Record): string { + const entries = Object.entries(params) + .filter(([, value]) => value !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return JSON.stringify(Object.fromEntries(entries)); +} + +function readVersion( + redis: Redis, + scope: AnalyticsCacheScope, + organizationId: string | null +): Effect.Effect { + return Effect.tryPromise(() => + redis.get(versionKey(scope, organizationId)) + ).pipe( + Effect.orElseSucceed(() => null), + Effect.map((version) => version ?? INITIAL_CACHE_VERSION) + ); +} + +export function cachedQuery( + options: CachedQueryOptions +): Promise { + const redis = getAnalyticsRedis(); + if (!redis) { + return options.fetch(); + } + const program = Effect.gen(function* () { + const version = yield* readVersion( + redis, + options.scope, + options.organizationId + ); + const key = `${QUERY_CACHE_KEY_PREFIX}:${options.scope}:${version}:${options.pipe}:${stableParams(options.params)}`; + const hit = yield* Effect.tryPromise(() => redis.get(key)).pipe( + Effect.orElseSucceed(() => null) + ); + if (hit !== null) { + return hit; + } + const fresh = yield* Effect.tryPromise(() => options.fetch()); + if (fresh !== null) { + yield* Effect.tryPromise(() => + redis.set(key, toJsonSafe(fresh), { ex: QUERY_CACHE_TTL_SECONDS }) + ).pipe(Effect.ignore); + } + return fresh; + }); + return Effect.runPromise(program); +} + +export function bumpAnalyticsVersions( + scope: AnalyticsCacheScope, + organizationIds: ReadonlyArray +): Promise { + const redis = getAnalyticsRedis(); + const keys = [...new Set(organizationIds.map((id) => versionKey(scope, id)))]; + if (!redis || keys.length === 0) { + return Promise.resolve(); + } + const program = Effect.tryPromise(() => { + const pipeline = redis.pipeline(); + for (const key of keys) { + pipeline.incr(key); + } + return pipeline.exec(); + }).pipe(Effect.ignore); + return Effect.runPromise(program); +} diff --git a/packages/analytics/src/cache/redis.ts b/packages/analytics/src/cache/redis.ts new file mode 100644 index 000000000..c2131507d --- /dev/null +++ b/packages/analytics/src/cache/redis.ts @@ -0,0 +1,15 @@ +import { Redis } from "@upstash/redis"; + +let client: Redis | null = null; + +export function getAnalyticsRedis(): Redis | null { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!(url && token)) { + return null; + } + if (!client) { + client = new Redis({ url, token }); + } + return client; +} diff --git a/packages/analytics/src/constants/cache.ts b/packages/analytics/src/constants/cache.ts new file mode 100644 index 000000000..a62b59aed --- /dev/null +++ b/packages/analytics/src/constants/cache.ts @@ -0,0 +1,5 @@ +export const QUERY_CACHE_KEY_PREFIX = "tb:q"; +export const VERSION_KEY_PREFIX = "tb:ver"; +export const QUERY_CACHE_TTL_SECONDS = 21_600; +export const GLOBAL_SCOPE_ID = "global"; +export const INITIAL_CACHE_VERSION = 0; diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index 94241e247..62afcc1d4 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -1,4 +1,6 @@ import { type IngestResult, type QueryResult, Tinybird } from "@tinybirdco/sdk"; +import { bumpAnalyticsVersions, cachedQuery } from "../cache/query-cache"; +import type { AnalyticsCacheScope } from "../types/cache"; import { type AiTrafficEventRow, aiTrafficEvents, @@ -117,6 +119,8 @@ function getTinybirdClient() { async function ingestRows( rows: TRow[], + scope: AnalyticsCacheScope, + organizationIds: ReadonlyArray, ingest: ( client: NonNullable>, batch: TRow[] @@ -126,253 +130,329 @@ async function ingestRows( if (!client || rows.length === 0) { return null; } - return await ingest(client, rows); + const result = await ingest(client, rows); + await bumpAnalyticsVersions(scope, organizationIds); + return result; +} + +function cachedPipeQuery, TRow>( + scope: AnalyticsCacheScope, + pipe: string, + params: TParams, + organizationId: string | null, + query: ( + client: NonNullable> + ) => Promise> +): Promise | null> { + const client = getTinybirdClient(); + if (!client) { + return Promise.resolve(null); + } + return cachedQuery({ + scope, + pipe, + organizationId, + params, + fetch: () => query(client), + }); } export function ingestSocialAccounts( rows: SocialAccountRow[] ): Promise { - return ingestRows(rows, (client, batch) => - client.socialAccounts.ingestBatch(batch) + return ingestRows( + rows, + "social", + rows.map((row) => row.organization_id), + (client, batch) => client.socialAccounts.ingestBatch(batch) ); } export function ingestSocialAccountStats( rows: SocialAccountStatsRow[] ): Promise { - return ingestRows(rows, (client, batch) => - client.socialAccountStats.ingestBatch(batch) + return ingestRows( + rows, + "social", + rows.map((row) => row.organization_id), + (client, batch) => client.socialAccountStats.ingestBatch(batch) ); } export function ingestSocialPosts( rows: SocialPostRow[] ): Promise { - return ingestRows(rows, (client, batch) => - client.socialPosts.ingestBatch(batch) + return ingestRows( + rows, + "social", + rows.map((row) => row.organization_id), + (client, batch) => client.socialPosts.ingestBatch(batch) ); } export function ingestSocialPostStats( rows: SocialPostStatsRow[] ): Promise { - return ingestRows(rows, (client, batch) => - client.socialPostStats.ingestBatch(batch) + return ingestRows( + rows, + "social", + rows.map((row) => row.organization_id), + (client, batch) => client.socialPostStats.ingestBatch(batch) ); } export function ingestSocialPostSources( rows: SocialPostSourceRow[] ): Promise { - return ingestRows(rows, (client, batch) => - client.socialPostSources.ingestBatch(batch) + return ingestRows( + rows, + "social", + rows.map((row) => row.organization_id), + (client, batch) => client.socialPostSources.ingestBatch(batch) ); } export function ingestGeoMentionChecks( rows: GeoMentionCheckRow[] ): Promise { - return ingestRows(rows, (client, batch) => - client.geoMentionChecks.ingestBatch(batch) + return ingestRows( + rows, + "geo", + rows.map((row) => row.organization_id), + (client, batch) => client.geoMentionChecks.ingestBatch(batch) ); } export function ingestModelUsageShare( rows: ModelUsageShareRow[] ): Promise { - return ingestRows(rows, (client, batch) => + return ingestRows(rows, "model", [null], (client, batch) => client.modelUsageShare.ingestBatch(batch) ); } -export async function querySocialOverview( +export function ingestAiTrafficEvents( + rows: AiTrafficEventRow[] +): Promise { + return ingestRows( + rows, + "traffic", + rows.map((row) => row.organization_id), + (client, batch) => client.aiTrafficEvents.ingestBatch(batch) + ); +} + +export function querySocialOverview( params: SocialOverviewParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.socialOverview.query(params); + return cachedPipeQuery( + "social", + "social_overview", + params, + params.organization_id, + (client) => client.socialOverview.query(params) + ); } -export async function queryEngagementTimeseries( +export function queryEngagementTimeseries( params: EngagementTimeseriesParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.engagementTimeseries.query(params); + return cachedPipeQuery( + "social", + "engagement_timeseries", + params, + params.organization_id, + (client) => client.engagementTimeseries.query(params) + ); } -export async function queryTopPosts( +export function queryTopPosts( params: TopPostsParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.topPosts.query(params); + return cachedPipeQuery( + "social", + "top_posts", + params, + params.organization_id, + (client) => client.topPosts.query(params) + ); } -export async function queryFollowerGrowth( +export function queryFollowerGrowth( params: FollowerGrowthParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.followerGrowth.query(params); + return cachedPipeQuery( + "social", + "follower_growth", + params, + params.organization_id, + (client) => client.followerGrowth.query(params) + ); } -export async function queryPostingPerformance( +export function queryPostingPerformance( params: PostingPerformanceParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.postingPerformance.query(params); + return cachedPipeQuery( + "social", + "posting_performance", + params, + params.organization_id, + (client) => client.postingPerformance.query(params) + ); } -export async function queryNotraAdoption(params: { +export function queryNotraAdoption(params: { organization_id: string; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.notraAdoption.query(params); + return cachedPipeQuery( + "social", + "notra_adoption", + params, + params.organization_id, + (client) => client.notraAdoption.query(params) + ); } -export async function queryGeoOverview(params: { +export function queryGeoOverview(params: { organization_id: string; days?: number; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.geoOverview.query(params); + return cachedPipeQuery( + "geo", + "geo_overview", + params, + params.organization_id, + (client) => client.geoOverview.query(params) + ); } -export async function queryGeoTimeseries(params: { +export function queryGeoTimeseries(params: { organization_id: string; days?: number; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.geoTimeseries.query(params); + return cachedPipeQuery( + "geo", + "geo_timeseries", + params, + params.organization_id, + (client) => client.geoTimeseries.query(params) + ); } -export async function queryGeoPromptResults(params: { +export function queryGeoPromptResults(params: { organization_id: string; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.geoPromptResults.query(params); + return cachedPipeQuery( + "geo", + "geo_prompt_results", + params, + params.organization_id, + (client) => client.geoPromptResults.query(params) + ); } -export async function queryGeoCompetitorShare(params: { +export function queryGeoCompetitorShare(params: { organization_id: string; days?: number; limit?: number; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.geoCompetitorShare.query(params); + return cachedPipeQuery( + "geo", + "geo_competitor_share", + params, + params.organization_id, + (client) => client.geoCompetitorShare.query(params) + ); } -export async function queryAccountLeaderboard( +export function queryAccountLeaderboard( params: AccountLeaderboardParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.accountLeaderboard.query(params); + return cachedPipeQuery( + "social", + "account_leaderboard", + params, + params.organization_id, + (client) => client.accountLeaderboard.query(params) + ); } -export async function queryPostMetricsLookup(params: { +export function queryPostMetricsLookup(params: { organization_id: string; post_ids: string[]; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - // The SDK serializes arrays as repeated query keys (post_ids=a&post_ids=b), - // but Tinybird's Array() template reads a single comma-separated value, so - // repeated keys silently collapse to one id. Send one pre-joined value. - return await client.postMetricsLookup.query({ - organization_id: params.organization_id, - post_ids: [params.post_ids.join(",")], - }); + return cachedPipeQuery( + "social", + "post_metrics_lookup", + params, + params.organization_id, + (client) => + // The SDK serializes arrays as repeated query keys (post_ids=a&post_ids=b), + // but Tinybird's Array() template reads a single comma-separated value, so + // repeated keys silently collapse to one id. Send one pre-joined value. + client.postMetricsLookup.query({ + organization_id: params.organization_id, + post_ids: [params.post_ids.join(",")], + }) + ); } -export async function queryModelUsageLatest( +export function queryModelUsageLatest( params: ModelUsageLatestParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.modelUsageLatest.query(params); + return cachedPipeQuery( + "model", + "model_usage_latest", + params, + null, + (client) => client.modelUsageLatest.query(params) + ); } -export async function queryModelUsageTrend( +export function queryModelUsageTrend( params: ModelUsageTrendParams ): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.modelUsageTrend.query(params); -} - -export function ingestAiTrafficEvents( - rows: AiTrafficEventRow[] -): Promise { - return ingestRows(rows, (client, batch) => - client.aiTrafficEvents.ingestBatch(batch) + return cachedPipeQuery("model", "model_usage_trend", params, null, (client) => + client.modelUsageTrend.query(params) ); } -export async function queryAiTrafficOverview(params: { +export function queryAiTrafficOverview(params: { organization_id: string; days?: number; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.aiTrafficOverview.query(params); + return cachedPipeQuery( + "traffic", + "ai_traffic_overview", + params, + params.organization_id, + (client) => client.aiTrafficOverview.query(params) + ); } -export async function queryAiTrafficTimeseries(params: { +export function queryAiTrafficTimeseries(params: { organization_id: string; days?: number; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.aiTrafficTimeseries.query(params); + return cachedPipeQuery( + "traffic", + "ai_traffic_timeseries", + params, + params.organization_id, + (client) => client.aiTrafficTimeseries.query(params) + ); } -export async function queryAiTrafficLog(params: { +export function queryAiTrafficLog(params: { organization_id: string; limit?: number; }): Promise | null> { - const client = getTinybirdClient(); - if (!client) { - return null; - } - return await client.aiTrafficLog.query(params); + return cachedPipeQuery( + "traffic", + "ai_traffic_log", + params, + params.organization_id, + (client) => client.aiTrafficLog.query(params) + ); } diff --git a/packages/analytics/src/types/cache.ts b/packages/analytics/src/types/cache.ts new file mode 100644 index 000000000..728b2470f --- /dev/null +++ b/packages/analytics/src/types/cache.ts @@ -0,0 +1,9 @@ +export type AnalyticsCacheScope = "social" | "geo" | "model" | "traffic"; + +export interface CachedQueryOptions { + scope: AnalyticsCacheScope; + pipe: string; + organizationId: string | null; + params: Record; + fetch: () => Promise; +} From 521e62342b7c09586be657e8023e9b75aebbf8e6 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 23:09:34 +0200 Subject: [PATCH 22/26] fix(analytics): purge removed accounts from clickhouse and add pointer cursors --- .../components/analytics/account-filter.tsx | 2 +- .../analytics/connect-accounts-buttons.tsx | 4 +- .../src/lib/orpc/routers/analytics.ts | 77 ++++++++++----- packages/analytics/src/tinybird/purge.ts | 97 +++++++++++++++++++ packages/analytics/src/types/purge.ts | 5 + 5 files changed, 157 insertions(+), 28 deletions(-) create mode 100644 packages/analytics/src/tinybird/purge.ts create mode 100644 packages/analytics/src/types/purge.ts diff --git a/apps/dashboard/src/components/analytics/account-filter.tsx b/apps/dashboard/src/components/analytics/account-filter.tsx index 0b6398677..e1d9c9ac2 100644 --- a/apps/dashboard/src/components/analytics/account-filter.tsx +++ b/apps/dashboard/src/components/analytics/account-filter.tsx @@ -34,7 +34,7 @@ export function AccountFilter({ + ); + })} +
+

+ Each scan also asks the engines in these languages so you can see + how you perform beyond English. +

+
+
+ + + + {point.language} + + + + {point.mentions}/{point.checks} checks + +
+
+
+
+
+ + {formatMentionRate(point.mentionRate)} + +
+
+ ); +} + +export function LanguagePerformanceCard({ + points, + configuredLanguages, +}: LanguagePerformanceCardProps) { + const hasExtraLanguages = + configuredLanguages.length > 0 || + points.some((point) => point.language !== "English"); + + return ( + + {hasExtraLanguages && points.length > 0 ? ( +
+ {points.map((point) => ( + + ))} +
+ ) : ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/geo/prompt-manager.tsx b/apps/dashboard/src/components/geo/prompt-manager.tsx index 57394e244..11d772cf6 100644 --- a/apps/dashboard/src/components/geo/prompt-manager.tsx +++ b/apps/dashboard/src/components/geo/prompt-manager.tsx @@ -15,11 +15,13 @@ import { Input } from "@notra/ui/components/ui/input"; import { Switch } from "@notra/ui/components/ui/switch"; import { Loader2Icon } from "lucide-react"; import { useState } from "react"; +import { GEO_LANGUAGE_FLAGS } from "@/constants/geo"; import { useGeoPromptCreate, useGeoPromptDelete, useGeoPrompts, useGeoPromptToggle, + useGeoSettings, } from "@/lib/hooks/use-geo"; import type { GeoTrackedPrompt } from "@/types/geo"; @@ -32,9 +34,11 @@ const MIN_PROMPT_LENGTH = 8; function PromptRow({ prompt, organizationId, + languages, }: { prompt: GeoTrackedPrompt; organizationId: string; + languages: string[]; }) { const toggle = useGeoPromptToggle(organizationId); const remove = useGeoPromptDelete(organizationId); @@ -42,6 +46,15 @@ function PromptRow({ return (

{prompt.prompt}

+ {languages.length > 0 && ( + + {languages.map((language) => ( + + {GEO_LANGUAGE_FLAGS[language]} + + ))} + + )} {prompt.source === "auto" ? ( Auto @@ -71,6 +84,8 @@ function PromptRow({ } export function PromptManager({ organizationId }: PromptManagerProps) { + const { data: settingsData } = useGeoSettings(organizationId); + const languages = settingsData?.settings?.languages ?? []; const [draft, setDraft] = useState(""); const { data } = useGeoPrompts(organizationId); const create = useGeoPromptCreate(organizationId); @@ -124,6 +139,7 @@ export function PromptManager({ organizationId }: PromptManagerProps) { {customPrompts.map((prompt) => ( @@ -131,6 +147,7 @@ export function PromptManager({ organizationId }: PromptManagerProps) { {autoPrompts.map((prompt) => ( diff --git a/apps/dashboard/src/components/geo/prompt-results-preview.tsx b/apps/dashboard/src/components/geo/prompt-results-preview.tsx index 9d5e6ce3c..fd164c43f 100644 --- a/apps/dashboard/src/components/geo/prompt-results-preview.tsx +++ b/apps/dashboard/src/components/geo/prompt-results-preview.tsx @@ -8,6 +8,7 @@ import { InstrumentEmpty, InstrumentModule, } from "@/components/instrument/instrument-module"; +import { GEO_LANGUAGE_FLAGS } from "@/constants/geo"; import type { GeoPresenceStatus, GeoPromptResult } from "@/types/geo"; import { classifyPromptPresence } from "@/utils/geo-presence"; @@ -15,6 +16,7 @@ interface PromptResultsPreviewProps { results: GeoPromptResult[]; limit?: number; action?: ReactNode; + languages?: string[]; } interface PromptSummary { @@ -67,6 +69,7 @@ export function PromptResultsPreview({ results, limit = DEFAULT_LIMIT, action, + languages = [], }: PromptResultsPreviewProps) { const summaries = useMemo(() => summarize(results), [results]); @@ -92,6 +95,15 @@ export function PromptResultsPreview({

{summary.prompt}

+ {languages.length > 0 && ( + + {languages.map((language) => ( + + {GEO_LANGUAGE_FLAGS[language]} + + ))} + + )} {summary.bestPosition !== null && ( = { }; export const GEO_TREND_MIN_DAYS = 5; + +export const GEO_MAX_LANGUAGES = 3; +export const GEO_LANGUAGE_MAX_PROMPTS = 5; +export const GEO_LANGUAGE_GROUNDED_MAX_PROMPTS = 3; +export const GEO_TRANSLATION_MAX_TOKENS = 2000; + +export const GEO_LANGUAGE_FLAGS: Record = LANGUAGE_FLAGS; diff --git a/apps/dashboard/src/lib/geo/scan.ts b/apps/dashboard/src/lib/geo/scan.ts index ff06326ff..80b74cff3 100644 --- a/apps/dashboard/src/lib/geo/scan.ts +++ b/apps/dashboard/src/lib/geo/scan.ts @@ -16,8 +16,12 @@ import { GEO_GROUNDED_MAX_PROMPTS, GEO_JUDGE_MAX_TOKENS, GEO_JUDGE_MODEL, + GEO_LANGUAGE_GROUNDED_MAX_PROMPTS, + GEO_LANGUAGE_MAX_PROMPTS, + GEO_MAX_LANGUAGES, GEO_MAX_PROMPTS, GEO_SCAN_CONCURRENCY, + GEO_TRANSLATION_MAX_TOKENS, } from "@/constants/geo"; import { buildGroundedInvocation, @@ -26,7 +30,10 @@ import { import { GeoScanError } from "@/lib/geo/errors"; import { captureModelUsageShare } from "@/lib/geo/model-usage"; import { buildGeoPrompts } from "@/lib/geo/prompts"; -import { geoJudgeResultSchema } from "@/schemas/geo"; +import { + geoJudgeResultSchema, + geoTranslationResultSchema, +} from "@/schemas/geo"; import type { GeoGroundedEngine, GeoJudgeResult, @@ -42,6 +49,7 @@ interface GeoCheckTask { engine: string; grounded: GeoGroundedEngine | null; prompt: GeoPromptDefinition; + language: string; } interface GeoCheckContext { @@ -85,7 +93,9 @@ Analyze the answer and report: - position: the 1-based rank of the company among the recommended brands if the answer contains an ordered or bulleted list of brands, otherwise null. - sentiment: the sentiment expressed toward the company ("positive", "neutral" or "negative"), or null if it is not mentioned. - competitors: up to ${MAX_JUDGE_COMPETITORS} other brand or product names mentioned in the answer, excluding the company and its aliases. -- excerpt: at most ${GEO_EXCERPT_MAX_LENGTH} characters of the answer around the mention, or the first 200 characters of the answer if the company is not mentioned.`; +- excerpt: at most ${GEO_EXCERPT_MAX_LENGTH} characters of the answer around the mention, or the first 200 characters of the answer if the company is not mentioned. + +The answer may be written in any language or script; count mentions of the company or its aliases regardless of language.`; } const askEngine = Effect.fn("geo.askEngine")(function* ( @@ -156,6 +166,40 @@ const judgeAnswer = Effect.fn("geo.judgeAnswer")(function* ( return judged; }); +const translatePrompts = Effect.fn("geo.translatePrompts")(function* ( + language: string, + prompts: GeoPromptDefinition[] +) { + const result = yield* Effect.tryPromise({ + try: () => + generateText({ + model: gateway(GEO_JUDGE_MODEL), + output: Output.object({ schema: geoTranslationResultSchema }), + prompt: `Translate each prompt into ${language}. Keep brand and product names unchanged. Return the translations in the same order.\n\n${JSON.stringify(prompts.map((prompt) => prompt.text))}`, + system: + "You translate user prompts faithfully, preserving intent and named entities. Respond only with the requested structured data.", + maxOutputTokens: GEO_TRANSLATION_MAX_TOKENS, + }), + catch: (cause) => + new GeoScanError({ + message: `Translation to ${language} failed`, + cause, + }), + }); + const translations = result.output.translations; + if (translations.length !== prompts.length) { + return yield* Effect.fail( + new GeoScanError({ + message: `Translation to ${language} returned ${translations.length} prompts, expected ${prompts.length}`, + }) + ); + } + return prompts.map((prompt, index) => ({ + id: prompt.id, + text: translations[index] ?? prompt.text, + })); +}); + const runGeoCheck = Effect.fn("geo.runCheck")(function* ( context: GeoCheckContext, task: GeoCheckTask @@ -177,6 +221,7 @@ const runGeoCheck = Effect.fn("geo.runCheck")(function* ( sentiment: judged.sentiment, competitors: judged.competitors.slice(0, MAX_JUDGE_COMPETITORS), excerpt: judged.excerpt.slice(0, GEO_EXCERPT_MAX_LENGTH), + language: task.language, }; return row; @@ -205,6 +250,7 @@ export const runGeoScan = Effect.fn("geo.runScan")(function* ( companyName: settingsRow.companyName, aliases: settingsRow.aliases, competitors: settingsRow.competitors, + languages: settingsRow.languages ?? [], enabled: settingsRow.enabled, createdAt: settingsRow.createdAt.toISOString(), updatedAt: settingsRow.updatedAt.toISOString(), @@ -266,7 +312,7 @@ export const runGeoScan = Effect.fn("geo.runScan")(function* ( const tasks: GeoCheckTask[] = []; for (const engine of GEO_ENGINES) { for (const prompt of prompts) { - tasks.push({ engine, grounded: null, prompt }); + tasks.push({ engine, grounded: null, prompt, language: "English" }); } } @@ -274,7 +320,44 @@ export const runGeoScan = Effect.fn("geo.runScan")(function* ( const groundedPrompts = prompts.slice(0, GEO_GROUNDED_MAX_PROMPTS); for (const grounded of groundedEngines) { for (const prompt of groundedPrompts) { - tasks.push({ engine: grounded.key, grounded, prompt }); + tasks.push({ + engine: grounded.key, + grounded, + prompt, + language: "English", + }); + } + } + + const extraLanguages = settings.languages + .filter((language) => language !== "English") + .slice(0, GEO_MAX_LANGUAGES); + for (const language of extraLanguages) { + const localized = yield* translatePrompts( + language, + prompts.slice(0, GEO_LANGUAGE_MAX_PROMPTS) + ).pipe( + Effect.catch((error: GeoScanError) => { + console.error(`[GEO] skipping language ${language}:`, error); + return Effect.succeed(null); + }) + ); + if (!localized) { + continue; + } + for (const engine of GEO_ENGINES) { + for (const prompt of localized) { + tasks.push({ engine, grounded: null, prompt, language }); + } + } + const localizedGrounded = localized.slice( + 0, + GEO_LANGUAGE_GROUNDED_MAX_PROMPTS + ); + for (const grounded of groundedEngines) { + for (const prompt of localizedGrounded) { + tasks.push({ engine: grounded.key, grounded, prompt, language }); + } } } diff --git a/apps/dashboard/src/lib/hooks/use-geo.ts b/apps/dashboard/src/lib/hooks/use-geo.ts index a9e5fb5b4..f4f7f965b 100644 --- a/apps/dashboard/src/lib/hooks/use-geo.ts +++ b/apps/dashboard/src/lib/hooks/use-geo.ts @@ -7,6 +7,7 @@ import type { BeaconSetupResponse, GeoCompetitorShareResponse, GeoGenerateFromWebsiteInput, + GeoLanguageShareResponse, GeoModelUsageResponse, GeoOverviewResponse, GeoPromptCreateInput, @@ -94,6 +95,16 @@ export function useGeoCompetitorShare(organizationId: string, days?: number) { }); } +export function useGeoLanguageShare(organizationId: string, days?: number) { + return useQuery({ + ...dashboardOrpc.geo.languageShare.queryOptions({ + input: { organizationId, days: days ?? DEFAULT_GEO_DAYS }, + }), + enabled: !!organizationId, + meta: { errorMessage: "Failed to load language performance" }, + }); +} + export function useModelUsage(organizationId: string, days?: number) { return useQuery({ ...dashboardOrpc.geo.modelUsage.queryOptions({ diff --git a/apps/dashboard/src/lib/orpc/routers/geo.ts b/apps/dashboard/src/lib/orpc/routers/geo.ts index 13bf6bbd5..8da6ac279 100644 --- a/apps/dashboard/src/lib/orpc/routers/geo.ts +++ b/apps/dashboard/src/lib/orpc/routers/geo.ts @@ -4,6 +4,7 @@ import { queryAiTrafficOverview, queryAiTrafficTimeseries, queryGeoCompetitorShare, + queryGeoLanguageShare, queryGeoOverview, queryGeoPromptResults, queryGeoTimeseries, @@ -47,6 +48,7 @@ import type { BeaconSetupResponse, GeoCompetitorShareResponse, GeoGenerateFromWebsiteResult, + GeoLanguageShareResponse, GeoModelUsageResponse, GeoModelUsageRow, GeoOverviewResponse, @@ -65,6 +67,7 @@ interface GeoSettingsRow { companyName: string; aliases: string[]; competitors: string[]; + languages: string[] | null; enabled: boolean; createdAt: Date; updatedAt: Date; @@ -77,6 +80,7 @@ function toGeoSettings(row: GeoSettingsRow): GeoSettings { companyName: row.companyName, aliases: row.aliases, competitors: row.competitors, + languages: row.languages ?? [], enabled: row.enabled, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -174,6 +178,7 @@ export const geoRouter = { companyName: input.companyName, aliases: input.aliases, competitors: input.competitors, + languages: input.languages, enabled: input.enabled, }) .onConflictDoUpdate({ @@ -182,6 +187,7 @@ export const geoRouter = { companyName: input.companyName, aliases: input.aliases, competitors: input.competitors, + languages: input.languages, enabled: input.enabled, }, }) @@ -192,6 +198,34 @@ export const geoRouter = { settings: row ? toGeoSettings(row) : null, }; }), + languageShare: authorizedProcedure + .input(geoTimeseriesInputSchema) + .handler(async ({ context, input }): Promise => { + await assertOrganizationAccess({ + headers: context.headers, + organizationId: input.organizationId, + user: context.user, + }); + + const result = await queryGeoLanguageShare({ + organization_id: input.organizationId, + days: input.days, + }).catch((error) => { + console.error("[GEO] language share query failed:", error); + return null; + }); + + return { + configured: isTinybirdConfigured(), + points: (result?.data ?? []).map((row) => ({ + language: row.language_name, + checks: Number(row.checks), + mentions: Number(row.mentions), + mentionRate: Number(row.mention_rate), + avgPosition: toNullableNumber(row.avg_position), + })), + }; + }), overview: authorizedProcedure .input(geoTimeseriesInputSchema) .handler(async ({ context, input }): Promise => { diff --git a/apps/dashboard/src/schemas/geo.ts b/apps/dashboard/src/schemas/geo.ts index 855ed1c7b..c06643b7e 100644 --- a/apps/dashboard/src/schemas/geo.ts +++ b/apps/dashboard/src/schemas/geo.ts @@ -1,3 +1,4 @@ +import { SUPPORTED_LANGUAGES } from "@notra/ai/constants/languages"; import { array, boolean, enum as enumType, number, object, string } from "zod"; import { GEO_DISCOVERY_MAX_ALIASES, @@ -5,11 +6,13 @@ import { GEO_DISCOVERY_MAX_PROMPTS, GEO_DISCOVERY_MIN_COMPETITORS, GEO_DISCOVERY_MIN_PROMPTS, + GEO_MAX_LANGUAGES, GEO_PROMPT_MAX_LENGTH, GEO_PROMPT_MIN_LENGTH, } from "@/constants/geo"; import { publicWebsiteUrlSchema } from "@/schemas/url"; +const GEO_SUPPORTED_LANGUAGE_SET = new Set(SUPPORTED_LANGUAGES); const MAX_ALIASES = 10; const MAX_COMPETITORS = 10; const MAX_JUDGE_COMPETITORS = 15; @@ -27,9 +30,22 @@ export const geoSettingsUpsertInputSchema = object({ companyName: string().min(1), aliases: array(string().min(1)).max(MAX_ALIASES), competitors: array(string().min(1)).max(MAX_COMPETITORS), + languages: array(string().min(1)) + .max(GEO_MAX_LANGUAGES) + .refine( + (values) => + values.every((value) => GEO_SUPPORTED_LANGUAGE_SET.has(value)), + { + message: "Unsupported language", + } + ), enabled: boolean(), }); +export const geoTranslationResultSchema = object({ + translations: array(string().min(1)), +}); + export const geoOrganizationInputSchema = object({ organizationId: string().min(1), }); diff --git a/apps/dashboard/src/types/geo.ts b/apps/dashboard/src/types/geo.ts index 968a1b618..29e6daeb7 100644 --- a/apps/dashboard/src/types/geo.ts +++ b/apps/dashboard/src/types/geo.ts @@ -4,6 +4,7 @@ export interface GeoSettings { companyName: string; aliases: string[]; competitors: string[]; + languages: string[]; enabled: boolean; createdAt: string; updatedAt: string; @@ -71,6 +72,7 @@ export interface GeoSettingsUpsertInput { companyName: string; aliases: string[]; competitors: string[]; + languages: string[]; enabled: boolean; } @@ -286,6 +288,24 @@ export interface GeoStatTile { hint: string; } +export interface GeoLanguageSharePoint { + language: string; + checks: number; + mentions: number; + mentionRate: number; + avgPosition: number | null; +} + +export interface GeoLanguageShareResponse { + configured: boolean; + points: GeoLanguageSharePoint[]; +} + +export interface LanguagePerformanceCardProps { + points: GeoLanguageSharePoint[]; + configuredLanguages: string[]; +} + export interface MentionRateCardProps { engines: GeoOverviewEngine[]; } diff --git a/packages/analytics/src/tinybird/client.ts b/packages/analytics/src/tinybird/client.ts index 62afcc1d4..a7911cb97 100644 --- a/packages/analytics/src/tinybird/client.ts +++ b/packages/analytics/src/tinybird/client.ts @@ -36,10 +36,12 @@ import { type FollowerGrowthRow, followerGrowth, type GeoCompetitorShareRow, + type GeoLanguageShareRow, type GeoOverviewRow, type GeoPromptResultsRow, type GeoTimeseriesRow, geoCompetitorShare, + geoLanguageShare, geoOverview, geoPromptResults, geoTimeseries, @@ -95,6 +97,7 @@ function createTinybirdClient() { geoTimeseries, geoPromptResults, geoCompetitorShare, + geoLanguageShare, accountLeaderboard, modelUsageLatest, modelUsageTrend, @@ -366,6 +369,19 @@ export function queryGeoCompetitorShare(params: { ); } +export function queryGeoLanguageShare(params: { + organization_id: string; + days?: number; +}): Promise | null> { + return cachedPipeQuery( + "geo", + "geo_language_share", + params, + params.organization_id, + (client) => client.geoLanguageShare.query(params) + ); +} + export function queryAccountLeaderboard( params: AccountLeaderboardParams ): Promise | null> { diff --git a/packages/analytics/src/tinybird/datasources.ts b/packages/analytics/src/tinybird/datasources.ts index e16f1b59c..ae06ae839 100644 --- a/packages/analytics/src/tinybird/datasources.ts +++ b/packages/analytics/src/tinybird/datasources.ts @@ -216,6 +216,7 @@ export const geoMentionChecks = defineDatasource("geo_mention_checks", { sentiment: t.string().lowCardinality().nullable(), competitors: t.array(t.string()).jsonPath("$.competitors[:]"), excerpt: t.string(), + language: t.string().lowCardinality(), }, engine: engine.mergeTree({ sortingKey: ["organization_id", "engine", "prompt_id", "captured_at"], diff --git a/packages/analytics/src/tinybird/endpoints.ts b/packages/analytics/src/tinybird/endpoints.ts index 4949fd20c..13211e895 100644 --- a/packages/analytics/src/tinybird/endpoints.ts +++ b/packages/analytics/src/tinybird/endpoints.ts @@ -578,6 +578,7 @@ export const geoOverview = defineEndpoint("geo_overview", { FROM geo_mention_checks WHERE organization_id = {{String(organization_id)}} AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) + AND language IN ('', 'English') GROUP BY engine ORDER BY mention_rate DESC `, @@ -611,6 +612,7 @@ export const geoTimeseries = defineEndpoint("geo_timeseries", { FROM geo_mention_checks WHERE organization_id = {{String(organization_id)}} AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) + AND language IN ('', 'English') GROUP BY day, engine ORDER BY day ASC `, @@ -644,6 +646,7 @@ export const geoPromptResults = defineEndpoint("geo_prompt_results", { max(captured_at) AS last_checked_at FROM geo_mention_checks WHERE organization_id = {{String(organization_id)}} + AND language IN ('', 'English') GROUP BY prompt_id, engine ORDER BY prompt_id ASC, engine ASC `, @@ -690,6 +693,42 @@ export const geoCompetitorShare = defineEndpoint("geo_competitor_share", { }, }); +export const geoLanguageShare = defineEndpoint("geo_language_share", { + description: + "Mention rate per answer language over the trailing window; legacy rows without a language count as English", + params: { + organization_id: p.string().describe("Organization id"), + days: p.int32().optional(30).describe("Number of trailing days"), + }, + nodes: [ + node({ + name: "per_language", + sql: ` + SELECT + if(language = '', 'English', language) AS language_name, + count() AS checks, + countIf(mentioned) AS mentions, + round(countIf(mentioned) / count(), 3) AS mention_rate, + round(avgIf(position, mentioned AND position IS NOT NULL), 1) AS avg_position, + max(captured_at) AS last_checked_at + FROM geo_mention_checks + WHERE organization_id = {{String(organization_id)}} + AND captured_at >= now() - toIntervalDay({{Int32(days, 30)}}) + GROUP BY language_name + ORDER BY mention_rate DESC + `, + }), + ], + output: { + language_name: t.string(), + checks: t.uint64(), + mentions: t.uint64(), + mention_rate: t.float64(), + avg_position: t.float64().nullable(), + last_checked_at: t.dateTime(), + }, +}); + export const modelUsageLatest = defineEndpoint("model_usage_latest", { description: "Most recent industry-wide usage share snapshot per model, ranked by share", @@ -786,6 +825,7 @@ export type GeoOverviewRow = InferOutputRow; export type GeoTimeseriesRow = InferOutputRow; export type GeoPromptResultsRow = InferOutputRow; export type GeoCompetitorShareRow = InferOutputRow; +export type GeoLanguageShareRow = InferOutputRow; export const postMetricsLookup = defineEndpoint("post_metrics_lookup", { description: "Latest metric snapshot for specific posts by platform post id", diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e06a055fd..3bbca1b1c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1465,6 +1465,7 @@ export const geoSettings = pgTable( .array() .notNull() .default(sql`ARRAY[]::text[]`), + languages: text("languages").array(), enabled: boolean("enabled").notNull().default(true), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") From af6bcd362995070d5c7dd445a72162d4422c8bb5 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Sun, 2 Aug 2026 23:41:17 +0200 Subject: [PATCH 25/26] chore(db): add languages column to geo settings --- packages/db/migrations/0065_cool_prism.sql | 1 + .../db/migrations/meta/0065_snapshot.json | 9310 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + 3 files changed, 9318 insertions(+) create mode 100644 packages/db/migrations/0065_cool_prism.sql create mode 100644 packages/db/migrations/meta/0065_snapshot.json diff --git a/packages/db/migrations/0065_cool_prism.sql b/packages/db/migrations/0065_cool_prism.sql new file mode 100644 index 000000000..e6b19b33d --- /dev/null +++ b/packages/db/migrations/0065_cool_prism.sql @@ -0,0 +1 @@ +ALTER TABLE "geo_settings" ADD COLUMN "languages" text[]; \ No newline at end of file diff --git a/packages/db/migrations/meta/0065_snapshot.json b/packages/db/migrations/meta/0065_snapshot.json new file mode 100644 index 000000000..756c7b128 --- /dev/null +++ b/packages/db/migrations/meta/0065_snapshot.json @@ -0,0 +1,9310 @@ +{ + "id": "2c7b2ce0-54f1-4316-9ef9-7ab2b9d1b1c8", + "prevId": "5565d305-8e7a-47db-8672-c03fa92c9ebf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_userId_idx": { + "name": "accounts_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eve_session_id": { + "name": "eve_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "continuation_token": { + "name": "continuation_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_index": { + "name": "stream_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentSessions_eveSessionId_uidx": { + "name": "agentSessions_eveSessionId_uidx", + "columns": [ + { + "expression": "eve_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_organizationId_idx": { + "name": "agentSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_chatId_idx": { + "name": "agentSessions_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_organization_id_organizations_id_fk": { + "name": "agent_sessions_organization_id_organizations_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_chat_id_chat_sessions_id_fk": { + "name": "agent_sessions_chat_id_chat_sessions_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_actions": { + "name": "autonomy_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "external_ref": { + "name": "external_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyActions_organizationId_idx": { + "name": "autonomyActions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_runId_idx": { + "name": "autonomyActions_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_org_capability_idempotency_uidx": { + "name": "autonomyActions_org_capability_idempotency_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_organizationId_status_idx": { + "name": "autonomyActions_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_actions_organization_id_organizations_id_fk": { + "name": "autonomy_actions_organization_id_organizations_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_run_id_autonomy_runs_id_fk": { + "name": "autonomy_actions_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_actions_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_checkpoints": { + "name": "autonomy_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyCheckpoints_organizationId_idx": { + "name": "autonomyCheckpoints_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyCheckpoints_runId_idx": { + "name": "autonomyCheckpoints_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_checkpoints_organization_id_organizations_id_fk": { + "name": "autonomy_checkpoints_organization_id_organizations_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_run_id_autonomy_runs_id_fk": { + "name": "autonomy_checkpoints_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_checkpoints_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_claims": { + "name": "autonomy_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_key": { + "name": "claim_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyClaims_scope_claimKey_uidx": { + "name": "autonomyClaims_scope_claimKey_uidx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyClaims_expiresAt_idx": { + "name": "autonomyClaims_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_claims_organization_id_organizations_id_fk": { + "name": "autonomy_claims_organization_id_organizations_id_fk", + "tableFrom": "autonomy_claims", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_controller_leases": { + "name": "autonomy_controller_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyControllerLeases_organizationId_idx": { + "name": "autonomyControllerLeases_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_controller_leases_organization_id_organizations_id_fk": { + "name": "autonomy_controller_leases_organization_id_organizations_id_fk", + "tableFrom": "autonomy_controller_leases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_goals": { + "name": "autonomy_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_goal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "origin_signal_ids": { + "name": "origin_signal_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyGoals_organizationId_idx": { + "name": "autonomyGoals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_mandateId_idx": { + "name": "autonomyGoals_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_organizationId_status_idx": { + "name": "autonomyGoals_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_goals_organization_id_organizations_id_fk": { + "name": "autonomy_goals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_goals_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_goals_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_mandates": { + "name": "autonomy_mandates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_mandate_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyMandates_organizationId_idx": { + "name": "autonomyMandates_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyMandates_organizationId_name_uidx": { + "name": "autonomyMandates_organizationId_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_mandates_organization_id_organizations_id_fk": { + "name": "autonomy_mandates_organization_id_organizations_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_mandates_created_by_user_id_users_id_fk": { + "name": "autonomy_mandates_created_by_user_id_users_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_outbox": { + "name": "autonomy_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyOutbox_organizationId_idx": { + "name": "autonomyOutbox_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_org_destination_dedupeKey_uidx": { + "name": "autonomyOutbox_org_destination_dedupeKey_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_status_nextAttemptAt_idx": { + "name": "autonomyOutbox_status_nextAttemptAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_outbox_organization_id_organizations_id_fk": { + "name": "autonomy_outbox_organization_id_organizations_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_outbox_run_id_autonomy_runs_id_fk": { + "name": "autonomy_outbox_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_runs": { + "name": "autonomy_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_version": { + "name": "mandate_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "autonomy_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "planner_input_hash": { + "name": "planner_input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planner_output": { + "name": "planner_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyRuns_organizationId_idx": { + "name": "autonomyRuns_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_mandateId_idx": { + "name": "autonomyRuns_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_goalId_idx": { + "name": "autonomyRuns_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_organizationId_status_idx": { + "name": "autonomyRuns_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_runs_organization_id_organizations_id_fk": { + "name": "autonomy_runs_organization_id_organizations_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_runs_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_runs_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_signals": { + "name": "autonomy_signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_signal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "coalesced_into_signal_id": { + "name": "coalesced_into_signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomySignals_organizationId_idx": { + "name": "autonomySignals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_dedupeHash_uidx": { + "name": "autonomySignals_organizationId_dedupeHash_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_status_occurredAt_idx": { + "name": "autonomySignals_organizationId_status_occurredAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_signals_organization_id_organizations_id_fk": { + "name": "autonomy_signals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_signals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomySignals_coalescedIntoSignalId_fk": { + "name": "autonomySignals_coalescedIntoSignalId_fk", + "tableFrom": "autonomy_signals", + "tableTo": "autonomy_signals", + "columnsFrom": [ + "coalesced_into_signal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_tasks": { + "name": "autonomy_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "depends_on_task_ids": { + "name": "depends_on_task_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "autonomy_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyTasks_organizationId_idx": { + "name": "autonomyTasks_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_goalId_idx": { + "name": "autonomyTasks_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_runId_idx": { + "name": "autonomyTasks_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_organizationId_status_waitUntil_idx": { + "name": "autonomyTasks_organizationId_status_waitUntil_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_tasks_organization_id_organizations_id_fk": { + "name": "autonomy_tasks_organization_id_organizations_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_tasks_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_run_id_autonomy_runs_id_fk": { + "name": "autonomy_tasks_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_assets": { + "name": "brand_guideline_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_asset_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "brand_guideline_asset_variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineAssets_guidelineId_idx": { + "name": "brandGuidelineAssets_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_idx": { + "name": "brandGuidelineAssets_guideline_kind_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_variant_uidx": { + "name": "brandGuidelineAssets_guideline_kind_variant_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_assets_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_assets_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_assets", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_colors": { + "name": "brand_guideline_colors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_color_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "light_value": { + "name": "light_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dark_value": { + "name": "dark_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineColors_guidelineId_idx": { + "name": "brandGuidelineColors_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineColors_guideline_role_idx": { + "name": "brandGuidelineColors_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_colors_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_colors_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_colors", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_fonts": { + "name": "brand_guideline_fonts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_font_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_height": { + "name": "line_height", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineFonts_guidelineId_idx": { + "name": "brandGuidelineFonts_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineFonts_guideline_role_idx": { + "name": "brandGuidelineFonts_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_fonts", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_screenshots": { + "name": "brand_guideline_screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_screenshot_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_page": { + "name": "full_page", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineScreenshots_guidelineId_idx": { + "name": "brandGuidelineScreenshots_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineScreenshots_guideline_kind_uidx": { + "name": "brandGuidelineScreenshots_guideline_kind_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_screenshots", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_tokens": { + "name": "brand_guideline_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "brand_guideline_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineTokens_guidelineId_idx": { + "name": "brandGuidelineTokens_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineTokens_guideline_type_idx": { + "name": "brandGuidelineTokens_guideline_type_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_tokens", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guidelines": { + "name": "brand_guidelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_guideline_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_generated_at": { + "name": "last_generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_generation_error": { + "name": "last_generation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelines_brandSettingsId_uidx": { + "name": "brandGuidelines_brandSettingsId_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelines_status_idx": { + "name": "brandGuidelines_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guidelines_brand_settings_id_brand_settings_id_fk": { + "name": "brand_guidelines_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_guidelines", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_references": { + "name": "brand_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "reference_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_key": { + "name": "source_snapshot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_hash": { + "name": "source_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_captured_at": { + "name": "source_captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_document_id": { + "name": "supermemory_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_memory_id": { + "name": "supermemory_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_synced_at": { + "name": "supermemory_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supermemory_last_sync_error": { + "name": "supermemory_last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicable_to": { + "name": "applicable_to", + "type": "applicable_platform[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['all']::applicable_platform[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandReferences_brandSettingsId_idx": { + "name": "brandReferences_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandReferences_brandSettingsId_sourceUrl_idx": { + "name": "brandReferences_brandSettingsId_sourceUrl_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_references_brand_settings_id_brand_settings_id_fk": { + "name": "brand_references_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_references", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_settings": { + "name": "brand_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tone_profile": { + "name": "tone_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_tone": { + "name": "custom_tone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_instructions": { + "name": "custom_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'English'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSettings_org_name_uidx": { + "name": "brandSettings_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_org_default_uidx": { + "name": "brandSettings_org_default_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brand_settings\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_organizationId_idx": { + "name": "brandSettings_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_settings_organization_id_organizations_id_fk": { + "name": "brand_settings_organization_id_organizations_id_fk", + "tableFrom": "brand_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brandSettings_toneProfile_check": { + "name": "brandSettings_toneProfile_check", + "value": "\"brand_settings\".\"tone_profile\" IS NULL OR \"brand_settings\".\"tone_profile\" IN ('Conversational', 'Professional', 'Casual', 'Formal')" + } + }, + "isRLSEnabled": false + }, + "public.brand_sitemap_pages": { + "name": "brand_sitemap_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sitemap_id": { + "name": "sitemap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "brand_sitemap_page_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_target": { + "name": "redirect_target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text_ratio": { + "name": "text_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "internal_links": { + "name": "internal_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_links": { + "name": "external_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "crawled_at": { + "name": "crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemapPages_sitemapId_idx": { + "name": "brandSitemapPages_sitemapId_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_category_idx": { + "name": "brandSitemapPages_sitemap_category_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_url_uidx": { + "name": "brandSitemapPages_sitemap_url_uidx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk": { + "name": "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk", + "tableFrom": "brand_sitemap_pages", + "tableTo": "brand_sitemaps", + "columnsFrom": [ + "sitemap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_sitemaps": { + "name": "brand_sitemaps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_sitemap_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "total_pages": { + "name": "total_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_pages": { + "name": "indexed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_pages": { + "name": "failed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_crawl_started_at": { + "name": "last_crawl_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawled_at": { + "name": "last_crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawl_error": { + "name": "last_crawl_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemaps_brandSettingsId_idx": { + "name": "brandSitemaps_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemaps_brandSettings_url_uidx": { + "name": "brandSitemaps_brandSettings_url_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemaps_brand_settings_id_brand_settings_id_fk": { + "name": "brand_sitemaps_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_sitemaps", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatAttachments_organizationId_createdAt_idx": { + "name": "chatAttachments_organizationId_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatAttachments_userId_idx": { + "name": "chatAttachments_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_user_id_users_id_fk": { + "name": "chat_attachments_user_id_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_attachments_key_unique": { + "name": "chat_attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_channel_source": { + "name": "external_channel_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_channel_id": { + "name": "external_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatSessions_organizationId_idx": { + "name": "chatSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_organizationId_deletedAt_idx": { + "name": "chatSessions_organizationId_deletedAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_org_externalChannel_uidx": { + "name": "chatSessions_org_externalChannel_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_sessions\".\"external_channel_source\" IN ('discord', 'slack') AND \"chat_sessions\".\"external_channel_id\" IS NOT NULL AND \"chat_sessions\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connected_social_accounts": { + "name": "connected_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connectedSocialAccounts_organizationId_idx": { + "name": "connectedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connectedSocialAccounts_org_provider_account_uidx": { + "name": "connectedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connected_social_accounts_organization_id_organizations_id_fk": { + "name": "connected_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "connected_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_trigger_lookback_windows": { + "name": "content_trigger_lookback_windows", + "schema": "", + "columns": { + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window": { + "name": "window", + "type": "lookback_window", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk": { + "name": "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk", + "tableFrom": "content_trigger_lookback_windows", + "tableTo": "content_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_triggers": { + "name": "content_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Schedule'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_config": { + "name": "source_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_config": { + "name": "output_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_publish": { + "name": "auto_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contentTriggers_organizationId_idx": { + "name": "contentTriggers_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentTriggers_organization_dedupe_uidx": { + "name": "contentTriggers_organization_dedupe_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_triggers_organization_id_organizations_id_fk": { + "name": "content_triggers_organization_id_organizations_id_fk", + "tableFrom": "content_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_prompts": { + "name": "geo_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoPrompts_organizationId_idx": { + "name": "geoPrompts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_prompts_organization_id_organizations_id_fk": { + "name": "geo_prompts_organization_id_organizations_id_fk", + "tableFrom": "geo_prompts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_settings": { + "name": "geo_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "competitors": { + "name": "competitors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoSettings_organizationId_uidx": { + "name": "geoSettings_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_settings_organization_id_organizations_id_fk": { + "name": "geo_settings_organization_id_organizations_id_fk", + "tableFrom": "geo_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubAppInstallations_organizationId_idx": { + "name": "githubAppInstallations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_createdByUserId_idx": { + "name": "githubAppInstallations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_organization_installation_uidx": { + "name": "githubAppInstallations_organization_installation_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_organization_id_organizations_id_fk": { + "name": "github_app_installations_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_app_installations_created_by_user_id_users_id_fk": { + "name": "github_app_installations_created_by_user_id_users_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integrations": { + "name": "github_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_installation_id": { + "name": "github_app_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_private": { + "name": "github_repository_private", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_enabled": { + "name": "repository_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubIntegrations_organizationId_idx": { + "name": "githubIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_createdByUserId_idx": { + "name": "githubIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_organization_owner_repo_uidx": { + "name": "githubIntegrations_organization_owner_repo_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_integrations_organization_id_organizations_id_fk": { + "name": "github_integrations_organization_id_organizations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_created_by_user_id_users_id_fk": { + "name": "github_integrations_created_by_user_id_users_id_fk", + "tableFrom": "github_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_github_app_installation_id_github_app_installations_id_fk": { + "name": "github_integrations_github_app_installation_id_github_app_installations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "github_app_installations", + "columnsFrom": [ + "github_app_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.granola_integrations": { + "name": "granola_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "granolaIntegrations_organizationId_idx": { + "name": "granolaIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "granolaIntegrations_createdByUserId_idx": { + "name": "granolaIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "granola_integrations_organization_id_organizations_id_fk": { + "name": "granola_integrations_organization_id_organizations_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "granola_integrations_created_by_user_id_users_id_fk": { + "name": "granola_integrations_created_by_user_id_users_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organizationId_idx": { + "name": "invitations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_integrations": { + "name": "linear_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_name": { + "name": "linear_organization_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_name": { + "name": "linear_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linearIntegrations_organizationId_idx": { + "name": "linearIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_createdByUserId_idx": { + "name": "linearIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_team_uidx": { + "name": "linearIntegrations_org_linearOrg_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_no_team_uidx": { + "name": "linearIntegrations_org_linearOrg_no_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"linear_integrations\".\"linear_team_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_integrations_organization_id_organizations_id_fk": { + "name": "linear_integrations_organization_id_organizations_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "linear_integrations_created_by_user_id_users_id_fk": { + "name": "linear_integrations_created_by_user_id_users_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_credentials": { + "name": "mcp_oauth_credentials", + "schema": "", + "columns": { + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_tokens": { + "name": "encrypted_tokens", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_refresh_at": { + "name": "access_token_refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_lease_id": { + "name": "refresh_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_lease_expires_at": { + "name": "refresh_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthCredentials_organizationId_idx": { + "name": "mcpOAuthCredentials_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthCredentials_connectedByUserId_idx": { + "name": "mcpOAuthCredentials_connectedByUserId_idx", + "columns": [ + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_organization_id_organizations_id_fk": { + "name": "mcp_oauth_credentials_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_connected_by_user_id_users_id_fk": { + "name": "mcp_oauth_credentials_connected_by_user_id_users_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "users", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthCredentials_org_server_fk": { + "name": "mcpOAuthCredentials_org_server_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpOAuthCredentials_status_check": { + "name": "mcpOAuthCredentials_status_check", + "value": "\"mcp_oauth_credentials\".\"status\" IN ('connected', 'refreshing', 'reauth_required')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_oauth_pending_authorizations": { + "name": "mcp_oauth_pending_authorizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_path": { + "name": "callback_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_code_verifier": { + "name": "encrypted_code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthPendingAuthorizations_organizationId_idx": { + "name": "mcpOAuthPendingAuthorizations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_userId_idx": { + "name": "mcpOAuthPendingAuthorizations_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_serverIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_serverIntegrationId_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_expiresAt_idx": { + "name": "mcpOAuthPendingAuthorizations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk": { + "name": "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_user_id_users_id_fk": { + "name": "mcp_oauth_pending_authorizations_user_id_users_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_org_server_fk": { + "name": "mcpOAuthPendingAuthorizations_org_server_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_pending_authorizations_state_hash_unique": { + "name": "mcp_oauth_pending_authorizations_state_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "state_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_integrations": { + "name": "mcp_server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connection'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_light_url": { + "name": "logo_light_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_dark_url": { + "name": "logo_dark_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_featured_at": { + "name": "store_featured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_status": { + "name": "store_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_tool_sync_at": { + "name": "last_tool_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tool_sync_status": { + "name": "tool_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "tool_sync_error": { + "name": "tool_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexed_tool_count": { + "name": "indexed_tool_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpServerIntegrations_resourceType_idx": { + "name": "mcpServerIntegrations_resourceType_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeStatus_idx": { + "name": "mcpServerIntegrations_storeStatus_idx", + "columns": [ + { + "expression": "store_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_organizationId_idx": { + "name": "mcpServerIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_createdByUserId_idx": { + "name": "mcpServerIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeSourceIntegrationId_idx": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_id_uidx": { + "name": "mcpServerIntegrations_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_resourceType_name_uidx": { + "name": "mcpServerIntegrations_org_resourceType_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_storeSource_uidx": { + "name": "mcpServerIntegrations_org_storeSource_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"store_source_integration_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeListing_slug_uidx": { + "name": "mcpServerIntegrations_storeListing_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"resource_type\" = 'store_listing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_integrations_organization_id_organizations_id_fk": { + "name": "mcp_server_integrations_organization_id_organizations_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_integrations_created_by_user_id_users_id_fk": { + "name": "mcp_server_integrations_created_by_user_id_users_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpServerIntegrations_storeSourceIntegrationId_fk": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpServerIntegrations_authType_check": { + "name": "mcpServerIntegrations_authType_check", + "value": "\"mcp_server_integrations\".\"auth_type\" IN ('none', 'headers', 'oauth')" + }, + "mcpServerIntegrations_storeStatus_check": { + "name": "mcpServerIntegrations_storeStatus_check", + "value": "\"mcp_server_integrations\".\"store_status\" IN ('draft', 'pending_review', 'live', 'rejected')" + }, + "mcpServerIntegrations_resourceType_check": { + "name": "mcpServerIntegrations_resourceType_check", + "value": "\"mcp_server_integrations\".\"resource_type\" IN ('connection', 'store_listing')" + }, + "mcpServerIntegrations_category_check": { + "name": "mcpServerIntegrations_category_check", + "value": "\"mcp_server_integrations\".\"category\" IS NULL OR \"mcp_server_integrations\".\"category\" IN ('AI', 'Source control', 'Project management', 'Communication', 'Design', 'Notes', 'Deploys', 'Productivity', 'Marketing', 'Publishing')" + }, + "mcpServerIntegrations_resourceState_check": { + "name": "mcpServerIntegrations_resourceState_check", + "value": "(\n (\"mcp_server_integrations\".\"resource_type\" = 'store_listing' AND \"mcp_server_integrations\".\"store_source_integration_id\" IS NULL)\n OR\n (\"mcp_server_integrations\".\"resource_type\" = 'connection' AND \"mcp_server_integrations\".\"store_status\" = 'draft' AND \"mcp_server_integrations\".\"review_note\" IS NULL AND \"mcp_server_integrations\".\"submitted_at\" IS NULL AND \"mcp_server_integrations\".\"reviewed_at\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.mcp_session_tool_activations": { + "name": "mcp_session_tool_activations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mcp_tool_index_id": { + "name": "mcp_tool_index_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_query": { + "name": "source_query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcpSessionToolActivations_session_tool_uidx": { + "name": "mcpSessionToolActivations_session_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_tool_index_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_session_idx": { + "name": "mcpSessionToolActivations_session_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_expiresAt_idx": { + "name": "mcpSessionToolActivations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_session_tool_activations_organization_id_organizations_id_fk": { + "name": "mcp_session_tool_activations_organization_id_organizations_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk": { + "name": "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "mcp_tool_index_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpSessionToolActivations_org_tool_fk": { + "name": "mcpSessionToolActivations_org_tool_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "organization_id", + "mcp_tool_index_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tool_index": { + "name": "mcp_tool_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_tool_name": { + "name": "server_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_present": { + "name": "action_phrase_present", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_past": { + "name": "action_phrase_past", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_indexed_at": { + "name": "last_indexed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpToolIndex_server_tool_uidx": { + "name": "mcpToolIndex_server_tool_uidx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_id_uidx": { + "name": "mcpToolIndex_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_runtime_tool_uidx": { + "name": "mcpToolIndex_org_runtime_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "runtime_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_organizationId_status_idx": { + "name": "mcpToolIndex_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_serverIntegrationId_status_idx": { + "name": "mcpToolIndex_serverIntegrationId_status_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_searchText_gin_idx": { + "name": "mcpToolIndex_searchText_gin_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "mcp_tool_index_organization_id_organizations_id_fk": { + "name": "mcp_tool_index_organization_id_organizations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpToolIndex_org_server_fk": { + "name": "mcpToolIndex_org_server_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "members_organizationId_idx": { + "name": "members_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_userId_idx": { + "name": "members_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessTokens_clientId_idx": { + "name": "oauthAccessTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_sessionId_idx": { + "name": "oauthAccessTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_userId_idx": { + "name": "oauthAccessTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessTokens_refreshId_idx": { + "name": "oauthAccessTokens_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClients_userId_idx": { + "name": "oauthClients_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consents": { + "name": "oauth_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthConsents_clientId_idx": { + "name": "oauthConsents_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsents_userId_idx": { + "name": "oauthConsents_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshTokens_clientId_idx": { + "name": "oauthRefreshTokens_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_sessionId_idx": { + "name": "oauthRefreshTokens_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshTokens_userId_idx": { + "name": "oauthRefreshTokens_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_tokens_token_unique": { + "name": "oauth_refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_suggestions": { + "name": "onboarding_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "onboarding_suggestion_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingSuggestions_org_type_idx": { + "name": "onboardingSuggestions_org_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_suggestions_organization_id_organizations_id_fk": { + "name": "onboarding_suggestions_organization_id_organizations_id_fk", + "tableFrom": "onboarding_suggestions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_settings": { + "name": "organization_notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_content_creation": { + "name": "scheduled_content_creation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "scheduled_content_failed": { + "name": "scheduled_content_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scheduled_content_skipped": { + "name": "scheduled_content_skipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketing_emails": { + "name": "marketing_emails", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orgNotificationSettings_organizationId_uidx": { + "name": "orgNotificationSettings_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_notification_settings_organization_id_organizations_id_fk": { + "name": "organization_notification_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_notification_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_source": { + "name": "heard_about_notra_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_other": { + "name": "heard_about_notra_other", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_dismissed": { + "name": "onboarding_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_ran": { + "name": "onboarding_agent_ran", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_started_at": { + "name": "onboarding_agent_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_uidx": { + "name": "organizations_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_collections": { + "name": "post_collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "post_collection_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_source": { + "name": "name_source", + "type": "post_collection_name_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "content_types": { + "name": "content_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expected_post_count": { + "name": "expected_post_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_post_count": { + "name": "completed_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_collections_org_created_at_idx": { + "name": "post_collections_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_source_idx": { + "name": "post_collections_source_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_chat_source_uidx": { + "name": "post_collections_chat_source_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"post_collections\".\"source\" = 'chat' AND \"post_collections\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_collections_organization_id_organizations_id_fk": { + "name": "post_collections_organization_id_organizations_id_fk", + "tableFrom": "post_collections", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recommendations": { + "name": "recommendations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_org_slug_uidx": { + "name": "posts_org_slug_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"posts\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_createdAt_id_idx": { + "name": "posts_org_createdAt_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_collection_id_idx": { + "name": "posts_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_organization_id_organizations_id_fk": { + "name": "posts_organization_id_organizations_id_fk", + "tableFrom": "posts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_collection_id_post_collections_id_fk": { + "name": "posts_collection_id_post_collections_id_fk", + "tableFrom": "posts", + "tableTo": "post_collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_outputs": { + "name": "repository_outputs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositoryOutputs_repositoryId_idx": { + "name": "repositoryOutputs_repositoryId_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositoryOutputs_repository_outputType_uidx": { + "name": "repositoryOutputs_repository_outputType_uidx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "output_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_outputs_repository_id_github_integrations_id_fk": { + "name": "repository_outputs_repository_id_github_integrations_id_fk", + "tableFrom": "repository_outputs", + "tableTo": "github_integrations", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_userId_idx": { + "name": "sessions_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_organizationId_idx": { + "name": "skills_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_org_name_uidx": { + "name": "skills_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_organization_id_organizations_id_fk": { + "name": "skills_organization_id_organizations_id_fk", + "tableFrom": "skills", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_integrations": { + "name": "slack_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_bot_token": { + "name": "encrypted_bot_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_bot_user_id": { + "name": "slack_bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_channel_ids": { + "name": "allowed_channel_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slackIntegrations_organizationId_idx": { + "name": "slackIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_createdByUserId_idx": { + "name": "slackIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_teamId_uidx": { + "name": "slackIntegrations_teamId_uidx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_integrations_organization_id_organizations_id_fk": { + "name": "slack_integrations_organization_id_organizations_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_integrations_created_by_user_id_users_id_fk": { + "name": "slack_integrations_created_by_user_id_users_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_experiments": { + "name": "social_experiments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hypothesis": { + "name": "hypothesis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_a_post_id": { + "name": "variant_a_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_b_post_id": { + "name": "variant_b_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "winner": { + "name": "winner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "socialExperiments_organizationId_idx": { + "name": "socialExperiments_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_experiments_organization_id_organizations_id_fk": { + "name": "social_experiments_organization_id_organizations_id_fk", + "tableFrom": "social_experiments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_social_accounts": { + "name": "tracked_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trackedSocialAccounts_organizationId_idx": { + "name": "trackedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedSocialAccounts_org_provider_account_uidx": { + "name": "trackedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_social_accounts_organization_id_organizations_id_fk": { + "name": "tracked_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "tracked_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hide_personal_data": { + "name": "hide_personal_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_agent_stats": { + "name": "show_agent_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.applicable_platform": { + "name": "applicable_platform", + "schema": "public", + "values": [ + "all", + "twitter", + "linkedin", + "blog" + ] + }, + "public.autonomy_action_status": { + "name": "autonomy_action_status", + "schema": "public", + "values": [ + "pending", + "executing", + "succeeded", + "failed", + "unknown", + "compensated", + "canceled" + ] + }, + "public.autonomy_goal_status": { + "name": "autonomy_goal_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "blocked", + "completed", + "abandoned" + ] + }, + "public.autonomy_mandate_status": { + "name": "autonomy_mandate_status", + "schema": "public", + "values": [ + "active", + "paused", + "revoked" + ] + }, + "public.autonomy_outbox_status": { + "name": "autonomy_outbox_status", + "schema": "public", + "values": [ + "pending", + "attempting", + "delivered", + "failed", + "canceled" + ] + }, + "public.autonomy_run_status": { + "name": "autonomy_run_status", + "schema": "public", + "values": [ + "planning", + "executing", + "completed", + "failed", + "canceled" + ] + }, + "public.autonomy_run_trigger": { + "name": "autonomy_run_trigger", + "schema": "public", + "values": [ + "signal", + "wake", + "manual", + "repair" + ] + }, + "public.autonomy_signal_status": { + "name": "autonomy_signal_status", + "schema": "public", + "values": [ + "pending", + "coalesced", + "processed", + "discarded" + ] + }, + "public.autonomy_task_status": { + "name": "autonomy_task_status", + "schema": "public", + "values": [ + "pending", + "ready", + "running", + "waiting", + "completed", + "failed", + "canceled" + ] + }, + "public.brand_guideline_asset_kind": { + "name": "brand_guideline_asset_kind", + "schema": "public", + "values": [ + "logo", + "wordmark" + ] + }, + "public.brand_guideline_asset_variant": { + "name": "brand_guideline_asset_variant", + "schema": "public", + "values": [ + "light", + "dark" + ] + }, + "public.brand_guideline_color_role": { + "name": "brand_guideline_color_role", + "schema": "public", + "values": [ + "primary", + "secondary", + "accent", + "background", + "foreground", + "neutral", + "custom" + ] + }, + "public.brand_guideline_font_role": { + "name": "brand_guideline_font_role", + "schema": "public", + "values": [ + "heading", + "body", + "button", + "unknown" + ] + }, + "public.brand_guideline_screenshot_kind": { + "name": "brand_guideline_screenshot_kind", + "schema": "public", + "values": [ + "desktop_hero", + "desktop_full_page", + "mobile_hero" + ] + }, + "public.brand_guideline_status": { + "name": "brand_guideline_status", + "schema": "public", + "values": [ + "queued", + "generating", + "ready", + "failed" + ] + }, + "public.brand_guideline_token_type": { + "name": "brand_guideline_token_type", + "schema": "public", + "values": [ + "spacing", + "radius", + "shadow", + "component", + "unknown" + ] + }, + "public.brand_sitemap_page_category": { + "name": "brand_sitemap_page_category", + "schema": "public", + "values": [ + "crawled", + "redirect", + "queued", + "failed" + ] + }, + "public.brand_sitemap_status": { + "name": "brand_sitemap_status", + "schema": "public", + "values": [ + "queued", + "crawling", + "ready", + "failed" + ] + }, + "public.lookback_window": { + "name": "lookback_window", + "schema": "public", + "values": [ + "current_day", + "yesterday", + "last_7_days", + "last_14_days", + "last_30_days" + ] + }, + "public.onboarding_suggestion_type": { + "name": "onboarding_suggestion_type", + "schema": "public", + "values": [ + "schedule_automation", + "event_automation" + ] + }, + "public.post_collection_name_source": { + "name": "post_collection_name_source", + "schema": "public", + "values": [ + "generated", + "user", + "backfill" + ] + }, + "public.post_collection_source": { + "name": "post_collection_source", + "schema": "public", + "values": [ + "manual", + "chat", + "schedule", + "automation", + "api", + "backfill" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.reference_type": { + "name": "reference_type", + "schema": "public", + "values": [ + "twitter_post", + "linkedin_post", + "blog_post", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index dc3b155df..80eca0974 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -456,6 +456,13 @@ "when": 1785669176828, "tag": "0064_exotic_scourge", "breakpoints": true + }, + { + "idx": 65, + "version": "7", + "when": 1785706877007, + "tag": "0065_cool_prism", + "breakpoints": true } ] } \ No newline at end of file From 5ac2895a4ac725395c211352a1b387924bb361db Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Mon, 3 Aug 2026 14:28:06 +0200 Subject: [PATCH 26/26] feat(analytics): simplify leaderboard header and columns Remove the track button and dialog, drop the rank-change column, and pin the window select to the standard module header height so row lines align across modules. --- .../components/analytics/leaderboard-card.tsx | 90 +++------ .../analytics/track-account-dialog.tsx | 190 ------------------ .../src/lib/hooks/use-social-analytics.ts | 33 --- 3 files changed, 23 insertions(+), 290 deletions(-) delete mode 100644 apps/dashboard/src/components/analytics/track-account-dialog.tsx diff --git a/apps/dashboard/src/components/analytics/leaderboard-card.tsx b/apps/dashboard/src/components/analytics/leaderboard-card.tsx index 573c92f58..866ec2a97 100644 --- a/apps/dashboard/src/components/analytics/leaderboard-card.tsx +++ b/apps/dashboard/src/components/analytics/leaderboard-card.tsx @@ -15,9 +15,7 @@ import { SelectTrigger, SelectValue, } from "@notra/ui/components/ui/select"; -import { Loader2Icon } from "lucide-react"; import { useState } from "react"; -import { TrackAccountDialog } from "@/components/analytics/track-account-dialog"; import { XVerificationBadge } from "@/components/icons/x-verification-badge"; import { InstrumentEmpty, @@ -67,23 +65,6 @@ function detailMetrics(account: SocialOverviewAccount): DetailMetric[] { ]; } -function RankChange({ entry }: { entry: LeaderboardEntry }) { - if (entry.rankChange === null || entry.rankChange === 0) { - return ; - } - const up = entry.rankChange > 0; - return ( - - {up ? "▲" : "▼"} {Math.abs(entry.rankChange)} - - ); -} - function LeaderboardRow({ entry, organizationId, @@ -111,9 +92,6 @@ function LeaderboardRow({ {entry.rank} - - - (7); - const [trackOpen, setTrackOpen] = useState(false); const [expandedKey, setExpandedKey] = useState(null); const detailsByUsername = new Map( accountDetails.map((account) => [account.username.toLowerCase(), account]) @@ -215,53 +192,37 @@ export function LeaderboardCard({ return ( - - -
-
- {notFound && ( -

- No account found with that handle. Check the spelling and try - again. -

- )} - {account && ( -
- - {account.profileImageUrl && ( - - )} - - {account.username.slice(0, 2).toUpperCase()} - - -
-

- - {account.displayName ?? account.username} - - -

-

- @{account.username} - {account.followersCount !== null && - ` · ${formatMetric(account.followersCount)} followers`} -

-
-
- )} -
- - - - - - ); -} diff --git a/apps/dashboard/src/lib/hooks/use-social-analytics.ts b/apps/dashboard/src/lib/hooks/use-social-analytics.ts index 8500d193f..4390d2a5b 100644 --- a/apps/dashboard/src/lib/hooks/use-social-analytics.ts +++ b/apps/dashboard/src/lib/hooks/use-social-analytics.ts @@ -11,7 +11,6 @@ import type { PostingPerformanceResponse, SocialOverviewResponse, TopPostsResponse, - TrackAccountPreviewResponse, } from "@/types/analytics"; import { dashboardOrpc } from "../orpc/query"; @@ -83,27 +82,6 @@ export function useLeaderboard( }); } -export function useTrackAccount(organizationId: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (username: string) => - dashboardOrpc.analytics.trackAccount.call({ organizationId, username }), - onSuccess: async (result) => { - await queryClient.invalidateQueries({ - queryKey: dashboardOrpc.analytics.leaderboard.key(), - }); - toast.success(`Tracking @${result.username}`); - }, - onError: (error) => { - toast.error( - error instanceof Error && error.message - ? error.message - : "Failed to track account" - ); - }, - }); -} - export function useUntrackAccount(organizationId: string) { const queryClient = useQueryClient(); return useMutation({ @@ -136,14 +114,3 @@ export function useNotraAdoption(organizationId: string) { meta: { errorMessage: "Failed to load adoption data" }, }); } - -export function useTrackAccountPreview(organizationId: string) { - return useMutation({ - mutationFn: (username: string): Promise => - dashboardOrpc.analytics.previewTrackAccount.call({ - organizationId, - username, - }), - onError: () => toast.error("Failed to look up account"), - }); -}