Compare commits

...

3 commits

28 changed files with 1633 additions and 159 deletions

1
.gitignore vendored
View file

@ -8,6 +8,7 @@
/.direnv
/.env.local
/.env.production
TODO.md
# Build artifacts
tsconfig.tsbuildinfo

1
.npmrc
View file

@ -1 +0,0 @@
legacy-peer-deps=true

92
TODO.md
View file

@ -1,92 +0,0 @@
# Roadmap Débats.co
## En cours
- [ ] **Pérenniser le mécanisme dajout de contenu depuis Claude** — le script `import:content` atteint ses limites. Réfléchir au meilleur moyen dalimenter le site efficacement sans tout renvoyer à chaque fois. Un skill qui utilise lAPI de production ?
## Prochaines priorités
- [ ] **Page détail prises de position** — route `/p/[slug]/s/[subjectSlug]` pour afficher les prises de position dune personnalité sur un sujet
- [ ] **Page daccueil : Limiter le nombre de personnalités actives (thumbnails) sous chaque sujet**
- [ ] **Ajouter un évènement Plausible quon on utilise la recherche de personnalité**
- [ ] **Édition du profil utilisateur** — permettre de modifier nom, email, mot de passe depuis `/me` (le dashboard existe déjà)
- [ ] **Arguments (CRUD + liaison aux statements)** — créer, lister, associer des arguments aux prises de position (tables existantes en DB, aucune UI)
- [ ] **CRUD complet personnalités (edit/delete)** — use cases `updatePublicFigure` / `deletePublicFigure` + UI (actuellement Create + Read seulement)
- [ ] **Pagination** — paginer les listes sujets et personnalités (tout est chargé en une fois actuellement)
- [ ] Page détail sujet selon maquette `PAGE SUJET.png`
- [ ] Page d'accueil selon maquette `ACCUEIL.png`
## Idées & souhaits
### Fonctionnalités — priorité moyenne
- [ ] **Optimiser les meta inline (dates, json+ld, …)**
- [ ] **Afficher «une personnnalité au hasard» en haut de la page /p**
- [ ] **Afficher «un sujet au hasard» en haut de la page /s**
- [ ] **Lister Personnalités et sujets sans statements dans la page `Contribuer`**
- [ ] **Notion dorganisation** - Des organisations peuvent prendre positions sur des sujets
- [ ] **Mettre des logos sur les domaines connus pour les sources**
- [ ] **Masquer les indications dans les formulaires de saisie une fois que lutilisateur a créé suffisamment de contenu**
- [ ] **Sujet connexes** - Pouvoir indiquer/lier que 2 sujets sont connexes.
- [ ] **Upload fichier pour les preuves** — accepter PDF, images, audio/vidéo en plus des URLs (Supabase Storage)
- [ ] **Upload photo pour les sujets** — exposer le champ `picture_url` dans le formulaire de création/édition
- [ ] **Historique des slugs / redirections** — rediriger les anciens slugs après renommage (301) pour ne pas casser les liens
- [ ] **Notifications flash** — système de messages globaux (succès, info, erreur) après chaque action
- [ ] **Reset mot de passe** — flow "mot de passe oublié" via Supabase Auth (formulaire + email + page de reset)
- [ ] Recherche et filtres sur sujets et personnalités
- [ ] Timeline des positions d'une personnalité sur un sujet (évolution dans le temps)
### Fonctionnalités — priorité basse
- [ ] **Formulaire embarqué page personnalité** — ajouter une prise de position directement depuis `/p/[slug]` avec autocomplete sujet
- [ ] **Emails personnalisés (branding Débats.co)** — templates HTML pour les emails transactionnels Supabase
- [ ] **Inscription libre avec activation par email** — en complément du système d'invitation actuel
- [ ] **Infrastructure i18n** — préparer l'internationalisation (pas bloquant pour un site francophone)
- [ ] Notifications real-time (Supabase Realtime)
- [ ] Système de votes sur les arguments
### Qualité & technique
- [ ] Tests e2e avec Playwright
- [ ] SEO et meta tags OpenGraph par page
- [ ] Accessibilité (audit WCAG)
## Contenu à ajouter
- [ ] Alimenter les sujets présidentielle 2022 (plan dans memory)
- [ ] **Sujet Gaza** — ajouter le sujet sur la guerre à Gaza (intitulé exact à préciser) avec positions et personnalités
- [ ] **Import nosdeputes.fr** — explorer l'API de nosdeputes.fr pour extraire les prises de position des députés (votes, interventions, propositions de loi) et les mapper sur nos sujets
## Fait
- [x] Issues Sentry — 2 issues analysées (bug Node.js streams + erreur réseau isolée), ignorées car non applicatives
- [x] Bug recherche sujet en prod — downshift remplace les types string par des nombres en production
- [x] Bug scroll index `/p` — scroll remontait en haut avec l'index alphabétique
- [x] PWA — application installable sur smartphone (manifest, icônes, meta tags)
- [x] Refonte page personnalités `/p` avec compteurs, recherche, index A-Z
- [x] Fix notoriety gap dans `create-public-figure-with-statement`
- [x] Analytics Plausible
- [x] `wikipedia_url` rendu optionnel + `notoriety_sources` ajouté
- [x] UI édition et suppression des sujets
- [x] Page profil `/me` avec dashboard compact
- [x] Table `reputation_events` comme source de vérité
- [x] Page historique de réputation `/reputation`
- [x] Page détail personnalité `/p/[slug]` avec citation et source
- [x] Page personnalités `/p` avec liste et ajout
- [x] Page hub « Contribuer » avec lien dans le header
- [x] Formulaire unifié « Nouvelle prise de position » avec upload photo
- [x] Wizard « Nouvelle personnalité » avec premier statement obligatoire
- [x] Wizard « Nouvelle position » avec premier statement obligatoire
- [x] CRUD sujets (création, édition, suppression, pré-remplissage formulaire)
- [x] Système d'invitation par contributeurs Éloquent (1000+ pts)
- [x] Authentification Supabase (inscription, invitation, middleware session)
- [x] Monitoring erreurs Sentry
- [x] Migration Next.js 15 → 16 (Turbopack, ESLint 9)
- [x] Validation Wikipedia (vérifier page biographie existante)
- [x] Validation date de preuve (refuser dates futures)
- [x] Script d'import de contenu (`npm run import:content`)
- [x] Compteurs dynamiques depuis la DB
- [x] UI authentique recréée (header, footer, page `/s`, sidebar)
- [x] CSS Modules et design system Débats préservé
- [x] Architecture DDD/Clean avec Effect Schema
- [x] Setup Supabase avec tables de base et types générés
- [x] Migration Nx vers Next.js 15 standalone

