Skip to content

Commit 940e536

Browse files
Merge pull request #501 from CodeForAfrica/further-fixes
feat(promises): add publish status filtering to promise queries
2 parents e6759d9 + c626691 commit 940e536

File tree

10 files changed

+292
-51
lines changed

10 files changed

+292
-51
lines changed

src/app/api/meedan-sync/route.ts

Lines changed: 212 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import { unlink } from "node:fs/promises";
44
import * as Sentry from "@sentry/nextjs";
55

66
import { getGlobalPayload } from "@/lib/payload";
7-
import type { Media, Promise as PromiseDoc } from "@/payload-types";
87
import { downloadFile } from "@/utils/files";
8+
import type {
9+
Promise as PayloadPromise,
10+
Document as PayloadDocument,
11+
AiExtraction as PayloadAiExtraction,
12+
} from "@/payload-types";
913

1014
const WEBHOOK_SECRET_ENV_KEY = "WEBHOOK_SECRET_KEY";
15+
type PayloadClient = Awaited<ReturnType<typeof getGlobalPayload>>;
1116

1217
type 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+
2040
type 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+
4572
const 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+
73173
export 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 {

src/components/Hero/index.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,18 @@ export const Hero = async ({ entitySlug, ...block }: HeroProps) => {
292292
const { docs: promiseDocs } = await payload.find({
293293
collection: "promises",
294294
where: {
295-
politicalEntity: {
296-
equals: entity.id,
297-
},
295+
and: [
296+
{
297+
politicalEntity: {
298+
equals: entity.id,
299+
},
300+
},
301+
{
302+
publishStatus: {
303+
equals: "published",
304+
},
305+
},
306+
],
298307
},
299308
limit: -1,
300309
depth: 1,

0 commit comments

Comments
 (0)