diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 2c2a65f5c..661c7174b 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -73,9 +73,11 @@ export const WaitingRoomScreen: React.FC = ({ // Always enable ads in the waiting room — this is where monetization lives. // forceStart bypasses the "wait for first user message" gate inside the hook, // which would otherwise block ads here since no conversation exists yet. + // Uses Carbon (BuySellAds); in-chat ads still use the Gravity default. const { ad, adData, recordImpression } = useGravityAd({ enabled: true, forceStart: true, + provider: 'carbon', }) useFreebuffCtrlCExit() diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 5b48a97f2..e52b4bdd8 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -16,7 +16,7 @@ const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then p const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache -// Ad response type (matches Gravity API response, credits added after impression) +// Ad response type (normalized shape across providers; credits added after impression) export type AdResponse = { adText: string title: string @@ -30,6 +30,12 @@ export type AdResponse = { export type AdVariant = 'banner' | 'choice' +/** + * Which upstream ad network to query. The server maps each provider onto the + * same normalized response shape, so the rest of the hook is provider-agnostic. + */ +export type AdProvider = 'gravity' | 'carbon' + export type AdData = | { variant: 'banner'; ad: AdResponse } | { variant: 'choice'; ads: AdResponse[] } @@ -102,9 +108,12 @@ export const useGravityAd = (options?: { /** Skip the "wait for first user message" gate. Used by the freebuff * waiting room, which has no conversation but still needs ads. */ forceStart?: boolean + /** Which ad network to query. Defaults to Gravity. */ + provider?: AdProvider }): GravityAdState => { const enabled = options?.enabled ?? true const forceStart = options?.forceStart ?? false + const provider: AdProvider = options?.provider ?? 'gravity' const [ad, setAd] = useState(null) const [adData, setAdData] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -159,7 +168,7 @@ export const useGravityAd = (options?: { const authToken = getAuthToken() if (!authToken) { - logger.warn('[gravity] No auth token, skipping impression recording') + logger.warn('[ads] No auth token, skipping impression recording') return } @@ -179,7 +188,7 @@ export const useGravityAd = (options?: { if (data.creditsGranted > 0) { logger.info( { creditsGranted: data.creditsGranted }, - '[gravity] Ad impression credits granted', + '[ads] Ad impression credits granted', ) setAd((cur) => cur?.impUrl === impUrl @@ -205,7 +214,7 @@ export const useGravityAd = (options?: { } }) .catch((err) => { - logger.debug({ err }, '[gravity] Failed to record ad impression') + logger.debug({ err }, '[ads] Failed to record ad impression') }) } @@ -235,7 +244,7 @@ export const useGravityAd = (options?: { const authToken = getAuthToken() if (!authToken) { - logger.warn('[gravity] No auth token available') + logger.warn('[ads] No auth token available') return null } @@ -277,16 +286,21 @@ export const useGravityAd = (options?: { Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ + provider, messages: adMessages, sessionId: useChatStore.getState().chatSessionId, device: getDeviceInfo(), + // Carbon requires a real browser-ish useragent for targeting/fraud + // detection. Gravity ignores it. We source one centrally so every + // provider that needs it sees the same value. + userAgent: getAdUserAgent(), }), }) if (!response.ok) { logger.warn( - { status: response.status, response: await response.json() }, - '[gravity] Web API returned error', + { provider, status: response.status, response: await response.json() }, + '[ads] Web API returned error', ) return null } @@ -304,7 +318,7 @@ export const useGravityAd = (options?: { return null } catch (err) { - logger.error({ err }, '[gravity] Failed to fetch ad') + logger.error({ err }, '[ads] Failed to fetch ad') return null } } @@ -465,3 +479,22 @@ function getDeviceInfo(): DeviceInfo { return { os, timezone, locale } } + +/** + * Useragent string passed to ad providers. Carbon (BuySellAds) requires a + * plausible browser useragent for targeting and fraud screening. We send a + * stable desktop Chrome-on-{os} UA per platform so targeting is consistent + * across users on the same platform without sharing anything identifying. + * + * Chrome version needs bumping periodically — stale UAs look bot-ish to ad + * networks. Last bumped: 2026-04-21. Revisit roughly every 6 months. + */ +const AD_CHROME_VERSION = '124.0.0.0' +function getAdUserAgent(): string { + const osUA: Record = { + darwin: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + win32: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + linux: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + } + return osUA[process.platform] ?? osUA.linux +} diff --git a/packages/internal/src/db/migrations/0045_mean_sleeper.sql b/packages/internal/src/db/migrations/0045_mean_sleeper.sql new file mode 100644 index 000000000..0f0f9c4d7 --- /dev/null +++ b/packages/internal/src/db/migrations/0045_mean_sleeper.sql @@ -0,0 +1,3 @@ +ALTER TABLE "ad_impression" ALTER COLUMN "payout" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "ad_impression" ADD COLUMN "provider" text DEFAULT 'gravity' NOT NULL;--> statement-breakpoint +ALTER TABLE "ad_impression" ADD COLUMN "extra_pixels" text[]; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0045_snapshot.json b/packages/internal/src/db/migrations/meta/0045_snapshot.json new file mode 100644 index 000000000..a421bd575 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0045_snapshot.json @@ -0,0 +1,3227 @@ +{ + "id": "76196ef1-2384-4edd-b832-c9ff8085d809", + "prevId": "108f2bd2-7ddc-4c15-b351-28f2b55d5348", + "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 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gravity'" + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "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 + }, + "extra_pixels": { + "name": "extra_pixels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "stripe_subscription_id": { + "name": "stripe_subscription_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": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "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.free_session": { + "name": "free_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "free_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active_instance_id": { + "name": "active_instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_session_queue": { + "name": "idx_free_session_queue", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_session_expiry": { + "name": "idx_free_session_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_user_id_user_id_fk": { + "name": "free_session_user_id_user_id_fk", + "tableFrom": "free_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "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": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "ttft_ms": { + "name": "ttft_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 + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "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 + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "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.free_session_status": { + "name": "free_session_status", + "schema": "public", + "values": [ + "queued", + "active" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "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" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "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 bba4ab5ed..f67ef37dc 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1776719872222, "tag": "0044_violet_stingray", "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1776813242936, + "tag": "0045_mean_sleeper", + "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 ba481c89a..b6f170d29 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -431,7 +431,10 @@ export const adImpression = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), - // Ad content from Gravity API + // Which upstream ad network served this ad ('gravity', 'carbon', 'zeroclick', ...) + provider: text('provider').notNull().default('gravity'), + + // Ad content (normalized across providers) ad_text: text('ad_text').notNull(), title: text('title').notNull(), cta: text('cta').notNull().default(''), @@ -439,7 +442,13 @@ export const adImpression = pgTable( 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(), + // Extra tracking pixel URLs (e.g. Carbon's `pixel` field, `||`-separated). + // Each string may contain `[timestamp]` which is substituted at fire time. + extra_pixels: text('extra_pixels').array(), + // Payout is Gravity-shaped; Carbon uses CPM and reports no per-impression + // payout, so this is nullable to avoid polluting revenue dashboards with + // fake numbers. + payout: numeric('payout', { precision: 10, scale: 6 }), // Credit tracking credits_granted: integer('credits_granted').notNull(), diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 2f2532b92..8efa8f83c 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -12,6 +12,11 @@ export const serverEnvSchema = clientEnvSchema.extend({ LINKUP_API_KEY: z.string().min(1), CONTEXT7_API_KEY: z.string().optional(), GRAVITY_API_KEY: z.string().min(1), + // BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad. + // Optional: when unset the Carbon provider returns no ad and callers fall + // back to their cached ads / fallback content. `CVADC53U` is the public + // test key from BSA docs and is safe to use in dev. + CARBON_ZONE_KEY: z.string().min(1).optional(), PORT: z.coerce.number().min(1000), // Web/Database variables @@ -70,6 +75,7 @@ export const serverProcessEnv: ServerInput = { LINKUP_API_KEY: process.env.LINKUP_API_KEY, CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY, GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, + CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY, PORT: process.env.PORT, // Web/Database variables diff --git a/packages/internal/src/env.ts b/packages/internal/src/env.ts index a0af1c971..b32f90564 100644 --- a/packages/internal/src/env.ts +++ b/packages/internal/src/env.ts @@ -35,6 +35,15 @@ if (isCI) { // Only log environment in non-production if (process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod') { console.log('Using environment:', process.env.NEXT_PUBLIC_CB_ENVIRONMENT) + + // `CVADC53U` is the public test zone documented by BuySellAds — safe to use + // in dev/CI so nobody has to configure anything to see Carbon ads render. + // Prod intentionally has no default: if CARBON_ZONE_KEY isn't set there, + // waiting-room requests return no ad rather than silently hitting test + // inventory. + if (!process.env.CARBON_ZONE_KEY) { + process.env.CARBON_ZONE_KEY = 'CVADC53U' + } } export const env = serverEnvSchema.parse(serverProcessEnv) diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 39daa5d31..fc1fa07a5 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -1,7 +1,4 @@ -import { createHash } from 'crypto' - import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { buildArray } from '@codebuff/common/util/array' import { getErrorObject } from '@codebuff/common/util/error' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -10,6 +7,14 @@ import { z } from 'zod' import { requireUserFromApiKey } from '../_helpers' +import { createCarbonProvider } from '@/lib/ad-providers/carbon' +import { createGravityProvider } from '@/lib/ad-providers/gravity' + +import type { + AdProvider, + AdProviderId, + NormalizedAd, +} from '@/lib/ad-providers/types' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { @@ -18,28 +23,6 @@ import type { } from '@codebuff/common/types/contracts/logger' import type { NextRequest } from 'next/server' -const DEFAULT_PAYOUT = 0.04 - -// A/B test: 50% of users see the "choice" ad variant (4 ads as bullet points) -type AdVariant = 'banner' | 'choice' - -const CHOICE_AD_PLACEMENT_IDS = [ - 'choice-ad-1', - 'choice-ad-2', - 'choice-ad-3', - 'choice-ad-4', -] - -/** - * Deterministically assign a user to an ad variant based on their userId. - * Uses a hash so the assignment is stable across requests. - */ -function getAdVariant(userId: string): AdVariant { - const hash = createHash('sha256').update(`ad-variant:${userId}`).digest() - // Use first byte: even = banner, odd = choice (50/50 split) - return hash[0] % 2 === 0 ? 'banner' : 'choice' -} - const messageSchema = z.object({ role: z.string(), content: z.string(), @@ -51,14 +34,20 @@ const deviceSchema = z.object({ locale: z.string().optional(), }) +const providerSchema = z.enum(['gravity', 'carbon']).default('gravity') + const bodySchema = z.object({ - messages: z.array(messageSchema), + provider: providerSchema.optional(), + messages: z.array(messageSchema).optional().default([]), sessionId: z.string().optional(), device: deviceSchema.optional(), + /** Browser/CLI useragent passed through to providers that require it. */ + userAgent: z.string().optional(), }) -export type GravityEnv = { +export type AdsEnv = { GRAVITY_API_KEY: string + CARBON_ZONE_KEY?: string CB_ENVIRONMENT: string } @@ -69,7 +58,7 @@ export async function postAds(params: { loggerWithContext: LoggerWithContextFn trackEvent: TrackEventFn fetch: typeof globalThis.fetch - serverEnv: GravityEnv + serverEnv: AdsEnv }) { const { req, @@ -92,22 +81,14 @@ export async function postAds(params: { const { userId, userInfo, logger } = authed.data - // Check if Gravity API key is configured - if (!serverEnv.GRAVITY_API_KEY) { - logger.warn('[ads] GRAVITY_API_KEY not configured') - return NextResponse.json({ ad: null }, { status: 200 }) - } - - // Extract client IP from request headers + // Client IP comes in via the load balancer's X-Forwarded-For header. Every + // provider that targets or bills by IP (Gravity, Carbon, ...) needs this. const forwardedFor = req.headers.get('x-forwarded-for') const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : (req.headers.get('x-real-ip') ?? undefined) - // Parse and validate request body - let messages: z.infer['messages'] - let sessionId: string | undefined - let deviceInfo: z.infer | undefined + let parsedBody: z.infer try { const json = await req.json() const parsed = bodySchema.safeParse(json) @@ -118,243 +99,144 @@ export async function postAds(params: { { status: 400 }, ) } - - // Filter out messages with no content and extract user message content from tags - messages = parsed.data.messages - .filter((message) => message.content) - .map((message) => { - // For user messages, extract content from the last tag if present - if (message.role === 'user') { - return { - ...message, - content: extractLastUserMessageContent(message.content), - } - } - return message - }) - sessionId = parsed.data.sessionId - deviceInfo = parsed.data.device + parsedBody = parsed.data } catch { - logger.error( - { error: 'Invalid JSON in request body' }, - '[ads] Invalid request body', - ) return NextResponse.json( { error: 'Invalid JSON in request body' }, { status: 400 }, ) } - // Keep just the last user message and the last assistant message before it - const lastUserMessageIndex = messages.findLastIndex( - (message) => message.role === 'user', - ) - const lastUserMessage = messages[lastUserMessageIndex] - const lastAssistantMessage = messages - .slice(0, lastUserMessageIndex) - .findLast((message) => message.role === 'assistant') - const filteredMessages = buildArray(lastAssistantMessage, lastUserMessage) - - // Build device object for Gravity API - const device = clientIp - ? { - ip: clientIp, - ...(deviceInfo?.os ? { os: deviceInfo.os } : {}), - ...(deviceInfo?.timezone ? { timezone: deviceInfo.timezone } : {}), - ...(deviceInfo?.locale ? { locale: deviceInfo.locale } : {}), + const providerId: AdProviderId = parsedBody.provider ?? 'gravity' + const userAgent = + parsedBody.userAgent ?? req.headers.get('user-agent') ?? undefined + + // Pick a provider. If the requested one isn't configured, return no ad + // rather than failing — the client falls back to its cache / fallback UI. + let provider: AdProvider | null = null + if (providerId === 'carbon') { + if (!serverEnv.CARBON_ZONE_KEY) { + logger.warn('[ads] CARBON_ZONE_KEY not configured') + return NextResponse.json({ ad: null, provider: providerId }, { status: 200 }) } - : undefined - - // Determine A/B test variant for this user - const variant = getAdVariant(userId) - - // Build placements based on variant - const placements = - variant === 'choice' - ? CHOICE_AD_PLACEMENT_IDS.map((id) => ({ - placement: 'below_response', - placement_id: id, - })) - : [{ placement: 'below_response', placement_id: 'code-assist-ad' }] + provider = createCarbonProvider({ zoneKey: serverEnv.CARBON_ZONE_KEY }) + } else { + if (!serverEnv.GRAVITY_API_KEY) { + logger.warn('[ads] GRAVITY_API_KEY not configured') + return NextResponse.json({ ad: null, provider: providerId }, { status: 200 }) + } + provider = createGravityProvider({ apiKey: serverEnv.GRAVITY_API_KEY }) + } try { - const requestBody = { - messages: filteredMessages, - sessionId: sessionId ?? userId, - placements, - testAd: serverEnv.CB_ENVIRONMENT !== 'prod', - relevancy: 0, - ...(device ? { device } : {}), - user: { - id: userId, - email: userInfo.email, - }, - } - // Call Gravity API - const response = await fetch('https://server.trygravity.ai/api/v1/ad', { - method: 'POST', - headers: { - Authorization: `Bearer ${serverEnv.GRAVITY_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), + const result = await provider.fetchAd({ + userId, + userEmail: userInfo.email ?? null, + sessionId: parsedBody.sessionId, + clientIp, + userAgent, + device: parsedBody.device, + messages: parsedBody.messages, + testMode: serverEnv.CB_ENVIRONMENT !== 'prod', + logger, + fetch, }) - // Handle 204 No Content first (no body to parse) - if (response.status === 204) { - logger.debug( - { request: requestBody, status: response.status }, - '[ads] No ad available from Gravity API', - ) - return NextResponse.json({ ad: null, variant }, { status: 200 }) - } - - // Check response.ok BEFORE parsing JSON to handle HTML error pages gracefully - if (!response.ok) { - // Try to get response body for logging, but don't fail if it's not JSON - let errorBody: unknown - try { - const contentType = response.headers.get('content-type') ?? '' - if (contentType.includes('application/json')) { - errorBody = await response.json() - } else { - // Likely an HTML error page from load balancer/CDN - errorBody = await response.text() - } - } catch { - errorBody = 'Unable to parse error response' - } - logger.error( - { request: requestBody, response: errorBody, status: response.status }, - '[ads] Gravity API returned error', + if (!result) { + return NextResponse.json( + { ad: null, provider: provider.id }, + { status: 200 }, ) - return NextResponse.json({ ad: null, variant }, { status: 200 }) } - // Now safe to parse JSON body since response.ok is true - const ads = await response.json() + const adsToPersist: NormalizedAd[] = + result.variant === 'choice' ? result.ads : [result.ad] - if (!Array.isArray(ads) || ads.length === 0) { - logger.debug( - { request: requestBody, response: ads, status: response.status }, - '[ads] No ads returned from Gravity API', - ) - return NextResponse.json({ ad: null, variant }, { status: 200 }) - } - - // Store all returned ads in the database (skip duplicates via imp_url unique constraint) - // Wrapped in try/catch so DB failures don't prevent serving ads to the client + // Persist served ads so the impression endpoint can validate + fire the + // correct pixels. Any DB failure is logged but doesn't block serving. try { - for (const ad of ads) { - const payout = ad.payout || DEFAULT_PAYOUT - await db - .insert(schema.adImpression) - .values({ - user_id: userId, - ad_text: ad.adText, - title: ad.title, - cta: ad.cta, - url: ad.url, - favicon: ad.favicon, - click_url: ad.clickUrl, - imp_url: ad.impUrl, - payout: String(payout), - credits_granted: 0, - }) - .onConflictDoNothing() - } + await Promise.all( + adsToPersist.map((ad) => + db + .insert(schema.adImpression) + .values({ + user_id: userId, + provider: provider.id, + ad_text: ad.adText, + title: ad.title, + cta: ad.cta, + url: ad.url, + favicon: ad.favicon, + click_url: ad.clickUrl, + imp_url: ad.impUrl, + extra_pixels: ad.extraPixels ?? null, + payout: ad.payout != null ? String(ad.payout) : null, + credits_granted: 0, + }) + .onConflictDoNothing(), + ), + ) } catch (dbError) { logger.warn( { userId, - adCount: ads.length, + provider: provider.id, + adCount: adsToPersist.length, error: dbError instanceof Error ? { name: dbError.name, message: dbError.message } : dbError, }, - '[ads] Failed to persist ad_impression rows, serving ads anyway', + '[ads] Failed to persist ad_impression rows, serving anyway', ) } - // Strip payout from all ads before returning to client - const sanitizeAd = (ad: Record) => { - const { payout: _payout, ...rest } = ad + // Strip server-only fields before sending to the CLI. + const toClient = (ad: NormalizedAd) => { + const { payout: _p, extraPixels: _e, ...rest } = ad return rest } - if (variant === 'choice') { - // Return all ads for the choice variant (up to 4) - const sanitizedAds = ads.map(sanitizeAd) - + if (result.variant === 'choice') { logger.info( - { - variant, - adCount: sanitizedAds.length, - request: requestBody, - status: response.status, - }, - '[ads] Fetched choice ads from Gravity API', + { provider: provider.id, variant: 'choice', adCount: result.ads.length }, + '[ads] Fetched choice ads', ) - - return NextResponse.json({ ads: sanitizedAds, variant }) + return NextResponse.json({ + ads: result.ads.map(toClient), + variant: 'choice', + provider: provider.id, + }) } - // Banner variant: return single ad (existing behavior) - const ad = ads[0] - const payout = ad.payout || DEFAULT_PAYOUT - logger.info( - { - ad, - variant, - request: requestBody, - status: response.status, - payout: { - included: ad.payout && ad.payout > 0, - recieved: ad.payout, - default: DEFAULT_PAYOUT, - final: payout, - }, - }, - '[ads] Fetched ad from Gravity API', + { provider: provider.id, variant: 'banner' }, + '[ads] Fetched banner ad', ) - - return NextResponse.json({ ad: sanitizeAd(ad), variant }) + return NextResponse.json({ + ad: toClient(result.ad), + variant: 'banner', + provider: provider.id, + }) } catch (error) { logger.error( { userId, - messages, - status: 500, + provider: providerId, error: error instanceof Error ? { name: error.name, message: error.message } : error, }, - '[ads] Failed to fetch ad from Gravity API', + '[ads] Failed to fetch ad', ) return NextResponse.json( - { ad: null, variant, error: getErrorObject(error) }, + { + ad: null, + provider: providerId, + error: getErrorObject(error), + }, { status: 500 }, ) } } - -/** - * Extract the content from the last tag in a string. - * If no tag is found, returns the original content. - */ -function extractLastUserMessageContent(content: string): string { - // Find all ... matches - const regex = /([\s\S]*?)<\/user_message>/gi - const matches = [...content.matchAll(regex)] - - if (matches.length > 0) { - // Return the content from the last match - const lastMatch = matches[matches.length - 1] - return lastMatch[1].trim() - } - - return content -} diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index 51482b9f3..3d6e53aee 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -178,23 +178,37 @@ export async function postAdImpression(params: { ) } - // Fire the impression pixel to Gravity - try { - await fetch(impUrl) - logger.info({ userId, 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 record the impression - } + // Fire the primary impression pixel plus any provider-specific extra + // tracking pixels (Carbon returns these via the `pixel` field). Each extra + // pixel may contain `[timestamp]` which we substitute with unix seconds. + const now = Math.floor(Date.now() / 1000).toString() + const extraPixels = (adRecord.extra_pixels ?? []).map((p) => + p.replaceAll('[timestamp]', now), + ) + const pixelUrls = [impUrl, ...extraPixels] + + await Promise.all( + pixelUrls.map(async (pixelUrl) => { + try { + await fetch(pixelUrl) + } catch (error) { + logger.warn( + { + pixelUrl, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to fire impression pixel', + ) + } + }), + ) + logger.info( + { userId, provider: adRecord.provider, pixelCount: pixelUrls.length }, + '[ads] Fired impression pixels', + ) // No credits granted for ad impressions const creditsGranted = 0 diff --git a/web/src/app/api/v1/ads/route.ts b/web/src/app/api/v1/ads/route.ts index 6023c1483..0b90fd1ee 100644 --- a/web/src/app/api/v1/ads/route.ts +++ b/web/src/app/api/v1/ads/route.ts @@ -18,6 +18,7 @@ export async function POST(req: NextRequest) { fetch, serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY, + CARBON_ZONE_KEY: env.CARBON_ZONE_KEY, CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT, }, }) diff --git a/web/src/lib/ad-providers/carbon.ts b/web/src/lib/ad-providers/carbon.ts new file mode 100644 index 000000000..7933a0471 --- /dev/null +++ b/web/src/lib/ad-providers/carbon.ts @@ -0,0 +1,138 @@ +import type { + AdProvider, + FetchAdInput, + FetchAdResult, + NormalizedAd, +} from './types' + +/** + * BuySellAds (Carbon) Ad Serving API. + * + * Docs: https://docs.buysellads.com/ad-serving-api + * + * Key facts: + * - GET https://srv.buysellads.com/ads/{zonekey}.json + * - Required query params: `useragent` (URL-encoded) and `forwardedip` (IPv4) + * - The test zone key `CVADC53U` is public and safe to use while developing. + * - Response has an `ads` array. An ad is only considered filled if the first + * entry has a `statlink` (click URL). `statimp` is the primary impression + * pixel. An optional `pixel` field contains additional tracking pixels + * separated by `||`, each of which may contain `[timestamp]`. + */ +const CARBON_URL_BASE = 'https://srv.buysellads.com/ads' + +type CarbonAd = { + statlink?: string + statimp?: string + statview?: string + description?: string + company?: string + callToAction?: string + image?: string + logo?: string + pixel?: string +} + +type CarbonResponse = { + ads?: CarbonAd[] +} + +/** + * Carbon returns `//srv.buysellads.com/...` for its pixel URLs. Normalize to + * https:// so we (and the CLI) can fetch them directly. + */ +function withScheme(url: string): string { + if (url.startsWith('//')) return `https:${url}` + return url +} + +function splitPixels(pixel: string | undefined): string[] { + if (!pixel) return [] + return pixel + .split('||') + .map((s) => s.trim()) + .filter(Boolean) + .map(withScheme) +} + +export function createCarbonProvider(config: { + zoneKey: string +}): AdProvider { + return { + id: 'carbon', + fetchAd: async (input: FetchAdInput): Promise => { + const { clientIp, userAgent, testMode, logger, fetch } = input + + if (!clientIp || !userAgent) { + logger.debug( + { hasIp: !!clientIp, hasUA: !!userAgent }, + '[ads:carbon] Missing required clientIp or userAgent', + ) + return null + } + + const params = new URLSearchParams({ + useragent: userAgent, + forwardedip: clientIp, + }) + // Carbon's `ignore=yes` loads ads without counting impressions. Use it + // in non-prod so we never accidentally bill advertisers for dev traffic. + if (testMode) params.set('ignore', 'yes') + + const url = `${CARBON_URL_BASE}/${config.zoneKey}.json?${params.toString()}` + + const response = await fetch(url, { method: 'GET' }) + + if (!response.ok) { + let body: unknown + try { + body = await response.text() + } catch { + body = 'Unable to parse error response' + } + logger.error( + { url, status: response.status, body }, + '[ads:carbon] API returned error', + ) + return null + } + + const data = (await response.json()) as CarbonResponse + const first = data.ads?.[0] + + // Per Carbon docs: if `statlink` is missing the zone had no fill. + if (!first?.statlink || !first.statimp) { + logger.debug({ url }, '[ads:carbon] No ad fill') + return null + } + + const clickUrl = withScheme(first.statlink) + const impUrl = withScheme(first.statimp) + + // `statview` is Carbon's IAB viewable-impression pixel (separate from the + // regular impression `statimp`). Our CLI ad is definitively viewable when + // rendered, so fire it alongside any advertiser pixels. + const extraPixels = [ + ...(first.statview ? [withScheme(first.statview)] : []), + ...splitPixels(first.pixel), + ] + + const normalized: NormalizedAd = { + adText: first.description ?? '', + title: first.company ?? '', + cta: first.callToAction ?? 'Learn more', + // Carbon doesn't expose a destination URL — `statlink` is a tracker + // that 302s to the advertiser. Leave `url` empty so the UI doesn't + // render "srv.buysellads.com" as the ad's domain. Clicks use + // `clickUrl` and get correctly routed through tracking. + url: '', + favicon: first.image ?? first.logo ?? '', + clickUrl, + impUrl, + extraPixels, + } + + return { variant: 'banner', ad: normalized } + }, + } +} diff --git a/web/src/lib/ad-providers/gravity.ts b/web/src/lib/ad-providers/gravity.ts new file mode 100644 index 000000000..ed9209cb0 --- /dev/null +++ b/web/src/lib/ad-providers/gravity.ts @@ -0,0 +1,190 @@ +import { createHash } from 'crypto' + +import { buildArray } from '@codebuff/common/util/array' + +import type { + AdMessage, + AdProvider, + AdVariant, + FetchAdInput, + FetchAdResult, + NormalizedAd, +} from './types' + +const GRAVITY_URL = 'https://server.trygravity.ai/api/v1/ad' +const BANNER_PLACEMENT_ID = 'code-assist-ad' +const CHOICE_PLACEMENT_IDS = [ + 'choice-ad-1', + 'choice-ad-2', + 'choice-ad-3', + 'choice-ad-4', +] + +type GravityRawAd = { + adText: string + title: string + cta: string + url: string + favicon: string + clickUrl: string + impUrl: string + payout?: number +} + +function normalize(raw: GravityRawAd): NormalizedAd { + return { + adText: raw.adText, + title: raw.title, + cta: raw.cta, + url: raw.url, + favicon: raw.favicon, + clickUrl: raw.clickUrl, + impUrl: raw.impUrl, + payout: raw.payout, + } +} + +/** + * A/B test: deterministically assign a user to the `banner` or `choice` + * variant based on their userId. Stable across requests. + */ +function getGravityVariant(userId: string): AdVariant { + const hash = createHash('sha256').update(`ad-variant:${userId}`).digest() + return hash[0] % 2 === 0 ? 'banner' : 'choice' +} + +/** + * Extract the content from the last tag in a string. + * The CLI wraps raw user text in that tag; if no tag is found, returns the + * original content. + */ +function extractLastUserMessageContent(content: string): string { + const regex = /([\s\S]*?)<\/user_message>/gi + const matches = [...content.matchAll(regex)] + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1] + return lastMatch[1].trim() + } + return content +} + +/** + * Gravity only wants the last user turn plus the last preceding assistant + * turn for relevancy signals. We also strip empties and normalize user + * messages through the tag. + */ +function prepareGravityMessages(messages: AdMessage[]): AdMessage[] { + const cleaned = messages + .filter((m) => m.content) + .map((m) => + m.role === 'user' + ? { ...m, content: extractLastUserMessageContent(m.content) } + : m, + ) + const lastUserIndex = cleaned.findLastIndex((m) => m.role === 'user') + const lastUser = lastUserIndex >= 0 ? cleaned[lastUserIndex] : undefined + const lastAssistant = cleaned + .slice(0, lastUserIndex >= 0 ? lastUserIndex : cleaned.length) + .findLast((m) => m.role === 'assistant') + return buildArray(lastAssistant, lastUser) +} + +export function createGravityProvider(config: { apiKey: string }): AdProvider { + return { + id: 'gravity', + fetchAd: async (input: FetchAdInput): Promise => { + const { + userId, + userEmail, + sessionId, + clientIp, + device, + messages = [], + testMode, + logger, + fetch, + } = input + + const variant = getGravityVariant(userId) + const filteredMessages = prepareGravityMessages(messages) + + const placements = + variant === 'choice' + ? CHOICE_PLACEMENT_IDS.map((id) => ({ + placement: 'below_response', + placement_id: id, + })) + : [{ placement: 'below_response', placement_id: BANNER_PLACEMENT_ID }] + + const deviceBody = clientIp + ? { + ip: clientIp, + ...(device?.os ? { os: device.os } : {}), + ...(device?.timezone ? { timezone: device.timezone } : {}), + ...(device?.locale ? { locale: device.locale } : {}), + } + : undefined + + const requestBody = { + messages: filteredMessages, + sessionId: sessionId ?? userId, + placements, + testAd: testMode, + relevancy: 0, + ...(deviceBody ? { device: deviceBody } : {}), + user: { + id: userId, + email: userEmail ?? undefined, + }, + } + + const response = await fetch(GRAVITY_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (response.status === 204) { + logger.debug( + { request: requestBody, status: response.status }, + '[ads:gravity] No ad available', + ) + return null + } + + if (!response.ok) { + let errorBody: unknown + try { + const contentType = response.headers.get('content-type') ?? '' + errorBody = contentType.includes('application/json') + ? await response.json() + : await response.text() + } catch { + errorBody = 'Unable to parse error response' + } + logger.error( + { request: requestBody, response: errorBody, status: response.status }, + '[ads:gravity] API returned error', + ) + return null + } + + const ads = (await response.json()) as GravityRawAd[] | unknown + if (!Array.isArray(ads) || ads.length === 0) { + logger.debug( + { request: requestBody, status: response.status }, + '[ads:gravity] No ads returned', + ) + return null + } + + if (variant === 'choice') { + return { variant: 'choice', ads: ads.map(normalize) } + } + return { variant: 'banner', ad: normalize(ads[0]) } + }, + } +} diff --git a/web/src/lib/ad-providers/types.ts b/web/src/lib/ad-providers/types.ts new file mode 100644 index 000000000..5b664332b --- /dev/null +++ b/web/src/lib/ad-providers/types.ts @@ -0,0 +1,69 @@ +import type { Logger } from '@codebuff/common/types/contracts/logger' + +/** + * Identifies which upstream ad network served an ad. Stored on + * `ad_impression.provider` so we can slice analytics and know which request + * shape to expect when firing impressions. Add a new id here when wiring in + * another provider (e.g. 'zeroclick'). + */ +export type AdProviderId = 'gravity' | 'carbon' + +export type AdVariant = 'banner' | 'choice' + +/** + * Normalized ad shape returned by every provider. The CLI renders against + * this shape; provider modules are responsible for mapping their upstream + * response into it. + */ +export type NormalizedAd = { + adText: string + title: string + cta: string + url: string + favicon: string + clickUrl: string + /** Primary impression pixel URL. Fired once when the ad becomes visible. */ + impUrl: string + /** + * Additional impression pixels (e.g. Carbon's `pixel` field). Each string + * may contain `[timestamp]` which must be substituted at fire time. + */ + extraPixels?: string[] + /** Server-only: stripped before the ad is sent to the client. */ + payout?: number +} + +export type AdMessage = { role: string; content: string } + +export type AdDeviceInfo = { + os?: 'macos' | 'windows' | 'linux' + timezone?: string + locale?: string +} + +export type FetchAdInput = { + userId: string + userEmail: string | null + sessionId?: string + /** Client IP, parsed from X-Forwarded-For upstream. */ + clientIp?: string + /** Browser/CLI useragent string, passed through to upstream. */ + userAgent?: string + device?: AdDeviceInfo + /** Last user + last preceding assistant message, if any. Used by Gravity. */ + messages?: AdMessage[] + /** Set in non-prod so providers can request test ads. */ + testMode: boolean + logger: Logger + fetch: typeof globalThis.fetch +} + +export type FetchAdResult = + | { variant: 'banner'; ad: NormalizedAd } + | { variant: 'choice'; ads: NormalizedAd[] } + | null + +export type AdProvider = { + id: AdProviderId + fetchAd: (input: FetchAdInput) => Promise +}