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[] +} => { + logger.info('[gravity] Enabling ads') + + saveSettings({ adsEnabled: true }) + + return { + postUserMessage: (messages) => [ + ...messages, + getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'), + ], + } +} + +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..9e7db60b2 --- /dev/null +++ b/cli/src/components/ad-banner.tsx @@ -0,0 +1,119 @@ +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 '../hooks/use-gravity-ad' + +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) + 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) + const maxTextWidth = separatorWidth - 5 + + return ( + + {/* Horizontal divider line */} + {'─'.repeat(terminalWidth)} + {/* Top line: ad text + Ad label */} + + + {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/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/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 4ff6b8497..c8ecbdb61 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 and earn credits', + }, + { + 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..29fff1b0b --- /dev/null +++ b/cli/src/hooks/use-gravity-ad.ts @@ -0,0 +1,291 @@ +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 { getAuthToken } from '../utils/auth' +import { logger } from '../utils/logger' + +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 + +// Ad response type (matches Gravity API response, credits added after impression) +export type AdResponse = { + adText: string + title: string + url: string + favicon: string + clickUrl: string + impUrl: string + credits?: number // Set after impression is recorded (in cents) +} + +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 [shouldShowAd, setShouldShowAd] = useState(false) + 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) + + // Fire impression via web API when ad changes (grants credits) + // Only fire impressions when ad is actually being shown + useEffect(() => { + if (shouldShowAd && ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { + const currentImpUrl = ad.impUrl + impressionFiredRef.current.add(currentImpUrl) + logger.info( + { impUrl: currentImpUrl }, + '[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}`, + }, + body: JSON.stringify({ + impUrl: currentImpUrl, + }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.creditsGranted > 0) { + logger.info( + { 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) => { + logger.debug({ err }, '[gravity] Failed to record ad impression') + }) + } + }, [ad, shouldShowAd]) + + // 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 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') + return null + } + + const currentRunState = useChatStore.getState().runState + const messageHistory = + currentRunState?.sessionState?.mainAgentState?.messageHistory ?? [] + + logger.info( + { messageCount: messageHistory.length }, + '[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}`, + }, + body: JSON.stringify({ messages: messageHistory }), + }) + + 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( + { + ad, + }, + '[gravity] Received ad response', + ) + return ad + } 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]) + + // Prefetch ad on startup (before any messages are sent) + useEffect(() => { + const adsEnabled = getAdsEnabled() + const hasAuth = !!getAuthToken() + + if (adsEnabled && hasAuth && !isStartedRef.current) { + logger.info('[gravity] Prefetching ad on startup') + isStartedRef.current = true + setIsLoading(true) + + // Prefetch first ad immediately + fetchAdAsync().then((firstAd) => { + setAd(firstAd) + setIsLoading(false) + scheduleNextCycle() + }) + } + }, [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]) + + // Only return the ad if we should show it (after first user message) + return { ad: shouldShowAd ? ad : null, isLoading, reportActivity } +} 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/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 } 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/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/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/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/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 6d9311210..fabf97315 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().min(1), 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..f2cbac2f8 --- /dev/null +++ b/web/src/app/api/v1/ads/_post.ts @@ -0,0 +1,176 @@ +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' +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), +}) + +export type GravityEnv = { + GRAVITY_API_KEY: string + CB_ENVIRONMENT: string +} + +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: AnalyticsEvent.ADS_API_AUTH_ERROR, + }) + if (!authed.ok) return authed.response + + const { userId, 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, + user: { uid: userId }, + testAd: serverEnv.CB_ENVIRONMENT !== 'prod', + }), + }) + + 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', + ) + + // 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 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( + { + 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..01a200278 --- /dev/null +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -0,0 +1,314 @@ +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' + +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< + string, + { 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. + */ +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) { + // 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({ + // Only impUrl needed - we look up the ad data from our database + impUrl: z.url(), +}) + +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 + 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 + } 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, + }) + if (!authed.ok) return authed.response + + const { userId, logger } = authed.data + + // 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 }, + '[ads] Rate limited ad impression request', + ) + return NextResponse.json( + { success: false, error: 'Rate limited', creditsGranted: 0 }, + { status: 429 }, + ) + } + + // Get payout from the trusted database record + const payout = parseFloat(adRecord.payout) + + // Generate deterministic operation ID for deduplication + 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 + } + + // 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 (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 - 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, + }) +} 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..7e64fe50d --- /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, CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT }, + }) +} diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index c58957d1c..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' @@ -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),