import { prisma } from "./prisma"; // Settings keys and their env var fallbacks const SETTINGS_KEYS = { // Claude Code CLI authentication (OAuth access token from Claude Max) CLAUDE_CODE_OAUTH_TOKEN: { envVar: "CLAUDE_CODE_OAUTH_TOKEN", label: "Claude Code OAuth Token", placeholder: "sk-ant-oat01-...", secret: true }, // Postiz POSTIZ_URL: { envVar: "POSTIZ_URL", label: "Postiz URL", placeholder: "http://localhost:5000" }, POSTIZ_API_KEY: { envVar: "POSTIZ_API_KEY", label: "Postiz API Key", placeholder: "your-postiz-api-key", secret: true }, // Tavily (Research) TAVILY_API_KEY: { envVar: "TAVILY_API_KEY", label: "Tavily API Key", placeholder: "tvly-...", secret: true }, // Google Gemini (NanoBanana image generation) GEMINI_API_KEY: { envVar: "GEMINI_API_KEY", label: "Google Gemini API Key", placeholder: "AIza...", secret: true }, // Nextdoor NEXTDOOR_API_TOKEN: { envVar: "NEXTDOOR_API_TOKEN", label: "Nextdoor API Token", placeholder: "your-nextdoor-token", secret: true }, NEXTDOOR_ADVERTISER_ID: { envVar: "NEXTDOOR_ADVERTISER_ID", label: "Nextdoor Advertiser ID", placeholder: "your-advertiser-id" }, } as const; export type SettingKey = keyof typeof SETTINGS_KEYS; export const SETTINGS_CONFIG = SETTINGS_KEYS; // Grouped for UI export const SETTINGS_GROUPS = [ { name: "Claude", description: "OAuth access token for the Claude Code CLI subprocess that drives every pipeline agent. Get one with `claude setup-token` then `security find-generic-password -s 'Claude Code-credentials' -a $(whoami) -w` and use the `claudeAiOauth.accessToken` field. Tokens expire — refresh here when launches start failing with auth errors.", docsUrl: "https://docs.claude.com/en/docs/claude-code/setup", keys: ["CLAUDE_CODE_OAUTH_TOKEN"] as SettingKey[], }, { name: "Postiz", description: "Self-hosted social media scheduling. Handles Instagram and TikTok publishing.", docsUrl: "https://postiz.com", keys: ["POSTIZ_URL", "POSTIZ_API_KEY"] as SettingKey[], }, { name: "Tavily", description: "AI-powered web research. Used by the Trend Scout and Research agents. Free tier: 1,000 searches/month.", docsUrl: "https://tavily.com", keys: ["TAVILY_API_KEY"] as SettingKey[], }, { name: "Gemini", description: "Google Gemini powers NanoBanana MCP for AI image generation in static ads. ~$0.04-0.13/image.", docsUrl: "https://aistudio.google.com/apikey", keys: ["GEMINI_API_KEY"] as SettingKey[], }, { name: "Nextdoor", description: "Direct Nextdoor Ads API integration for local advertising.", docsUrl: "https://developer.nextdoor.com", keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"] as SettingKey[], }, ]; /** * Get a setting value. Checks DB first, falls back to env var. */ export async function getSetting(key: SettingKey): Promise { try { const setting = await prisma.setting.findUnique({ where: { key } }); if (setting?.value) return setting.value; } catch { // DB not available, fall through to env } const config = SETTINGS_KEYS[key]; return process.env[config.envVar] || ""; } /** * Get all settings as a map. */ export async function getAllSettings(): Promise> { const result: Record = {}; for (const [key, config] of Object.entries(SETTINGS_KEYS)) { try { const setting = await prisma.setting.findUnique({ where: { key } }); result[key] = setting?.value || process.env[config.envVar] || ""; } catch { result[key] = process.env[config.envVar] || ""; } } return result; } /** * Save a setting to the database. */ export async function saveSetting(key: SettingKey, value: string) { await prisma.setting.upsert({ where: { key }, update: { value }, create: { key, value }, }); } export type IntegrationName = "claude" | "postiz" | "tavily" | "gemini" | "nextdoor"; export type IntegrationStatus = { connected: boolean; error?: string }; function errorMessage(e: unknown): string { if (e instanceof Error) { if (e.name === "TimeoutError" || e.name === "AbortError") return "Timed out"; return e.message; } return "Connection failed"; } /** * Live-test a single integration by making a real API call. */ export async function checkIntegration(name: IntegrationName): Promise { const settings = await getAllSettings(); switch (name) { case "claude": { // OAuth token — validate by hitting the messages API with a 1-token call. if (!settings.CLAUDE_CODE_OAUTH_TOKEN) return { connected: false, error: "Not configured" }; try { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "anthropic-version": "2023-06-01", "anthropic-beta": "oauth-2025-04-20", Authorization: `Bearer ${settings.CLAUDE_CODE_OAUTH_TOKEN}`, }, body: JSON.stringify({ model: "claude-haiku-4-5-20251001", max_tokens: 1, messages: [{ role: "user", content: "." }], }), signal: AbortSignal.timeout(10000), }); if (res.ok) return { connected: true }; if (res.status === 401) return { connected: false, error: "Token expired or invalid" }; return { connected: false, error: `HTTP ${res.status}` }; } catch (e) { return { connected: false, error: errorMessage(e) }; } } case "postiz": { if (!settings.POSTIZ_URL || !settings.POSTIZ_API_KEY) return { connected: false, error: "Not configured" }; try { const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, { headers: { Authorization: settings.POSTIZ_API_KEY }, signal: AbortSignal.timeout(5000), }); if (res.ok) return { connected: true }; return { connected: false, error: `HTTP ${res.status}` }; } catch (e) { return { connected: false, error: errorMessage(e) }; } } case "tavily": { if (!settings.TAVILY_API_KEY) return { connected: false, error: "Not configured" }; try { const res = await fetch("https://api.tavily.com/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ api_key: settings.TAVILY_API_KEY, query: "ping", max_results: 1, }), signal: AbortSignal.timeout(8000), }); if (res.ok) return { connected: true }; if (res.status === 401 || res.status === 403) return { connected: false, error: "Invalid API key" }; return { connected: false, error: `HTTP ${res.status}` }; } catch (e) { return { connected: false, error: errorMessage(e) }; } } case "gemini": { if (!settings.GEMINI_API_KEY) return { connected: false, error: "Not configured" }; try { const res = await fetch( `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(settings.GEMINI_API_KEY)}`, { signal: AbortSignal.timeout(8000) } ); if (res.ok) return { connected: true }; if (res.status === 400 || res.status === 403) return { connected: false, error: "Invalid API key" }; return { connected: false, error: `HTTP ${res.status}` }; } catch (e) { return { connected: false, error: errorMessage(e) }; } } case "nextdoor": { if (!settings.NEXTDOOR_API_TOKEN || !settings.NEXTDOOR_ADVERTISER_ID) { return { connected: false, error: "Not configured" }; } try { const res = await fetch( `https://api.nextdoor.com/v1/advertiser/${encodeURIComponent(settings.NEXTDOOR_ADVERTISER_ID)}`, { headers: { Authorization: `Bearer ${settings.NEXTDOOR_API_TOKEN}` }, signal: AbortSignal.timeout(8000), } ); if (res.ok) return { connected: true }; if (res.status === 401 || res.status === 403) return { connected: false, error: "Token expired or invalid" }; if (res.status === 404) return { connected: false, error: "Advertiser ID not found" }; return { connected: false, error: `HTTP ${res.status}` }; } catch (e) { return { connected: false, error: errorMessage(e) }; } } } } const ALL_INTEGRATIONS: IntegrationName[] = ["claude", "postiz", "tavily", "gemini", "nextdoor"]; /** * Check connectivity status for every integration in parallel. */ export async function checkIntegrationStatus(): Promise> { const results = await Promise.all( ALL_INTEGRATIONS.map(async (name) => [name, await checkIntegration(name)] as const) ); return Object.fromEntries(results); }