From 768d8a9b147786e741e3618a2f9af9e34626461a Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Thu, 5 Mar 2026 02:00:51 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20revalidateTag=20requires=202=20args?= =?UTF-8?q?=20in=20Next.js=2016=20=E2=80=94=20add=20{=20expire:=200=20}=20?= =?UTF-8?q?for=20immediate=20invalidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/webhooks/sanity-revalidate/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/webhooks/sanity-revalidate/route.ts b/app/api/webhooks/sanity-revalidate/route.ts index cf39938a..fb715b8a 100644 --- a/app/api/webhooks/sanity-revalidate/route.ts +++ b/app/api/webhooks/sanity-revalidate/route.ts @@ -25,7 +25,8 @@ export async function POST(request: NextRequest) { // Revalidate all sanity-tagged caches (the "heavy hammer" approach) // This is a backup for when no visitors are active to trigger SanityLive revalidation - revalidateTag("sanity"); + // Next.js 16 requires a second argument — { expire: 0 } for immediate invalidation + revalidateTag("sanity", { expire: 0 }); return NextResponse.json({ revalidated: true, From 8b9d08806b3568d102d7c5d283a45308a4c88829 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Thu, 5 Mar 2026 07:37:28 -0500 Subject: [PATCH 2/2] fix: remove process.env fallbacks from getConfigValue calls, improve Sanity schema descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1: Remove redundant env var fallbacks — Sanity is now the single source of truth - lib/services/elevenlabs.ts: remove process.env.ELEVENLABS_VOICE_ID fallback - lib/services/remotion.ts: remove REMOTION_AWS_REGION, REMOTION_SERVE_URL, REMOTION_FUNCTION_NAME fallbacks - lib/services/gcs.ts: remove GCS_BUCKET, GCS_PROJECT_ID fallbacks - lib/gemini.ts: replace process.env.GEMINI_MODEL with getConfigValue (make generateWithGemini async-aware) - lib/sponsor/gemini-intent.ts: replace process.env.GEMINI_MODEL with getConfigValue - lib/youtube-upload.ts: replace process.env.YOUTUBE_UPLOAD_VISIBILITY with getConfigValue Task 2: Add clear description fields to all Sanity config schemas - pipelineConfig, remotionConfig, contentConfig, sponsorConfig, distributionConfig, gcsConfig --- lib/gemini.ts | 4 ++- lib/services/elevenlabs.ts | 2 +- lib/services/gcs.ts | 21 +++++++----- lib/services/remotion.ts | 34 ++++++++++--------- lib/sponsor/gemini-intent.ts | 4 ++- lib/youtube-upload.ts | 13 +++---- sanity/schemas/singletons/contentConfig.ts | 6 ++++ .../schemas/singletons/distributionConfig.ts | 4 +++ sanity/schemas/singletons/gcsConfig.ts | 2 ++ sanity/schemas/singletons/pipelineConfig.ts | 8 +++++ sanity/schemas/singletons/remotionConfig.ts | 6 ++++ sanity/schemas/singletons/sponsorConfig.ts | 4 +++ 12 files changed, 74 insertions(+), 34 deletions(-) diff --git a/lib/gemini.ts b/lib/gemini.ts index fc02cde3..0e3110da 100644 --- a/lib/gemini.ts +++ b/lib/gemini.ts @@ -1,4 +1,5 @@ import { GoogleGenerativeAI } from "@google/generative-ai"; +import { getConfigValue } from "@/lib/config"; const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ""); @@ -12,8 +13,9 @@ export async function generateWithGemini( prompt: string, systemInstruction?: string, ): Promise { + const geminiModel = await getConfigValue("pipeline_config", "geminiModel", "gemini-2.0-flash"); const model = genAI.getGenerativeModel({ - model: process.env.GEMINI_MODEL || "gemini-2.5-flash", + model: geminiModel, ...(systemInstruction && { systemInstruction }), }); const result = await model.generateContent(prompt); diff --git a/lib/services/elevenlabs.ts b/lib/services/elevenlabs.ts index c9e7202a..8f3a8cd8 100644 --- a/lib/services/elevenlabs.ts +++ b/lib/services/elevenlabs.ts @@ -67,7 +67,7 @@ async function getElevenLabsConfig(): Promise { const apiKey = process.env.ELEVENLABS_API_KEY; const voiceId = await getConfigValue( "pipeline_config", "elevenLabsVoiceId", - process.env.ELEVENLABS_VOICE_ID || "pNInz6obpgDQGcFmaJgB" + "pNInz6obpgDQGcFmaJgB" ); if (!apiKey) { diff --git a/lib/services/gcs.ts b/lib/services/gcs.ts index 8709cc40..4077ef4c 100644 --- a/lib/services/gcs.ts +++ b/lib/services/gcs.ts @@ -5,11 +5,11 @@ * Authenticates via service account JWT → OAuth2 access token exchange. * * Requires env vars: - * - GCS_BUCKET - * - GCS_PROJECT_ID * - GCS_CLIENT_EMAIL * - GCS_PRIVATE_KEY * + * GCS bucket and project config is read from Sanity singletons via getConfigValue(). + * * @module lib/services/gcs */ @@ -58,27 +58,30 @@ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes // --------------------------------------------------------------------------- /** - * Get GCS configuration from environment variables. - * Throws if any required env var is missing. + * Get GCS configuration from Sanity config + environment variables. + * Bucket and project ID come from Sanity; credentials from env vars. + * Throws if any required value is missing. * * The private key may contain literal `\\n` sequences from the env var; * these are converted to real newline characters. */ export async function getGCSConfig(): Promise { - const bucket = await getConfigValue("gcs_config", "bucketName", process.env.GCS_BUCKET); - const projectId = await getConfigValue("gcs_config", "projectId", process.env.GCS_PROJECT_ID); + const bucket = await getConfigValue("gcs_config", "bucketName", "codingcatdev-content-engine"); + const projectId = await getConfigValue("gcs_config", "projectId", "codingcatdev"); const clientEmail = process.env.GCS_CLIENT_EMAIL; let privateKey = process.env.GCS_PRIVATE_KEY; if (!bucket || !projectId || !clientEmail || !privateKey) { const missing = [ - !bucket && "GCS_BUCKET", - !projectId && "GCS_PROJECT_ID", + !bucket && "gcs_config.bucketName", + !projectId && "gcs_config.projectId", !clientEmail && "GCS_CLIENT_EMAIL", !privateKey && "GCS_PRIVATE_KEY", ].filter(Boolean); throw new Error( - `[GCS] Missing required environment variables: ${missing.join(", ")}` + `[GCS] Missing required configuration: ${missing.join(", ")}. ` + + `Bucket and project ID are managed in the Sanity GCS Config singleton. ` + + `Credentials (GCS_CLIENT_EMAIL, GCS_PRIVATE_KEY) come from env vars.` ); } diff --git a/lib/services/remotion.ts b/lib/services/remotion.ts index 8cb72b54..cbb85af0 100644 --- a/lib/services/remotion.ts +++ b/lib/services/remotion.ts @@ -10,9 +10,9 @@ * Requires env vars: * - AWS_ACCESS_KEY_ID * - AWS_SECRET_ACCESS_KEY - * - REMOTION_AWS_REGION - * - REMOTION_SERVE_URL (generated during Lambda deployment) - * - REMOTION_FUNCTION_NAME (optional, defaults to DEFAULT_FUNCTION_NAME) + * + * Remotion-specific config (region, serveUrl, functionName) is read from + * Sanity singletons via getConfigValue(). * * @module lib/services/remotion */ @@ -76,7 +76,7 @@ export interface RenderResult { // Constants // --------------------------------------------------------------------------- -/** Default Lambda function name if REMOTION_FUNCTION_NAME is not set */ +/** Default Lambda function name if not configured in Sanity */ const DEFAULT_FUNCTION_NAME = "remotion-render-4-0-431"; /** Polling interval in ms when waiting for render completion */ @@ -128,26 +128,28 @@ function mapInputProps(input: RenderInput): Record { // --------------------------------------------------------------------------- /** - * Get Remotion Lambda configuration from environment variables. - * Throws if any required env var is missing. + * Get Remotion Lambda configuration from Sanity config + environment variables. + * AWS credentials come from env vars; Remotion-specific config from Sanity. + * Throws if any required value is missing. */ export async function getRemotionConfig(): Promise { const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - const region = await getConfigValue("remotion_config", "awsRegion", process.env.REMOTION_AWS_REGION); - const serveUrl = await getConfigValue("remotion_config", "serveUrl", process.env.REMOTION_SERVE_URL); + const region = await getConfigValue("remotion_config", "awsRegion", "us-east-1"); + const serveUrl = await getConfigValue("remotion_config", "serveUrl", undefined); const missing: string[] = []; if (!awsAccessKeyId) missing.push("AWS_ACCESS_KEY_ID"); if (!awsSecretAccessKey) missing.push("AWS_SECRET_ACCESS_KEY"); - if (!region) missing.push("REMOTION_AWS_REGION"); - if (!serveUrl) missing.push("REMOTION_SERVE_URL"); + if (!region) missing.push("remotion_config.awsRegion"); + if (!serveUrl) missing.push("remotion_config.serveUrl"); if (missing.length > 0) { throw new Error( - `[REMOTION] Missing required environment variables: ${missing.join(", ")}. ` + - `Set these before calling any render function. ` + - `REMOTION_SERVE_URL is generated by running deployRemotionLambda().` + `[REMOTION] Missing required configuration: ${missing.join(", ")}. ` + + `AWS credentials come from env vars. Remotion config (region, serveUrl, functionName) ` + + `is managed in the Sanity Remotion Config singleton. ` + + `serveUrl is generated by running deployRemotionLambda().` ); } @@ -160,10 +162,10 @@ export async function getRemotionConfig(): Promise { } /** - * Get the Lambda function name from env or use the default. + * Get the Lambda function name from Sanity config or use the default. */ async function getFunctionName(): Promise { - return getConfigValue("remotion_config", "functionName", process.env.REMOTION_FUNCTION_NAME || DEFAULT_FUNCTION_NAME); + return getConfigValue("remotion_config", "functionName", "remotion-render-4-0-431-mem2048mb-disk2048mb-240sec"); } // --------------------------------------------------------------------------- @@ -239,7 +241,7 @@ async function startRender( throw new Error( `[REMOTION] Failed to trigger Lambda render for "${composition}": ${message}. ` + `Ensure the Lambda function "${functionName}" is deployed in ${region} ` + - `and REMOTION_SERVE_URL points to a valid Remotion bundle.` + `and the Remotion Config serveUrl points to a valid Remotion bundle.` ); } } diff --git a/lib/sponsor/gemini-intent.ts b/lib/sponsor/gemini-intent.ts index a4b02bc8..9a9024d4 100644 --- a/lib/sponsor/gemini-intent.ts +++ b/lib/sponsor/gemini-intent.ts @@ -1,4 +1,5 @@ import { GoogleGenerativeAI } from '@google/generative-ai' +import { getConfigValue } from '@/lib/config' const SPONSORSHIP_TIERS = [ 'dedicated-video', @@ -33,8 +34,9 @@ export async function extractSponsorIntent(message: string): Promise { + const value = await getConfigValue("pipeline_config", "youtubeUploadVisibility", "private"); + if (value === "public" || value === "private" || value === "unlisted") { + return value; } - return "private"; // Default to private when not set + return "private"; // Default to private when not set or invalid } interface UploadOptions { @@ -36,7 +37,7 @@ export async function uploadVideo(opts: UploadOptions): Promise<{ videoId: strin const response = await fetch(opts.videoUrl); if (!response.ok) throw new Error(`Failed to fetch video: ${response.statusText}`); - const resolvedPrivacyStatus = opts.privacyStatus || getDefaultPrivacyStatus(); + const resolvedPrivacyStatus = opts.privacyStatus || await getDefaultPrivacyStatus(); console.log(`[youtube-upload] Uploading "${opts.title.slice(0, 60)}" with privacy: ${resolvedPrivacyStatus}`); // Convert Web ReadableStream to Node.js Readable stream // googleapis expects a Node.js stream with .pipe(), not a Web ReadableStream diff --git a/sanity/schemas/singletons/contentConfig.ts b/sanity/schemas/singletons/contentConfig.ts index 909febe3..6c67db02 100644 --- a/sanity/schemas/singletons/contentConfig.ts +++ b/sanity/schemas/singletons/contentConfig.ts @@ -10,6 +10,7 @@ export default defineType({ name: "rssFeeds", title: "RSS Feeds", type: "array", + description: "RSS/Atom feeds to monitor for trending topics. The ingest cron checks these daily for new content ideas", of: [ { type: "object", @@ -42,6 +43,7 @@ export default defineType({ name: "trendSourcesEnabled", title: "Trend Sources Enabled", type: "object", + description: "Toggle individual trend discovery sources on/off. Disabling a source skips it during the daily ingest scan", fields: [ defineField({ name: "hn", @@ -79,24 +81,28 @@ export default defineType({ name: "systemInstruction", title: "System Instruction", type: "text", + description: "The AI system prompt used for script generation. Defines the writing style, tone, and format for all generated video scripts", 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", + description: "Target duration for generated videos in seconds. Scripts are calibrated to this length", initialValue: 90, }), defineField({ name: "sceneCountMin", title: "Scene Count Min", type: "number", + description: "Minimum number of scenes per video. The AI generates at least this many visual segments", initialValue: 3, }), defineField({ name: "sceneCountMax", title: "Scene Count Max", type: "number", + description: "Maximum number of scenes per video. Keeps videos focused and within duration targets", initialValue: 5, }), ], diff --git a/sanity/schemas/singletons/distributionConfig.ts b/sanity/schemas/singletons/distributionConfig.ts index e2d3f892..2d67cafe 100644 --- a/sanity/schemas/singletons/distributionConfig.ts +++ b/sanity/schemas/singletons/distributionConfig.ts @@ -10,6 +10,7 @@ export default defineType({ name: "notificationEmails", title: "Notification Emails", type: "array", + description: "Email addresses that receive notifications when a new video is published", of: [{ type: "string" }], initialValue: ["alex@codingcat.dev"], }), @@ -17,12 +18,14 @@ export default defineType({ name: "youtubeDescriptionTemplate", title: "YouTube Description Template", type: "text", + description: "Template for YouTube video descriptions. Use {{title}} and {{summary}} placeholders", 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", + description: "Default tags applied to all uploaded YouTube videos. Topic-specific tags are added by AI", of: [{ type: "string" }], initialValue: ["web development", "coding", "programming", "tutorial", "codingcat"], }), @@ -30,6 +33,7 @@ export default defineType({ name: "resendFromEmail", title: "Resend From Email", type: "string", + description: "Sender email address for notification emails via Resend", initialValue: "content@codingcat.dev", }), ], diff --git a/sanity/schemas/singletons/gcsConfig.ts b/sanity/schemas/singletons/gcsConfig.ts index 4a486069..14d82545 100644 --- a/sanity/schemas/singletons/gcsConfig.ts +++ b/sanity/schemas/singletons/gcsConfig.ts @@ -10,12 +10,14 @@ export default defineType({ name: "bucketName", title: "Bucket Name", type: "string", + description: "Google Cloud Storage bucket name for storing video assets, audio files, and renders", initialValue: "codingcatdev-content-engine", }), defineField({ name: "projectId", title: "Project ID", type: "string", + description: "Google Cloud project ID that owns the GCS bucket", initialValue: "codingcatdev", }), ], diff --git a/sanity/schemas/singletons/pipelineConfig.ts b/sanity/schemas/singletons/pipelineConfig.ts index 904a08c3..964c9ef3 100644 --- a/sanity/schemas/singletons/pipelineConfig.ts +++ b/sanity/schemas/singletons/pipelineConfig.ts @@ -10,18 +10,21 @@ export default defineType({ name: "geminiModel", title: "Gemini Model", type: "string", + description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.0-flash, gemini-2.5-pro)", initialValue: "gemini-2.0-flash", }), defineField({ name: "elevenLabsVoiceId", title: "ElevenLabs Voice ID", type: "string", + description: "ElevenLabs voice ID for text-to-speech narration. Find voice IDs at elevenlabs.io/voice-library", initialValue: "pNInz6obpgDQGcFmaJgB", }), defineField({ name: "youtubeUploadVisibility", title: "YouTube Upload Visibility", type: "string", + description: "Default visibility for uploaded YouTube videos. Use 'private' for testing, 'unlisted' for review, 'public' for production", initialValue: "private", options: { list: ["private", "unlisted", "public"], @@ -31,18 +34,21 @@ export default defineType({ name: "youtubeChannelId", title: "YouTube Channel ID", type: "string", + description: "Your YouTube channel ID \u2014 used for analytics and upload targeting", initialValue: "", }), defineField({ name: "enableNotebookLmResearch", title: "Enable NotebookLM Research", type: "boolean", + description: "When enabled, the ingest cron creates a NotebookLM notebook for deep research before script generation. Requires NOTEBOOKLM_AUTH_JSON env var", initialValue: false, }), defineField({ name: "qualityThreshold", title: "Quality Threshold", type: "number", + description: "Minimum quality score (0-100) from the AI critic. Videos scoring below this are flagged for manual review instead of auto-publishing", initialValue: 50, validation: (rule) => rule.min(0).max(100), }), @@ -50,6 +56,7 @@ export default defineType({ name: "stuckTimeoutMinutes", title: "Stuck Timeout Minutes", type: "number", + description: "Minutes before a pipeline document is considered stuck and auto-flagged. Sub-statuses use proportional timeouts (infographics: 50%, enriching: 33%)", initialValue: 30, validation: (rule) => rule.min(5).max(120), }), @@ -57,6 +64,7 @@ export default defineType({ name: "maxIdeasPerRun", title: "Max Ideas Per Run", type: "number", + description: "Maximum number of content ideas to process per ingest cron run. Keep low (1-3) to stay within serverless time limits", initialValue: 1, validation: (rule) => rule.min(1).max(10), }), diff --git a/sanity/schemas/singletons/remotionConfig.ts b/sanity/schemas/singletons/remotionConfig.ts index 2e36f1ab..99aa1790 100644 --- a/sanity/schemas/singletons/remotionConfig.ts +++ b/sanity/schemas/singletons/remotionConfig.ts @@ -10,36 +10,42 @@ export default defineType({ name: "awsRegion", title: "AWS Region", type: "string", + description: "AWS region where Remotion Lambda is deployed (must match your Lambda function region)", initialValue: "us-east-1", }), defineField({ name: "functionName", title: "Function Name", type: "string", + description: "Name of the deployed Remotion Lambda function (created by deployFunction())", initialValue: "", }), defineField({ name: "serveUrl", title: "Serve URL", type: "string", + description: "S3 URL of the deployed Remotion bundle (created by deploySite()). Required for rendering", initialValue: "", }), defineField({ name: "maxRenderTimeoutSec", title: "Max Render Timeout (sec)", type: "number", + description: "Maximum seconds to wait for a Lambda render before timing out", initialValue: 240, }), defineField({ name: "memoryMb", title: "Memory (MB)", type: "number", + description: "Memory allocated to the Remotion Lambda function in MB", initialValue: 2048, }), defineField({ name: "diskMb", title: "Disk (MB)", type: "number", + description: "Ephemeral disk space for the Lambda function in MB", initialValue: 2048, }), ], diff --git a/sanity/schemas/singletons/sponsorConfig.ts b/sanity/schemas/singletons/sponsorConfig.ts index 8d3e1434..27b5bb1c 100644 --- a/sanity/schemas/singletons/sponsorConfig.ts +++ b/sanity/schemas/singletons/sponsorConfig.ts @@ -10,12 +10,14 @@ export default defineType({ name: "cooldownDays", title: "Cooldown Days", type: "number", + description: "Days to wait before contacting a sponsor again after the last outreach", initialValue: 14, }), defineField({ name: "rateCardTiers", title: "Rate Card Tiers", type: "array", + description: "Sponsorship pricing tiers shown in outreach emails. Each tier has a name, description, and price", of: [ { type: "object", @@ -48,12 +50,14 @@ export default defineType({ name: "outreachEmailTemplate", title: "Outreach Email Template", type: "text", + description: "Template for automated sponsor outreach emails. Use {{companyName}} for personalization", initialValue: "Hi {{companyName}},\n\nI run CodingCat.dev...", }), defineField({ name: "maxOutreachPerRun", title: "Max Outreach Per Run", type: "number", + description: "Maximum sponsor emails to send per cron run. Keeps volume manageable and avoids spam flags", initialValue: 10, }), ],