Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/app/(frontend)/api/editorial/reclassify-posts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import config from '@payload-config'
import { headers } from 'next/headers'
import { createLocalReq, getPayload } from 'payload'

import { classifyExistingPost, curatedPostRubrics } from '@/editorial/postReclassification'

type RequestBody = {
action?: 'preview' | 'apply'
approvals?: { id: number; revision: string }[]
}

export async function POST(request: Request): Promise<Response> {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return Response.json({ error: 'Connexion Payload requise.' }, { status: 403 })

let body: RequestBody
try {
body = (await request.json()) as RequestBody
} catch {
return Response.json({ error: 'Requête JSON invalide.' }, { status: 400 })
}
if (body.action !== 'preview' && body.action !== 'apply') {
return Response.json({ error: 'Action invalide.' }, { status: 400 })
}
const req = await createLocalReq({ user }, payload)

try {
const categories = await payload.find({
collection: 'categories',
req,
overrideAccess: false,
locale: 'fr',
fallbackLocale: false,
depth: 0,
limit: 100,
pagination: false,
})
const categoriesBySlug = new Map(categories.docs.map((cat) => [
cat.slug,
{ id: cat.id, slug: cat.slug },
]))

const result = await payload.find({
collection: 'posts',
req,
overrideAccess: false,
locale: 'fr',
fallbackLocale: false,
draft: false,
depth: 1,
limit: 200,
pagination: false,
sort: 'id',
})

const rows = result.docs.map((post) => {
const current = (post.categories || []).flatMap((cat) =>
typeof cat === 'object' && cat !== null && typeof cat.slug === 'string'
? [{ id: cat.id, slug: cat.slug }]
: [],
)
const planned = classifyExistingPost(post.slug, current, categoriesBySlug)
return {
id: post.id,
title: post.title,
slug: post.slug,
revision: post.updatedAt,
published: post._status === 'published',
previous: current.map((cat) => cat.slug),
status: planned.status,
target: 'rubric' in planned ? planned.rubric?.id ?? null : null,
categories: 'categories' in planned ? planned.categories : null,
}
})

if (body.action === 'preview') {
return Response.json({
rows,
counts: {
changes: rows.filter((row) => row.status === 'change').length,
unchanged: rows.filter((row) => row.status === 'unchanged').length,
unlisted: rows.filter((row) => row.status === 'unlisted').length,
missing: rows.filter((row) => row.status === 'missing-category').length,
},
message: 'Aperçu uniquement : aucun article modifié.',
})
}

const approvals = body.approvals
if (!Array.isArray(approvals) || approvals.length === 0 || approvals.length > 200) {
return Response.json({ error: 'Prévisualise d’abord les articles.' }, { status: 400 })
}
if (new Set(approvals.map((item) => item.id)).size !== approvals.length ||
approvals.some((item) => !Number.isInteger(item.id) || typeof item.revision !== 'string')) {
return Response.json({ error: 'Sélection invalide.' }, { status: 400 })
}

const approved = new Map(approvals.map((item) => [item.id, item.revision]))
const results: { id: number; slug: string; result: string }[] = []
for (const row of rows) {
if (!approved.has(row.id)) continue
if (!(row.slug in curatedPostRubrics) || row.revision !== approved.get(row.id)) {
results.push({ id: row.id, slug: row.slug, result: 'skipped-revision-or-unlisted' })
continue
}
if (row.status !== 'change' || !row.categories) {
results.push({ id: row.id, slug: row.slug, result: row.status })
continue
}

// Check again immediately before writing: editors might be working while
// the batch is running. Only the category relationship is updated.
const latest = await payload.findByID({
collection: 'posts',
id: row.id,
req,
overrideAccess: false,
locale: 'fr',
fallbackLocale: false,
depth: 1,
draft: false,
})
if (latest.slug !== row.slug || latest.updatedAt !== row.revision) {
results.push({ id: row.id, slug: row.slug, result: 'skipped-concurrent-edit' })
continue
}

await payload.update({
collection: 'posts',
id: row.id,
req,
overrideAccess: false,
locale: 'fr',
fallbackLocale: false,
draft: false,
depth: 0,
data: { categories: row.categories },
context: { disableIndexNow: true },
})
results.push({ id: row.id, slug: row.slug, result: 'updated' })
}

return Response.json({
results,
message: `${results.filter((item) => item.result === 'updated').length} articles reclassés. Les anciennes catégories et les statuts de publication sont conservés.`,
})
} catch (error) {
payload.logger.error({ err: error, message: 'Curated editorial category migration failed' })
return Response.json({ error: 'Reclassement interrompu. Réessaie l’aperçu.' }, { status: 500 })
}
}
2 changes: 2 additions & 0 deletions src/app/(payload)/admin/importMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { RowLabel as RowLabel_1f6ff6ff633e3695d348f4f3c58f1466 } from '@/Footer/
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
import { NewsRadarNavLink as NewsRadarNavLink_8d91a43e5c2a4ab5 } from '@/components/NewsRadarNavLink'
import { SetupRubricsNavControl } from '@/components/SetupRubricsNavControl'
import { ReclassifyPostsNavControl } from '@/components/ReclassifyPostsNavControl'
import { VercelBlobClientUploadHandler as VercelBlobClientUploadHandler_16c82c5e25f430251a3e3ba57219ff4e } from '@payloadcms/storage-vercel-blob/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'

