diff --git a/src/app/(frontend)/api/editorial/reclassify-posts/route.ts b/src/app/(frontend)/api/editorial/reclassify-posts/route.ts new file mode 100644 index 0000000..e20bd51 --- /dev/null +++ b/src/app/(frontend)/api/editorial/reclassify-posts/route.ts @@ -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 { + 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 }) + } +} diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 6903f27..26c2176 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -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' @@ -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 } diff --git a/src/components/ReclassifyPostsNavControl.tsx b/src/components/ReclassifyPostsNavControl.tsx new file mode 100644 index 0000000..241cc9d --- /dev/null +++ b/src/components/ReclassifyPostsNavControl.tsx @@ -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(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 ( +
+ + {rows && ( +
+

{changes.length} articles à reclasser sur {rows.length} (publiés et brouillons).

+
+ {changes.map((row) => ( +
+ {row.title} +
{row.published ? 'Publié' : 'Brouillon'} · {row.target} · anciennes catégories conservées
+
+ ))} +
+ {changes.length > 0 && ( + + )} +
+ )} + {message &&

{message}

} + {error &&

{error}

} +
+ ) +} diff --git a/src/components/insights/InsightsHome.tsx b/src/components/insights/InsightsHome.tsx index f1aa288..0af2b6c 100644 --- a/src/components/insights/InsightsHome.tsx +++ b/src/components/insights/InsightsHome.tsx @@ -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' @@ -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({ diff --git a/src/components/insights/LocalizedDeskPage.tsx b/src/components/insights/LocalizedDeskPage.tsx index 186b088..526e077 100644 --- a/src/components/insights/LocalizedDeskPage.tsx +++ b/src/components/insights/LocalizedDeskPage.tsx @@ -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({ @@ -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([ diff --git a/src/editorial/postReclassification.ts b/src/editorial/postReclassification.ts new file mode 100644 index 0000000..b52df39 --- /dev/null +++ b/src/editorial/postReclassification.ts @@ -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 = { + '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, +) { + 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 } +} diff --git a/src/editorial/rubrics.ts b/src/editorial/rubrics.ts index 3587287..1267c01 100644 --- a/src/editorial/rubrics.ts +++ b/src/editorial/rubrics.ts @@ -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)) +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 0a73e80..a91e02c 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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), diff --git a/tests/unit/postReclassification.spec.ts b/tests/unit/postReclassification.spec.ts new file mode 100644 index 0000000..3518981 --- /dev/null +++ b/tests/unit/postReclassification.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { postBelongsToRubric } from '@/editorial/rubrics' +import { + classifyExistingPost, + classificationForSlug, + curatedPostRubrics, + orderedCategoryIds, +} from '@/editorial/postReclassification' + +describe('Curated legacy-safe editorial post reclassification', () => { + it('does not cross-list a reviewed gaming story under its legacy technology/business labels', () => { + const labels = ['gaming-culture', 'technology', 'business'] + expect(postBelongsToRubric('gaming-culture', labels)).toBe(true) + expect(postBelongsToRubric('tech-ai', labels)).toBe(false) + expect(postBelongsToRubric('business-finance', labels)).toBe(false) + expect(postBelongsToRubric('tech-ai', ['technology', 'business'])).toBe(true) + expect(postBelongsToRubric('business-finance', ['technology', 'business'])).toBe(true) + }) + + it('has one reviewed canonical primary section for every inventoried article', () => { + expect(Object.keys(curatedPostRubrics)).toHaveLength(21) + expect(classificationForSlug('fc-27-prix-performances-et-achat-avant-la-sortie')?.id).toBe('gaming-culture') + expect(classificationForSlug('whisp-wetransfer-swisstransfer-cameroun')?.id).toBe('made-in-africa') + expect(classificationForSlug('driivio-cameroun-code-permis-conduire-prix')?.id).toBe('made-in-africa') + expect(classificationForSlug('transfert-argent-cameroun-afrique-europe-monde')?.id).toBe('business-finance') + expect(classificationForSlug('unknown-future-article')).toBeNull() + }) + + it('prepends new primary without dropping or duplicating legacy references', () => { + expect(orderedCategoryIds([{ id: 1, slug: 'technology' }, { id: 2, slug: 'business' }], { id: 8, slug: 'gaming-culture' })) + .toEqual([8, 1, 2]) + expect(orderedCategoryIds([{ id: 8, slug: 'gaming-culture' }, { id: 1, slug: 'technology' }], { id: 8, slug: 'gaming-culture' })) + .toEqual([8, 1]) + }) + + it('requires new category installation and is idempotent', () => { + const original = [{ id: 1, slug: 'technology' }, { id: 2, slug: 'business' }] + const missing = classifyExistingPost('fc-27-prix-performances-et-achat-avant-la-sortie', original, new Map()) + expect(missing.status).toBe('missing-category') + const known = new Map([['gaming-culture', { id: 8, slug: 'gaming-culture' }]]) + const planned = classifyExistingPost('fc-27-prix-performances-et-achat-avant-la-sortie', original, known) + expect(planned).toMatchObject({ status: 'change', categories: [8, 1, 2] }) + const second = classifyExistingPost( + 'fc-27-prix-performances-et-achat-avant-la-sortie', + [{ id: 8, slug: 'gaming-culture' }, ...original], known, + ) + expect(second).toMatchObject({ status: 'unchanged', categories: [8, 1, 2] }) + }) +})