Skip to content

Commit d1e0c58

Browse files
authored
Merge pull request #614 from CodingCatDev/dev
release: Config-to-Sanity Phase A+B — 6 singletons + all routes migrated
2 parents 4df823b + a873a90 commit d1e0c58

File tree

9 files changed

+108
-56
lines changed

9 files changed

+108
-56
lines changed

app/api/cron/check-research/route.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { NotebookLMClient } from '@/lib/services/notebooklm/client';
88
import { initAuth } from '@/lib/services/notebooklm/auth';
99
import { ArtifactTypeCode, ArtifactStatus } from '@/lib/services/notebooklm/types';
1010
import { generateWithGemini, stripCodeFences } from '@/lib/gemini';
11+
import { getConfigValue } from '@/lib/config';
1112
import type { ResearchPayload } from '@/lib/services/research';
1213

1314
// ---------------------------------------------------------------------------
@@ -94,12 +95,15 @@ interface StepResult {
9495
// Constants
9596
// ---------------------------------------------------------------------------
9697

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

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

139144
for (const doc of docs) {
140-
const threshold = STUCK_THRESHOLDS[doc.status];
145+
const threshold = stuckThresholds[doc.status];
141146
if (!threshold) continue;
142147

143148
const docAge = now - new Date(doc._updatedAt).getTime();
@@ -419,6 +424,11 @@ async function stepEnriching(
419424
// Generate enriched script with Gemini
420425
let enrichedScript: EnrichedScript | null = null;
421426
try {
427+
const SYSTEM_INSTRUCTION = await getConfigValue(
428+
'content_config',
429+
'systemInstruction',
430+
SYSTEM_INSTRUCTION_FALLBACK,
431+
);
422432
const prompt = buildEnrichmentPrompt(doc, researchPayload);
423433
const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION);
424434
const cleaned = stripCodeFences(rawResponse);
@@ -434,7 +444,8 @@ async function stepEnriching(
434444
const criticScore = criticResult.score;
435445
console.log(`[check-research] Critic score: ${criticScore}/100 — ${criticResult.summary}`);
436446

437-
const isFlagged = criticScore < 50;
447+
const qualityThreshold = await getConfigValue('pipeline_config', 'qualityThreshold', 50);
448+
const isFlagged = criticScore < qualityThreshold;
438449

439450
await sanity
440451
.patch(doc._id)
@@ -481,7 +492,9 @@ async function stepEnriching(
481492
// Gemini Script Enrichment
482493
// ---------------------------------------------------------------------------
483494

484-
const SYSTEM_INSTRUCTION = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
495+
// SYSTEM_INSTRUCTION fallback — used when content_config singleton doesn't exist yet in Sanity.
496+
// The live value is fetched from getConfigValue() inside stepEnriching().
497+
const SYSTEM_INSTRUCTION_FALLBACK = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
485498
486499
Your style is inspired by Cleo Abram's "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles:
487500
- Start with a BOLD claim or surprising fact that makes people stop scrolling
@@ -813,7 +826,8 @@ export async function GET(request: NextRequest) {
813826
const results: StepResult[] = [];
814827

815828
// Phase 1: Stuck detection — runs FIRST, no external API calls
816-
const stuckResults = await flagStuckDocs(docs, sanity);
829+
const stuckThresholds = await buildStuckThresholds();
830+
const stuckResults = await flagStuckDocs(docs, sanity, stuckThresholds);
817831
results.push(...stuckResults);
818832

819833
// Remove flagged docs from further processing

app/api/cron/ingest/route.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { NextRequest } from "next/server";
44

55
import { generateWithGemini, stripCodeFences } from "@/lib/gemini";
66
import { writeClient } from "@/lib/sanity-write-client";
7+
import { getConfigValue } from "@/lib/config";
78
import { discoverTrends, type TrendResult } from "@/lib/services/trend-discovery";
89
import type { ResearchPayload } from "@/lib/services/research";
910
import { NotebookLMClient } from "@/lib/services/notebooklm/client";
@@ -102,7 +103,7 @@ const FALLBACK_TRENDS: TrendResult[] = [
102103
slug: "webassembly-web-apps",
103104
score: 60,
104105
signals: [{ source: "blog", title: "WebAssembly", url: "https://webassembly.org/", score: 60 }],
105-
whyTrending: "WASM adoption growing in [REDACTED SECRET: NEXT_PUBLIC_SANITY_DATASET] apps",
106+
whyTrending: "WASM adoption growing in production apps",
106107
suggestedAngle: "Real-world use cases where WASM outperforms JS",
107108
},
108109
];
@@ -111,7 +112,9 @@ const FALLBACK_TRENDS: TrendResult[] = [
111112
// Gemini Script Generation
112113
// ---------------------------------------------------------------------------
113114

114-
const SYSTEM_INSTRUCTION = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
115+
// SYSTEM_INSTRUCTION fallback — used when content_config singleton doesn't exist yet in Sanity.
116+
// The live value is fetched from getConfigValue() inside the GET handler.
117+
const SYSTEM_INSTRUCTION_FALLBACK = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
115118
116119
Your style is inspired by Cleo Abram's "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles:
117120
- Start with a BOLD claim or surprising fact that makes people stop scrolling
@@ -330,10 +333,11 @@ async function createSanityDocuments(
330333
script: GeneratedScript,
331334
criticResult: CriticResult,
332335
trends: TrendResult[],
336+
qualityThreshold: number,
333337
research?: ResearchPayload,
334338
researchMeta?: { notebookId: string; taskId: string },
335339
) {
336-
const isFlagged = criticResult.score < 50;
340+
const isFlagged = criticResult.score < qualityThreshold;
337341
// When research is in-flight, status is "researching" (check-research cron will transition to script_ready)
338342
const isResearching = !!researchMeta?.notebookId;
339343
const status = isFlagged ? "flagged" : isResearching ? "researching" : "script_ready";
@@ -404,6 +408,23 @@ export async function GET(request: NextRequest) {
404408
}
405409

406410
try {
411+
// Fetch config values once per invocation (5-min in-memory cache)
412+
const SYSTEM_INSTRUCTION = await getConfigValue(
413+
"content_config",
414+
"systemInstruction",
415+
SYSTEM_INSTRUCTION_FALLBACK,
416+
);
417+
const enableNotebookLmResearch = await getConfigValue(
418+
"pipeline_config",
419+
"enableNotebookLmResearch",
420+
false,
421+
);
422+
const qualityThreshold = await getConfigValue(
423+
"pipeline_config",
424+
"qualityThreshold",
425+
50,
426+
);
427+
407428
// Step 1: Discover trending topics (replaces fetchTrendingTopics)
408429
console.log("[CRON/ingest] Discovering trending topics...");
409430
let trends: TrendResult[];
@@ -425,7 +446,7 @@ export async function GET(request: NextRequest) {
425446
// When research is enabled, we create a notebook and start research
426447
// but DON'T wait for it — the check-research cron will poll and enrich later
427448
let researchMeta: { notebookId: string; taskId: string } | undefined;
428-
if (process.env.ENABLE_NOTEBOOKLM_RESEARCH === "true") {
449+
if (enableNotebookLmResearch) {
429450
console.log(`[CRON/ingest] Starting fire-and-forget research on: "${trends[0].topic}"...`);
430451
try {
431452
const auth = await initAuth();
@@ -494,7 +515,7 @@ export async function GET(request: NextRequest) {
494515
);
495516

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

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

app/api/cron/sponsor-outreach/route.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { sanityWriteClient } from '@/lib/sanity-write-client'
55
import { generateOutreachEmail } from '@/lib/sponsor/gemini-outreach'
66
import { sendSponsorEmail } from '@/lib/sponsor/email-service'
77
import type { SponsorPoolEntry } from '@/lib/sponsor/gemini-outreach'
8-
9-
const MAX_PER_RUN = 5
10-
const COOLDOWN_DAYS = 14
8+
import { getConfig } from '@/lib/config'
119

1210
export async function POST(request: Request) {
1311
// Auth: Bearer token check against CRON_SECRET
@@ -25,9 +23,19 @@ export async function POST(request: Request) {
2523
try {
2624
console.log('[SPONSOR] Starting outbound sponsor outreach cron...')
2725

26+
// Fetch config from Sanity singleton
27+
const sponsorCfg = await getConfig("sponsor_config");
28+
const maxPerRun = sponsorCfg.maxOutreachPerRun;
29+
const cooldownDays = sponsorCfg.cooldownDays;
30+
31+
// Build rate card string from config tiers
32+
const rateCard = sponsorCfg.rateCardTiers
33+
.map((t) => `- ${t.name} ($${t.price}) — ${t.description}`)
34+
.join('\n');
35+
2836
// Calculate the cutoff date for cooldown
2937
const cutoffDate = new Date()
30-
cutoffDate.setDate(cutoffDate.getDate() - COOLDOWN_DAYS)
38+
cutoffDate.setDate(cutoffDate.getDate() - cooldownDays)
3139
const cutoffISO = cutoffDate.toISOString()
3240

3341
// Query Sanity for eligible sponsor pool entries
@@ -38,7 +46,7 @@ export async function POST(request: Request) {
3846
!defined(lastContactedAt)
3947
|| lastContactedAt < $cutoffDate
4048
)
41-
] | order(relevanceScore desc) [0...${MAX_PER_RUN - 1}] {
49+
] | order(relevanceScore desc) [0...${maxPerRun - 1}] {
4250
_id,
4351
companyName,
4452
contactName,
@@ -67,8 +75,8 @@ export async function POST(request: Request) {
6775

6876
for (const sponsor of sponsors) {
6977
try {
70-
// Generate personalized outreach email
71-
const email = await generateOutreachEmail(sponsor)
78+
// Generate personalized outreach email with config rate card
79+
const email = await generateOutreachEmail(sponsor, rateCard)
7280

7381
// Send the email (stubbed)
7482
const sendResult = await sendSponsorEmail(

app/api/webhooks/sanity-distribute/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { generateWithGemini } from "@/lib/gemini";
55
import { uploadVideo, uploadShort, generateShortsMetadata } from "@/lib/youtube-upload";
66
import { notifySubscribers } from "@/lib/resend-notify";
77
import { postVideoAnnouncement } from "@/lib/x-social";
8+
import { getConfig } from "@/lib/config";
89

910
const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET;
1011

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

144+
// Fetch distribution config from Sanity singleton
145+
const distConfig = await getConfig("distribution_config");
146+
143147
try {
144148
await updateStatus(docId, "uploading");
145149

@@ -197,7 +201,7 @@ async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<v
197201
log.push(logEntry("youtube-short", "skipped", { error: "No shortUrl" }));
198202
}
199203

200-
// Step 4: Email notification (non-fatal)
204+
// Step 4: Email notification (non-fatal) — uses distribution config
201205
console.log("[sanity-distribute] Step 4/6 - Sending email");
202206
const ytUrl = youtubeVideoId ? `https://www.youtube.com/watch?v=${youtubeVideoId}` : doc.videoUrl || "";
203207
try {
@@ -206,6 +210,8 @@ async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<v
206210
videoTitle: metadata.title,
207211
videoUrl: ytUrl,
208212
description: metadata.description.slice(0, 280),
213+
fromEmail: distConfig.resendFromEmail,
214+
notificationEmails: distConfig.notificationEmails,
209215
});
210216
log.push(logEntry("email", "success"));
211217
} catch (e) {

lib/resend-notify.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export async function notifySubscribers(opts: {
77
videoTitle: string;
88
videoUrl: string;
99
description: string;
10+
fromEmail?: string;
11+
notificationEmails?: string[];
1012
}): Promise<{ sent: boolean; error?: string }> {
1113
const apiKey = process.env.RESEND_API_KEY;
1214
if (!apiKey) {
@@ -19,8 +21,8 @@ export async function notifySubscribers(opts: {
1921
const resend = new Resend(apiKey);
2022

2123
await resend.emails.send({
22-
from: "CodingCat.dev <noreply@codingcat.dev>",
23-
to: ["subscribers@codingcat.dev"], // TODO: fetch subscriber list
24+
from: `CodingCat.dev <${opts.fromEmail || "noreply@codingcat.dev"}>`,
25+
to: opts.notificationEmails || ["subscribers@codingcat.dev"],
2426
subject: opts.subject,
2527
html: `
2628
<h1>${opts.videoTitle}</h1>

lib/services/elevenlabs.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type WordTimestamp,
1313
type SceneAudioResult,
1414
} from "@/lib/utils/audio-timestamps";
15+
import { getConfigValue } from "@/lib/config";
1516

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

@@ -62,9 +63,12 @@ interface TTSWithTimestampsResponse {
6263
* @returns The resolved {@link ElevenLabsConfig}.
6364
* @throws {Error} If required environment variables are missing.
6465
*/
65-
function getConfig(): ElevenLabsConfig {
66+
async function getElevenLabsConfig(): Promise<ElevenLabsConfig> {
6667
const apiKey = process.env.ELEVENLABS_API_KEY;
67-
const voiceId = process.env.ELEVENLABS_VOICE_ID;
68+
const voiceId = await getConfigValue(
69+
"pipeline_config", "elevenLabsVoiceId",
70+
process.env.ELEVENLABS_VOICE_ID || "pNInz6obpgDQGcFmaJgB"
71+
);
6872

6973
if (!apiKey) {
7074
throw new Error(
@@ -73,13 +77,6 @@ function getConfig(): ElevenLabsConfig {
7377
);
7478
}
7579

76-
if (!voiceId) {
77-
throw new Error(
78-
"Missing ELEVENLABS_VOICE_ID environment variable. " +
79-
"Set it in your .env.local or deployment environment."
80-
);
81-
}
82-
8380
return { apiKey, voiceId };
8481
}
8582

@@ -106,7 +103,7 @@ export async function generateSpeech(text: string): Promise<Buffer> {
106103
throw new Error("Cannot generate speech from empty text.");
107104
}
108105

109-
const { apiKey, voiceId } = getConfig();
106+
const { apiKey, voiceId } = await getElevenLabsConfig();
110107

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

@@ -244,7 +241,7 @@ export async function generateSpeechWithTimestamps(
244241
throw new Error("Cannot generate speech from empty text.");
245242
}
246243

247-
const { apiKey, voiceId } = getConfig();
244+
const { apiKey, voiceId } = await getElevenLabsConfig();
248245

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

lib/services/gcs.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
import * as crypto from "crypto";
17+
import { getConfigValue } from "@/lib/config";
1718

1819
// ---------------------------------------------------------------------------
1920
// Types
@@ -63,9 +64,9 @@ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes
6364
* The private key may contain literal `\\n` sequences from the env var;
6465
* these are converted to real newline characters.
6566
*/
66-
export function getGCSConfig(): GCSConfig {
67-
const bucket = process.env.GCS_BUCKET;
68-
const projectId = process.env.GCS_PROJECT_ID;
67+
export async function getGCSConfig(): Promise<GCSConfig> {
68+
const bucket = await getConfigValue("gcs_config", "bucketName", process.env.GCS_BUCKET);
69+
const projectId = await getConfigValue("gcs_config", "projectId", process.env.GCS_PROJECT_ID);
6970
const clientEmail = process.env.GCS_CLIENT_EMAIL;
7071
let privateKey = process.env.GCS_PRIVATE_KEY;
7172

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

208-
const config = getGCSConfig();
209+
const config = await getGCSConfig();
209210
const jwt = createServiceAccountJWT(config);
210211
cachedToken = await exchangeJWTForToken(jwt);
211212
return cachedToken.accessToken;
@@ -231,7 +232,7 @@ export async function uploadToGCS(
231232
path: string,
232233
contentType: string
233234
): Promise<UploadResult> {
234-
const config = getGCSConfig();
235+
const config = await getGCSConfig();
235236
const token = await getAccessToken();
236237

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

343344
// For public objects, the public URL is sufficient
344345
void expiresInMinutes; // acknowledged but unused for public objects

0 commit comments

Comments
 (0)