Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/api/cron/check-renders/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Config migration: audited — no tweakable config in this route.
// Remotion/ElevenLabs config is in the service layer (owned by @videopipe).
// YouTube SEO prompt is specific to this route, not the shared system instruction.
export const fetchCache = 'force-no-store';
export const maxDuration = 60;

Expand Down
34 changes: 24 additions & 10 deletions app/api/cron/check-research/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NotebookLMClient } from '@/lib/services/notebooklm/client';
import { initAuth } from '@/lib/services/notebooklm/auth';
import { ArtifactTypeCode, ArtifactStatus } from '@/lib/services/notebooklm/types';
import { generateWithGemini, stripCodeFences } from '@/lib/gemini';
import { getConfigValue } from '@/lib/config';
import type { ResearchPayload } from '@/lib/services/research';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -94,12 +95,15 @@ interface StepResult {
// Constants
// ---------------------------------------------------------------------------

/** Stuck thresholds per status (ms) */
const STUCK_THRESHOLDS: Record<string, number> = {
researching: 30 * 60 * 1000, // 30 minutes
infographics_generating: 15 * 60 * 1000, // 15 minutes
enriching: 10 * 60 * 1000, // 10 minutes
};
/** Build stuck thresholds from config (with fallbacks) */
async function buildStuckThresholds(): Promise<Record<string, number>> {
const stuckMinutes = await getConfigValue('pipeline_config', 'stuckTimeoutMinutes', 30);
return {
researching: stuckMinutes * 60 * 1000,
infographics_generating: Math.round(stuckMinutes * 0.5) * 60 * 1000, // half the main timeout
enriching: Math.round(stuckMinutes * 0.33) * 60 * 1000, // third of main timeout
};
}

/** Max docs to process per status per run — keeps total time well under 60s */
const MAX_DOCS_PER_STATUS = 2;
Expand Down Expand Up @@ -132,12 +136,13 @@ function getSanityWriteClient(): SanityClient {
async function flagStuckDocs(
docs: PipelineDoc[],
sanity: SanityClient,
stuckThresholds: Record<string, number>,
): Promise<StepResult[]> {
const results: StepResult[] = [];
const now = Date.now();

for (const doc of docs) {
const threshold = STUCK_THRESHOLDS[doc.status];
const threshold = stuckThresholds[doc.status];
if (!threshold) continue;

const docAge = now - new Date(doc._updatedAt).getTime();
Expand Down Expand Up @@ -419,6 +424,11 @@ async function stepEnriching(
// Generate enriched script with Gemini
let enrichedScript: EnrichedScript | null = null;
try {
const SYSTEM_INSTRUCTION = await getConfigValue(
'content_config',
'systemInstruction',
SYSTEM_INSTRUCTION_FALLBACK,
);
const prompt = buildEnrichmentPrompt(doc, researchPayload);
const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION);
const cleaned = stripCodeFences(rawResponse);
Expand All @@ -434,7 +444,8 @@ async function stepEnriching(
const criticScore = criticResult.score;
console.log(`[check-research] Critic score: ${criticScore}/100 — ${criticResult.summary}`);

const isFlagged = criticScore < 50;
const qualityThreshold = await getConfigValue('pipeline_config', 'qualityThreshold', 50);
const isFlagged = criticScore < qualityThreshold;

await sanity
.patch(doc._id)
Expand Down Expand Up @@ -481,7 +492,9 @@ async function stepEnriching(
// Gemini Script Enrichment
// ---------------------------------------------------------------------------

const SYSTEM_INSTRUCTION = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
// SYSTEM_INSTRUCTION is now fetched from content_config singleton via getConfigValue().
// Fallback value preserved below for graceful degradation if the Sanity document doesn't exist yet.
const SYSTEM_INSTRUCTION_FALLBACK = `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
Expand Down Expand Up @@ -813,7 +826,8 @@ export async function GET(request: NextRequest) {
const results: StepResult[] = [];

// Phase 1: Stuck detection — runs FIRST, no external API calls
const stuckResults = await flagStuckDocs(docs, sanity);
const stuckThresholds = await buildStuckThresholds();
const stuckResults = await flagStuckDocs(docs, sanity, stuckThresholds);
results.push(...stuckResults);

// Remove flagged docs from further processing
Expand Down
5 changes: 3 additions & 2 deletions app/api/cron/ingest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,10 +330,11 @@ async function createSanityDocuments(
script: GeneratedScript,
criticResult: CriticResult,
trends: TrendResult[],
qualityThreshold: number,
research?: ResearchPayload,
researchMeta?: { notebookId: string; taskId: string },
) {
const isFlagged = criticResult.score < 50;
const isFlagged = criticResult.score < qualityThreshold;
// When research is in-flight, status is "researching" (check-research cron will transition to script_ready)
const isResearching = !!researchMeta?.notebookId;
const status = isFlagged ? "flagged" : isResearching ? "researching" : "script_ready";
Expand Down Expand Up @@ -494,7 +495,7 @@ export async function GET(request: NextRequest) {
);

console.log("[CRON/ingest] Creating Sanity documents...");
const result = await createSanityDocuments(script, criticResult, trends, undefined, researchMeta);
const result = await createSanityDocuments(script, criticResult, trends, qualityThreshold, undefined, researchMeta);

console.log("[CRON/ingest] Done!", result);

Expand Down
25 changes: 19 additions & 6 deletions app/api/cron/sponsor-outreach/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import { NextResponse } from 'next/server'
import { sanityWriteClient } from '@/lib/sanity-write-client'
import { generateOutreachEmail } from '@/lib/sponsor/gemini-outreach'
import { sendSponsorEmail } from '@/lib/sponsor/email-service'
import { getConfig } from '@/lib/config'
import type { SponsorPoolEntry } from '@/lib/sponsor/gemini-outreach'

const MAX_PER_RUN = 5
const COOLDOWN_DAYS = 14

export async function POST(request: Request) {
// Auth: Bearer token check against CRON_SECRET
const cronSecret = process.env.CRON_SECRET;
Expand All @@ -25,9 +23,24 @@ export async function POST(request: Request) {
try {
console.log('[SPONSOR] Starting outbound sponsor outreach cron...')

// Fetch sponsor config from Sanity singleton
const sponsorCfg = await getConfig("sponsor_config");
const maxPerRun = sponsorCfg.maxOutreachPerRun;
const cooldownDays = sponsorCfg.cooldownDays;

// Build rate card string from config tiers
const rateCard = [
'CodingCat.dev Sponsorship Tiers:',
...sponsorCfg.rateCardTiers.map(
(t) => `- ${t.name} ($${t.price.toLocaleString()}) — ${t.description}`
),
'',
'Our audience: 50K+ developers interested in web development, JavaScript/TypeScript, React, Next.js, and modern dev tools.',
].join('\n');

// Calculate the cutoff date for cooldown
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - COOLDOWN_DAYS)
cutoffDate.setDate(cutoffDate.getDate() - cooldownDays)
const cutoffISO = cutoffDate.toISOString()

// Query Sanity for eligible sponsor pool entries
Expand All @@ -38,7 +51,7 @@ export async function POST(request: Request) {
!defined(lastContactedAt)
|| lastContactedAt < $cutoffDate
)
] | order(relevanceScore desc) [0...${MAX_PER_RUN - 1}] {
] | order(relevanceScore desc) [0...${maxPerRun - 1}] {
_id,
companyName,
contactName,
Expand Down Expand Up @@ -68,7 +81,7 @@ export async function POST(request: Request) {
for (const sponsor of sponsors) {
try {
// Generate personalized outreach email
const email = await generateOutreachEmail(sponsor)
const email = await generateOutreachEmail(sponsor, rateCard)

// Send the email (stubbed)
const sendResult = await sendSponsorEmail(
Expand Down
6 changes: 6 additions & 0 deletions app/api/webhooks/sanity-distribute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { generateWithGemini } from "@/lib/gemini";
import { uploadVideo, uploadShort, generateShortsMetadata } from "@/lib/youtube-upload";
import { notifySubscribers } from "@/lib/resend-notify";
import { postVideoAnnouncement } from "@/lib/x-social";
import { getConfig } from "@/lib/config";

const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET;

Expand Down Expand Up @@ -140,6 +141,9 @@ async function appendDistributionLog(docId: string, entries: DistributionLogEntr
async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<void> {
const log: DistributionLogEntry[] = [];

// Fetch distribution config from Sanity singleton
const distConfig = await getConfig("distribution_config");

try {
await updateStatus(docId, "uploading");

Expand Down Expand Up @@ -206,6 +210,8 @@ async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<v
videoTitle: metadata.title,
videoUrl: ytUrl,
description: metadata.description.slice(0, 280),
fromEmail: distConfig.resendFromEmail,
notificationEmails: distConfig.notificationEmails,
});
log.push(logEntry("email", "success"));
} catch (e) {
Expand Down
6 changes: 4 additions & 2 deletions lib/resend-notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export async function notifySubscribers(opts: {
videoTitle: string;
videoUrl: string;
description: string;
fromEmail?: string;
notificationEmails?: string[];
}): Promise<{ sent: boolean; error?: string }> {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
Expand All @@ -19,8 +21,8 @@ export async function notifySubscribers(opts: {
const resend = new Resend(apiKey);

await resend.emails.send({
from: "CodingCat.dev <noreply@codingcat.dev>",
to: ["subscribers@codingcat.dev"], // TODO: fetch subscriber list
from: `CodingCat.dev <${opts.fromEmail || "noreply@codingcat.dev"}>`,
to: opts.notificationEmails || ["subscribers@codingcat.dev"],
subject: opts.subject,
html: `
<h1>${opts.videoTitle}</h1>
Expand Down
23 changes: 11 additions & 12 deletions lib/services/elevenlabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type WordTimestamp,
type SceneAudioResult,
} from "@/lib/utils/audio-timestamps";
import { getConfigValue } from "@/lib/config";

const ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1";

Expand Down Expand Up @@ -57,14 +58,14 @@ interface TTSWithTimestampsResponse {
}

/**
* Reads the ElevenLabs configuration from environment variables.
* Reads the ElevenLabs configuration from Sanity config + environment variables.
* API key (secret) stays as process.env. Voice ID comes from Sanity config with env fallback.
*
* @returns The resolved {@link ElevenLabsConfig}.
* @throws {Error} If required environment variables are missing.
* @throws {Error} If the API key is missing.
*/
function getConfig(): ElevenLabsConfig {
async function getElevenLabsConfig(): Promise<ElevenLabsConfig> {
const apiKey = process.env.ELEVENLABS_API_KEY;
const voiceId = process.env.ELEVENLABS_VOICE_ID;

if (!apiKey) {
throw new Error(
Expand All @@ -73,12 +74,10 @@ function getConfig(): ElevenLabsConfig {
);
}

if (!voiceId) {
throw new Error(
"Missing ELEVENLABS_VOICE_ID environment variable. " +
"Set it in your .env.local or deployment environment."
);
}
const voiceId = await getConfigValue(
"pipeline_config", "elevenLabsVoiceId",
process.env.ELEVENLABS_VOICE_ID || "pNInz6obpgDQGcFmaJgB"
);

return { apiKey, voiceId };
}
Expand Down Expand Up @@ -106,7 +105,7 @@ export async function generateSpeech(text: string): Promise<Buffer> {
throw new Error("Cannot generate speech from empty text.");
}

const { apiKey, voiceId } = getConfig();
const { apiKey, voiceId } = await getElevenLabsConfig();

const url = `${ELEVENLABS_API_BASE}/text-to-speech/${voiceId}`;

Expand Down Expand Up @@ -244,7 +243,7 @@ export async function generateSpeechWithTimestamps(
throw new Error("Cannot generate speech from empty text.");
}

const { apiKey, voiceId } = getConfig();
const { apiKey, voiceId } = await getElevenLabsConfig();

const url = `${ELEVENLABS_API_BASE}/text-to-speech/${voiceId}/with-timestamps`;

Expand Down
19 changes: 10 additions & 9 deletions lib/services/gcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

import * as crypto from "crypto";
import { getConfigValue } from "@/lib/config";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -63,21 +64,21 @@ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes
* The private key may contain literal `\\n` sequences from the env var;
* these are converted to real newline characters.
*/
export function getGCSConfig(): GCSConfig {
const bucket = process.env.GCS_BUCKET;
const projectId = process.env.GCS_PROJECT_ID;
export async function getGCSConfig(): Promise<GCSConfig> {
const bucket = await getConfigValue("gcs_config", "bucketName", process.env.GCS_BUCKET);
const projectId = await getConfigValue("gcs_config", "projectId", process.env.GCS_PROJECT_ID);
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_BUCKET / gcs_config.bucketName",
!projectId && "GCS_PROJECT_ID / 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(", ")}`
);
}

Expand Down Expand Up @@ -205,7 +206,7 @@ async function getAccessToken(): Promise<string> {
return cachedToken.accessToken;
}

const config = getGCSConfig();
const config = await getGCSConfig();
const jwt = createServiceAccountJWT(config);
cachedToken = await exchangeJWTForToken(jwt);
return cachedToken.accessToken;
Expand All @@ -231,7 +232,7 @@ export async function uploadToGCS(
path: string,
contentType: string
): Promise<UploadResult> {
const config = getGCSConfig();
const config = await getGCSConfig();
const token = await getAccessToken();

const encodedPath = encodeURIComponent(path);
Expand Down Expand Up @@ -338,7 +339,7 @@ export async function getSignedUrl(
expiresInMinutes = 60
): Promise<string> {
// We still validate config to fail fast if env vars are missing
const config = getGCSConfig();
const config = await getGCSConfig();

// For public objects, the public URL is sufficient
void expiresInMinutes; // acknowledged but unused for public objects
Expand Down
Loading
Loading