@@ -2,8 +2,13 @@ import { type NextRequest, NextResponse } from "next/server";
22import * as crypto from "node:crypto" ;
33import { writeClient } from "@/lib/sanity-write-client" ;
44import { generateWithGemini } from "@/lib/gemini" ;
5- import { uploadVideo , uploadShort } from "@/lib/youtube-upload" ;
5+ import { uploadVideo , uploadShort , generateShortsMetadata } from "@/lib/youtube-upload" ;
66import { notifySubscribers } from "@/lib/resend-notify" ;
7+ import { postVideoAnnouncement } from "@/lib/x-social" ;
8+
9+ // ---------------------------------------------------------------------------
10+ // Webhook signature validation
11+ // ---------------------------------------------------------------------------
712
813function isValidSignature ( body : string , signature : string | null ) : boolean {
914 const secret = process . env . SANITY_WEBHOOK_SECRET ;
@@ -22,7 +27,10 @@ function isValidSignature(body: string, signature: string | null): boolean {
2227 }
2328}
2429
25- // Minimal webhook payload — we fetch the full doc from Sanity
30+ // ---------------------------------------------------------------------------
31+ // Types
32+ // ---------------------------------------------------------------------------
33+
2634interface WebhookPayload {
2735 _id : string ;
2836 _type : string ;
@@ -56,33 +64,69 @@ interface AutomatedVideoDoc {
5664
5765interface YouTubeMetadata { title : string ; description : string ; tags : string [ ] ; }
5866
67+ // ---------------------------------------------------------------------------
68+ // Gemini metadata generation for long-form videos
69+ // ---------------------------------------------------------------------------
70+
5971async function generateYouTubeMetadata ( doc : AutomatedVideoDoc ) : Promise < YouTubeMetadata > {
6072 const scriptText = doc . script
6173 ? [ doc . script . hook , ...( doc . script . scenes ?. map ( ( s ) => s . narration ) ?? [ ] ) , doc . script . cta ] . filter ( Boolean ) . join ( "\n\n" )
6274 : "" ;
63- const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.\n\nVideo Title: ${ doc . title } \nScript: ${ scriptText } \n\nReturn JSON: {"title": "SEO title max 100 chars", "description": "500-1000 chars", "tags": ["10-15 tags"]}` ;
75+
76+ const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.
77+
78+ Video Title: ${ doc . title }
79+ Script: ${ scriptText }
80+
81+ Generate optimized YouTube metadata for a LONG-FORM video (not Shorts).
82+
83+ Return JSON:
84+ {
85+ "title": "SEO-optimized title, max 100 chars, engaging but not clickbait",
86+ "description": "500-1000 chars with key points, timestamps placeholder, channel links, and hashtags",
87+ "tags": ["10-15 relevant tags for discoverability"]
88+ }
89+
90+ Include in the description:
91+ - Brief summary of what viewers will learn
92+ - Key topics covered
93+ - Links section placeholder (🔗 Links mentioned in this video:)
94+ - Social links placeholder
95+ - Relevant hashtags at the end` ;
96+
6497 const raw = await generateWithGemini ( prompt ) ;
6598 try {
6699 const parsed = JSON . parse ( raw . replace ( / ` ` ` j s o n \n ? | \n ? ` ` ` / g, "" ) . trim ( ) ) as YouTubeMetadata ;
67- return { title : parsed . title ?. slice ( 0 , 100 ) || doc . title , description : parsed . description || doc . title , tags : Array . isArray ( parsed . tags ) ? parsed . tags . slice ( 0 , 15 ) : [ ] } ;
100+ return {
101+ title : parsed . title ?. slice ( 0 , 100 ) || doc . title ,
102+ description : parsed . description || doc . title ,
103+ tags : Array . isArray ( parsed . tags ) ? parsed . tags . slice ( 0 , 15 ) : [ ] ,
104+ } ;
68105 } catch {
69106 return { title : doc . title , description : doc . title , tags : [ ] } ;
70107 }
71108}
72109
110+ // ---------------------------------------------------------------------------
111+ // Sanity helpers
112+ // ---------------------------------------------------------------------------
113+
73114async function updateStatus ( docId : string , status : string , extra : Record < string , unknown > = { } ) : Promise < void > {
74115 await writeClient . patch ( docId ) . set ( { status, ...extra } ) . commit ( ) ;
75116 console . log ( `[sanity-distribute] ${ docId } -> ${ status } ` ) ;
76117}
77118
119+ // ---------------------------------------------------------------------------
120+ // POST handler
121+ // ---------------------------------------------------------------------------
122+
78123export async function POST ( req : NextRequest ) : Promise < NextResponse > {
79124 const rawBody = await req . text ( ) ;
80125 const signature = req . headers . get ( "sanity-webhook-signature" ) ;
81126 if ( ! isValidSignature ( rawBody , signature ) ) {
82127 return NextResponse . json ( { error : "Invalid signature" } , { status : 401 } ) ;
83128 }
84129
85- // Parse the minimal webhook payload (just _id, _type, status)
86130 let webhookPayload : WebhookPayload ;
87131 try { webhookPayload = JSON . parse ( rawBody ) ; } catch { return NextResponse . json ( { error : "Invalid JSON" } , { status : 400 } ) ; }
88132
@@ -91,18 +135,17 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
91135
92136 const docId = webhookPayload . _id ;
93137
94- // Fetch the full document from Sanity (webhook only sends minimal projection)
138+ // Fetch the full document from Sanity
95139 const doc = await writeClient . fetch < AutomatedVideoDoc | null > (
96140 `*[_id == $id][0]` ,
97141 { id : docId }
98142 ) ;
99143
100144 if ( ! doc ) {
101- console . error ( `[sanity-distribute] Document ${ docId } not found in Sanity ` ) ;
145+ console . error ( `[sanity-distribute] Document ${ docId } not found` ) ;
102146 return NextResponse . json ( { error : "Document not found" } , { status : 404 } ) ;
103147 }
104148
105- // Re-check status from the actual document (in case of race condition)
106149 if ( doc . status !== "video_gen" ) {
107150 return NextResponse . json ( { skipped : true , reason : `Document status is "${ doc . status } ", not "video_gen"` } ) ;
108151 }
@@ -115,36 +158,69 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
115158 try {
116159 await updateStatus ( docId , "uploading" ) ;
117160
118- // Step 1: Gemini metadata
161+ // Step 1: Generate long-form YouTube metadata via Gemini
162+ console . log ( "[sanity-distribute] Step 1/6 - Generating long-form metadata" ) ;
119163 const metadata = await generateYouTubeMetadata ( doc ) ;
120164
121- // Step 2: Upload main video
165+ // Step 2: Upload main video to YouTube
122166 let youtubeVideoId = "" ;
123167 if ( doc . videoUrl ) {
168+ console . log ( "[sanity-distribute] Step 2/6 - Uploading main video" ) ;
124169 const r = await uploadVideo ( { videoUrl : doc . videoUrl , title : metadata . title , description : metadata . description , tags : metadata . tags } ) ;
125170 youtubeVideoId = r . videoId ;
126171 }
127172
128- // Step 3: Upload short
173+ // Step 3: Generate Shorts-optimized metadata + upload Short
129174 let youtubeShortId = "" ;
130175 if ( doc . shortUrl ) {
131- const r = await uploadShort ( { videoUrl : doc . shortUrl , title : metadata . title , description : metadata . description , tags : metadata . tags } ) ;
176+ console . log ( "[sanity-distribute] Step 3/6 - Generating Shorts metadata + uploading" ) ;
177+ const shortsMetadata = await generateShortsMetadata ( generateWithGemini , doc ) ;
178+ const r = await uploadShort ( {
179+ videoUrl : doc . shortUrl ,
180+ title : shortsMetadata . title ,
181+ description : shortsMetadata . description ,
182+ tags : shortsMetadata . tags ,
183+ } ) ;
132184 youtubeShortId = r . videoId ;
133185 }
134186
135- // Step 4: Email (non-fatal)
187+ // Step 4: Email notification (non-fatal)
188+ console . log ( "[sanity-distribute] Step 4/6 - Sending email" ) ;
136189 const ytUrl = youtubeVideoId ? `https://www.youtube.com/watch?v=${ youtubeVideoId } ` : doc . videoUrl || "" ;
137190 try {
138- await notifySubscribers ( { subject : `New Video: ${ metadata . title } ` , videoTitle : metadata . title , videoUrl : ytUrl , description : metadata . description . slice ( 0 , 280 ) } ) ;
191+ await notifySubscribers ( {
192+ subject : `New Video: ${ metadata . title } ` ,
193+ videoTitle : metadata . title ,
194+ videoUrl : ytUrl ,
195+ description : metadata . description . slice ( 0 , 280 ) ,
196+ } ) ;
139197 } catch ( e ) { console . warn ( "[sanity-distribute] Email error:" , e ) ; }
140198
141- // Step 5: Mark published
142- await updateStatus ( docId , "published" , { youtubeId : youtubeVideoId || undefined , youtubeShortId : youtubeShortId || undefined } ) ;
143-
199+ // Step 5: Post to X/Twitter (non-fatal)
200+ console . log ( "[sanity-distribute] Step 5/6 - Posting to X/Twitter" ) ;
201+ try {
202+ const tweetResult = await postVideoAnnouncement ( {
203+ videoTitle : metadata . title ,
204+ youtubeUrl : ytUrl ,
205+ tags : metadata . tags ,
206+ } ) ;
207+ if ( ! tweetResult . success ) {
208+ console . warn ( `[sanity-distribute] Tweet failed: ${ tweetResult . error } ` ) ;
209+ }
210+ } catch ( e ) { console . warn ( "[sanity-distribute] X/Twitter error:" , e ) ; }
211+
212+ // Step 6: Mark published in Sanity
213+ console . log ( "[sanity-distribute] Step 6/6 - Marking published" ) ;
214+ await updateStatus ( docId , "published" , {
215+ youtubeId : youtubeVideoId || undefined ,
216+ youtubeShortId : youtubeShortId || undefined ,
217+ } ) ;
218+
219+ console . log ( `[sanity-distribute] ✅ Distribution complete for ${ docId } ` ) ;
144220 return NextResponse . json ( { success : true , docId, youtubeId : youtubeVideoId , youtubeShortId } ) ;
145221 } catch ( err ) {
146222 const msg = err instanceof Error ? err . message : String ( err ) ;
147- console . error ( `[sanity-distribute] Failed ${ docId } : ${ msg } ` ) ;
223+ console . error ( `[sanity-distribute] ❌ Failed ${ docId } : ${ msg } ` ) ;
148224 try { await updateStatus ( docId , "flagged" , { flaggedReason : `Distribution error: ${ msg } ` } ) ; } catch { }
149225 return NextResponse . json ( { error : "Distribution failed" , details : msg } , { status : 500 } ) ;
150226 }
0 commit comments