1- import { type NextRequest , NextResponse } from "next/server" ;
1+ import { NextResponse , after } from "next/server" ;
22import { isValidSignature , SIGNATURE_HEADER_NAME } from "@sanity/webhook" ;
33import { writeClient } from "@/lib/sanity-write-client" ;
44import { 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
4656interface 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:
7196Include 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