Skip to content

Commit b62c10d

Browse files
author
Miriad
committed
feat: add after() pattern + distribution log for error recovery
- Use after() from next/server to return 200 immediately and run the heavy distribution pipeline (YouTube upload, Gemini, email, social) in the background. Prevents Vercel from killing the function mid-upload. Same pattern as sanity-content webhook route. - Add distributionLog array field to automatedVideo schema. Each step (gemini-metadata, youtube-upload, youtube-short, email, x-twitter) logs success/failed/skipped with timestamps, error messages, and result IDs. Enables dashboard retry of failed steps. - Non-fatal steps (email, X/Twitter) now log failures instead of silently swallowing them. YouTube upload failures are also logged but don't block the pipeline from marking published if at least the main video uploaded.
1 parent 363805a commit b62c10d

File tree

2 files changed

+268
-68
lines changed

2 files changed

+268
-68
lines changed

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

Lines changed: 249 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type NextRequest, NextResponse } from "next/server";
1+
import { NextResponse, after } from "next/server";
22
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
33
import { writeClient } from "@/lib/sanity-write-client";
44
import { generateWithGemini } from "@/lib/gemini";
@@ -41,10 +41,35 @@ interface AutomatedVideoDoc {
4141
youtubeShortId?: string;
4242
flaggedReason?: string;
4343
scheduledPublishAt?: string;
44+
distributionLog?: DistributionLogEntry[];
45+
}
46+
47+
interface DistributionLogEntry {
48+
_key: string;
49+
step: string;
50+
status: "success" | "failed" | "skipped";
51+
error?: string;
52+
timestamp: string;
53+
result?: string;
4454
}
4555

4656
interface YouTubeMetadata { title: string; description: string; tags: string[]; }
4757

