Files
ClaudeMarketing/lib/settings.ts
T
Trey T 55fb221faa feat(settings): per-integration Test button with live API checks
Each integration card now has a Test button that re-runs its
connectivity check on demand and updates the badge in place.

- Refactors checkIntegrationStatus into checkIntegration(name) so a
  single integration can be tested without firing the others.
- Adds POST /api/settings/test for the on-demand check.
- Replaces the "key exists ⇒ connected" placeholder for Tavily,
  Gemini, and Nextdoor with real API calls (Tavily search, Gemini
  models list, Nextdoor advertiser fetch). 401/403 surface as
  "Invalid API key" / "Token expired or invalid" so a stale
  credential is obvious instead of pretending to be healthy.
- Fixes Postiz auth header (was Bearer, the rest of the codebase
  passes the API key bare — matches lib/postiz.ts now).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:03:16 -05:00

234 lines
8.7 KiB
TypeScript

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<string> {
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<Record<string, string>> {
const result: Record<string, string> = {};
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<IntegrationStatus> {
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<Record<string, IntegrationStatus>> {
const results = await Promise.all(
ALL_INTEGRATIONS.map(async (name) => [name, await checkIntegration(name)] as const)
);
return Object.fromEntries(results);
}