View file

@ -0,0 +1,35 @@
'use server'
import { redirect } from 'next/navigation'
import { Either } from 'effect'
import { createAdminSupabaseClient } from '../../infra/supabase/admin'
import { createPositionRepository } from '../../infra/database/position-repository-supabase'
import { mergePositions } from '../../domain/use-cases/merge-positions'
import { getAdminContributor } from './admin-guard'
import type { ActionResult } from './validate-draft-action'
export async function mergePositionsAction(
sourcePositionId: string,
targetPositionId: string,
subjectSlug: string,
): Promise<ActionResult> {
const contributor = await getAdminContributor()
if (!contributor) {
return { success: false, error: 'Accès refusé.' }
}
const supabase = createAdminSupabaseClient()
const result = await mergePositions({
sourcePositionId,
targetPositionId,
contributor: { id: contributor.id, reputation: contributor.reputation },
positionRepo: createPositionRepository(supabase),
})
if (Either.isLeft(result)) {
return { success: false, error: result.left }
}
redirect(`/s/${subjectSlug}/position/${targetPositionId}`)
}

View file

@ -9,7 +9,6 @@ import { StatementWithFigure } from '../../../domain/repositories/statement-repo
import { isMajorSubject } from '../../../domain/entities/subject'
import { canPerform } from '../../../domain/reputation/permissions'
import { getAuthenticatedContributor } from '../../actions/get-authenticated-contributor'
import EditLink from '../../../components/ui/EditLink'
import FigureAvatar from '../../../components/figures/FigureAvatar'
import SubjectActions from '../../../components/subjects/SubjectActions'
import Button from '../../../components/ui/Button'
@ -99,7 +98,6 @@ export default async function SubjectDetailPage({ params }: PageProps) {
const canAddPosition = !!contributor && canPerform(contributor.reputation, 'add_position')
const canEditSubject = !!contributor && canPerform(contributor.reputation, 'edit_subject')
const canEditPosition = !!contributor && canPerform(contributor.reputation, 'edit_position')
const major = isMajorSubject(subject, stats.statementsCount)
const canDelete =
!!contributor &&
@ -161,10 +159,12 @@ export default async function SubjectDetailPage({ params }: PageProps) {
{positions.map(({ position, figures }) => (
<div key={position.id} className={styles.positionItem}>
<h3 className={styles.positionTitle}>
{position.title}
{canEditPosition && (
<EditLink href={`/s/${slug}/position/${position.id}/modifier`} />
)}
<Link
href={`/s/${slug}/position/${position.id}`}
className={styles.positionLink}
>
{position.title}
</Link>
</h3>
<p className={styles.positionDescription}>{position.description}</p>
<a href="#" className={styles.viewArguments}>

View file

@ -0,0 +1,32 @@
.container {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-family: var(--font-gotham-bold);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-light);
}
.select {
padding: 6px 8px;
border: 1px solid var(--border-light);
border-radius: 4px;
font-size: 12px;
font-family: inherit;
width: 100%;
}
.select:focus {
outline: none;
border-color: var(--debats-red);
}
.error {
font-size: 11px;
color: #c62828;
}

View file

@ -0,0 +1,76 @@
'use client'
import { useState, useCallback } from 'react'
import { isRedirectError } from 'next/dist/client/components/redirect-error'
import * as Sentry from '@sentry/nextjs'
import { mergePositionsAction } from '../../../../../actions/merge-positions-action'
import Button from '../../../../../../components/ui/Button'
import styles from './MergePositionForm.module.css'
interface PositionOption {
id: string
title: string
}
interface MergePositionFormProps {
sourcePositionId: string
subjectSlug: string
otherPositions: PositionOption[]
}
export default function MergePositionForm({
sourcePositionId,
subjectSlug,
otherPositions,
}: MergePositionFormProps) {
const [targetId, setTargetId] = useState('')
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<string>()
const handleMerge = useCallback(async () => {
if (!targetId) return
setError(undefined)
setIsPending(true)
try {
const result = await mergePositionsAction(sourcePositionId, targetId, subjectSlug)
// redirect() throws NEXT_REDIRECT, so we only reach here on business error
if (result && !result.success) {
setError(result.error)
setIsPending(false)
}
} catch (err: unknown) {
if (isRedirectError(err)) throw err
Sentry.captureException(err, { extra: { sourcePositionId, targetId } })
setError('Une erreur inattendue est survenue.')
setIsPending(false)
}
}, [sourcePositionId, subjectSlug, targetId])
if (otherPositions.length === 0) return null
return (
<div className={styles.container}>
<label className={styles.label} htmlFor="merge-target">
Fusionner vers
</label>
<select
id="merge-target"
className={styles.select}
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
>
<option value=""> Choisir </option>
{otherPositions.map((p) => (
<option key={p.id} value={p.id}>
{p.title}
</option>
))}
</select>
<Button variant="danger" size="small" onClick={handleMerge} disabled={!targetId || isPending}>
{isPending ? 'Fusion…' : 'Fusionner'}
</Button>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}

View file

@ -43,13 +43,13 @@ export default async function EditPositionPage({ params }: PageProps) {
if (!position) notFound()
if (position.subjectId !== subject.id) notFound()
const returnHref = `/s/${slug}`
const returnHref = `/s/${slug}/position/${positionId}`
return (
<ContentWithSidebar topMargin>
<FormPageHeader
backHref={returnHref}
backLabel="Retour au sujet"
backLabel="Retour à la position"
title="Modifier la position"
subtitle={`${position.title}${subject.title}`}
/>

View file

@ -0,0 +1,180 @@
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Effect } from 'effect'
import { createSSRSupabaseClient } from '../../../../../infra/supabase/ssr'
import { createSubjectRepository } from '../../../../../infra/database/subject-repository-supabase'
import { createPositionRepository } from '../../../../../infra/database/position-repository-supabase'
import { createStatementRepository } from '../../../../../infra/database/statement-repository-supabase'
import { canPerform } from '../../../../../domain/reputation/permissions'
import { getAuthenticatedContributor } from '../../../../actions/get-authenticated-contributor'
import FigureAvatar from '../../../../../components/figures/FigureAvatar'
import AdminMenu from '../../../../../components/ui/AdminMenu'
import Button from '../../../../../components/ui/Button'
import ContentWithSidebar from '../../../../../components/layout/ContentWithSidebar'
import ErrorDisplay from '../../../../../components/layout/ErrorDisplay'
import MergePositionForm from './MergePositionForm'
import styles from './position-detail.module.css'
interface PageProps {
params: Promise<{ slug: string; positionId: string }>
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug, positionId } = await params
try {
const supabase = await createSSRSupabaseClient()
const subjectRepo = createSubjectRepository(supabase)
const positionRepo = createPositionRepository(supabase)
const [subject, position] = await Promise.all([
Effect.runPromise(subjectRepo.findBySlug(slug)),
Effect.runPromise(positionRepo.findById(positionId)),
])
if (!subject || !position) return { title: 'Position introuvable' }
return {
title: `${position.title}${subject.title}`,
description: position.description,
}
} catch {
return { title: 'Position' }
}
}
export default async function PositionDetailPage({ params }: PageProps) {
const { slug, positionId } = await params
try {
const supabase = await createSSRSupabaseClient()
const subjectRepo = createSubjectRepository(supabase)
const positionRepo = createPositionRepository(supabase)
const statementRepo = createStatementRepository(supabase)
const [subject, position, contributor] = await Promise.all([
Effect.runPromise(subjectRepo.findBySlug(slug)),
Effect.runPromise(positionRepo.findById(positionId)),
getAuthenticatedContributor(),
])
if (!subject) notFound()
if (!position) notFound()
if (position.subjectId !== subject.id) notFound()
const positionStatements = await Effect.runPromise(
statementRepo.findByPositionIdWithFigures(positionId),
)
const allPositions = await Effect.runPromise(positionRepo.findBySubjectId(subject.id))
const otherPositions = allPositions
.filter((p) => p.id !== positionId)
.map((p) => ({ id: p.id, title: p.title }))
const canEdit = !!contributor && canPerform(contributor.reputation, 'edit_position')
const isAdmin = !!contributor && canPerform(contributor.reputation, 'admin')
return (
<ContentWithSidebar topMargin>
<nav className={styles.breadcrumb}>
<Link href={`/s/${slug}`} className={styles.breadcrumbLink}>
{subject.title}
</Link>
<span className={styles.breadcrumbSeparator}>/</span>
<span>{position.title}</span>
</nav>
<header className={styles.header}>
<div className={styles.titleRow}>
<h1 className={styles.title}>{position.title}</h1>
{(canEdit || isAdmin) && (
<AdminMenu
actions={[
...(canEdit
? [
{
label: 'Modifier',
icon: '✎',
href: `/s/${slug}/position/${positionId}/modifier`,
},
]
: []),
]}
>
{isAdmin && otherPositions.length > 0 && (
<MergePositionForm
sourcePositionId={positionId}
subjectSlug={slug}
otherPositions={otherPositions}
/>
)}
</AdminMenu>
)}
</div>
<p className={styles.description}>{position.description}</p>
</header>
<section>
<h2 className={styles.sectionTitle}>
PRISES DE POSITION <span className={styles.count}>{positionStatements.length}</span>
</h2>
{positionStatements.length === 0 ? (
<p className={styles.empty}>
Aucune prise de position enregistrée pour cette position.
</p>
) : (
<div className={styles.statementsList}>
{positionStatements.map(({ statement, publicFigure }) => (
<div key={statement.id} className={styles.statementItem}>
<div className={styles.figureInfo}>
<Link href={`/p/${publicFigure.slug}`}>
<FigureAvatar slug={publicFigure.slug} name={publicFigure.name} size={48} />
</Link>
<div>
<Link href={`/p/${publicFigure.slug}`} className={styles.figureName}>
{publicFigure.name}
</Link>
<span className={styles.statementDate}>
{statement.statedAt.toLocaleDateString('fr-FR')}
</span>
</div>
</div>
<blockquote className={styles.quote}>{statement.quote}</blockquote>
{statement.sourceUrl ? (
<a
href={statement.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.source}
>
{statement.sourceName}
</a>
) : (
<span className={styles.source}>{statement.sourceName}</span>
)}
</div>
))}
</div>
)}
</section>
{contributor && (
<div className={styles.actions}>
<Button
href={`/nouvelle-prise-de-position?subjectId=${subject.id}&subjectTitle=${encodeURIComponent(subject.title)}&positionId=${positionId}`}
size="small"
>
Ajouter une prise de position
</Button>
</div>
)}
</ContentWithSidebar>
)
} catch (error) {
return (
<ErrorDisplay
title="Erreur"
message="Impossible de charger la position."
detail={error instanceof Error ? error.message : 'Erreur inconnue'}
/>
)
}
}

View file

@ -0,0 +1,128 @@
.breadcrumb {
font-family: var(--font-gotham-book);
font-size: 13px;
color: var(--text-light);
margin-bottom: 20px;
}
.breadcrumbLink {
color: var(--debats-red);
text-decoration: none;
}
.breadcrumbLink:hover {
text-decoration: underline;
}
.breadcrumbSeparator {
margin: 0 8px;
}
.header {
margin-bottom: 30px;
}
.titleRow {
display: flex;
align-items: baseline;
gap: 10px;
}
.title {
font-family: var(--font-gotham-bold);
font-size: 1.5em;
color: var(--debats-red);
margin: 0 0 10px 0;
}
.description {
font-family: var(--font-gotham-book);
font-size: 0.9em;
line-height: 1.6;
color: var(--text-dark);
margin: 0;
}
.sectionTitle {
font-family: var(--font-gotham-bold);
font-size: 13px;
letter-spacing: 1px;
color: var(--text-dark);
margin: 0 0 20px 0;
}
.count {
color: var(--debats-red);
}
.empty {
font-family: var(--font-gotham-book);
font-size: 14px;
color: var(--text-light);
}
.statementsList {
display: flex;
flex-direction: column;
gap: 20px;
}
.statementItem {
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.statementItem:last-child {
border-bottom: none;
}
.figureInfo {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.figureName {
font-family: var(--font-gotham-bold);
font-size: 14px;
color: var(--text-dark);
text-decoration: none;
display: block;
}
.figureName:hover {
color: var(--debats-red);
}
.statementDate {
font-family: var(--font-gotham-book);
font-size: 12px;
color: var(--text-light);
}
.quote {
font-family: var(--font-gotham-book);
font-size: 14px;
line-height: 1.6;
color: var(--text-dark);
margin: 0 0 8px 0;
padding-left: 14px;
border-left: 3px solid var(--debats-red);
font-style: italic;
}
.source {
font-family: var(--font-gotham-book);
font-size: 12px;
color: var(--debats-red);
text-decoration: none;
}
.source:hover {
text-decoration: underline;
}
.actions {
margin-top: 20px;
}

View file

@ -87,6 +87,15 @@
gap: 12px;
}
.positionLink {
color: inherit;
text-decoration: none;
}
.positionLink:hover {
color: var(--debats-red);
}
.positionDescription {
font-family: var(--font-gotham-book);
font-size: 0.9em;

View file

@ -0,0 +1,98 @@
.container {
position: relative;
display: inline-flex;
vertical-align: middle;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border: 1px solid var(--border-light);
border-radius: 4px;
background: white;
color: var(--text-light);
font-family: var(--font-gotham-book);
font-size: 11px;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background-color 0.15s ease;
}
.trigger:hover,
.triggerOpen {
color: var(--debats-red);
border-color: var(--debats-red);
background-color: #fef8f8;
}
.triggerIcon {
font-size: 12px;
line-height: 1;
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 200px;
background: white;
border: 1px solid var(--border-light);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 50;
overflow: hidden;
}
.header {
font-family: var(--font-gotham-bold);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-light);
padding: 10px 14px 6px;
}
.action {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
font-family: var(--font-gotham-book);
font-size: 13px;
color: var(--text-dark);
text-decoration: none;
cursor: pointer;
transition: background-color 0.1s ease;
text-align: left;
}
.action:hover {
background-color: #f5f3ef;
}
.actionDanger {
color: #c62828;
}
.actionDanger:hover {
background-color: #fef2f2;
}
.actionIcon {
font-size: 13px;
width: 18px;
text-align: center;
flex-shrink: 0;
}
.extra {
border-top: 1px solid #f0ece6;
padding: 10px 14px;
}

View file

@ -0,0 +1,84 @@
'use client'
import { useState, useCallback, useEffect, useRef, ReactNode } from 'react'
import styles from './AdminMenu.module.css'
interface AdminMenuAction {
label: string
icon: string
href?: string
onClick?: () => void
variant?: 'default' | 'danger'
}
interface AdminMenuProps {
actions: AdminMenuAction[]
/** Optional slot rendered inside the dropdown below the action buttons */
children?: ReactNode
}
export default function AdminMenu({ actions, children }: AdminMenuProps) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const toggle = useCallback(() => setOpen((prev) => !prev), [])
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<div className={styles.container} ref={menuRef}>
<button
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
onClick={toggle}
aria-label="Actions d'administration"
aria-expanded={open}
>
<span className={styles.triggerIcon}></span> Gérer
</button>
{open && (
<div className={styles.dropdown}>
<div className={styles.header}>Administration</div>
{actions.map((action) => {
const className = `${styles.action} ${action.variant === 'danger' ? styles.actionDanger : ''}`
if (action.href) {
return (
<a key={action.label} href={action.href} className={className}>
<span className={styles.actionIcon}>{action.icon}</span>
{action.label}
</a>
)
}
return (
<button
key={action.label}
type="button"
className={className}
onClick={() => {
action.onClick?.()
setOpen(false)
}}
>
<span className={styles.actionIcon}>{action.icon}</span>
{action.label}
</button>
)
})}
{children && <div className={styles.extra}>{children}</div>}
</div>
)}
</div>
)
}

View file

@ -1,12 +1,28 @@
.editLink {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-gotham-book);
font-style: normal;
font-size: 0.85em;
font-size: 11px;
color: var(--text-light);
text-decoration: none;
border: 1px solid var(--border-light);
border-radius: 3px;
padding: 3px 8px;
transition:
color 0.15s ease,
border-color 0.15s ease;
vertical-align: middle;
}
.editLink:hover {
color: var(--debats-red);
text-decoration: underline;
border-color: var(--debats-red);
text-decoration: none;
}
.icon {
font-size: 10px;
line-height: 1;
}

View file

@ -9,6 +9,7 @@ interface EditLinkProps {
export default function EditLink({ href, label = 'Modifier' }: EditLinkProps) {
return (
<Link href={href} className={styles.editLink}>
<span className={styles.icon}></span>
{label}
</Link>
)

View file

@ -7,4 +7,6 @@ export interface PositionRepository {
findBySubjectId(subjectId: string): Effect.Effect<Position[], DatabaseError>
create(position: Position): Effect.Effect<Position, DatabaseError>
update(position: Position): Effect.Effect<Position, DatabaseError>
delete(id: string): Effect.Effect<void, DatabaseError>
mergeInto(sourceId: string, targetId: string): Effect.Effect<void, DatabaseError>
}

View file

@ -33,6 +33,10 @@ export interface StatementRepository {
findByPositionId(positionId: string): Effect.Effect<Statement[], DatabaseError>
findByPositionIdWithFigures(
positionId: string,
): Effect.Effect<StatementWithFigure[], DatabaseError>
/**
* Get all statements for a public figure with position and subject details
*/

View file

@ -19,6 +19,8 @@ const fakePositionRepo = {
findBySubjectId: () => Effect.succeed([] as Position[]),
create: (p: Position) => Effect.succeed(p),
update: (p: Position) => Effect.succeed(p),
delete: () => Effect.succeed(undefined as void),
mergeInto: () => Effect.succeed(undefined as void),
}
const fakeSubjectRepo = {

View file

@ -38,6 +38,7 @@ const fakeStatementRepo = {
findById: () => Effect.succeed(null),
findByPublicFigureId: () => Effect.succeed([]),
findByPositionId: () => Effect.succeed([]),
findByPositionIdWithFigures: () => Effect.succeed([]),
findByPublicFigureWithDetails: () => Effect.succeed([]),
findByPublicFigureAndSubject: () => Effect.succeed([]),
findBySubjectWithFigures: () => Effect.succeed([]),
@ -51,6 +52,8 @@ const fakePositionRepo = {
findBySubjectId: () => Effect.succeed([fakePosition]),
create: (p: Position) => Effect.succeed(p),
update: (p: Position) => Effect.succeed(p),
delete: () => Effect.succeed(undefined as void),
mergeInto: () => Effect.succeed(undefined as void),
}
const fakePublicFigureRepo = {

View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest'
import { Effect, Either } from 'effect'
import { mergePositions } from './merge-positions'
import { PositionRepository } from '../repositories/position-repository'
import { Position, PositionId, PositionTitle } from '../entities/position'
function makePosition(id: string, subjectId: string, title: string): Position {
return Position.make({
id: PositionId.make(id),
title: PositionTitle.make(title),
description: 'Description test.',
subjectId,
createdAt: new Date(),
updatedAt: new Date(),
})
}
function makeRepos(overrides: { source?: Position | null; target?: Position | null }) {
const positionRepo = {
findById: vi.fn((id: string) => {
if (id === 'source-id') return Effect.succeed(overrides.source ?? null)
if (id === 'target-id') return Effect.succeed(overrides.target ?? null)
return Effect.succeed(null)
}),
mergeInto: vi.fn(() => Effect.succeed(undefined)),
} as unknown as PositionRepository & {
findById: ReturnType<typeof vi.fn>
mergeInto: ReturnType<typeof vi.fn>
}
return { positionRepo }
}
describe('mergePositions', () => {
it('should fail if source and target are the same', async () => {
const { positionRepo } = makeRepos({})
const result = await mergePositions({
sourcePositionId: 'source-id',
targetPositionId: 'source-id',
contributor: { id: 'user-1', reputation: 1_000_000 },
positionRepo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) expect(result.left).toContain('identiques')
})
it('should fail if contributor lacks admin permission', async () => {
const { positionRepo } = makeRepos({
source: makePosition('source-id', 'sub-1', 'Position source'),
target: makePosition('target-id', 'sub-1', 'Position cible'),
})
const result = await mergePositions({
sourcePositionId: 'source-id',
targetPositionId: 'target-id',
contributor: { id: 'user-1', reputation: 100 },
positionRepo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) expect(result.left).toContain('Fondateur')
})
it('should fail if source position not found', async () => {
const { positionRepo } = makeRepos({
source: null,
target: makePosition('target-id', 'sub-1', 'Position cible'),
})
const result = await mergePositions({
sourcePositionId: 'source-id',
targetPositionId: 'target-id',
contributor: { id: 'user-1', reputation: 1_000_000 },
positionRepo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) expect(result.left).toContain('source')
})
it('should fail if positions belong to different subjects', async () => {
const { positionRepo } = makeRepos({
source: makePosition('source-id', 'sub-1', 'Position source'),
target: makePosition('target-id', 'sub-2', 'Position cible'),
})
const result = await mergePositions({
sourcePositionId: 'source-id',
targetPositionId: 'target-id',
contributor: { id: 'user-1', reputation: 1_000_000 },
positionRepo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) expect(result.left).toContain('même sujet')
})
it('should call mergeInto and succeed', async () => {
const { positionRepo } = makeRepos({
source: makePosition('source-id', 'sub-1', 'Position source'),
target: makePosition('target-id', 'sub-1', 'Position cible'),
})
const result = await mergePositions({
sourcePositionId: 'source-id',
targetPositionId: 'target-id',
contributor: { id: 'user-1', reputation: 1_000_000 },
positionRepo,
})
expect(Either.isRight(result)).toBe(true)
expect(positionRepo.mergeInto).toHaveBeenCalledWith('source-id', 'target-id')
})
})

View file

@ -0,0 +1,54 @@
import { Effect, Either } from 'effect'
import { PositionRepository } from '../repositories/position-repository'
import { canPerform } from '../reputation/permissions'
import { ContributorIdentity } from './types'
type MergePositionsParams = {
sourcePositionId: string
targetPositionId: string
contributor: ContributorIdentity
positionRepo: PositionRepository
}
export async function mergePositions(
params: MergePositionsParams,
): Promise<Either.Either<void, string>> {
const { sourcePositionId, targetPositionId, contributor, positionRepo } = params
if (sourcePositionId === targetPositionId) {
return Either.left('Les deux positions sont identiques.')
}
if (!canPerform(contributor.reputation, 'admin')) {
return Either.left('Vous devez être Fondateur pour fusionner des positions.')
}
const sourceResult = await Effect.runPromise(
Effect.either(positionRepo.findById(sourcePositionId)),
)
if (sourceResult._tag === 'Left')
return Either.left('Erreur lors de la lecture de la position source.')
if (!sourceResult.right) return Either.left('La position source est introuvable.')
const source = sourceResult.right
const targetResult = await Effect.runPromise(
Effect.either(positionRepo.findById(targetPositionId)),
)
if (targetResult._tag === 'Left')
return Either.left('Erreur lors de la lecture de la position cible.')
if (!targetResult.right) return Either.left('La position cible est introuvable.')
const target = targetResult.right
if (source.subjectId !== target.subjectId) {
return Either.left('Les deux positions doivent appartenir au même sujet.')
}
const mergeResult = await Effect.runPromise(
Effect.either(positionRepo.mergeInto(sourcePositionId, targetPositionId)),
)
if (mergeResult._tag === 'Left') {
return Either.left('Erreur lors de la fusion des positions.')
}
return Either.right(undefined)
}

View file

@ -19,11 +19,12 @@ const existingPosition = createPosition({
})
const fakePositionRepo = {
findById: (id: string) =>
Effect.succeed(id === existingPosition.id ? existingPosition : null),
findById: (id: string) => Effect.succeed(id === existingPosition.id ? existingPosition : null),
findBySubjectId: () => Effect.succeed([existingPosition]),
create: (p: Position) => Effect.succeed(p),
update: (p: Position) => Effect.succeed(p),
delete: () => Effect.succeed(undefined as void),
mergeInto: () => Effect.succeed(undefined as void),
}
const fakeReputationRepo = {

View file

@ -38,10 +38,10 @@ const existingStatement = createStatement({
const allPositions = [existingPosition, anotherPosition, positionOnOtherSubject]
const fakeStatementRepo = {
findById: (id: string) =>
Effect.succeed(id === existingStatement.id ? existingStatement : null),
findById: (id: string) => Effect.succeed(id === existingStatement.id ? existingStatement : null),
findByPublicFigureId: () => Effect.succeed([]),
findByPositionId: () => Effect.succeed([]),
findByPositionIdWithFigures: () => Effect.succeed([]),
findByPublicFigureWithDetails: () => Effect.succeed([]),
findByPublicFigureAndSubject: () => Effect.succeed([]),
findBySubjectWithFigures: () => Effect.succeed([]),
@ -53,11 +53,12 @@ const fakeStatementRepo = {
}
const fakePositionRepo = {
findById: (id: string) =>
Effect.succeed(allPositions.find((p) => p.id === id) ?? null),
findById: (id: string) => Effect.succeed(allPositions.find((p) => p.id === id) ?? null),
findBySubjectId: () => Effect.succeed([]),
create: (p: Position) => Effect.succeed(p),
update: (p: Position) => Effect.succeed(p),
delete: () => Effect.succeed(undefined as void),
mergeInto: () => Effect.succeed(undefined as void),
}
const fakeReputationRepo = {

View file

@ -119,5 +119,26 @@ export function createPositionRepository(supabase: SupabaseClient): PositionRepo
},
catch: (error) => dbError('Failed to update position', error),
}),
delete: (id: string) =>
Effect.tryPromise({
try: async () => {
const { error } = await supabase.from('positions').delete().eq('id', id)
if (error) throw error
},
catch: (error) => dbError('Failed to delete position', error),
}),
mergeInto: (sourceId: string, targetId: string) =>
Effect.tryPromise({
try: async () => {
const { error } = await supabase.rpc('merge_positions', {
source_id: sourceId,
target_id: targetId,
})
if (error) throw error
},
catch: (error) => dbError('Failed to merge positions', error),
}),
}
}

View file

@ -161,6 +161,38 @@ export function createStatementRepository(supabase: SupabaseClient<Database>): S
catch: (error) => dbError('Failed to fetch statements', error),
}),
findByPositionIdWithFigures: (positionId: string) =>
Effect.tryPromise({
try: async () => {
const { data, error } = await supabase
.from('statements')
.select(
`
id, public_figure_id, position_id, source_name, source_url, quote, stated_at,
created_by, created_at, updated_at,
positions!inner (
id, title, description, subject_id, created_by, created_at, updated_at
),
public_figures!inner (
id, name, slug, presentation, wikipedia_url, notoriety_sources, website_url,
created_by, created_at, updated_at
)
`,
)
.eq('position_id', positionId)
.order('stated_at', { ascending: false })
if (error) throw error
return data.map((row) => ({
statement: mapStatementRow(row),
position: mapPositionRow(row.positions),
publicFigure: mapPublicFigureRow(row.public_figures),
}))
},
catch: (error) => dbError('Failed to fetch statements with figures for position', error),
}),
findByPublicFigureWithDetails: (publicFigureId: string) =>
Effect.tryPromise({
try: async () => {

766
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@
"date-fns": "^4.1.0",
"downshift": "^9.3.0",
"effect": "^3.16.12",
"next": "16.1.1-canary.27",
"next": "^16.2.0",
"next-plausible": "^3.12.5",
"node-addon-api": "^8.5.0",
"node-gyp": "^12.2.0",

View file

@ -0,0 +1,8 @@
CREATE OR REPLACE FUNCTION merge_positions(source_id UUID, target_id UUID)
RETURNS VOID AS $$
BEGIN
UPDATE statements SET position_id = target_id, updated_at = now()
WHERE position_id = source_id;
DELETE FROM positions WHERE id = source_id;
END;
$$ LANGUAGE plpgsql;