Expand Down Expand Up @@ -61,6 +62,7 @@ export const importMap = {
"@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e,
"@/components/NewsRadarNavLink#NewsRadarNavLink": NewsRadarNavLink_8d91a43e5c2a4ab5,
"@/components/SetupRubricsNavControl#SetupRubricsNavControl": SetupRubricsNavControl,
"@/components/ReclassifyPostsNavControl#ReclassifyPostsNavControl": ReclassifyPostsNavControl,
"@payloadcms/storage-vercel-blob/client#VercelBlobClientUploadHandler": VercelBlobClientUploadHandler_16c82c5e25f430251a3e3ba57219ff4e,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
92 changes: 92 additions & 0 deletions src/components/ReclassifyPostsNavControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use client'

import { useState } from 'react'

type PreviewRow = {
id: number
title: string
slug: string
revision: string
published: boolean
previous: string[]
status: string
target: string | null
categories: number[] | null
}

export function ReclassifyPostsNavControl() {
const [busy, setBusy] = useState(false)
const [rows, setRows] = useState<PreviewRow[] | null>(null)
const [message, setMessage] = useState('')
const [error, setError] = useState('')

async function call(action: 'preview' | 'apply') {
setBusy(true)
setError('')
try {
const changes = (rows || []).filter((row) => row.status === 'change')
const response = await fetch('/api/editorial/reclassify-posts', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action,
...(action === 'apply'
? { approvals: changes.map(({ id, revision }) => ({ id, revision })) }
: {}),
}),
})
const body = await response.json()
if (!response.ok) {
setError(body.error || 'Échec du reclassement.')
return
}
if (action === 'preview') {
setRows(body.rows || [])
setMessage(body.message || '')
} else {
setRows(null)
setMessage(body.message || 'Reclassement terminé.')
}
} catch {
setError('Impossible de joindre le serveur.')
} finally {
setBusy(false)
}
}

const changes = rows?.filter((row) => row.status === 'change') || []