58+
// ---------------------------------------------------------------------------
59+
// Distribution log helpers
60+
// ---------------------------------------------------------------------------
61+
62+
function logEntry(step: string, status: "success" | "failed" | "skipped", opts?: { error?: string; result?: string }): DistributionLogEntry {
63+
return {
64+
_key: `${step}-${Date.now()}`,
65+
step,
66+
status,
67+
error: opts?.error,
68+
timestamp: new Date().toISOString(),
69+
result: opts?.result,
70+
};
71+
}
72+
4873
// ---------------------------------------------------------------------------
4974
// Gemini metadata generation for long-form videos
5075
// ---------------------------------------------------------------------------
@@ -71,8 +96,7 @@ Return JSON:
7196
Include in the description:
7297
- Brief summary of what viewers will learn
7398
- Key topics covered
74-
- Links section placeholder (🔗 Links mentioned in this video:)
75-
- Social links placeholder
99+
- Links section placeholder
76100
- Relevant hashtags at the end`;
77101

78102
const raw = await generateWithGemini(prompt);
@@ -97,79 +121,80 @@ async function updateStatus(docId: string, status: string, extra: Record<string,
97121
console.log(`[sanity-distribute] ${docId} -> ${status}`);
98122
}
99123

100-
// ---------------------------------------------------------------------------
101-
// POST handler
102-
// ---------------------------------------------------------------------------
103-
104-
export async function POST(req: NextRequest): Promise<NextResponse> {
105-
const rawBody = await req.text();
106-
const signature = req.headers.get(SIGNATURE_HEADER_NAME);
107-
108-
if (!WEBHOOK_SECRET) {
109-
console.error("[sanity-distribute] Missing SANITY_WEBHOOK_SECRET");
110-
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
111-
}
112-
113-
if (!signature || !(await isValidSignature(rawBody, signature, WEBHOOK_SECRET))) {
114-
console.log("[sanity-distribute] Invalid signature");
115-
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
116-
}
117-
118-
let webhookPayload: WebhookPayload;
119-
try { webhookPayload = JSON.parse(rawBody); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
120-
121-
if (webhookPayload._type !== "automatedVideo") return NextResponse.json({ skipped: true, reason: "Not automatedVideo" });
122-
if (webhookPayload.status !== "video_gen") return NextResponse.json({ skipped: true, reason: `Status "${webhookPayload.status}" != "video_gen"` });
123-
124-
const docId = webhookPayload._id;
125-
126-
// Fetch the full document from Sanity
127-
const doc = await writeClient.fetch<AutomatedVideoDoc | null>(
128-
`*[_id == $id][0]`,
129-
{ id: docId }
130-
);
131-
132-
if (!doc) {
133-
console.error(`[sanity-distribute] Document ${docId} not found`);
134-
return NextResponse.json({ error: "Document not found" }, { status: 404 });
124+
async function appendDistributionLog(docId: string, entries: DistributionLogEntry[]): Promise<void> {
125+
const ops = entries.map((entry) => ({
126+
insert: { after: "distributionLog[-1]", items: [entry] },
127+
}));
128+
// Use setIfMissing to create the array if it doesn't exist, then append
129+
let patch = writeClient.patch(docId).setIfMissing({ distributionLog: [] });
130+
for (const entry of entries) {
131+
patch = patch.append("distributionLog", [entry]);
135132
}
133+
await patch.commit();
134+
}
136135

137-
if (doc.status !== "video_gen") {
138-
return NextResponse.json({ skipped: true, reason: `Document status is "${doc.status}", not "video_gen"` });
139-
}
140-
if (doc.flaggedReason) {
141-
return NextResponse.json({ skipped: true, reason: "Flagged" });
142-
}
136+
// ---------------------------------------------------------------------------
137+
// Core distribution pipeline (runs inside after())
138+
// ---------------------------------------------------------------------------
143139

144-
console.log(`[sanity-distribute] Processing ${docId}: "${doc.title}"`);
140+
async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<void> {
141+
const log: DistributionLogEntry[] = [];
145142

146143
try {
147144
await updateStatus(docId, "uploading");
148145

149146
// Step 1: Generate long-form YouTube metadata via Gemini
150147
console.log("[sanity-distribute] Step 1/6 - Generating long-form metadata");
151-
const metadata = await generateYouTubeMetadata(doc);
148+
let metadata: YouTubeMetadata;
149+
try {
150+
metadata = await generateYouTubeMetadata(doc);
151+
log.push(logEntry("gemini-metadata", "success"));
152+
} catch (e) {
153+
const msg = e instanceof Error ? e.message : String(e);
154+
console.error("[sanity-distribute] Gemini metadata failed:", msg);
155+
log.push(logEntry("gemini-metadata", "failed", { error: msg }));
156+
// Fallback metadata so we can still upload
157+
metadata = { title: doc.title, description: doc.title, tags: [] };
158+
}
152159

153160
// Step 2: Upload main video to YouTube
154161
let youtubeVideoId = "";
155162
if (doc.videoUrl) {
156163
console.log("[sanity-distribute] Step 2/6 - Uploading main video");
157-
const r = await uploadVideo({ videoUrl: doc.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
158-
youtubeVideoId = r.videoId;
164+
try {
165+
const r = await uploadVideo({ videoUrl: doc.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
166+
youtubeVideoId = r.videoId;
167+
log.push(logEntry("youtube-upload", "success", { result: youtubeVideoId }));
168+
} catch (e) {
169+
const msg = e instanceof Error ? e.message : String(e);
170+
console.error("[sanity-distribute] YouTube upload failed:", msg);
171+
log.push(logEntry("youtube-upload", "failed", { error: msg }));
172+
}
173+
} else {
174+
log.push(logEntry("youtube-upload", "skipped", { error: "No videoUrl" }));
159175
}
160176

161177
// Step 3: Generate Shorts-optimized metadata + upload Short
162178
let youtubeShortId = "";
163179
if (doc.shortUrl) {
164180
console.log("[sanity-distribute] Step 3/6 - Generating Shorts metadata + uploading");
165-
const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc);
166-
const r = await uploadShort({
167-
videoUrl: doc.shortUrl,
168-
title: shortsMetadata.title,
169-
description: shortsMetadata.description,
170-
tags: shortsMetadata.tags,
171-
});
172-
youtubeShortId = r.videoId;
181+
try {
182+
const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc);
183+
const r = await uploadShort({
184+
videoUrl: doc.shortUrl,
185+
title: shortsMetadata.title,
186+
description: shortsMetadata.description,
187+
tags: shortsMetadata.tags,
188+
});
189+
youtubeShortId = r.videoId;
190+
log.push(logEntry("youtube-short", "success", { result: youtubeShortId }));
191+
} catch (e) {
192+
const msg = e instanceof Error ? e.message : String(e);
193+
console.error("[sanity-distribute] Short upload failed:", msg);
194+
log.push(logEntry("youtube-short", "failed", { error: msg }));
195+
}
196+
} else {
197+
log.push(logEntry("youtube-short", "skipped", { error: "No shortUrl" }));
173198
}
174199

175200
// Step 4: Email notification (non-fatal)
@@ -182,7 +207,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
182207
videoUrl: ytUrl,
183208
description: metadata.description.slice(0, 280),
184209
});
185-
} catch (e) { console.warn("[sanity-distribute] Email error:", e); }
210+
log.push(logEntry("email", "success"));
211+
} catch (e) {
212+
const msg = e instanceof Error ? e.message : String(e);
213+
console.warn("[sanity-distribute] Email error:", msg);
214+
log.push(logEntry("email", "failed", { error: msg }));
215+
}
186216

187217
// Step 5: Post to X/Twitter (non-fatal)
188218
console.log("[sanity-distribute] Step 5/6 - Posting to X/Twitter");
@@ -192,24 +222,175 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
192222
youtubeUrl: ytUrl,
193223
tags: metadata.tags,
194224
});
195-
if (!tweetResult.success) {
196-
console.warn(`[sanity-distribute] Tweet failed: ${tweetResult.error}`);
225+
if (tweetResult.success) {
226+
log.push(logEntry("x-twitter", "success", { result: tweetResult.tweetId }));
227+
} else {
228+
log.push(logEntry("x-twitter", "failed", { error: tweetResult.error }));
197229
}
198-
} catch (e) { console.warn("[sanity-distribute] X/Twitter error:", e); }
230+
} catch (e) {
231+
const msg = e instanceof Error ? e.message : String(e);
232+
console.warn("[sanity-distribute] X/Twitter error:", msg);
233+
log.push(logEntry("x-twitter", "failed", { error: msg }));
234+
}
199235

200-
// Step 6: Mark published in Sanity
236+
// Step 6: Mark published in Sanity + save distribution log
201237
console.log("[sanity-distribute] Step 6/6 - Marking published");
202-
await updateStatus(docId, "published", {
203-
youtubeId: youtubeVideoId || undefined,
204-
youtubeShortId: youtubeShortId || undefined,
205-
});
238+
await writeClient
239+
.patch(docId)
240+
.set({
241+
status: "published",
242+
youtubeId: youtubeVideoId || undefined,
243+
youtubeShortId: youtubeShortId || undefined,
244+
})
245+
.setIfMissing({ distributionLog: [] })
246+
.append("distributionLog", log)
247+
.commit();
206248

207249
console.log(`[sanity-distribute] ✅ Distribution complete for ${docId}`);
208-
return NextResponse.json({ success: true, docId, youtubeId: youtubeVideoId, youtubeShortId });
209250
} catch (err) {
210251
const msg = err instanceof Error ? err.message : String(err);
211252
console.error(`[sanity-distribute] ❌ Failed ${docId}: ${msg}`);
212-
try { await updateStatus(docId, "flagged", { flaggedReason: `Distribution error: ${msg}` }); } catch {}
213-
return NextResponse.json({ error: "Distribution failed", details: msg }, { status: 500 });
253+
log.push(logEntry("pipeline", "failed", { error: msg }));
254+
255+
try {
256+
await writeClient
257+
.patch(docId)
258+
.set({
259+
status: "flagged",
260+
flaggedReason: `Distribution error: ${msg}`,
261+
})
262+
.setIfMissing({ distributionLog: [] })
263+
.append("distributionLog", log)
264+
.commit();
265+
} catch {
266+
// Last resort — at least try to save the log
267+
console.error("[sanity-distribute] Failed to save error state");
268+
}
269+
}
270+
}
271+
272+
// ---------------------------------------------------------------------------
273+
// POST handler
274+
// ---------------------------------------------------------------------------
275+
276+
/**
277+
* Sanity webhook handler for the distribution pipeline.
278+
*
279+
* Listens for automatedVideo documents transitioning to "video_gen" status
280+
* and triggers YouTube upload, email notification, and social posting.
281+
*
282+
* Uses after() to return 200 immediately and run the heavy pipeline work
283+
* in the background — prevents Vercel from killing the function mid-upload.
284+
*
285+
* Configure in Sanity: Webhook → POST → filter: `_type == "automatedVideo"`
286+
* with projection: `{ _id, _type, status }`
287+
*/
288+
export async function POST(request: Request) {
289+
try {
290+
if (!WEBHOOK_SECRET) {
291+
console.log("[sanity-distribute] Missing SANITY_WEBHOOK_SECRET environment variable");
292+
return NextResponse.json(
293+
{ error: "Server misconfigured: missing webhook secret" },
294+
{ status: 500 }
295+
);
296+
}
297+
298+
// Read the raw body as text for signature verification
299+
const rawBody = await request.text();
300+
const signature = request.headers.get(SIGNATURE_HEADER_NAME);
301+
302+
if (!signature) {
303+
console.log("[sanity-distribute] Missing signature header");
304+
return NextResponse.json(
305+
{ error: "Missing signature" },
306+
{ status: 401 }
307+
);
308+
}
309+
310+
// Verify the webhook signature (same as sanity-content route)
311+
const isValid = await isValidSignature(rawBody, signature, WEBHOOK_SECRET);
312+
313+
if (!isValid) {
314+
console.log("[sanity-distribute] Invalid signature received");
315+
return NextResponse.json(
316+
{ error: "Invalid signature" },
317+
{ status: 401 }
318+
);
319+
}
320+
321+
// Parse the verified body
322+
let webhookPayload: WebhookPayload;
323+
try {
324+
webhookPayload = JSON.parse(rawBody);
325+
} catch {
326+
console.log("[sanity-distribute] Failed to parse webhook body");
327+
return NextResponse.json(
328+
{ skipped: true, reason: "Invalid JSON body" },
329+
{ status: 200 }
330+
);
331+
}
332+
333+
console.log(`[sanity-distribute] Received: type=${webhookPayload._type}, id=${webhookPayload._id}, status=${webhookPayload.status}`);
334+
335+
if (webhookPayload._type !== "automatedVideo") {
336+
return NextResponse.json(
337+
{ skipped: true, reason: `Not automatedVideo` },
338+
{ status: 200 }
339+
);
340+
}
341+
342+
if (webhookPayload.status !== "video_gen") {
343+
return NextResponse.json(
344+
{ skipped: true, reason: `Status "${webhookPayload.status}" is not "video_gen"` },
345+
{ status: 200 }
346+
);
347+
}
348+
349+
const docId = webhookPayload._id;
350+
351+
// Fetch the full document from Sanity (webhook only sends minimal projection)
352+
const doc = await writeClient.fetch<AutomatedVideoDoc | null>(
353+
`*[_id == $id][0]`,
354+
{ id: docId }
355+
);
356+
357+
if (!doc) {
358+
console.error(`[sanity-distribute] Document ${docId} not found`);
359+
return NextResponse.json({ error: "Document not found" }, { status: 404 });
360+
}
361+
362+
// Re-check status from the actual document (race condition guard)
363+
if (doc.status !== "video_gen") {
364+
return NextResponse.json(
365+
{ skipped: true, reason: `Document status is "${doc.status}", not "video_gen"` },
366+
{ status: 200 }
367+
);
368+
}
369+
if (doc.flaggedReason) {
370+
return NextResponse.json(
371+
{ skipped: true, reason: "Flagged" },
372+
{ status: 200 }
373+
);
374+
}
375+
376+
// Use after() to run the distribution pipeline after the response is sent.
377+
// On Vercel, serverless functions terminate after the response — fire-and-forget
378+
// (promise.catch() without await) silently dies. after() keeps the function alive.
379+
console.log(`[sanity-distribute] Triggering distribution for: ${docId}`);
380+
after(async () => {
381+
try {
382+
await runDistribution(docId, doc);
383+
} catch (error) {
384+
console.error(`[sanity-distribute] Background processing error for ${docId}:`, error);
385+
}
386+
});
387+
388+
return NextResponse.json({ triggered: true, docId }, { status: 200 });
389+
} catch (error) {
390+
console.log("[sanity-distribute] Unexpected error processing webhook:", error);
391+
return NextResponse.json(
392+
{ error: "Internal server error" },
393+
{ status: 500 }
394+
);
214395
}
215396
}

0 commit comments

Comments
 (0)