From 11428d6712636bc095c3aab0a73c27a75825b5a0 Mon Sep 17 00:00:00 2001 From: Miriad Date: Thu, 5 Mar 2026 05:39:28 +0000 Subject: [PATCH] feat: pivot config to Sanity singletons - 6 singleton schemas (pipelineConfig, remotionConfig, contentConfig, sponsorConfig, distributionConfig, gcsConfig) - Rewrite lib/config.ts to use writeClient.fetch instead of Supabase - Update lib/types/config.ts to camelCase field names - Remove Supabase migration SQL and invalidate endpoint - Register all singletons in sanity.config.ts Co-authored-by: content --- app/api/dashboard/config/invalidate/route.ts | 61 ----- lib/config.ts | 208 ++++++--------- lib/types/config.ts | 118 +++++---- sanity.config.ts | 14 +- sanity/schemas/singletons/contentConfig.ts | 108 ++++++++ .../schemas/singletons/distributionConfig.ts | 41 +++ sanity/schemas/singletons/gcsConfig.ts | 27 ++ sanity/schemas/singletons/pipelineConfig.ts | 69 +++++ sanity/schemas/singletons/remotionConfig.ts | 51 ++++ sanity/schemas/singletons/sponsorConfig.ts | 65 +++++ supabase/migrations/003_config_tables.sql | 244 ------------------ 11 files changed, 519 insertions(+), 487 deletions(-) delete mode 100644 app/api/dashboard/config/invalidate/route.ts create mode 100644 sanity/schemas/singletons/contentConfig.ts create mode 100644 sanity/schemas/singletons/distributionConfig.ts create mode 100644 sanity/schemas/singletons/gcsConfig.ts create mode 100644 sanity/schemas/singletons/pipelineConfig.ts create mode 100644 sanity/schemas/singletons/remotionConfig.ts create mode 100644 sanity/schemas/singletons/sponsorConfig.ts delete mode 100644 supabase/migrations/003_config_tables.sql diff --git a/app/api/dashboard/config/invalidate/route.ts b/app/api/dashboard/config/invalidate/route.ts deleted file mode 100644 index 888f1133..00000000 --- a/app/api/dashboard/config/invalidate/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; -import { invalidateConfig } from "@/lib/config"; -import type { ConfigTable } from "@/lib/types/config"; - -export const dynamic = "force-dynamic"; - -const VALID_TABLES: ConfigTable[] = [ - "pipeline_config", - "remotion_config", - "content_config", - "sponsor_config", - "distribution_config", - "gcs_config", -]; - -export async function POST(request: Request) { - // Fail-closed auth - const hasSupabase = - (process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) && - (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY); - - if (!hasSupabase) { - return NextResponse.json({ error: "Auth not configured" }, { status: 503 }); - } - - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const body = await request.json().catch(() => ({})); - const { table } = body as { table?: string }; - - // Validate table name if provided - if (table && !VALID_TABLES.includes(table as ConfigTable)) { - return NextResponse.json( - { error: `Invalid table: ${table}. Valid tables: ${VALID_TABLES.join(", ")}` }, - { status: 400 }, - ); - } - - invalidateConfig(table as ConfigTable | undefined); - - return NextResponse.json({ - success: true, - invalidated: table ?? "all", - }); - } catch (error) { - console.error("Failed to invalidate config:", error); - return NextResponse.json( - { error: "Failed to invalidate config" }, - { status: 500 }, - ); - } -} diff --git a/lib/config.ts b/lib/config.ts index e7255f1c..763cda9f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,155 +1,113 @@ -import { createClient } from "@supabase/supabase-js"; +import { writeClient } from "@/lib/sanity-write-client"; import type { ConfigTable, ConfigTypeMap } from "@/lib/types/config"; /** - * Supabase config module with stale-while-revalidate caching. + * Sanity config module with in-memory caching. * - * Caching behavior: - * - Cold start: fetches from Supabase, caches result - * - Warm (< TTL): returns cached data, zero DB queries - * - Stale (> TTL): returns cached data immediately, refreshes in background - * - Invalidate: called by dashboard "Save" button via /api/dashboard/config/invalidate + * Each config "table" maps to a Sanity singleton document type. + * Uses writeClient.fetch for server-side reads. * - * Serverless reality: each Vercel instance has its own in-memory cache. - * Worst case: TTL propagation delay across instances. Acceptable for config. + * Caching: 5-minute TTL with stale-while-revalidate. + * Sanity changes propagate on next cache miss. */ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes interface CacheEntry { - data: T; - fetchedAt: number; - refreshing: boolean; + data: T; + fetchedAt: number; + refreshing: boolean; } const cache = new Map>(); -/** - * Create a Supabase client for config reads. - * Uses service role key for server-side access (no RLS). - */ -function getSupabaseClient() { - const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; - const key = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!url || !key) { - throw new Error( - "Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY for config access", - ); - } - - return createClient(url, key, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }); -} +// Map config table names to Sanity document type names +const TABLE_TO_TYPE: Record = { + pipeline_config: "pipelineConfig", + remotion_config: "remotionConfig", + content_config: "contentConfig", + sponsor_config: "sponsorConfig", + distribution_config: "distributionConfig", + gcs_config: "gcsConfig", +}; -/** - * Fetch a singleton config row from Supabase and update the cache. - */ async function refreshConfig( - table: T, + table: T, ): Promise { - const supabase = getSupabaseClient(); - const { data, error } = await supabase - .from(table) - .select("*") - .limit(1) - .single(); - - if (error) { - throw new Error(`Config fetch failed for ${table}: ${error.message}`); - } - - cache.set(table, { - data, - fetchedAt: Date.now(), - refreshing: false, - }); - - return data as ConfigTypeMap[T]; + const sanityType = TABLE_TO_TYPE[table]; + const data = await writeClient.fetch( + `*[_type == $type][0]`, + { type: sanityType } as Record, + ); + + if (!data) { + throw new Error(`Config not found for ${sanityType} — create the singleton document in Sanity Studio`); + } + + cache.set(table, { + data, + fetchedAt: Date.now(), + refreshing: false, + }); + + return data as ConfigTypeMap[T]; } -/** - * Get config for a table with stale-while-revalidate caching. - * - * @param table - The config table name - * @param ttlMs - Cache TTL in milliseconds (default: 5 minutes) - * @returns The config row data - * - * @example - * const pipeline = await getConfig("pipeline_config"); - * console.log(pipeline.gemini_model); // "gemini-2.0-flash" - */ export async function getConfig( - table: T, - ttlMs = DEFAULT_TTL_MS, + table: T, + ttlMs = DEFAULT_TTL_MS, ): Promise { - const cached = cache.get(table) as CacheEntry | undefined; - const now = Date.now(); - - // Fresh cache — return immediately - if (cached && now - cached.fetchedAt < ttlMs) { - return cached.data; - } - - // Stale cache — return stale data, refresh in background - if (cached && !cached.refreshing) { - cached.refreshing = true; - refreshConfig(table).catch((err) => { - console.error(`Background config refresh failed for ${table}:`, err); - cached.refreshing = false; - }); - return cached.data; - } - - // No cache — must fetch synchronously - return refreshConfig(table); -} - -/** - * Force-invalidate cached config. Called by the dashboard after saving settings. - * - * @param table - Specific table to invalidate, or undefined to clear all - */ -export function invalidateConfig(table?: ConfigTable) { - if (table) { - cache.delete(table); - } else { - cache.clear(); - } + const cached = cache.get(table) as CacheEntry | undefined; + const now = Date.now(); + + // Fresh cache — return immediately + if (cached && now - cached.fetchedAt < ttlMs) { + return cached.data; + } + + // Stale cache — return stale, refresh in background + if (cached && !cached.refreshing) { + cached.refreshing = true; + refreshConfig(table).catch((err) => { + console.error(`[config] Background refresh failed for ${table}:`, err); + const entry = cache.get(table) as CacheEntry | undefined; + if (entry) entry.refreshing = false; + }); + return cached.data; + } + + // No cache — must fetch synchronously + return refreshConfig(table); } /** - * Get a config value with an env var fallback. - * Use during migration — once all config is in Supabase, remove fallbacks. - * - * @example - * const model = await getConfigValue("pipeline_config", "gemini_model", process.env.GEMINI_MODEL); + * Get a single config value with optional env var fallback. + * Useful during migration period. */ export async function getConfigValue< - T extends ConfigTable, - K extends keyof ConfigTypeMap[T], + T extends ConfigTable, + K extends keyof ConfigTypeMap[T], >( - table: T, - key: K, - fallback?: ConfigTypeMap[T][K], + table: T, + key: K, + fallback?: ConfigTypeMap[T][K], ): Promise { - try { - const config = await getConfig(table); - const value = config[key]; - if (value !== undefined && value !== null) { - return value; - } - } catch (err) { - console.warn(`Config lookup failed for ${String(table)}.${String(key)}, using fallback:`, err); - } - - if (fallback !== undefined) { - return fallback; - } + try { + const config = await getConfig(table); + return config[key]; + } catch { + if (fallback !== undefined) return fallback; + throw new Error(`Config value ${String(key)} not found in ${table}`); + } +} - throw new Error(`No config value for ${String(table)}.${String(key)} and no fallback provided`); +/** + * Force-clear cached config. Called when config is known to have changed. + */ +export function invalidateConfig(table?: ConfigTable) { + if (table) { + cache.delete(table); + } else { + cache.clear(); + } } diff --git a/lib/types/config.ts b/lib/types/config.ts index 67d82a31..ffc7888d 100644 --- a/lib/types/config.ts +++ b/lib/types/config.ts @@ -1,81 +1,87 @@ /** - * Supabase config table types. - * Each interface maps to a singleton row in the corresponding Supabase table. + * Sanity config singleton types. + * Each interface maps to a singleton document in Sanity. */ export interface PipelineConfig { - id: number; - gemini_model: string; - elevenlabs_voice_id: string; - youtube_upload_visibility: string; - youtube_channel_id: string; - enable_notebooklm_research: boolean; - quality_threshold: number; - stuck_timeout_minutes: number; - max_ideas_per_run: number; - updated_at: string; + _id: string; + _type: "pipelineConfig"; + geminiModel: string; + elevenLabsVoiceId: string; + youtubeUploadVisibility: string; + youtubeChannelId: string; + enableNotebookLmResearch: boolean; + qualityThreshold: number; + stuckTimeoutMinutes: number; + maxIdeasPerRun: number; + _updatedAt: string; } export interface RemotionConfig { - id: number; - aws_region: string; - function_name: string; - serve_url: string; - max_render_timeout_sec: number; - memory_mb: number; - disk_mb: number; - updated_at: string; + _id: string; + _type: "remotionConfig"; + awsRegion: string; + functionName: string; + serveUrl: string; + maxRenderTimeoutSec: number; + memoryMb: number; + diskMb: number; + _updatedAt: string; } export interface ContentConfig { - id: number; - rss_feeds: { name: string; url: string }[]; - trend_sources_enabled: Record; - system_instruction: string; - target_video_duration_sec: number; - scene_count_min: number; - scene_count_max: number; - updated_at: string; + _id: string; + _type: "contentConfig"; + rssFeeds: { name: string; url: string }[]; + trendSourcesEnabled: Record; + systemInstruction: string; + targetVideoDurationSec: number; + sceneCountMin: number; + sceneCountMax: number; + _updatedAt: string; } export interface SponsorConfig { - id: number; - cooldown_days: number; - rate_card_tiers: { name: string; description: string; price: number }[]; - outreach_email_template: string; - max_outreach_per_run: number; - updated_at: string; + _id: string; + _type: "sponsorConfig"; + cooldownDays: number; + rateCardTiers: { name: string; description: string; price: number }[]; + outreachEmailTemplate: string; + maxOutreachPerRun: number; + _updatedAt: string; } export interface DistributionConfig { - id: number; - notification_emails: string[]; - youtube_description_template: string; - youtube_default_tags: string[]; - resend_from_email: string; - updated_at: string; + _id: string; + _type: "distributionConfig"; + notificationEmails: string[]; + youtubeDescriptionTemplate: string; + youtubeDefaultTags: string[]; + resendFromEmail: string; + _updatedAt: string; } export interface GcsConfig { - id: number; - bucket_name: string; - project_id: string; - updated_at: string; + _id: string; + _type: "gcsConfig"; + bucketName: string; + projectId: string; + _updatedAt: string; } export type ConfigTable = - | "pipeline_config" - | "remotion_config" - | "content_config" - | "sponsor_config" - | "distribution_config" - | "gcs_config"; + | "pipeline_config" + | "remotion_config" + | "content_config" + | "sponsor_config" + | "distribution_config" + | "gcs_config"; export type ConfigTypeMap = { - pipeline_config: PipelineConfig; - remotion_config: RemotionConfig; - content_config: ContentConfig; - sponsor_config: SponsorConfig; - distribution_config: DistributionConfig; - gcs_config: GcsConfig; + pipeline_config: PipelineConfig; + remotion_config: RemotionConfig; + content_config: ContentConfig; + sponsor_config: SponsorConfig; + distribution_config: DistributionConfig; + gcs_config: GcsConfig; }; diff --git a/sanity.config.ts b/sanity.config.ts index ee5d433f..a67731fb 100644 --- a/sanity.config.ts +++ b/sanity.config.ts @@ -44,6 +44,12 @@ import podcastType from "@/sanity/schemas/documents/podcastType"; import post from "@/sanity/schemas/documents/post"; import settings from "@/sanity/schemas/singletons/settings"; import dashboardSettings from "@/sanity/schemas/singletons/dashboardSettings"; +import pipelineConfig from "@/sanity/schemas/singletons/pipelineConfig"; +import remotionConfig from "@/sanity/schemas/singletons/remotionConfig"; +import contentConfig from "@/sanity/schemas/singletons/contentConfig"; +import sponsorConfig from "@/sanity/schemas/singletons/sponsorConfig"; +import distributionConfig from "@/sanity/schemas/singletons/distributionConfig"; +import gcsConfig from "@/sanity/schemas/singletons/gcsConfig"; import sponsor from "@/sanity/schemas/documents/sponsor"; import sponsorshipRequest from "@/sanity/schemas/documents/sponsorshipRequest"; @@ -140,6 +146,12 @@ export default defineConfig({ // Singletons settings, dashboardSettings, + pipelineConfig, + remotionConfig, + contentConfig, + sponsorConfig, + distributionConfig, + gcsConfig, // Documents author, course, @@ -208,7 +220,7 @@ export default defineConfig({ }), structureTool({ structure: podcastStructure() }), // Configures the global "new document" button, and document actions, to suit the Settings document singleton - singletonPlugin([settings.name, dashboardSettings.name]), + singletonPlugin([settings.name, dashboardSettings.name, pipelineConfig.name, remotionConfig.name, contentConfig.name, sponsorConfig.name, distributionConfig.name, gcsConfig.name]), // Sets up AI Assist with preset prompts // https://www.sanity.io/docs/ai-assistPcli assistWithPresets(), diff --git a/sanity/schemas/singletons/contentConfig.ts b/sanity/schemas/singletons/contentConfig.ts new file mode 100644 index 00000000..909febe3 --- /dev/null +++ b/sanity/schemas/singletons/contentConfig.ts @@ -0,0 +1,108 @@ +import { defineField, defineType } from "sanity"; + +export default defineType({ + name: "contentConfig", + title: "Content Config", + type: "document", + icon: () => "📝", + fields: [ + defineField({ + name: "rssFeeds", + title: "RSS Feeds", + type: "array", + of: [ + { + type: "object", + fields: [ + defineField({ + name: "name", + title: "Name", + type: "string", + }), + defineField({ + name: "url", + title: "URL", + type: "string", + }), + ], + }, + ], + initialValue: [ + { _type: "object", _key: "hn", name: "HN Top", url: "https://hnrss.org/newest?points=100&count=20" }, + { _type: "object", _key: "devtojs", name: "Dev.to JavaScript", url: "https://dev.to/feed/tag/javascript" }, + { _type: "object", _key: "devtoweb", name: "Dev.to WebDev", url: "https://dev.to/feed/tag/webdev" }, + { _type: "object", _key: "csstricks", name: "CSS-Tricks", url: "https://css-tricks.com/feed/" }, + { _type: "object", _key: "chromium", name: "Chromium Blog", url: "https://blog.chromium.org/feeds/posts/default" }, + { _type: "object", _key: "webdev", name: "web.dev", url: "https://web.dev/feed.xml" }, + { _type: "object", _key: "smashing", name: "Smashing Magazine", url: "https://www.smashingmagazine.com/feed/" }, + { _type: "object", _key: "jsweekly", name: "JavaScript Weekly", url: "https://javascriptweekly.com/rss/" }, + ], + }), + defineField({ + name: "trendSourcesEnabled", + title: "Trend Sources Enabled", + type: "object", + fields: [ + defineField({ + name: "hn", + title: "Hacker News", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "devto", + title: "Dev.to", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "blogs", + title: "Blogs", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "youtube", + title: "YouTube", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "github", + title: "GitHub", + type: "boolean", + initialValue: true, + }), + ], + }), + defineField({ + name: "systemInstruction", + title: "System Instruction", + type: "text", + initialValue: "You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.\n\nYour style is inspired by Cleo Abram's \"Huge If True\" — you make complex technical topics feel exciting, accessible, and important. Key principles:\n- Start with a BOLD claim or surprising fact that makes people stop scrolling\n- Use analogies and real-world comparisons to explain technical concepts\n- Build tension: \"Here's the problem... here's why it matters... here's the breakthrough\"\n- Keep energy HIGH — short sentences, active voice, conversational tone\n- End with a clear takeaway that makes the viewer feel smarter\n- Target audience: developers who want to stay current but don't have time to read everything\n\nScript format: 60-90 second explainer videos. Think TikTok/YouTube Shorts energy with real educational depth.\n\nCodingCat.dev covers: React, Next.js, TypeScript, Svelte, web APIs, CSS, Node.js, cloud services, AI/ML for developers, and web platform updates.", + }), + defineField({ + name: "targetVideoDurationSec", + title: "Target Video Duration (sec)", + type: "number", + initialValue: 90, + }), + defineField({ + name: "sceneCountMin", + title: "Scene Count Min", + type: "number", + initialValue: 3, + }), + defineField({ + name: "sceneCountMax", + title: "Scene Count Max", + type: "number", + initialValue: 5, + }), + ], + preview: { + prepare() { + return { title: "Content Config" }; + }, + }, +}); diff --git a/sanity/schemas/singletons/distributionConfig.ts b/sanity/schemas/singletons/distributionConfig.ts new file mode 100644 index 00000000..e2d3f892 --- /dev/null +++ b/sanity/schemas/singletons/distributionConfig.ts @@ -0,0 +1,41 @@ +import { defineField, defineType } from "sanity"; + +export default defineType({ + name: "distributionConfig", + title: "Distribution Config", + type: "document", + icon: () => "📡", + fields: [ + defineField({ + name: "notificationEmails", + title: "Notification Emails", + type: "array", + of: [{ type: "string" }], + initialValue: ["alex@codingcat.dev"], + }), + defineField({ + name: "youtubeDescriptionTemplate", + title: "YouTube Description Template", + type: "text", + initialValue: "{{title}}\n\n{{summary}}\n\n🔗 Learn more at https://codingcat.dev\n\n#webdev #coding #programming", + }), + defineField({ + name: "youtubeDefaultTags", + title: "YouTube Default Tags", + type: "array", + of: [{ type: "string" }], + initialValue: ["web development", "coding", "programming", "tutorial", "codingcat"], + }), + defineField({ + name: "resendFromEmail", + title: "Resend From Email", + type: "string", + initialValue: "content@codingcat.dev", + }), + ], + preview: { + prepare() { + return { title: "Distribution Config" }; + }, + }, +}); diff --git a/sanity/schemas/singletons/gcsConfig.ts b/sanity/schemas/singletons/gcsConfig.ts new file mode 100644 index 00000000..4a486069 --- /dev/null +++ b/sanity/schemas/singletons/gcsConfig.ts @@ -0,0 +1,27 @@ +import { defineField, defineType } from "sanity"; + +export default defineType({ + name: "gcsConfig", + title: "GCS Config", + type: "document", + icon: () => "☁️", + fields: [ + defineField({ + name: "bucketName", + title: "Bucket Name", + type: "string", + initialValue: "codingcatdev-content-engine", + }), + defineField({ + name: "projectId", + title: "Project ID", + type: "string", + initialValue: "codingcatdev", + }), + ], + preview: { + prepare() { + return { title: "GCS Config" }; + }, + }, +}); diff --git a/sanity/schemas/singletons/pipelineConfig.ts b/sanity/schemas/singletons/pipelineConfig.ts new file mode 100644 index 00000000..904a08c3 --- /dev/null +++ b/sanity/schemas/singletons/pipelineConfig.ts @@ -0,0 +1,69 @@ +import { defineField, defineType } from "sanity"; + +export default defineType({ + name: "pipelineConfig", + title: "Pipeline Config", + type: "document", + icon: () => "🔧", + fields: [ + defineField({ + name: "geminiModel", + title: "Gemini Model", + type: "string", + initialValue: "gemini-2.0-flash", + }), + defineField({ + name: "elevenLabsVoiceId", + title: "ElevenLabs Voice ID", + type: "string", + initialValue: "pNInz6obpgDQGcFmaJgB", + }), + defineField({ + name: "youtubeUploadVisibility", + title: "YouTube Upload Visibility", + type: "string", + initialValue: "private", + options: { + list: ["private", "unlisted", "public"], + }, + }), + defineField({ + name: "youtubeChannelId", + title: "YouTube Channel ID", + type: "string", + initialValue: "", + }), + defineField({ + name: "enableNotebookLmResearch", + title: "Enable NotebookLM Research", + type: "boolean", + initialValue: false, + }), + defineField({ + name: "qualityThreshold", + title: "Quality Threshold", + type: "number", + initialValue: 50, + validation: (rule) => rule.min(0).max(100), + }), + defineField({ + name: "stuckTimeoutMinutes", + title: "Stuck Timeout Minutes", + type: "number", + initialValue: 30, + validation: (rule) => rule.min(5).max(120), + }), + defineField({ + name: "maxIdeasPerRun", + title: "Max Ideas Per Run", + type: "number", + initialValue: 1, + validation: (rule) => rule.min(1).max(10), + }), + ], + preview: { + prepare() { + return { title: "Pipeline Config" }; + }, + }, +}); diff --git a/sanity/schemas/singletons/remotionConfig.ts b/sanity/schemas/singletons/remotionConfig.ts new file mode 100644 index 00000000..2e36f1ab --- /dev/null +++ b/sanity/schemas/singletons/remotionConfig.ts @@ -0,0 +1,51 @@ +import { defineField, defineType } from "sanity"; + +export default defineType({ + name: "remotionConfig", + title: "Remotion Config", + type: "document", + icon: () => "🎬", + fields: [ + defineField({ + name: "awsRegion", + title: "AWS Region", + type: "string", + initialValue: "us-east-1", + }), + defineField({ + name: "functionName", + title: "Function Name", + type: "string", + initialValue: "", + }), + defineField({ + name: "serveUrl", + title: "Serve URL", + type: "string", + initialValue: "", + }), + defineField({ + name: "maxRenderTimeoutSec", + title: "Max Render Timeout (sec)", + type: "number", + initialValue: 240, + }), + defineField({ + name: "memoryMb", + title: "Memory (MB)", + type: "number", + initialValue: 2048, + }), + defineField({ + name: "diskMb", + title: "Disk (MB)", + type: "number", + initialValue: 2048, + }), + ], + preview: { + prepare() { + return { title: "Remotion Config" }; + }, + }, +}); diff --git a/sanity/schemas/singletons/sponsorConfig.ts b/sanity/schemas/singletons/sponsorConfig.ts new file mode 100644 index 00000000..8d3e1434 --- /dev/null +++ b/sanity/schemas/singletons/sponsorConfig.ts @@ -0,0 +1,65 @@ +import { defineField, defineType } from "sanity"; + +export default defineType({ + name: "sponsorConfig", + title: "Sponsor Config", + type: "document", + icon: () => "💰", + fields: [ + defineField({ + name: "cooldownDays", + title: "Cooldown Days", + type: "number", + initialValue: 14, + }), + defineField({ + name: "rateCardTiers", + title: "Rate Card Tiers", + type: "array", + of: [ + { + type: "object", + fields: [ + defineField({ + name: "name", + title: "Name", + type: "string", + }), + defineField({ + name: "description", + title: "Description", + type: "string", + }), + defineField({ + name: "price", + title: "Price", + type: "number", + }), + ], + }, + ], + initialValue: [ + { _type: "object", _key: "starter", name: "starter", description: "5k-10k impressions", price: 500 }, + { _type: "object", _key: "growth", name: "growth", description: "10k-50k impressions", price: 1500 }, + { _type: "object", _key: "premium", name: "premium", description: "50k+ impressions", price: 3000 }, + ], + }), + defineField({ + name: "outreachEmailTemplate", + title: "Outreach Email Template", + type: "text", + initialValue: "Hi {{companyName}},\n\nI run CodingCat.dev...", + }), + defineField({ + name: "maxOutreachPerRun", + title: "Max Outreach Per Run", + type: "number", + initialValue: 10, + }), + ], + preview: { + prepare() { + return { title: "Sponsor Config" }; + }, + }, +}); diff --git a/supabase/migrations/003_config_tables.sql b/supabase/migrations/003_config_tables.sql deleted file mode 100644 index d275f04f..00000000 --- a/supabase/migrations/003_config_tables.sql +++ /dev/null @@ -1,244 +0,0 @@ --- ============================================================================= --- 003_config_tables.sql --- Six singleton config tables for the CodingCat.dev Automated Content Engine. --- Each table enforces a single row via CHECK (id = 1). --- RLS: service_role can SELECT/UPDATE; anon and authenticated are blocked. --- ============================================================================= - --- --------------------------------------------------------------------------- --- Shared trigger function: auto-update updated_at on any row change --- --------------------------------------------------------------------------- -CREATE OR REPLACE FUNCTION update_updated_at() -RETURNS trigger AS $$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- ========================================================================= --- 1. pipeline_config --- ========================================================================= -CREATE TABLE IF NOT EXISTS pipeline_config ( - id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1), - gemini_model text NOT NULL DEFAULT 'gemini-2.0-flash', - elevenlabs_voice_id text NOT NULL DEFAULT 'pNInz6obpgDQGcFmaJgB', - youtube_upload_visibility text NOT NULL DEFAULT 'private', - youtube_channel_id text NOT NULL DEFAULT '', - enable_notebooklm_research boolean NOT NULL DEFAULT false, - quality_threshold integer NOT NULL DEFAULT 50, - stuck_timeout_minutes integer NOT NULL DEFAULT 30, - max_ideas_per_run integer NOT NULL DEFAULT 1, - updated_at timestamptz NOT NULL DEFAULT now() -); - -ALTER TABLE pipeline_config ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role can read pipeline_config" - ON pipeline_config FOR SELECT - TO service_role - USING (true); - -CREATE POLICY "service_role can update pipeline_config" - ON pipeline_config FOR UPDATE - TO service_role - USING (true) - WITH CHECK (true); - -INSERT INTO pipeline_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING; - -CREATE TRIGGER trg_pipeline_config_updated_at - BEFORE UPDATE ON pipeline_config - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -COMMENT ON TABLE pipeline_config IS 'Core video pipeline settings: Gemini model, voice, YouTube visibility, quality thresholds'; - --- ========================================================================= --- 2. remotion_config --- ========================================================================= -CREATE TABLE IF NOT EXISTS remotion_config ( - id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1), - aws_region text NOT NULL DEFAULT 'us-east-1', - function_name text NOT NULL DEFAULT '', - serve_url text NOT NULL DEFAULT '', - max_render_timeout_sec integer NOT NULL DEFAULT 240, - memory_mb integer NOT NULL DEFAULT 2048, - disk_mb integer NOT NULL DEFAULT 2048, - updated_at timestamptz NOT NULL DEFAULT now() -); - -ALTER TABLE remotion_config ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role can read remotion_config" - ON remotion_config FOR SELECT - TO service_role - USING (true); - -CREATE POLICY "service_role can update remotion_config" - ON remotion_config FOR UPDATE - TO service_role - USING (true) - WITH CHECK (true); - -INSERT INTO remotion_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING; - -CREATE TRIGGER trg_remotion_config_updated_at - BEFORE UPDATE ON remotion_config - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -COMMENT ON TABLE remotion_config IS 'Remotion Lambda rendering: AWS region, function name, serve URL, resource limits'; - --- ========================================================================= --- 3. content_config --- ========================================================================= -CREATE TABLE IF NOT EXISTS content_config ( - id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1), - rss_feeds jsonb NOT NULL DEFAULT '[{"name":"HN Top","url":"https://hnrss.org/newest?points=100&count=20"},{"name":"Dev.to JavaScript","url":"https://dev.to/feed/tag/javascript"},{"name":"Dev.to WebDev","url":"https://dev.to/feed/tag/webdev"},{"name":"CSS-Tricks","url":"https://css-tricks.com/feed/"},{"name":"Chromium Blog","url":"https://blog.chromium.org/feeds/posts/default"},{"name":"web.dev","url":"https://web.dev/feed.xml"},{"name":"Smashing Magazine","url":"https://www.smashingmagazine.com/feed/"},{"name":"JavaScript Weekly","url":"https://javascriptweekly.com/rss/"}]'::jsonb, - trend_sources_enabled jsonb NOT NULL DEFAULT '{"hn":true,"devto":true,"blogs":true,"youtube":true,"github":true}'::jsonb, - system_instruction text NOT NULL DEFAULT 'You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson. - -Your style is inspired by Cleo Abram''s "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles: -- Start with a BOLD claim or surprising fact that makes people stop scrolling -- Use analogies and real-world comparisons to explain technical concepts -- Build tension: "Here''s the problem... here''s why it matters... here''s the breakthrough" -- Keep energy HIGH — short sentences, active voice, conversational tone -- End with a clear takeaway that makes the viewer feel smarter -- Target audience: developers who want to stay current but don''t have time to read everything - -Script format: 60-90 second explainer videos. Think TikTok/YouTube Shorts energy with real educational depth. - -CodingCat.dev covers: React, Next.js, TypeScript, Svelte, web APIs, CSS, Node.js, cloud services, AI/ML for developers, and web platform updates.', - target_video_duration_sec integer NOT NULL DEFAULT 90, - scene_count_min integer NOT NULL DEFAULT 3, - scene_count_max integer NOT NULL DEFAULT 5, - updated_at timestamptz NOT NULL DEFAULT now() -); - -ALTER TABLE content_config ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role can read content_config" - ON content_config FOR SELECT - TO service_role - USING (true); - -CREATE POLICY "service_role can update content_config" - ON content_config FOR UPDATE - TO service_role - USING (true) - WITH CHECK (true); - -INSERT INTO content_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING; - -CREATE TRIGGER trg_content_config_updated_at - BEFORE UPDATE ON content_config - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -COMMENT ON TABLE content_config IS 'Content discovery and generation: RSS feeds, trend sources, system prompt, video duration'; - --- ========================================================================= --- 4. sponsor_config --- ========================================================================= -CREATE TABLE IF NOT EXISTS sponsor_config ( - id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1), - cooldown_days integer NOT NULL DEFAULT 14, - rate_card_tiers jsonb NOT NULL DEFAULT '[{"name":"starter","description":"5k-10k impressions","price":500},{"name":"growth","description":"10k-50k impressions","price":1500},{"name":"premium","description":"50k+ impressions","price":3000}]'::jsonb, - outreach_email_template text NOT NULL DEFAULT 'Hi {{companyName}}, - -I run CodingCat.dev, a web development education channel. We''d love to explore a sponsorship opportunity with you. - -Best, -Alex Patterson', - max_outreach_per_run integer NOT NULL DEFAULT 10, - updated_at timestamptz NOT NULL DEFAULT now() -); - -ALTER TABLE sponsor_config ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role can read sponsor_config" - ON sponsor_config FOR SELECT - TO service_role - USING (true); - -CREATE POLICY "service_role can update sponsor_config" - ON sponsor_config FOR UPDATE - TO service_role - USING (true) - WITH CHECK (true); - -INSERT INTO sponsor_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING; - -CREATE TRIGGER trg_sponsor_config_updated_at - BEFORE UPDATE ON sponsor_config - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -COMMENT ON TABLE sponsor_config IS 'Sponsor pipeline: cooldown periods, rate card tiers, outreach templates'; - --- ========================================================================= --- 5. distribution_config --- ========================================================================= -CREATE TABLE IF NOT EXISTS distribution_config ( - id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1), - notification_emails jsonb NOT NULL DEFAULT '["alex@codingcat.dev"]'::jsonb, - youtube_description_template text NOT NULL DEFAULT '{{title}} - -{{summary}} - -🔗 Learn more at https://codingcat.dev - -#webdev #coding #programming', - youtube_default_tags jsonb NOT NULL DEFAULT '["web development","coding","programming","tutorial","codingcat"]'::jsonb, - resend_from_email text NOT NULL DEFAULT 'content@codingcat.dev', - updated_at timestamptz NOT NULL DEFAULT now() -); - -ALTER TABLE distribution_config ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role can read distribution_config" - ON distribution_config FOR SELECT - TO service_role - USING (true); - -CREATE POLICY "service_role can update distribution_config" - ON distribution_config FOR UPDATE - TO service_role - USING (true) - WITH CHECK (true); - -INSERT INTO distribution_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING; - -CREATE TRIGGER trg_distribution_config_updated_at - BEFORE UPDATE ON distribution_config - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -COMMENT ON TABLE distribution_config IS 'Distribution: notification emails, YouTube templates, default tags'; - --- ========================================================================= --- 6. gcs_config --- ========================================================================= -CREATE TABLE IF NOT EXISTS gcs_config ( - id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1), - bucket_name text NOT NULL DEFAULT 'codingcatdev-content-engine', - project_id text NOT NULL DEFAULT 'codingcatdev', - updated_at timestamptz NOT NULL DEFAULT now() -); - -ALTER TABLE gcs_config ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role can read gcs_config" - ON gcs_config FOR SELECT - TO service_role - USING (true); - -CREATE POLICY "service_role can update gcs_config" - ON gcs_config FOR UPDATE - TO service_role - USING (true) - WITH CHECK (true); - -INSERT INTO gcs_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING; - -CREATE TRIGGER trg_gcs_config_updated_at - BEFORE UPDATE ON gcs_config - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -COMMENT ON TABLE gcs_config IS 'Google Cloud Storage: bucket name and project ID';