return (
<div style={{ padding: '12px', fontSize: 12, borderTop: '1px solid #393939' }}>
<button disabled={busy} onClick={() => void call('preview')} type="button" style={{ textAlign: 'left', fontWeight: 650 }}>
{busy ? 'Chargement…' : '✦ Prévisualiser le reclassement'}
</button>
{rows && (
<div style={{ marginTop: 10 }}>
<p>{changes.length} articles à reclasser sur {rows.length} (publiés et brouillons).</p>
<div style={{ maxHeight: 250, overflowY: 'auto', marginTop: 8 }}>
{changes.map((row) => (
<div key={row.id} style={{ padding: '6px 0', borderBottom: '1px solid #393939' }}>
<strong>{row.title}</strong>
<div>{row.published ? 'Publié' : 'Brouillon'} · {row.target} · anciennes catégories conservées</div>
</div>
))}
</div>
{changes.length > 0 && (
<button
disabled={busy}
onClick={() => void call('apply')}
style={{ marginTop: 12, padding: '8px 10px', background: '#e07520', color: '#171717', borderRadius: 5, fontWeight: 700 }}
type="button"
>
Confirmer le reclassement de {changes.length} articles
</button>
)}
</div>
)}
{message && <p aria-live="polite" style={{ marginTop: 8 }}>{message}</p>}
{error && <p role="alert" style={{ marginTop: 8, color: '#ff9a9a' }}>{error}</p>}
</div>
)
}
10 changes: 6 additions & 4 deletions src/components/insights/InsightsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Link from 'next/link'
import React from 'react'

import { NewsletterForm } from './NewsletterForm'
import { editorialRubrics, localizedRubricPath, rubricMatchesCategory, type EditorialRubricId } from '@/editorial/rubrics'
import { editorialRubrics, localizedRubricPath, postBelongsToRubric, type EditorialRubricId } from '@/editorial/rubrics'
import { ArticleImpressionBoundary } from '@/components/analytics/ArticleImpressionBoundary'
import { TrackedArticleLink } from '@/components/analytics/TrackedArticleLink'
import type { ArticlePlacement } from '@/lib/analytics/events'
Expand Down Expand Up @@ -66,9 +66,11 @@ function categorySlug(post: Post) {
}

function postMatchesChannel(post: Post, channel: EditorialRubricId) {
return post.categories?.some(
(item) => Boolean(item && typeof item === 'object' && rubricMatchesCategory(channel, item.slug)),
) ?? false
const slugs = (post.categories || [])
.filter((item) => item && typeof item === 'object')
.map((item) => (typeof item === 'object' && item ? item.slug : ''))
.filter((slug): slug is string => Boolean(slug))
return postBelongsToRubric(channel, slugs)
}

