From 4c25ba09a4d4bae4486bdb0202ace90b9fd99fe9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 30 Dec 2025 20:53:11 -0800 Subject: [PATCH 01/13] initial impl --- bun.lock | 7 +- cli/package.json | 1 + cli/src/chat.tsx | 45 ++++- cli/src/commands/ads.ts | 50 ++++++ cli/src/commands/command-registry.ts | 19 ++ cli/src/components/ad-banner.tsx | 113 ++++++++++++ cli/src/data/slash-commands.ts | 10 ++ cli/src/hooks/use-gravity-ad.ts | 253 +++++++++++++++++++++++++++ cli/src/types/env.ts | 3 + cli/src/utils/env.ts | 3 + cli/src/utils/settings.ts | 7 +- 11 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 cli/src/commands/ads.ts create mode 100644 cli/src/components/ad-banner.tsx create mode 100644 cli/src/hooks/use-gravity-ad.ts diff --git a/bun.lock b/bun.lock index 646394b2d..386eabb69 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", + "@gravity-ai/api": "^0.1.2", "@opentui/core": "^0.1.63", "@opentui/react": "^0.1.63", "@tanstack/react-query": "^5.90.12", @@ -639,6 +640,8 @@ "@google-cloud/promisify": ["@google-cloud/promisify@4.0.0", "", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="], + "@gravity-ai/api": ["@gravity-ai/api@0.1.2", "", { "dependencies": { "axios": "^1.13.2" } }, "sha512-txsAhyzvwB/TNrj5R8DoNqw8afM3JY2ahl7aaeaD5ZsxP+7rxff7C7keGI7+gU2KT3d2Mcw4QB1nHhbTSCJYHw=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], @@ -1503,7 +1506,7 @@ "axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="], - "axios": ["axios@1.13.1", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="], + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -3963,6 +3966,8 @@ "nextjs-linkedin-insight-tag/typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], + "nx/axios": ["axios@1.13.1", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="], + "nx/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "nx/cli-spinners": ["cli-spinners@2.6.1", "", {}, "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g=="], diff --git a/cli/package.json b/cli/package.json index cf7430893..938923d52 100644 --- a/cli/package.json +++ b/cli/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", + "@gravity-ai/api": "^0.1.2", "@opentui/core": "^0.1.63", "@opentui/react": "^0.1.63", "@tanstack/react-query": "^5.90.12", diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 702df05b8..0b8110ddb 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -10,7 +10,9 @@ import { } from 'react' import { useShallow } from 'zustand/react/shallow' +import { getAdsEnabled } from './commands/ads' import { routeUserPrompt, addBashMessageToHistory } from './commands/router' +import { AdBanner } from './components/ad-banner' import { ChatInputBar } from './components/chat-input-bar' import { LoadPreviousButton } from './components/load-previous-button' import { MessageWithAgents } from './components/message-with-agents' @@ -29,6 +31,7 @@ import { import { useClipboard } from './hooks/use-clipboard' import { useConnectionStatus } from './hooks/use-connection-status' import { useElapsedTime } from './hooks/use-elapsed-time' +import { useGravityAd } from './hooks/use-gravity-ad' import { useEvent } from './hooks/use-event' import { useExitHandler } from './hooks/use-exit-handler' import { useInputHistory } from './hooks/use-input-history' @@ -230,6 +233,7 @@ export const Chat = ({ const isConnected = useConnectionStatus(handleReconnection) const mainAgentTimer = useElapsedTime() + const { ad, reportActivity } = useGravityAd() const timerStartTime = mainAgentTimer.startTime // Set initial mode from CLI flag on mount @@ -415,6 +419,16 @@ export const Chat = ({ const setInputMode = useChatStore((state) => state.setInputMode) const askUserState = useChatStore((state) => state.askUserState) + // Filter slash commands based on current ads state - only show the option that changes state + const filteredSlashCommands = useMemo(() => { + const adsEnabled = getAdsEnabled() + return SLASH_COMMANDS.filter((cmd) => { + if (cmd.id === 'ads:enable') return !adsEnabled + if (cmd.id === 'ads:disable') return adsEnabled + return true + }) + }, [inputValue]) // Re-evaluate when input changes (user may have just toggled) + const { slashContext, mentionContext, @@ -428,7 +442,7 @@ export const Chat = ({ disableAgentSuggestions: forceFileOnlyMentions || inputMode !== 'default', inputValue: inputMode === 'bash' ? '' : inputValue, cursorPosition, - slashCommands: SLASH_COMMANDS, + slashCommands: filteredSlashCommands, localAgents, fileTree, currentAgentMode: agentMode, @@ -872,6 +886,17 @@ export const Chat = ({ useEffect(() => { inputValueRef.current = inputValue }, [inputValue]) + + // Report activity on input changes for ad rotation (debounced via separate effect) + const lastReportedActivityRef = useRef(0) + useEffect(() => { + const now = Date.now() + // Throttle to max once per second to avoid excessive calls + if (now - lastReportedActivityRef.current > 1000) { + lastReportedActivityRef.current = now + reportActivity() + } + }, [inputValue, reportActivity]) useEffect(() => { cursorPositionRef.current = cursorPosition }, [cursorPosition]) @@ -944,9 +969,11 @@ export const Chat = ({ }, [feedbackMode, askUserState, inputRef]) const handleSubmit = useCallback(async () => { + // Report activity for ad rotation + reportActivity() const result = await onSubmitPrompt(inputValue, agentMode) handleCommandResult(result) - }, [onSubmitPrompt, inputValue, agentMode, handleCommandResult]) + }, [onSubmitPrompt, inputValue, agentMode, handleCommandResult, reportActivity]) const totalMentionMatches = agentMatches.length + fileMatches.length const historyNavUpEnabled = @@ -1325,8 +1352,20 @@ export const Chat = ({ !feedbackMode && (hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom) + // Track mouse movement for ad activity (throttled) + const lastMouseActivityRef = useRef(0) + const handleMouseActivity = useCallback(() => { + const now = Date.now() + // Throttle to max once per second + if (now - lastMouseActivityRef.current > 1000) { + lastMouseActivityRef.current = now + reportActivity() + } + }, [reportActivity]) + return ( )} + {ad && getAdsEnabled() && } + ChatMessage[] +} => { + const apiKey = getCliEnv().GRAVITY_API_KEY + logger.info({ hasApiKey: !!apiKey }, '[gravity] Enabling ads') + + saveSettings({ adsEnabled: true }) + + if (!apiKey) { + return { + postUserMessage: (messages) => [ + ...messages, + getSystemMessage('Ads enabled, but GRAVITY_API_KEY is not set. Set the environment variable to see ads.'), + ], + } + } + + return { + postUserMessage: (messages) => [ + ...messages, + getSystemMessage('Ads enabled. You will see contextual ads above the input.'), + ], + } +} + +export const handleAdsDisable = (): { + postUserMessage: (messages: ChatMessage[]) => ChatMessage[] +} => { + logger.info('[gravity] Disabling ads') + saveSettings({ adsEnabled: false }) + + return { + postUserMessage: (messages) => [ + ...messages, + getSystemMessage('Ads disabled.'), + ], + } +} + +export const getAdsEnabled = (): boolean => { + const settings = loadSettings() + return settings.adsEnabled ?? false +} diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index e3c6a5821..03401fa04 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -1,3 +1,4 @@ +import { handleAdsEnable, handleAdsDisable } from './ads' import { handleHelpCommand } from './help' import { handleImageCommand } from './image' import { handleInitializationFlowLocally } from './init' @@ -155,6 +156,24 @@ const clearInput = (params: RouterParams) => { } export const COMMAND_REGISTRY: CommandDefinition[] = [ + defineCommand({ + name: 'ads:enable', + handler: (params) => { + const { postUserMessage } = handleAdsEnable() + params.setMessages((prev) => postUserMessage(prev)) + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + }, + }), + defineCommand({ + name: 'ads:disable', + handler: (params) => { + const { postUserMessage } = handleAdsDisable() + params.setMessages((prev) => postUserMessage(prev)) + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + }, + }), defineCommand({ name: 'help', aliases: ['h', '?'], diff --git a/cli/src/components/ad-banner.tsx b/cli/src/components/ad-banner.tsx new file mode 100644 index 000000000..e5b5da8b8 --- /dev/null +++ b/cli/src/components/ad-banner.tsx @@ -0,0 +1,113 @@ +import open from 'open' +import React, { useCallback, useEffect, useState } from 'react' + +import { Button } from './button' +import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' +import { useTheme } from '../hooks/use-theme' +import { logger } from '../utils/logger' + +import type { AdResponse } from '@gravity-ai/api' + +interface AdBannerProps { + ad: AdResponse +} + +const extractDomain = (url: string): string => { + try { + const parsed = new URL(url) + return parsed.hostname.replace(/^www\./, '') + } catch { + return url + } +} + +export const AdBanner: React.FC = ({ ad }) => { + useEffect(() => { + logger.info( + { adText: ad.adText?.substring(0, 50), hasClickUrl: !!ad.clickUrl }, + '[gravity] Rendering AdBanner' + ) + }, [ad]) + const theme = useTheme() + const { separatorWidth, terminalWidth } = useTerminalDimensions() + const [isLinkHovered, setIsLinkHovered] = useState(false) + + const handleClick = useCallback(() => { + if (ad.clickUrl) { + open(ad.clickUrl).catch((err) => { + logger.error(err, 'Failed to open ad link') + }) + } + }, [ad.clickUrl]) + + // Use 'url' field for display domain (the actual destination), fallback to clickUrl + const displayUrl = (ad as { url?: string }).url || ad.clickUrl + const domain = displayUrl ? extractDomain(displayUrl) : '' + const title = (ad as { title?: string }).title + // Use title as CTA, or fallback to "Learn more" if there's a clickUrl + const ctaText = title || (ad.clickUrl ? 'Learn more' : '') + + // Calculate available width for ad text + // Account for: padding (2), "Ad" label with space (3) + const maxTextWidth = separatorWidth - 5 + + return ( + + {/* Horizontal divider line */} + {'─'.repeat(terminalWidth)} + + + + {ad.adText} + + + {ctaText && ( + + )} + {domain && ( + {domain} + )} + + + Ad + + + ) +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 4ff6b8497..51a76cac9 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -15,6 +15,16 @@ const MODE_COMMANDS: SlashCommand[] = AGENT_MODES.map((mode) => ({ })) export const SLASH_COMMANDS: SlashCommand[] = [ + { + id: 'ads:enable', + label: 'ads:enable', + description: 'Enable contextual ads', + }, + { + id: 'ads:disable', + label: 'ads:disable', + description: 'Disable contextual ads', + }, { id: 'help', label: 'help', diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts new file mode 100644 index 000000000..4104c1f11 --- /dev/null +++ b/cli/src/hooks/use-gravity-ad.ts @@ -0,0 +1,253 @@ +import { Client } from '@gravity-ai/api' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { getAdsEnabled } from '../commands/ads' +import { useChatStore } from '../state/chat-store' +import { getCliEnv } from '../utils/env' +import { logger } from '../utils/logger' + +import type { AdResponse } from '@gravity-ai/api' +import type { Message } from '@codebuff/common/types/messages/codebuff-message' + +const MAX_MESSAGES_FOR_AD = 100 +const AD_DISPLAY_DURATION_MS = 60 * 1000 // 60 seconds per ad +const PREFETCH_BEFORE_MS = 5 * 1000 // Fetch next ad 5 seconds before swap +const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then stop + +type AdMessage = { role: 'user' | 'assistant'; content: string } + +/** + * Extract text content from a Message's content array + */ +const extractTextFromMessageContent = (content: Message['content']): string => { + if (!Array.isArray(content)) return '' + + return content + .filter( + (part): part is { type: 'text'; text: string } => + typeof part === 'object' && + part !== null && + 'type' in part && + part.type === 'text', + ) + .map((part) => part.text) + .join('\n') +} + +/** + * Convert LLM message history to ad API format. + * Includes all message types (user, assistant, tool, system) for analytics. + */ +const convertToAdMessages = (messages: Message[]): AdMessage[] => { + const adMessages: AdMessage[] = [] + + for (const message of messages.slice(-MAX_MESSAGES_FOR_AD)) { + const textContent = extractTextFromMessageContent(message.content) + if (textContent) { + // Map all roles to user/assistant as required by the API + const role = message.role === 'user' ? 'user' : 'assistant' + adMessages.push({ role, content: textContent }) + } + } + + return adMessages +} + +export type GravityAdState = { + ad: AdResponse | null + isLoading: boolean + reportActivity: () => void +} + +/** + * Hook for fetching and rotating Gravity ads. + * + * Behavior: + * - Ads rotate every 60 seconds + * - Next ad is pre-fetched 5 seconds before display for instant swap + * - After 3 ads without user activity, rotation stops + * - Any user activity resets the counter and resumes rotation + */ +export const useGravityAd = (): GravityAdState => { + const [ad, setAd] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const clientRef = useRef(null) + const impressionFiredRef = useRef>(new Set()) + + // Pre-fetched next ad ready to display + const nextAdRef = useRef(null) + + // Counter: how many ads shown since last user activity + const adsShownRef = useRef(0) + + // Is rotation currently paused (shown 3 ads without activity)? + const isPausedRef = useRef(false) + + // Timers + const prefetchTimerRef = useRef | null>(null) + const swapTimerRef = useRef | null>(null) + + // Has the first ad been fetched? + const isStartedRef = useRef(false) + + // Get runState from chat store + const runState = useChatStore((state) => state.runState) + + // Initialize client on mount + useEffect(() => { + const apiKey = getCliEnv().GRAVITY_API_KEY + logger.info( + { hasApiKey: !!apiKey, adsEnabled: getAdsEnabled() }, + '[gravity] Initializing Gravity ad client', + ) + if (apiKey) { + clientRef.current = new Client(apiKey) + logger.info('[gravity] Gravity client initialized successfully') + } else { + logger.warn('[gravity] No GRAVITY_API_KEY found in environment') + } + }, []) + + // Fire impression when ad changes + useEffect(() => { + if (ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { + impressionFiredRef.current.add(ad.impUrl) + logger.info({ impUrl: ad.impUrl }, '[gravity] Firing ad impression') + fetch(ad.impUrl).catch((err) => { + logger.debug({ err }, '[gravity] Failed to fire ad impression') + }) + } + }, [ad]) + + // Clear all timers + const clearTimers = useCallback(() => { + if (prefetchTimerRef.current) { + clearTimeout(prefetchTimerRef.current) + prefetchTimerRef.current = null + } + if (swapTimerRef.current) { + clearTimeout(swapTimerRef.current) + swapTimerRef.current = null + } + }, []) + + // Fetch an ad and return it (for pre-fetching) + const fetchAdAsync = useCallback(async (): Promise => { + const client = clientRef.current + if (!client || !getAdsEnabled()) return null + + const currentRunState = useChatStore.getState().runState + const messageHistory = + currentRunState?.sessionState?.mainAgentState?.messageHistory ?? [] + const adMessages = convertToAdMessages(messageHistory) + + if (adMessages.length === 0) return null + + logger.info('[gravity] Fetching ad from Gravity API') + + try { + const response = await client.getAd({ messages: adMessages }) + logger.info( + { + hasAd: !!response, + adText: response?.adText, + title: (response as { title?: string })?.title, + clickUrl: response?.clickUrl, + impUrl: response?.impUrl, + payout: response?.payout, + }, + '[gravity] Received ad response', + ) + return response + } catch (err) { + logger.error({ err }, '[gravity] Failed to fetch ad') + return null + } + }, []) + + // Schedule the next ad cycle + const scheduleNextCycle = useCallback(() => { + clearTimers() + + if (!getAdsEnabled() || isPausedRef.current) { + logger.debug( + { isPaused: isPausedRef.current }, + '[gravity] Not scheduling next cycle', + ) + return + } + + // Schedule pre-fetch (55 seconds from now) + prefetchTimerRef.current = setTimeout(async () => { + logger.debug('[gravity] Pre-fetching next ad') + nextAdRef.current = await fetchAdAsync() + }, AD_DISPLAY_DURATION_MS - PREFETCH_BEFORE_MS) + + // Schedule swap (60 seconds from now) + swapTimerRef.current = setTimeout(() => { + // Increment counter and check if we should pause + adsShownRef.current += 1 + logger.info( + { adsShown: adsShownRef.current, max: MAX_ADS_AFTER_ACTIVITY }, + '[gravity] Ad cycle complete', + ) + + if (adsShownRef.current >= MAX_ADS_AFTER_ACTIVITY) { + logger.info('[gravity] Max ads shown, pausing rotation') + isPausedRef.current = true + // Keep showing the current ad, just stop rotating + return + } + + // Swap to pre-fetched ad (or keep current if fetch failed) + if (nextAdRef.current) { + setAd(nextAdRef.current) + nextAdRef.current = null + } + + // Schedule next cycle + scheduleNextCycle() + }, AD_DISPLAY_DURATION_MS) + }, [clearTimers, fetchAdAsync]) + + // Report user activity - resets counter and resumes rotation if paused + const reportActivity = useCallback(() => { + const wasPaused = isPausedRef.current + + // Reset counter + adsShownRef.current = 0 + + if (wasPaused) { + logger.info('[gravity] User active, resuming ad rotation') + isPausedRef.current = false + // Restart the cycle from current ad + scheduleNextCycle() + } + }, [scheduleNextCycle]) + + // Start ad rotation when enabled and we have messages + useEffect(() => { + const messageHistory = + runState?.sessionState?.mainAgentState?.messageHistory ?? [] + const hasMessages = messageHistory.length > 0 + const adsEnabled = getAdsEnabled() + const hasClient = !!clientRef.current + + if (hasMessages && adsEnabled && hasClient && !isStartedRef.current) { + logger.info('[gravity] Starting ad rotation') + isStartedRef.current = true + setIsLoading(true) + + // Fetch and display first ad + fetchAdAsync().then((firstAd) => { + setAd(firstAd) + setIsLoading(false) + scheduleNextCycle() + }) + } + + return () => clearTimers() + }, [runState, fetchAdAsync, scheduleNextCycle, clearTimers]) + + return { ad, isLoading, reportActivity } +} diff --git a/cli/src/types/env.ts b/cli/src/types/env.ts index 5e6c283dc..6dcc71086 100644 --- a/cli/src/types/env.ts +++ b/cli/src/types/env.ts @@ -52,6 +52,9 @@ export type CliEnv = BaseEnv & { OPEN_TUI_THEME?: string OPENTUI_THEME?: string + // Gravity AI Ads + GRAVITY_API_KEY?: string + // Codebuff CLI-specific (set during binary build) CODEBUFF_IS_BINARY?: string CODEBUFF_CLI_VERSION?: string diff --git a/cli/src/utils/env.ts b/cli/src/utils/env.ts index f03582399..df7dd6891 100644 --- a/cli/src/utils/env.ts +++ b/cli/src/utils/env.ts @@ -52,6 +52,9 @@ export const getCliEnv = (): CliEnv => ({ OPEN_TUI_THEME: process.env.OPEN_TUI_THEME, OPENTUI_THEME: process.env.OPENTUI_THEME, + // Gravity AI Ads + GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, + // Binary build configuration CODEBUFF_IS_BINARY: process.env.CODEBUFF_IS_BINARY, CODEBUFF_CLI_VERSION: process.env.CODEBUFF_CLI_VERSION, diff --git a/cli/src/utils/settings.ts b/cli/src/utils/settings.ts index b9d0d5cd4..cea2cf5b3 100644 --- a/cli/src/utils/settings.ts +++ b/cli/src/utils/settings.ts @@ -12,7 +12,7 @@ import type { AgentMode } from './constants' */ export interface Settings { mode?: AgentMode - // Add new settings here over time + adsEnabled?: boolean } /** @@ -67,7 +67,10 @@ const validateSettings = (parsed: unknown): Settings => { settings.mode = obj.mode as AgentMode } - // Add validation for new settings here + // Validate adsEnabled + if (typeof obj.adsEnabled === 'boolean') { + settings.adsEnabled = obj.adsEnabled + } return settings } From 4a89b7806874a112a75125195cadfb5a20a9c649 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 10:24:33 -0800 Subject: [PATCH 02/13] Implement ads backend --- cli/src/commands/ads.ts | 15 +- cli/src/components/ad-banner.tsx | 12 +- cli/src/components/usage-banner.tsx | 3 +- cli/src/hooks/use-gravity-ad.ts | 117 +++++++--- cli/src/hooks/use-usage-query.ts | 3 + cli/src/types/env.ts | 3 - cli/src/utils/env.ts | 3 - cli/src/utils/usage-banner-state.ts | 8 + common/src/constants/grant-priorities.ts | 1 + common/src/types/grant.ts | 2 + packages/internal/src/env-schema.ts | 2 + web/src/app/api/v1/ads/_post.ts | 133 +++++++++++ web/src/app/api/v1/ads/impression/_post.ts | 216 ++++++++++++++++++ web/src/app/api/v1/ads/impression/route.ts | 21 ++ web/src/app/api/v1/ads/route.ts | 21 ++ .../app/profile/components/usage-display.tsx | 2 +- 16 files changed, 497 insertions(+), 65 deletions(-) create mode 100644 web/src/app/api/v1/ads/_post.ts create mode 100644 web/src/app/api/v1/ads/impression/_post.ts create mode 100644 web/src/app/api/v1/ads/impression/route.ts create mode 100644 web/src/app/api/v1/ads/route.ts diff --git a/cli/src/commands/ads.ts b/cli/src/commands/ads.ts index f668dcbcb..009a14c3c 100644 --- a/cli/src/commands/ads.ts +++ b/cli/src/commands/ads.ts @@ -1,5 +1,4 @@ import { saveSettings, loadSettings } from '../utils/settings' -import { getCliEnv } from '../utils/env' import { getSystemMessage } from '../utils/message-history' import { logger } from '../utils/logger' @@ -8,24 +7,14 @@ import type { ChatMessage } from '../types/chat' export const handleAdsEnable = (): { postUserMessage: (messages: ChatMessage[]) => ChatMessage[] } => { - const apiKey = getCliEnv().GRAVITY_API_KEY - logger.info({ hasApiKey: !!apiKey }, '[gravity] Enabling ads') + logger.info('[gravity] Enabling ads') saveSettings({ adsEnabled: true }) - if (!apiKey) { - return { - postUserMessage: (messages) => [ - ...messages, - getSystemMessage('Ads enabled, but GRAVITY_API_KEY is not set. Set the environment variable to see ads.'), - ], - } - } - return { postUserMessage: (messages) => [ ...messages, - getSystemMessage('Ads enabled. You will see contextual ads above the input.'), + getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'), ], } } diff --git a/cli/src/components/ad-banner.tsx b/cli/src/components/ad-banner.tsx index e5b5da8b8..6d1ffd4db 100644 --- a/cli/src/components/ad-banner.tsx +++ b/cli/src/components/ad-banner.tsx @@ -6,7 +6,7 @@ import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' import { logger } from '../utils/logger' -import type { AdResponse } from '@gravity-ai/api' +import type { AdResponse } from '../hooks/use-gravity-ad' interface AdBannerProps { ad: AdResponse @@ -40,12 +40,10 @@ export const AdBanner: React.FC = ({ ad }) => { } }, [ad.clickUrl]) - // Use 'url' field for display domain (the actual destination), fallback to clickUrl - const displayUrl = (ad as { url?: string }).url || ad.clickUrl - const domain = displayUrl ? extractDomain(displayUrl) : '' - const title = (ad as { title?: string }).title - // Use title as CTA, or fallback to "Learn more" if there's a clickUrl - const ctaText = title || (ad.clickUrl ? 'Learn more' : '') + // Use 'url' field for display domain (the actual destination) + const domain = extractDomain(ad.url) + // Use title as CTA + const ctaText = ad.title // Calculate available width for ad text // Account for: padding (2), "Ad" label with space (3) diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 9d5450344..70a7fcbf4 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -43,7 +43,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { type: 'usage-response' usage: number remainingBalance: number | null - balanceBreakdown?: { free: number; paid: number } + balanceBreakdown?: { free: number; paid: number; ad?: number } next_quota_reset: string | null }>({ queryKey: usageQueryKeys.current(), @@ -83,6 +83,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { sessionCreditsUsed, remainingBalance: activeData.remainingBalance, next_quota_reset: activeData.next_quota_reset, + adCredits: activeData.balanceBreakdown?.ad, }) return ( diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 4104c1f11..31cf97c8e 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -1,12 +1,11 @@ -import { Client } from '@gravity-ai/api' +import { WEBSITE_URL } from '@codebuff/sdk' import { useCallback, useEffect, useRef, useState } from 'react' import { getAdsEnabled } from '../commands/ads' import { useChatStore } from '../state/chat-store' -import { getCliEnv } from '../utils/env' +import { getAuthToken } from '../utils/auth' import { logger } from '../utils/logger' -import type { AdResponse } from '@gravity-ai/api' import type { Message } from '@codebuff/common/types/messages/codebuff-message' const MAX_MESSAGES_FOR_AD = 100 @@ -16,6 +15,17 @@ const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then s type AdMessage = { role: 'user' | 'assistant'; content: string } +// Ad response type (matches Gravity API response) +export type AdResponse = { + adText: string + title: string + url: string + favicon: string + clickUrl: string + impUrl: string + payout: number +} + /** * Extract text content from a Message's content array */ @@ -71,7 +81,6 @@ export type GravityAdState = { export const useGravityAd = (): GravityAdState => { const [ad, setAd] = useState(null) const [isLoading, setIsLoading] = useState(false) - const clientRef = useRef(null) const impressionFiredRef = useRef>(new Set()) // Pre-fetched next ad ready to display @@ -93,29 +102,42 @@ export const useGravityAd = (): GravityAdState => { // Get runState from chat store const runState = useChatStore((state) => state.runState) - // Initialize client on mount - useEffect(() => { - const apiKey = getCliEnv().GRAVITY_API_KEY - logger.info( - { hasApiKey: !!apiKey, adsEnabled: getAdsEnabled() }, - '[gravity] Initializing Gravity ad client', - ) - if (apiKey) { - clientRef.current = new Client(apiKey) - logger.info('[gravity] Gravity client initialized successfully') - } else { - logger.warn('[gravity] No GRAVITY_API_KEY found in environment') - } - }, []) - - // Fire impression when ad changes + // Fire impression via web API when ad changes (grants credits) useEffect(() => { if (ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { impressionFiredRef.current.add(ad.impUrl) - logger.info({ impUrl: ad.impUrl }, '[gravity] Firing ad impression') - fetch(ad.impUrl).catch((err) => { - logger.debug({ err }, '[gravity] Failed to fire ad impression') + logger.info({ impUrl: ad.impUrl, payout: ad.payout }, '[gravity] Recording ad impression') + + const authToken = getAuthToken() + if (!authToken) { + logger.warn('[gravity] No auth token, skipping impression recording') + return + } + + // Call our web API to fire impression and grant credits + fetch(`${WEBSITE_URL}/api/v1/ads/impression`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ + impUrl: ad.impUrl, + payout: ad.payout, + }), }) + .then((res) => res.json()) + .then((data) => { + if (data.creditsGranted > 0) { + logger.info( + { creditsGranted: data.creditsGranted }, + '[gravity] Ad impression credits granted', + ) + } + }) + .catch((err) => { + logger.debug({ err }, '[gravity] Failed to record ad impression') + }) } }, [ad]) @@ -131,10 +153,15 @@ export const useGravityAd = (): GravityAdState => { } }, []) - // Fetch an ad and return it (for pre-fetching) + // Fetch an ad via web API and return it (for pre-fetching) const fetchAdAsync = useCallback(async (): Promise => { - const client = clientRef.current - if (!client || !getAdsEnabled()) return null + if (!getAdsEnabled()) return null + + const authToken = getAuthToken() + if (!authToken) { + logger.warn('[gravity] No auth token available') + return null + } const currentRunState = useChatStore.getState().runState const messageHistory = @@ -143,22 +170,38 @@ export const useGravityAd = (): GravityAdState => { if (adMessages.length === 0) return null - logger.info('[gravity] Fetching ad from Gravity API') + logger.info('[gravity] Fetching ad from web API') try { - const response = await client.getAd({ messages: adMessages }) + const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ messages: adMessages }), + }) + + if (!response.ok) { + logger.warn({ status: response.status }, '[gravity] Web API returned error') + return null + } + + const data = await response.json() + const ad = data.ad as AdResponse | null + logger.info( { - hasAd: !!response, - adText: response?.adText, - title: (response as { title?: string })?.title, - clickUrl: response?.clickUrl, - impUrl: response?.impUrl, - payout: response?.payout, + hasAd: !!ad, + adText: ad?.adText, + title: ad?.title, + clickUrl: ad?.clickUrl, + impUrl: ad?.impUrl, + payout: ad?.payout, }, '[gravity] Received ad response', ) - return response + return ad } catch (err) { logger.error({ err }, '[gravity] Failed to fetch ad') return null @@ -231,9 +274,9 @@ export const useGravityAd = (): GravityAdState => { runState?.sessionState?.mainAgentState?.messageHistory ?? [] const hasMessages = messageHistory.length > 0 const adsEnabled = getAdsEnabled() - const hasClient = !!clientRef.current + const hasAuth = !!getAuthToken() - if (hasMessages && adsEnabled && hasClient && !isStartedRef.current) { + if (hasMessages && adsEnabled && hasAuth && !isStartedRef.current) { logger.info('[gravity] Starting ad rotation') isStartedRef.current = true setIsLoading(true) diff --git a/cli/src/hooks/use-usage-query.ts b/cli/src/hooks/use-usage-query.ts index 986f39094..e77fbe360 100644 --- a/cli/src/hooks/use-usage-query.ts +++ b/cli/src/hooks/use-usage-query.ts @@ -20,6 +20,9 @@ interface UsageResponse { balanceBreakdown?: { free: number paid: number + ad?: number + referral?: number + admin?: number } next_quota_reset: string | null autoTopupEnabled?: boolean diff --git a/cli/src/types/env.ts b/cli/src/types/env.ts index 6dcc71086..5e6c283dc 100644 --- a/cli/src/types/env.ts +++ b/cli/src/types/env.ts @@ -52,9 +52,6 @@ export type CliEnv = BaseEnv & { OPEN_TUI_THEME?: string OPENTUI_THEME?: string - // Gravity AI Ads - GRAVITY_API_KEY?: string - // Codebuff CLI-specific (set during binary build) CODEBUFF_IS_BINARY?: string CODEBUFF_CLI_VERSION?: string diff --git a/cli/src/utils/env.ts b/cli/src/utils/env.ts index df7dd6891..f03582399 100644 --- a/cli/src/utils/env.ts +++ b/cli/src/utils/env.ts @@ -52,9 +52,6 @@ export const getCliEnv = (): CliEnv => ({ OPEN_TUI_THEME: process.env.OPEN_TUI_THEME, OPENTUI_THEME: process.env.OPENTUI_THEME, - // Gravity AI Ads - GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, - // Binary build configuration CODEBUFF_IS_BINARY: process.env.CODEBUFF_IS_BINARY, CODEBUFF_CLI_VERSION: process.env.CODEBUFF_CLI_VERSION, diff --git a/cli/src/utils/usage-banner-state.ts b/cli/src/utils/usage-banner-state.ts index 031d4ba50..b3bd360cd 100644 --- a/cli/src/utils/usage-banner-state.ts +++ b/cli/src/utils/usage-banner-state.ts @@ -66,6 +66,8 @@ export interface UsageBannerTextOptions { sessionCreditsUsed: number remainingBalance: number | null next_quota_reset: string | null + /** Ad impression credits earned */ + adCredits?: number /** For testing purposes, allows overriding "today" */ today?: Date } @@ -87,6 +89,7 @@ export function generateUsageBannerText( sessionCreditsUsed, remainingBalance, next_quota_reset, + adCredits, today = new Date(), } = options @@ -96,6 +99,11 @@ export function generateUsageBannerText( text += `. Credits remaining: ${remainingBalance.toLocaleString()}` } + // Show ad credits earned if any + if (adCredits && adCredits > 0) { + text += ` (${adCredits.toLocaleString()} from ads)` + } + if (next_quota_reset) { const resetDate = new Date(next_quota_reset) const isToday = resetDate.toDateString() === today.toDateString() diff --git a/common/src/constants/grant-priorities.ts b/common/src/constants/grant-priorities.ts index 7712771d2..c9670fb06 100644 --- a/common/src/constants/grant-priorities.ts +++ b/common/src/constants/grant-priorities.ts @@ -2,6 +2,7 @@ import type { GrantType } from '@codebuff/common/types/grant' export const GRANT_PRIORITIES: Record = { free: 20, + ad: 30, // Ad credits consumed after free, before referral referral: 40, admin: 60, organization: 70, diff --git a/common/src/types/grant.ts b/common/src/types/grant.ts index fb7abe223..93d708cb6 100644 --- a/common/src/types/grant.ts +++ b/common/src/types/grant.ts @@ -4,6 +4,7 @@ export type GrantType = | 'purchase' | 'admin' | 'organization' + | 'ad' // Credits earned from ads (impressions, clicks, acquisitions, etc.) export const GrantTypeValues = [ 'free', @@ -11,4 +12,5 @@ export const GrantTypeValues = [ 'purchase', 'admin', 'organization', + 'ad', ] as const diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 6d9311210..c33e66ef7 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -7,6 +7,7 @@ export const serverEnvSchema = clientEnvSchema.extend({ OPENAI_API_KEY: z.string().min(1), LINKUP_API_KEY: z.string().min(1), CONTEXT7_API_KEY: z.string().optional(), + GRAVITY_API_KEY: z.string().optional(), PORT: z.coerce.number().min(1000), // Web/Database variables @@ -45,6 +46,7 @@ export const serverProcessEnv: ServerInput = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, LINKUP_API_KEY: process.env.LINKUP_API_KEY, CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY, + GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, PORT: process.env.PORT, // Web/Database variables diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts new file mode 100644 index 000000000..10fe20f74 --- /dev/null +++ b/web/src/app/api/v1/ads/_post.ts @@ -0,0 +1,133 @@ +// Note: Using existing analytics events as placeholders since ads-specific events don't exist yet +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { requireUserFromApiKey } from '../_helpers' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +const messageSchema = z.object({ + role: z.enum(['user', 'assistant']), + content: z.string(), +}) + +const bodySchema = z.object({ + messages: z.array(messageSchema).min(1), +}) + +export type GravityEnv = { + GRAVITY_API_KEY: string | undefined +} + +export async function postAds(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn + trackEvent: TrackEventFn + fetch: typeof globalThis.fetch + serverEnv: GravityEnv +}) { + const { + req, + getUserInfoFromApiKey, + loggerWithContext, + trackEvent, + fetch, + serverEnv, + } = params + const baseLogger = params.logger + + // Check if Gravity API key is configured + if (!serverEnv.GRAVITY_API_KEY) { + baseLogger.warn('[ads] GRAVITY_API_KEY not configured') + return NextResponse.json({ ad: null }, { status: 200 }) + } + + // Parse and validate request body + let messages: z.infer['messages'] + try { + const json = await req.json() + const parsed = bodySchema.safeParse(json) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 }, + ) + } + messages = parsed.data.messages + } catch { + return NextResponse.json( + { error: 'Invalid JSON in request body' }, + { status: 400 }, + ) + } + + const authed = await requireUserFromApiKey({ + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + authErrorEvent: 'api.ads_auth_error' as any, // TODO: Add proper analytics event + }) + if (!authed.ok) return authed.response + + const { logger } = authed.data + + try { + // Call Gravity API + const response = await fetch('https://server.trygravity.ai/ad', { + method: 'POST', + headers: { + Authorization: `Bearer ${serverEnv.GRAVITY_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ messages }), + }) + + if (response.status === 204) { + // No ad available + logger.debug('[ads] No ad available from Gravity API') + return NextResponse.json({ ad: null }, { status: 200 }) + } + + if (!response.ok) { + logger.error( + { status: response.status }, + '[ads] Gravity API returned error', + ) + return NextResponse.json({ ad: null }, { status: 200 }) + } + + const ad = await response.json() + + // Log the complete ad response from Gravity API + logger.info( + { + ad, + }, + '[ads] Fetched ad from Gravity API', + ) + + // Return complete ad to client (client will call /impression endpoint when displayed) + return NextResponse.json({ ad }) + } catch (error) { + logger.error( + { + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to fetch ad from Gravity API', + ) + return NextResponse.json({ ad: null }, { status: 200 }) + } +} diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts new file mode 100644 index 000000000..dfee00a64 --- /dev/null +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -0,0 +1,216 @@ +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { createHash } from 'crypto' +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { requireUserFromApiKey } from '../../_helpers' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { processAndGrantCredit as ProcessAndGrantCreditFn } from '@codebuff/billing/grant-credits' +import type { NextRequest } from 'next/server' + +// Revenue share: users get 75% of payout as credits +const AD_REVENUE_SHARE = 0.75 + +// Rate limiting: max impressions per user per hour +const MAX_IMPRESSIONS_PER_HOUR = 60 + +// In-memory rate limiter (resets on server restart, which is acceptable for this use case) +const impressionRateLimiter = new Map() + +/** + * Check and update rate limit for a user. + * Returns true if the request is allowed, false if rate limited. + */ +function checkRateLimit(userId: string): boolean { + const now = Date.now() + const hourMs = 60 * 60 * 1000 + + const userLimit = impressionRateLimiter.get(userId) + + if (!userLimit || now >= userLimit.resetAt) { + // Reset or initialize the counter + impressionRateLimiter.set(userId, { count: 1, resetAt: now + hourMs }) + return true + } + + if (userLimit.count >= MAX_IMPRESSIONS_PER_HOUR) { + return false + } + + userLimit.count++ + return true +} + +/** + * Generate a deterministic operation ID for deduplication. + * Same user + same impUrl = same operationId, preventing duplicate credits. + */ +function generateImpressionOperationId(userId: string, impUrl: string): string { + const hash = createHash('sha256') + .update(`${userId}:${impUrl}`) + .digest('hex') + .slice(0, 16) + return `ad-imp-${hash}` +} + +const bodySchema = z.object({ + impUrl: z.string().url(), + payout: z.number().optional(), +}) + +export async function postAdImpression(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn + trackEvent: TrackEventFn + processAndGrantCredit: typeof ProcessAndGrantCreditFn + fetch: typeof globalThis.fetch +}) { + const { + req, + getUserInfoFromApiKey, + loggerWithContext, + trackEvent, + processAndGrantCredit, + fetch, + } = params + const baseLogger = params.logger + + // Parse and validate request body + let impUrl: string + let payout: number | undefined + try { + const json = await req.json() + const parsed = bodySchema.safeParse(json) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 }, + ) + } + impUrl = parsed.data.impUrl + payout = parsed.data.payout + } catch { + return NextResponse.json( + { error: 'Invalid JSON in request body' }, + { status: 400 }, + ) + } + + const authed = await requireUserFromApiKey({ + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + authErrorEvent: AnalyticsEvent.USAGE_API_AUTH_ERROR, // Reuse existing event + }) + if (!authed.ok) return authed.response + + const { userId, logger } = authed.data + + // Check rate limit before processing + if (!checkRateLimit(userId)) { + logger.warn( + { userId, maxPerHour: MAX_IMPRESSIONS_PER_HOUR }, + '[ads] Rate limited ad impression request', + ) + return NextResponse.json( + { success: false, error: 'Rate limited', creditsGranted: 0 }, + { status: 429 }, + ) + } + + // Generate deterministic operation ID for deduplication + // Same user + same impUrl = same operationId, preventing duplicate credits + const operationId = generateImpressionOperationId(userId, impUrl) + + // Fire the impression pixel to Gravity + try { + await fetch(impUrl) + logger.info({ impUrl }, '[ads] Fired impression pixel') + } catch (error) { + logger.warn( + { + impUrl, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to fire impression pixel', + ) + // Continue anyway - we still want to grant credits + } + + // Grant credits if there's a payout + let creditsGranted = 0 + if (payout && payout > 0) { + // Calculate user's share (75%) and convert to credits (round down) + // Payout is in dollars, credits are 1:1 with cents, so multiply by 100 + const userShareDollars = payout * AD_REVENUE_SHARE + const creditsToGrant = Math.floor(userShareDollars * 100) + + if (creditsToGrant > 0) { + try { + await processAndGrantCredit({ + userId, + amount: creditsToGrant, + type: 'ad', + description: `Ad impression credit (${(userShareDollars * 100).toFixed(1)}¢ from $${payout.toFixed(4)} payout)`, + expiresAt: null, // Ad credits don't expire + operationId, + logger, + }) + + creditsGranted = creditsToGrant + + logger.info( + { + userId, + payout, + creditsGranted, + operationId, + }, + '[ads] Granted ad impression credits', + ) + + trackEvent({ + event: AnalyticsEvent.CREDIT_GRANT, + userId, + properties: { + type: 'ad', + amount: creditsGranted, + payout, + }, + logger, + }) + } catch (error) { + logger.error( + { + userId, + payout, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to grant ad impression credits', + ) + // Don't fail the request - impression was still recorded + } + } + } + + return NextResponse.json({ + success: true, + creditsGranted, + }) +} diff --git a/web/src/app/api/v1/ads/impression/route.ts b/web/src/app/api/v1/ads/impression/route.ts new file mode 100644 index 000000000..a07f66211 --- /dev/null +++ b/web/src/app/api/v1/ads/impression/route.ts @@ -0,0 +1,21 @@ +import { trackEvent } from '@codebuff/common/analytics' +import { processAndGrantCredit } from '@codebuff/billing/grant-credits' + +import { postAdImpression } from './_post' + +import type { NextRequest } from 'next/server' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +export async function POST(req: NextRequest) { + return postAdImpression({ + req, + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + processAndGrantCredit, + fetch, + }) +} diff --git a/web/src/app/api/v1/ads/route.ts b/web/src/app/api/v1/ads/route.ts new file mode 100644 index 000000000..d341d7cc7 --- /dev/null +++ b/web/src/app/api/v1/ads/route.ts @@ -0,0 +1,21 @@ +import { trackEvent } from '@codebuff/common/analytics' +import { env } from '@codebuff/internal/env' + +import { postAds } from './_post' + +import type { NextRequest } from 'next/server' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +export async function POST(req: NextRequest) { + return postAds({ + req, + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + fetch, + serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY }, + }) +} diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index c58957d1c..ca97255c2 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -31,7 +31,7 @@ interface UsageDisplayProps { isLoading?: boolean } -type FilteredGrantType = Exclude +type FilteredGrantType = Exclude const grantTypeInfo: Record< FilteredGrantType, From 5637208940365aa4a142d4fbb9e5a54efab0ae4e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 10:42:59 -0800 Subject: [PATCH 03/13] add table for ad impressions and migrate (includes new 'ad' grant type) --- cli/src/hooks/use-gravity-ad.ts | 2 +- .../migrations/0033_overconfident_skreet.sql | 22 + .../src/db/migrations/meta/0033_snapshot.json | 2779 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/internal/src/db/schema.ts | 40 + web/src/app/api/v1/ads/_post.ts | 39 +- web/src/app/api/v1/ads/impression/_post.ts | 201 +- 7 files changed, 3025 insertions(+), 65 deletions(-) create mode 100644 packages/internal/src/db/migrations/0033_overconfident_skreet.sql create mode 100644 packages/internal/src/db/migrations/meta/0033_snapshot.json diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 31cf97c8e..22ff6a67b 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -115,6 +115,7 @@ export const useGravityAd = (): GravityAdState => { } // Call our web API to fire impression and grant credits + // Only send impUrl - server looks up trusted ad data from database fetch(`${WEBSITE_URL}/api/v1/ads/impression`, { method: 'POST', headers: { @@ -123,7 +124,6 @@ export const useGravityAd = (): GravityAdState => { }, body: JSON.stringify({ impUrl: ad.impUrl, - payout: ad.payout, }), }) .then((res) => res.json()) diff --git a/packages/internal/src/db/migrations/0033_overconfident_skreet.sql b/packages/internal/src/db/migrations/0033_overconfident_skreet.sql new file mode 100644 index 000000000..af1c6090e --- /dev/null +++ b/packages/internal/src/db/migrations/0033_overconfident_skreet.sql @@ -0,0 +1,22 @@ +ALTER TYPE "public"."grant_type" ADD VALUE 'ad';--> statement-breakpoint +CREATE TABLE "ad_impression" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "ad_text" text NOT NULL, + "title" text NOT NULL, + "url" text NOT NULL, + "favicon" text NOT NULL, + "click_url" text NOT NULL, + "imp_url" text NOT NULL, + "payout" numeric(10, 6) NOT NULL, + "credits_granted" integer NOT NULL, + "grant_operation_id" text, + "served_at" timestamp with time zone DEFAULT now() NOT NULL, + "impression_fired_at" timestamp with time zone, + "clicked_at" timestamp with time zone, + CONSTRAINT "ad_impression_imp_url_unique" UNIQUE("imp_url") +); +--> statement-breakpoint +ALTER TABLE "ad_impression" ADD CONSTRAINT "ad_impression_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_ad_impression_user" ON "ad_impression" USING btree ("user_id","served_at");--> statement-breakpoint +CREATE INDEX "idx_ad_impression_imp_url" ON "ad_impression" USING btree ("imp_url"); \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0033_snapshot.json b/packages/internal/src/db/migrations/meta/0033_snapshot.json new file mode 100644 index 000000000..dc48b2107 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0033_snapshot.json @@ -0,0 +1,2779 @@ +{ + "id": "beec6465-6f7f-4e3a-a43f-b1fc0fe09b0c", + "prevId": "f3d7ae64-1976-4e52-a478-cc1ae35ec1e0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "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": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index ad939fae1..3e75250fe 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1765925589611, "tag": "0032_flawless_lily_hollister", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1767205858214, + "tag": "0033_overconfident_skreet", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index e72697a0e..9e5b9f23f 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -400,6 +400,46 @@ export const orgFeature = pgTable( ], ) +// Ad impression logging table +export const adImpression = pgTable( + 'ad_impression', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + user_id: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + + // Ad content from Gravity API + ad_text: text('ad_text').notNull(), + title: text('title').notNull(), + url: text('url').notNull(), + favicon: text('favicon').notNull(), + click_url: text('click_url').notNull(), + imp_url: text('imp_url').notNull().unique(), // Unique to prevent duplicates + payout: numeric('payout', { precision: 10, scale: 6 }).notNull(), + + // Credit tracking + credits_granted: integer('credits_granted').notNull(), + grant_operation_id: text('grant_operation_id'), // Links to credit_ledger.operation_id + + // Timestamps + served_at: timestamp('served_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), + impression_fired_at: timestamp('impression_fired_at', { + mode: 'date', + withTimezone: true, + }), + clicked_at: timestamp('clicked_at', { mode: 'date', withTimezone: true }), + }, + (table) => [ + index('idx_ad_impression_user').on(table.user_id, table.served_at), + index('idx_ad_impression_imp_url').on(table.imp_url), + ], +) + export type GitEvalMetadata = { numCases?: number // Number of eval cases successfully run (total) avgScore?: number // Average score across all cases diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 10fe20f74..a8feda174 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -1,4 +1,6 @@ // Note: Using existing analytics events as placeholders since ads-specific events don't exist yet +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' import { NextResponse } from 'next/server' import { z } from 'zod' @@ -79,7 +81,7 @@ export async function postAds(params: { }) if (!authed.ok) return authed.response - const { logger } = authed.data + const { userId, logger } = authed.data try { // Call Gravity API @@ -116,6 +118,41 @@ export async function postAds(params: { '[ads] Fetched ad from Gravity API', ) + // Insert ad_impression row to database (served_at = now) + // This stores the trusted ad data server-side so we don't have to trust the client later + try { + await db.insert(schema.adImpression).values({ + user_id: userId, + ad_text: ad.adText, + title: ad.title, + url: ad.url, + favicon: ad.favicon, + click_url: ad.clickUrl, + imp_url: ad.impUrl, + payout: String(ad.payout), + credits_granted: 0, // Will be updated when impression is fired + }) + + logger.info( + { userId, impUrl: ad.impUrl }, + '[ads] Created ad_impression record for served ad', + ) + } catch (error) { + // If insert fails (e.g., duplicate impUrl), log but continue + // The ad can still be shown, it just won't be tracked + logger.warn( + { + userId, + impUrl: ad.impUrl, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to create ad_impression record (likely duplicate)', + ) + } + // Return complete ad to client (client will call /impression endpoint when displayed) return NextResponse.json({ ad }) } catch (error) { diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index dfee00a64..651504d21 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -1,5 +1,8 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { createHash } from 'crypto' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' @@ -21,7 +24,10 @@ const AD_REVENUE_SHARE = 0.75 const MAX_IMPRESSIONS_PER_HOUR = 60 // In-memory rate limiter (resets on server restart, which is acceptable for this use case) -const impressionRateLimiter = new Map() +const impressionRateLimiter = new Map< + string, + { count: number; resetAt: number } +>() /** * Check and update rate limit for a user. @@ -30,19 +36,19 @@ const impressionRateLimiter = new Map= userLimit.resetAt) { // Reset or initialize the counter impressionRateLimiter.set(userId, { count: 1, resetAt: now + hourMs }) return true } - + if (userLimit.count >= MAX_IMPRESSIONS_PER_HOUR) { return false } - + userLimit.count++ return true } @@ -60,8 +66,8 @@ function generateImpressionOperationId(userId: string, impUrl: string): string { } const bodySchema = z.object({ + // Only impUrl needed - we look up the ad data from our database impUrl: z.string().url(), - payout: z.number().optional(), }) export async function postAdImpression(params: { @@ -85,7 +91,6 @@ export async function postAdImpression(params: { // Parse and validate request body let impUrl: string - let payout: number | undefined try { const json = await req.json() const parsed = bodySchema.safeParse(json) @@ -96,7 +101,6 @@ export async function postAdImpression(params: { ) } impUrl = parsed.data.impUrl - payout = parsed.data.payout } catch { return NextResponse.json( { error: 'Invalid JSON in request body' }, @@ -110,13 +114,55 @@ export async function postAdImpression(params: { logger: baseLogger, loggerWithContext, trackEvent, - authErrorEvent: AnalyticsEvent.USAGE_API_AUTH_ERROR, // Reuse existing event + authErrorEvent: AnalyticsEvent.USAGE_API_AUTH_ERROR, }) if (!authed.ok) return authed.response const { userId, logger } = authed.data - // Check rate limit before processing + // Look up the ad from our database using the impUrl + // This ensures we use server-side trusted data, not client-provided data + const adRecord = await db.query.adImpression.findFirst({ + where: eq(schema.adImpression.imp_url, impUrl), + }) + + if (!adRecord) { + logger.warn( + { userId, impUrl }, + '[ads] Ad impression not found in database - was it served through our API?', + ) + return NextResponse.json( + { success: false, error: 'Ad not found', creditsGranted: 0 }, + { status: 404 }, + ) + } + + // Verify the ad belongs to this user + if (adRecord.user_id !== userId) { + logger.warn( + { userId, adUserId: adRecord.user_id, impUrl }, + '[ads] User attempting to claim impression for ad served to different user', + ) + return NextResponse.json( + { success: false, error: 'Ad not found', creditsGranted: 0 }, + { status: 404 }, + ) + } + + // Check if impression was already fired (before rate limiting to not penalize duplicates) + if (adRecord.impression_fired_at) { + logger.debug( + { userId, impUrl }, + '[ads] Impression already recorded for this ad', + ) + return NextResponse.json({ + success: true, + creditsGranted: adRecord.credits_granted, + alreadyRecorded: true, + }) + } + + // Check rate limit (after duplicate check so duplicates don't consume quota) if (!checkRateLimit(userId)) { logger.warn( { userId, maxPerHour: MAX_IMPRESSIONS_PER_HOUR }, @@ -128,8 +174,10 @@ export async function postAdImpression(params: { ) } + // Get payout from the trusted database record + const payout = parseFloat(adRecord.payout) + // Generate deterministic operation ID for deduplication - // Same user + same impUrl = same operationId, preventing duplicate credits const operationId = generateImpressionOperationId(userId, impUrl) // Fire the impression pixel to Gravity @@ -150,65 +198,92 @@ export async function postAdImpression(params: { // Continue anyway - we still want to grant credits } - // Grant credits if there's a payout + // Calculate credits to grant (75% of payout, converted to credits) + // Payout is in dollars, credits are 1:1 with cents, so multiply by 100 + const userShareDollars = payout * AD_REVENUE_SHARE + const creditsToGrant = Math.floor(userShareDollars * 100) + + // Grant credits if any let creditsGranted = 0 - if (payout && payout > 0) { - // Calculate user's share (75%) and convert to credits (round down) - // Payout is in dollars, credits are 1:1 with cents, so multiply by 100 - const userShareDollars = payout * AD_REVENUE_SHARE - const creditsToGrant = Math.floor(userShareDollars * 100) - - if (creditsToGrant > 0) { - try { - await processAndGrantCredit({ + if (creditsToGrant > 0) { + try { + await processAndGrantCredit({ + userId, + amount: creditsToGrant, + type: 'ad', + description: `Ad impression credit (${(userShareDollars * 100).toFixed(1)}¢ from $${payout.toFixed(4)} payout)`, + expiresAt: null, // Ad credits don't expire + operationId, + logger, + }) + + creditsGranted = creditsToGrant + + logger.info( + { userId, - amount: creditsToGrant, - type: 'ad', - description: `Ad impression credit (${(userShareDollars * 100).toFixed(1)}¢ from $${payout.toFixed(4)} payout)`, - expiresAt: null, // Ad credits don't expire + payout, + creditsGranted, operationId, - logger, - }) - - creditsGranted = creditsToGrant - - logger.info( - { - userId, - payout, - creditsGranted, - operationId, - }, - '[ads] Granted ad impression credits', - ) - - trackEvent({ - event: AnalyticsEvent.CREDIT_GRANT, + }, + '[ads] Granted ad impression credits', + ) + + trackEvent({ + event: AnalyticsEvent.CREDIT_GRANT, + userId, + properties: { + type: 'ad', + amount: creditsGranted, + payout, + }, + logger, + }) + } catch (error) { + logger.error( + { userId, - properties: { - type: 'ad', - amount: creditsGranted, - payout, - }, - logger, - }) - } catch (error) { - logger.error( - { - userId, - payout, - error: - error instanceof Error - ? { name: error.name, message: error.message } - : error, - }, - '[ads] Failed to grant ad impression credits', - ) - // Don't fail the request - impression was still recorded - } + payout, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to grant ad impression credits', + ) + // Don't fail the request - we still want to update the impression record } } + // Update the ad_impression record with impression details + try { + await db + .update(schema.adImpression) + .set({ + impression_fired_at: new Date(), + credits_granted: creditsGranted, + grant_operation_id: creditsGranted > 0 ? operationId : null, + }) + .where(eq(schema.adImpression.id, adRecord.id)) + + logger.info( + { userId, impUrl, creditsGranted }, + '[ads] Updated ad impression record', + ) + } catch (error) { + logger.error( + { + userId, + impUrl, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to update ad impression record', + ) + } + return NextResponse.json({ success: true, creditsGranted, From db8b7517b023231bc47a4fb94a10223bd4c8f7b5 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 16:39:43 -0800 Subject: [PATCH 04/13] Include user id in request --- web/src/app/api/v1/ads/_post.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index a8feda174..7520b89ac 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -20,7 +20,7 @@ const messageSchema = z.object({ }) const bodySchema = z.object({ - messages: z.array(messageSchema).min(1), + messages: z.array(messageSchema), }) export type GravityEnv = { @@ -91,7 +91,10 @@ export async function postAds(params: { Authorization: `Bearer ${serverEnv.GRAVITY_API_KEY}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ messages }), + body: JSON.stringify({ + messages, + user: { uid: userId }, + }), }) if (response.status === 204) { From 9262e6d48a37b22a8044cf78eec0b94477cbf860 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 16:40:24 -0800 Subject: [PATCH 05/13] Don't clear timers until unmount --- cli/src/hooks/use-gravity-ad.ts | 65 ++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 22ff6a67b..5f6879f41 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -71,7 +71,7 @@ export type GravityAdState = { /** * Hook for fetching and rotating Gravity ads. - * + * * Behavior: * - Ads rotate every 60 seconds * - Next ad is pre-fetched 5 seconds before display for instant swap @@ -85,17 +85,17 @@ export const useGravityAd = (): GravityAdState => { // Pre-fetched next ad ready to display const nextAdRef = useRef(null) - + // Counter: how many ads shown since last user activity const adsShownRef = useRef(0) - + // Is rotation currently paused (shown 3 ads without activity)? const isPausedRef = useRef(false) - + // Timers const prefetchTimerRef = useRef | null>(null) const swapTimerRef = useRef | null>(null) - + // Has the first ad been fetched? const isStartedRef = useRef(false) @@ -106,21 +106,24 @@ export const useGravityAd = (): GravityAdState => { useEffect(() => { if (ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { impressionFiredRef.current.add(ad.impUrl) - logger.info({ impUrl: ad.impUrl, payout: ad.payout }, '[gravity] Recording ad impression') - + logger.info( + { impUrl: ad.impUrl, payout: ad.payout }, + '[gravity] Recording ad impression', + ) + const authToken = getAuthToken() if (!authToken) { logger.warn('[gravity] No auth token, skipping impression recording') return } - + // Call our web API to fire impression and grant credits // Only send impUrl - server looks up trusted ad data from database fetch(`${WEBSITE_URL}/api/v1/ads/impression`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, + Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ impUrl: ad.impUrl, @@ -156,7 +159,7 @@ export const useGravityAd = (): GravityAdState => { // Fetch an ad via web API and return it (for pre-fetching) const fetchAdAsync = useCallback(async (): Promise => { if (!getAdsEnabled()) return null - + const authToken = getAuthToken() if (!authToken) { logger.warn('[gravity] No auth token available') @@ -168,28 +171,32 @@ export const useGravityAd = (): GravityAdState => { currentRunState?.sessionState?.mainAgentState?.messageHistory ?? [] const adMessages = convertToAdMessages(messageHistory) - if (adMessages.length === 0) return null + logger.info( + { messageCount: adMessages.length }, + '[gravity] Fetching ad from web API', + ) - logger.info('[gravity] Fetching ad from web API') - try { const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, + Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ messages: adMessages }), }) - + if (!response.ok) { - logger.warn({ status: response.status }, '[gravity] Web API returned error') + logger.warn( + { status: response.status }, + '[gravity] Web API returned error', + ) return null } - + const data = await response.json() const ad = data.ad as AdResponse | null - + logger.info( { hasAd: !!ad, @@ -256,10 +263,10 @@ export const useGravityAd = (): GravityAdState => { // Report user activity - resets counter and resumes rotation if paused const reportActivity = useCallback(() => { const wasPaused = isPausedRef.current - + // Reset counter adsShownRef.current = 0 - + if (wasPaused) { logger.info('[gravity] User active, resuming ad rotation') isPausedRef.current = false @@ -268,29 +275,29 @@ export const useGravityAd = (): GravityAdState => { } }, [scheduleNextCycle]) - // Start ad rotation when enabled and we have messages + // Prefetch ad on startup (before any messages are sent) useEffect(() => { - const messageHistory = - runState?.sessionState?.mainAgentState?.messageHistory ?? [] - const hasMessages = messageHistory.length > 0 const adsEnabled = getAdsEnabled() const hasAuth = !!getAuthToken() - if (hasMessages && adsEnabled && hasAuth && !isStartedRef.current) { - logger.info('[gravity] Starting ad rotation') + if (adsEnabled && hasAuth && !isStartedRef.current) { + logger.info('[gravity] Prefetching ad on startup') isStartedRef.current = true setIsLoading(true) - - // Fetch and display first ad + + // Prefetch first ad immediately fetchAdAsync().then((firstAd) => { setAd(firstAd) setIsLoading(false) scheduleNextCycle() }) } + }, [fetchAdAsync, scheduleNextCycle]) + // Clear timers only on unmount + useEffect(() => { return () => clearTimers() - }, [runState, fetchAdAsync, scheduleNextCycle, clearTimers]) + }, [clearTimers]) return { ad, isLoading, reportActivity } } From e96fbec8bdedb00565e0061c2a8cd5093c1b2db6 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 16:47:41 -0800 Subject: [PATCH 06/13] Show test ads while not in prod --- packages/internal/src/env-schema.ts | 2 +- web/src/app/api/v1/ads/_post.ts | 4 +++- web/src/app/api/v1/ads/route.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index c33e66ef7..fabf97315 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -7,7 +7,7 @@ export const serverEnvSchema = clientEnvSchema.extend({ OPENAI_API_KEY: z.string().min(1), LINKUP_API_KEY: z.string().min(1), CONTEXT7_API_KEY: z.string().optional(), - GRAVITY_API_KEY: z.string().optional(), + GRAVITY_API_KEY: z.string().min(1), PORT: z.coerce.number().min(1000), // Web/Database variables diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 7520b89ac..fa1dd3a38 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -24,7 +24,8 @@ const bodySchema = z.object({ }) export type GravityEnv = { - GRAVITY_API_KEY: string | undefined + GRAVITY_API_KEY: string + CB_ENVIRONMENT: string } export async function postAds(params: { @@ -94,6 +95,7 @@ export async function postAds(params: { body: JSON.stringify({ messages, user: { uid: userId }, + testAd: serverEnv.CB_ENVIRONMENT !== 'prod', }), }) diff --git a/web/src/app/api/v1/ads/route.ts b/web/src/app/api/v1/ads/route.ts index d341d7cc7..7e64fe50d 100644 --- a/web/src/app/api/v1/ads/route.ts +++ b/web/src/app/api/v1/ads/route.ts @@ -16,6 +16,6 @@ export async function POST(req: NextRequest) { loggerWithContext, trackEvent, fetch, - serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY }, + serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY, CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT }, }) } From 52336caf98fe561018d1d4a5b755945b4543b9d3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 17:21:12 -0800 Subject: [PATCH 07/13] Pass credits and show them in ui --- cli/src/components/ad-banner.tsx | 72 ++++++++++++---------- cli/src/hooks/use-gravity-ad.ts | 26 ++++---- web/src/app/api/v1/ads/_post.ts | 5 +- web/src/app/api/v1/ads/impression/_post.ts | 2 +- 4 files changed, 58 insertions(+), 47 deletions(-) diff --git a/cli/src/components/ad-banner.tsx b/cli/src/components/ad-banner.tsx index 6d1ffd4db..9e7db60b2 100644 --- a/cli/src/components/ad-banner.tsx +++ b/cli/src/components/ad-banner.tsx @@ -25,7 +25,7 @@ export const AdBanner: React.FC = ({ ad }) => { useEffect(() => { logger.info( { adText: ad.adText?.substring(0, 50), hasClickUrl: !!ad.clickUrl }, - '[gravity] Rendering AdBanner' + '[gravity] Rendering AdBanner', ) }, [ad]) const theme = useTheme() @@ -58,6 +58,7 @@ export const AdBanner: React.FC = ({ ad }) => { > {/* Horizontal divider line */} {'─'.repeat(terminalWidth)} + {/* Top line: ad text + Ad label */} = ({ ad }) => { alignItems: 'flex-start', }} > - - - {ad.adText} - - - {ctaText && ( - - )} - {domain && ( - {domain} - )} - - + {ad.adText} + Ad + {/* Bottom line: button, domain, credits */} + + {ctaText && ( + + )} + {domain && {domain}} + + {ad.credits != null && ad.credits > 0 && ( + +{ad.credits} credits + )} + ) } diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 5f6879f41..1d96cbedb 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -15,7 +15,7 @@ const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then s type AdMessage = { role: 'user' | 'assistant'; content: string } -// Ad response type (matches Gravity API response) +// Ad response type (matches Gravity API response, credits added after impression) export type AdResponse = { adText: string title: string @@ -23,7 +23,7 @@ export type AdResponse = { favicon: string clickUrl: string impUrl: string - payout: number + credits?: number // Set after impression is recorded (in cents) } /** @@ -105,9 +105,10 @@ export const useGravityAd = (): GravityAdState => { // Fire impression via web API when ad changes (grants credits) useEffect(() => { if (ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { - impressionFiredRef.current.add(ad.impUrl) + const currentImpUrl = ad.impUrl + impressionFiredRef.current.add(currentImpUrl) logger.info( - { impUrl: ad.impUrl, payout: ad.payout }, + { impUrl: currentImpUrl }, '[gravity] Recording ad impression', ) @@ -126,7 +127,7 @@ export const useGravityAd = (): GravityAdState => { Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ - impUrl: ad.impUrl, + impUrl: currentImpUrl, }), }) .then((res) => res.json()) @@ -136,6 +137,12 @@ export const useGravityAd = (): GravityAdState => { { creditsGranted: data.creditsGranted }, '[gravity] Ad impression credits granted', ) + // Update ad with credits from impression response + setAd((currentAd) => + currentAd?.impUrl === currentImpUrl + ? { ...currentAd, credits: data.creditsGranted } + : currentAd, + ) } }) .catch((err) => { @@ -183,7 +190,7 @@ export const useGravityAd = (): GravityAdState => { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, - body: JSON.stringify({ messages: adMessages }), + body: JSON.stringify({ messages: messageHistory }), }) if (!response.ok) { @@ -199,12 +206,7 @@ export const useGravityAd = (): GravityAdState => { logger.info( { - hasAd: !!ad, - adText: ad?.adText, - title: ad?.title, - clickUrl: ad?.clickUrl, - impUrl: ad?.impUrl, - payout: ad?.payout, + ad, }, '[gravity] Received ad response', ) diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index fa1dd3a38..96f502c37 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -158,8 +158,9 @@ export async function postAds(params: { ) } - // Return complete ad to client (client will call /impression endpoint when displayed) - return NextResponse.json({ ad }) + // Return ad to client without payout (credits will come from impression endpoint) + const { payout: _payout, ...adWithoutPayout } = ad + return NextResponse.json({ ad: adWithoutPayout }) } catch (error) { logger.error( { diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index 651504d21..383e9d60c 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -67,7 +67,7 @@ function generateImpressionOperationId(userId: string, impUrl: string): string { const bodySchema = z.object({ // Only impUrl needed - we look up the ad data from our database - impUrl: z.string().url(), + impUrl: z.url(), }) export async function postAdImpression(params: { From c8308d22aacbac0ab275097e44713b05ef8cb000 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 17:50:48 -0800 Subject: [PATCH 08/13] cleanup --- cli/src/hooks/use-gravity-ad.ts | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 1d96cbedb..dbac3182f 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -44,25 +44,6 @@ const extractTextFromMessageContent = (content: Message['content']): string => { .join('\n') } -/** - * Convert LLM message history to ad API format. - * Includes all message types (user, assistant, tool, system) for analytics. - */ -const convertToAdMessages = (messages: Message[]): AdMessage[] => { - const adMessages: AdMessage[] = [] - - for (const message of messages.slice(-MAX_MESSAGES_FOR_AD)) { - const textContent = extractTextFromMessageContent(message.content) - if (textContent) { - // Map all roles to user/assistant as required by the API - const role = message.role === 'user' ? 'user' : 'assistant' - adMessages.push({ role, content: textContent }) - } - } - - return adMessages -} - export type GravityAdState = { ad: AdResponse | null isLoading: boolean @@ -99,9 +80,6 @@ export const useGravityAd = (): GravityAdState => { // Has the first ad been fetched? const isStartedRef = useRef(false) - // Get runState from chat store - const runState = useChatStore((state) => state.runState) - // Fire impression via web API when ad changes (grants credits) useEffect(() => { if (ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { @@ -176,10 +154,9 @@ export const useGravityAd = (): GravityAdState => { const currentRunState = useChatStore.getState().runState const messageHistory = currentRunState?.sessionState?.mainAgentState?.messageHistory ?? [] - const adMessages = convertToAdMessages(messageHistory) logger.info( - { messageCount: adMessages.length }, + { messageCount: messageHistory.length }, '[gravity] Fetching ad from web API', ) From 097bcac5f047f0a54924a4395cc5f6939092bf7c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 18:00:00 -0800 Subject: [PATCH 09/13] analytics event --- common/src/constants/analytics-events.ts | 3 +++ web/src/app/api/v1/ads/_post.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index fbbf779a8..b3fc3b39d 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -119,6 +119,9 @@ export enum AnalyticsEvent { DOCS_SEARCH_INSUFFICIENT_CREDITS = 'api.docs_search_insufficient_credits', DOCS_SEARCH_ERROR = 'api.docs_search_error', + // Web - Ads API + ADS_API_AUTH_ERROR = 'api.ads_auth_error', + // Common FLUSH_FAILED = 'common.flush_failed', } diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 96f502c37..f2cbac2f8 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -1,4 +1,4 @@ -// Note: Using existing analytics events as placeholders since ads-specific events don't exist yet +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { NextResponse } from 'next/server' @@ -78,7 +78,7 @@ export async function postAds(params: { logger: baseLogger, loggerWithContext, trackEvent, - authErrorEvent: 'api.ads_auth_error' as any, // TODO: Add proper analytics event + authErrorEvent: AnalyticsEvent.ADS_API_AUTH_ERROR, }) if (!authed.ok) return authed.response From b693fdc29f83dba44274761c9698f0952ffd8f8d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 18:00:12 -0800 Subject: [PATCH 10/13] fix memory leak found by reviewer --- web/src/app/api/v1/ads/impression/_post.ts | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index 383e9d60c..01a200278 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -29,6 +29,23 @@ const impressionRateLimiter = new Map< { count: number; resetAt: number } >() +/** + * Clean up expired entries from the rate limiter to prevent memory leaks. + * Called periodically during rate limit checks. + */ +function cleanupExpiredEntries(): void { + const now = Date.now() + for (const [userId, limit] of impressionRateLimiter) { + if (now >= limit.resetAt) { + impressionRateLimiter.delete(userId) + } + } +} + +// Track last cleanup time to avoid cleaning up on every request +let lastCleanupTime = 0 +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // Clean up every 5 minutes + /** * Check and update rate limit for a user. * Returns true if the request is allowed, false if rate limited. @@ -37,6 +54,12 @@ function checkRateLimit(userId: string): boolean { const now = Date.now() const hourMs = 60 * 60 * 1000 + // Periodically clean up expired entries to prevent memory leak + if (now - lastCleanupTime > CLEANUP_INTERVAL_MS) { + cleanupExpiredEntries() + lastCleanupTime = now + } + const userLimit = impressionRateLimiter.get(userId) if (!userLimit || now >= userLimit.resetAt) { From 76ddb0d5d1b9eb418a9af31cca22f33263c817cd Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 18:00:20 -0800 Subject: [PATCH 11/13] Show credits earned from ads on website --- web/src/app/profile/components/usage-display.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index ca97255c2..dae0f757f 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -9,6 +9,7 @@ import { Users, CreditCard, Star, + Megaphone, } from 'lucide-react' import React from 'react' @@ -31,7 +32,7 @@ interface UsageDisplayProps { isLoading?: boolean } -type FilteredGrantType = Exclude +type FilteredGrantType = Exclude const grantTypeInfo: Record< FilteredGrantType, @@ -76,6 +77,14 @@ const grantTypeInfo: Record< label: 'Special Grant', description: 'Special credits from Codebuff', }, + ad: { + bg: 'bg-purple-500', + text: 'text-purple-600 dark:text-purple-400', + gradient: 'from-purple-500/70 to-purple-600/70', + icon: , + label: 'Ad Credits', + description: 'Earned from viewing ads', + }, } interface CreditLeafProps { @@ -227,6 +236,7 @@ export const UsageDisplay = ({ referral: 0, purchase: 0, admin: 0, + ad: 0, } Object.entries(GRANT_PRIORITIES).forEach(([type]) => { @@ -243,7 +253,7 @@ export const UsageDisplay = ({ // Group credits by expiration type (excluding organization) const expiringTypes: FilteredGrantType[] = ['free', 'referral'] - const nonExpiringTypes: FilteredGrantType[] = ['admin', 'purchase'] + const nonExpiringTypes: FilteredGrantType[] = ['admin', 'purchase', 'ad'] const expiringTotal = expiringTypes.reduce( (acc, type) => acc + (principals?.[type] || breakdown[type] || 0), From a44a7a427efa971f7b99cea510b97f1fcd70fc0c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 18:22:05 -0800 Subject: [PATCH 12/13] cleanup / say you earn credits from enabling ads --- cli/src/data/slash-commands.ts | 2 +- cli/src/hooks/use-gravity-ad.ts | 23 ----------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 51a76cac9..c8ecbdb61 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -18,7 +18,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { id: 'ads:enable', label: 'ads:enable', - description: 'Enable contextual ads', + description: 'Enable contextual ads and earn credits', }, { id: 'ads:disable', diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index dbac3182f..496efe67f 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -6,15 +6,10 @@ import { useChatStore } from '../state/chat-store' import { getAuthToken } from '../utils/auth' import { logger } from '../utils/logger' -import type { Message } from '@codebuff/common/types/messages/codebuff-message' - -const MAX_MESSAGES_FOR_AD = 100 const AD_DISPLAY_DURATION_MS = 60 * 1000 // 60 seconds per ad const PREFETCH_BEFORE_MS = 5 * 1000 // Fetch next ad 5 seconds before swap const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then stop -type AdMessage = { role: 'user' | 'assistant'; content: string } - // Ad response type (matches Gravity API response, credits added after impression) export type AdResponse = { adText: string @@ -26,24 +21,6 @@ export type AdResponse = { credits?: number // Set after impression is recorded (in cents) } -/** - * Extract text content from a Message's content array - */ -const extractTextFromMessageContent = (content: Message['content']): string => { - if (!Array.isArray(content)) return '' - - return content - .filter( - (part): part is { type: 'text'; text: string } => - typeof part === 'object' && - part !== null && - 'type' in part && - part.type === 'text', - ) - .map((part) => part.text) - .join('\n') -} - export type GravityAdState = { ad: AdResponse | null isLoading: boolean From 93f506a2bda4efa6f099f6d8b32910848f22c6df Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 31 Dec 2025 18:32:06 -0800 Subject: [PATCH 13/13] Only show ad after first user message --- cli/src/hooks/use-gravity-ad.ts | 38 ++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 496efe67f..29fff1b0b 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -39,6 +39,7 @@ export type GravityAdState = { export const useGravityAd = (): GravityAdState => { const [ad, setAd] = useState(null) const [isLoading, setIsLoading] = useState(false) + const [shouldShowAd, setShouldShowAd] = useState(false) const impressionFiredRef = useRef>(new Set()) // Pre-fetched next ad ready to display @@ -58,8 +59,9 @@ export const useGravityAd = (): GravityAdState => { const isStartedRef = useRef(false) // Fire impression via web API when ad changes (grants credits) + // Only fire impressions when ad is actually being shown useEffect(() => { - if (ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { + if (shouldShowAd && ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { const currentImpUrl = ad.impUrl impressionFiredRef.current.add(currentImpUrl) logger.info( @@ -104,7 +106,7 @@ export const useGravityAd = (): GravityAdState => { logger.debug({ err }, '[gravity] Failed to record ad impression') }) } - }, [ad]) + }, [ad, shouldShowAd]) // Clear all timers const clearTimers = useCallback(() => { @@ -250,10 +252,40 @@ export const useGravityAd = (): GravityAdState => { } }, [fetchAdAsync, scheduleNextCycle]) + // Subscribe to UI messages to detect first user message + // Only show ads after the user has sent at least one message (clean startup UX) + // We use UI messages instead of runState.messageHistory because UI messages + // update immediately when the user sends a message + useEffect(() => { + if (shouldShowAd || !getAdsEnabled()) { + return + } + + // Check initial state + const initialMessages = useChatStore.getState().messages + if (initialMessages.some((msg) => msg.variant === 'user')) { + setShouldShowAd(true) + return + } + + const unsubscribe = useChatStore.subscribe((state) => { + const hasUserMessage = state.messages.some((msg) => msg.variant === 'user') + + if (hasUserMessage) { + unsubscribe() + logger.info('[gravity] First user message detected, showing ads') + setShouldShowAd(true) + } + }) + + return unsubscribe + }, [shouldShowAd]) + // Clear timers only on unmount useEffect(() => { return () => clearTimers() }, [clearTimers]) - return { ad, isLoading, reportActivity } + // Only return the ad if we should show it (after first user message) + return { ad: shouldShowAd ? ad : null, isLoading, reportActivity } }