@@ -4,10 +4,15 @@ import { unlink } from "node:fs/promises";
44import * as Sentry from "@sentry/nextjs" ;
55
66import { getGlobalPayload } from "@/lib/payload" ;
7- import type { Media , Promise as PromiseDoc } from "@/payload-types" ;
87import { downloadFile } from "@/utils/files" ;
8+ import type {
9+ Promise as PayloadPromise ,
10+ Document as PayloadDocument ,
11+ AiExtraction as PayloadAiExtraction ,
12+ } from "@/payload-types" ;
913
1014const WEBHOOK_SECRET_ENV_KEY = "WEBHOOK_SECRET_KEY" ;
15+ type PayloadClient = Awaited < ReturnType < typeof getGlobalPayload > > ;
1116
1217type MeedanFile = {
1318 url ?: unknown ;
@@ -17,15 +22,29 @@ type MeedanField = {
1722 value ?: unknown ;
1823} ;
1924
25+ type ReportDesignOptions = {
26+ title ?: unknown ;
27+ description ?: unknown ;
28+ text ?: unknown ;
29+ headline ?: unknown ;
30+ published_article_url ?: unknown ;
31+ deadline ?: unknown ;
32+ } ;
33+
34+ type ReportDesignData = {
35+ options ?: ReportDesignOptions | null ;
36+ state ?: unknown ;
37+ fields ?: MeedanField [ ] ;
38+ } ;
39+
2040type MeedanWebhookPayload = {
2141 data ?: {
2242 id ?: unknown ;
2343 } ;
2444 object ?: {
45+ annotation_type ?: string | null ;
2546 file ?: MeedanFile [ ] ;
26- data ?: {
27- fields ?: MeedanField [ ] ;
28- } ;
47+ data ?: ReportDesignData | null ;
2948 } ;
3049} ;
3150
@@ -42,6 +61,14 @@ const normaliseString = (value: unknown): string | null => {
4261 return null ;
4362} ;
4463
64+ type WithOptionalId = {
65+ id ?: unknown ;
66+ } ;
67+
68+ const hasIdProperty = ( value : unknown ) : value is WithOptionalId => {
69+ return typeof value === "object" && value !== null && "id" in value ;
70+ } ;
71+
4572const getRelationId = ( value : unknown ) : string | null => {
4673 if ( ! value ) {
4774 return null ;
@@ -56,8 +83,8 @@ const getRelationId = (value: unknown): string | null => {
5683 return String ( value ) ;
5784 }
5885
59- if ( typeof value === "object" ) {
60- const maybeId = ( value as { id ?: unknown } ) . id ;
86+ if ( hasIdProperty ( value ) ) {
87+ const maybeId = value . id ;
6188 if ( typeof maybeId === "string" ) {
6289 const trimmed = maybeId . trim ( ) ;
6390 return trimmed . length > 0 ? trimmed : null ;
@@ -70,6 +97,79 @@ const getRelationId = (value: unknown): string | null => {
7097 return null ;
7198} ;
7299
100+ const createDocumentResolver = ( payload : PayloadClient ) => {
101+ const cache = new Map < string , PayloadDocument | null > ( ) ;
102+
103+ return async (
104+ documentValue : PayloadAiExtraction [ "document" ]
105+ ) : Promise < PayloadDocument | null > => {
106+ if ( ! documentValue ) {
107+ return null ;
108+ }
109+
110+ if ( typeof documentValue === "string" ) {
111+ if ( cache . has ( documentValue ) ) {
112+ return cache . get ( documentValue ) ?? null ;
113+ }
114+
115+ try {
116+ const documentRecord = await payload . findByID ( {
117+ collection : "documents" ,
118+ id : documentValue ,
119+ depth : 1 ,
120+ } ) ;
121+
122+ cache . set ( documentValue , documentRecord ) ;
123+ return documentRecord ;
124+ } catch ( error ) {
125+ console . warn ( "meedan-sync:: Failed to resolve document" , {
126+ documentId : documentValue ,
127+ error,
128+ } ) ;
129+ }
130+
131+ cache . set ( documentValue , null ) ;
132+ return null ;
133+ }
134+
135+ return documentValue ;
136+ } ;
137+ } ;
138+
139+ const resolvePoliticalEntityIdFromExtractions = async (
140+ payload : PayloadClient ,
141+ meedanId : string ,
142+ resolveDocument : (
143+ documentValue : PayloadAiExtraction [ "document" ]
144+ ) => Promise < PayloadDocument | null >
145+ ) : Promise < string | null > => {
146+ const { docs } = await payload . find ( {
147+ collection : "ai-extractions" ,
148+ limit : - 1 ,
149+ depth : 2 ,
150+ } ) ;
151+
152+ for ( const extractionDoc of docs ?? [ ] ) {
153+ const documentRecord = await resolveDocument ( extractionDoc . document ) ;
154+ const politicalEntityId = documentRecord
155+ ? getRelationId ( documentRecord . politicalEntity )
156+ : null ;
157+
158+ if ( ! politicalEntityId ) {
159+ continue ;
160+ }
161+
162+ for ( const extraction of extractionDoc . extractions ?? [ ] ) {
163+ const checkMediaId = normaliseString ( extraction ?. checkMediaId ?? null ) ;
164+ if ( checkMediaId && checkMediaId === meedanId ) {
165+ return politicalEntityId ;
166+ }
167+ }
168+ }
169+
170+ return null ;
171+ } ;
172+
73173export const POST = async ( request : NextRequest ) => {
74174 const configuredSecret = process . env [ WEBHOOK_SECRET_ENV_KEY ] ;
75175
@@ -94,7 +194,7 @@ export const POST = async (request: NextRequest) => {
94194 let parsed : MeedanWebhookPayload ;
95195
96196 try {
97- parsed = ( await request . json ( ) ) as MeedanWebhookPayload ;
197+ parsed = await request . json ( ) ;
98198 } catch ( _error ) {
99199 const message = "meedan-sync:: Failed to parse JSON payload" ;
100200
@@ -106,6 +206,10 @@ export const POST = async (request: NextRequest) => {
106206 `meedan-sync:: Received webhook payload ${ JSON . stringify ( parsed ) } ` ,
107207 "info"
108208 ) ;
209+ const annotationType = parsed ?. object ?. annotation_type ?. trim ( ) ;
210+ if ( annotationType && annotationType !== "report_design" ) {
211+ return NextResponse . json ( { ok : true , skipped : true } , { status : 200 } ) ;
212+ }
109213 const meedanId = normaliseString ( parsed ?. data ?. id ) ;
110214
111215 if ( ! meedanId ) {
@@ -121,21 +225,39 @@ export const POST = async (request: NextRequest) => {
121225 ) ;
122226 }
123227
228+ const annotationData = parsed ?. object ?. data ;
229+ const options = annotationData ?. options ;
230+ const publishState = normaliseString ( annotationData ?. state ) ;
231+ const title = normaliseString ( options ?. title ) ;
232+ const description =
233+ normaliseString ( options ?. description ) ??
234+ normaliseString ( options ?. text ?? null ) ;
235+ const url = normaliseString ( options ?. published_article_url ) ;
236+ const headline = normaliseString ( options ?. headline ) ;
237+ const fieldValue = normaliseString (
238+ annotationData ?. fields ?. [ 0 ] ?. value ?? null
239+ ) ;
124240 const imageUrl = normaliseString ( parsed ?. object ?. file ?. [ 0 ] ?. url ?? null ) ;
125241
126- if ( ! imageUrl ) {
127- const message = "meedan-sync:: Missing image URL" ;
128-
129- console . error ( message ) ;
130- Sentry . captureMessage ( message , "error" ) ;
131- return NextResponse . json (
132- { error : "No image URL provided" } ,
133- { status : 200 }
134- ) ;
135- }
136-
137242 try {
138243 const payload = await getGlobalPayload ( ) ;
244+ const resolveDocumentRecord = createDocumentResolver ( payload ) ;
245+ let cachedExtractionPoliticalEntityId : string | null | undefined ;
246+
247+ const getExtractionPoliticalEntityId = async ( ) => {
248+ if ( cachedExtractionPoliticalEntityId !== undefined ) {
249+ return cachedExtractionPoliticalEntityId ;
250+ }
251+
252+ cachedExtractionPoliticalEntityId =
253+ ( await resolvePoliticalEntityIdFromExtractions (
254+ payload ,
255+ meedanId ,
256+ resolveDocumentRecord
257+ ) ) ?? null ;
258+
259+ return cachedExtractionPoliticalEntityId ;
260+ } ;
139261
140262 const { docs } = await payload . find ( {
141263 collection : "promises" ,
@@ -147,20 +269,77 @@ export const POST = async (request: NextRequest) => {
147269 } ,
148270 } ) ;
149271
150- const promise = ( docs [ 0 ] ?? null ) as PromiseDoc | null ;
272+ let promise : PayloadPromise | null = docs [ 0 ] ?? null ;
273+ let created = false ;
274+ let updated = false ;
275+
276+ const hasTextPayload = Boolean ( title || description || url ) ;
151277
152278 if ( ! promise ) {
153- const message = "meedan-sync:: Promise not found" ;
279+ if ( ! hasTextPayload ) {
280+ const message =
281+ "meedan-sync:: Promise not found and payload missing content" ;
282+
283+ console . error ( message ) ;
284+ Sentry . captureMessage ( message , "error" ) ;
285+ return NextResponse . json (
286+ { error : "Promise not found" } ,
287+ { status : 404 }
288+ ) ;
289+ }
290+
291+ const extractionPoliticalEntityId =
292+ await getExtractionPoliticalEntityId ( ) ;
293+ promise = await payload . create ( {
294+ collection : "promises" ,
295+ data : {
296+ title,
297+ description,
298+ url,
299+ publishStatus : publishState ,
300+ meedanId,
301+ politicalEntity : extractionPoliticalEntityId ,
302+ } ,
303+ } ) ;
304+
305+ created = true ;
306+ } else {
307+ const updateData : Partial < PayloadPromise > = { } ;
308+ if ( title ) updateData . title = title ;
309+ if ( description ) updateData . description = description ;
310+ if ( url ) updateData . url = url ;
311+ if ( publishState ) updateData . publishStatus = publishState ;
312+
313+ if ( Object . keys ( updateData ) . length > 0 ) {
314+ promise = await payload . update ( {
315+ collection : "promises" ,
316+ id : promise . id ,
317+ data : updateData ,
318+ } ) ;
319+ updated = true ;
320+ }
321+ }
322+
323+ if ( ! promise ) {
324+ const message = "meedan-sync:: Failed to persist promise record" ;
154325
155326 console . error ( message ) ;
156327 Sentry . captureMessage ( message , "error" ) ;
157- return NextResponse . json ( { error : "Promise not found" } , { status : 404 } ) ;
328+ return NextResponse . json (
329+ { error : "Failed to persist promise" } ,
330+ { status : 500 }
331+ ) ;
332+ }
333+
334+ if ( ! imageUrl ) {
335+ return NextResponse . json ( { ok : true , created, updated } , { status : 200 } ) ;
158336 }
159337
160338 const fallbackAlt =
161- (
162- parsed ?. object ?. data ?. fields ?. [ 0 ] ?. value as string | undefined
163- ) ?. trim ( ) ??
339+ headline ??
340+ title ??
341+ description ??
342+ fieldValue ??
164343 promise . title ?. trim ( ) ??
165344 promise . description ?. trim ( ) ??
166345 "Meedan promise image" ;
@@ -170,7 +349,7 @@ export const POST = async (request: NextRequest) => {
170349 try {
171350 filePath = await downloadFile ( imageUrl ) ;
172351
173- const existingImageId = getRelationId ( promise . image as unknown ) ;
352+ const existingImageId = getRelationId ( promise . image ) ;
174353 let mediaId = existingImageId ;
175354
176355 if ( existingImageId ) {
@@ -183,22 +362,22 @@ export const POST = async (request: NextRequest) => {
183362 filePath,
184363 } ) ;
185364 } else {
186- const media = ( await payload . create ( {
365+ const media = await payload . create ( {
187366 collection : "media" ,
188367 data : {
189368 alt : fallbackAlt ,
190369 } ,
191370 filePath,
192- } ) ) as Media ;
371+ } ) ;
193372
194- mediaId = getRelationId ( media as unknown ) ;
373+ mediaId = getRelationId ( media ) ;
195374 }
196375
197376 if ( ! mediaId ) {
198377 Sentry . withScope ( ( scope ) => {
199378 scope . setTag ( "route" , "meedan-sync" ) ;
200379 scope . setContext ( "promise" , {
201- id : promise . id ,
380+ id : promise ? .id ,
202381 existingImageId,
203382 meedanId,
204383 imageUrl,
@@ -222,7 +401,10 @@ export const POST = async (request: NextRequest) => {
222401 } ,
223402 } ) ;
224403
225- return NextResponse . json ( { ok : true , updated : true } , { status : 200 } ) ;
404+ return NextResponse . json (
405+ { ok : true , created, updated : true } ,
406+ { status : 200 }
407+ ) ;
226408 } finally {
227409 if ( filePath ) {
228410 try {
0 commit comments