function ArticleCardOverlay({
Expand Down
11 changes: 9 additions & 2 deletions src/components/insights/LocalizedDeskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getPayload } from 'payload'
import { CollectionArchive } from '@/components/CollectionArchive'
import type { CardPostData } from '@/components/Card'
import type { SiteLocale } from '@/i18n/config'
import { getEditorialRubric, localizedRubricPath, type EditorialRubricId } from '@/editorial/rubrics'
import { getEditorialRubric, localizedRubricPath, postBelongsToRubric, type EditorialRubricId } from '@/editorial/rubrics'
import { absoluteCanonicalURL, buildBreadcrumbJsonLd, serializeJsonLd } from '@/seo/structuredData'

export async function LocalizedDeskPage({
Expand Down Expand Up @@ -55,7 +55,14 @@ export async function LocalizedDeskPage({
},
select: { title: true, slug: true, categories: true, meta: true },
})
posts = result.docs.filter((post) => Boolean(post.title && post.slug))
posts = result.docs.filter((post) => {
if (!post.title || !post.slug) return false
const slugs = (post.categories || [])
.filter((cat) => cat && typeof cat === 'object')
.map((cat) => (typeof cat === 'object' && cat ? cat.slug : ''))
.filter((slug): slug is string => Boolean(slug))
return postBelongsToRubric(desk, slugs)
})
}

const breadcrumbJsonLd = buildBreadcrumbJsonLd([
Expand Down
61 changes: 61 additions & 0 deletions src/editorial/postReclassification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { editorialRubrics, type EditorialRubricId } from '@/editorial/rubrics'

/**
* Human-reviewed assignment for the inventory observed on 12 October 2026.
* Never derive editorial classification from keywords: this file is intentionally
* explicit, reviewable and versioned. Unlisted posts are skipped.
*/
export const curatedPostRubrics: Record<string, EditorialRubricId> = {
'pipeline-ia-multi-agents-quatre-heures': 'tech-ai',
'42-milliards-fcfa-data-center-ia-douala-cameroun': 'tech-ai',
'cemac-qr-code-interoperable-paiements-adoption': 'business-finance',
'cameroun-systemes-publics-interoperabilite-architecture': 'society-future',
'afrique-financement-startups-2026-reprise-inegale': 'business-finance',
'nous-apprenons-ia-a-envers': 'tech-ai',
'fc-27-prix-performances-et-achat-avant-la-sortie': 'gaming-culture',
'whisp-wetransfer-swisstransfer-cameroun': 'made-in-africa',
'cfao-mobility-cameroun-ventes-benefice-marge-2026': 'business-finance',
'freellmapi-claude-code-free-tiers-llm-router': 'tech-ai',
'cloudflare-containers-isolation-multi-tenant-faille': 'tech-ai',
'dangote-lamu-raffinerie-kenya-ecosysteme': 'business-finance',
'kempinski-douala-220-milliards-opportunites-ecosysteme': 'business-finance',
'internet-cameroun-ou-partent-nos-donnees': 'tech-ai',
'ia-afrique-peut-elle-vraiment-decider': 'society-future',
'nvidia-vraie-force-pas-seulement-puces-cuda': 'tech-ai',
'stablecoins-franc-cfa-menace-cemac': 'business-finance',
'cits-2026-cameroun-tech-africaine': 'tech-ai',
'transfert-argent-cameroun-afrique-europe-monde': 'business-finance',
'driivio-cameroun-code-permis-conduire-prix': 'made-in-africa',
'morena-ia-langues-africaines-cameroun': 'tech-ai',
}

export type CategoryReference = { id: number; slug: string }

export function orderedCategoryIds(
current: CategoryReference[],
newPrimary: CategoryReference,
): number[] {
return [newPrimary.id, ...current.filter((c) => c.id !== newPrimary.id).map((c) => c.id)]
}

export function classificationForSlug(slug: string) {
const id = curatedPostRubrics[slug]
if (!id) return null
return editorialRubrics.find((item) => item.id === id) ?? null
}

export function classifyExistingPost(
slug: string,
current: CategoryReference[],
categoriesBySlug: Map<string, CategoryReference>,
) {
const rubric = classificationForSlug(slug)
if (!rubric) return { status: 'unlisted' as const }
const primary = categoriesBySlug.get(rubric.id)
if (!primary) return { status: 'missing-category' as const, rubric }
const all = orderedCategoryIds(current, primary)
if (current.map((c) => c.id).join(',') === all.join(',')) {
return { status: 'unchanged' as const, rubric, categories: all }
}
return { status: 'change' as const, rubric, categories: all }
}
10 changes: 10 additions & 0 deletions src/editorial/rubrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,13 @@ export function localizedSectionSwitchPath(pathname: string, target: SiteLocale)
)
return rubric ? localizedRubricPath(rubric, target) : null
}

/**
* An explicit canonical category supersedes legacy aliases. Without one, older
* posts remain discoverable via legacy categories until reviewed in Payload.
*/
export function postBelongsToRubric(id: EditorialRubricId, slugs: readonly string[]) {
const canonical = editorialRubrics.find((rubric) => slugs.includes(rubric.id))
if (canonical) return canonical.id === id
return slugs.some((slug) => rubricMatchesCategory(id, slug))
}
2 changes: 1 addition & 1 deletion src/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default buildConfig({
// The `BeforeLogin` component renders a message that you see while logging into your admin panel.
// Feel free to delete this at any time. Simply remove the line below.
beforeLogin: ['@/components/BeforeLogin'],
afterNavLinks: ['@/components/NewsRadarNavLink#NewsRadarNavLink', '@/components/SetupRubricsNavControl#SetupRubricsNavControl'],
afterNavLinks: ['@/components/NewsRadarNavLink#NewsRadarNavLink', '@/components/SetupRubricsNavControl#SetupRubricsNavControl', '@/components/ReclassifyPostsNavControl#ReclassifyPostsNavControl'],
},
importMap: {
baseDir: path.resolve(dirname),
Expand Down
Loading
Loading