Compare commits
3 commits
1032806685
...
50c92a5675
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c92a5675 | |||
| c4fb7fc342 | |||
| 20aa53bb85 |
28 changed files with 1633 additions and 159 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@
|
|||
/.direnv
|
||||
/.env.local
|
||||
/.env.production
|
||||
TODO.md
|
||||
|
||||
# Build artifacts
|
||||
tsconfig.tsbuildinfo
|
||||
|
|
|
|||
1
.npmrc
1
.npmrc
|
|
@ -1 +0,0 @@
|
|||
legacy-peer-deps=true
|
||||
92
TODO.md
92
TODO.md
|
|
@ -1,92 +0,0 @@
|
|||
# Roadmap Débats.co
|
||||
|
||||
## En cours
|
||||
|
||||
- [ ] **Pérenniser le mécanisme d’ajout de contenu depuis Claude** — le script `import:content` atteint ses limites. Réfléchir au meilleur moyen d’alimenter le site efficacement sans tout renvoyer à chaque fois. Un skill qui utilise l’API de production ?
|
||||
|
||||
## Prochaines priorités
|
||||
- [ ] **Page détail prises de position** — route `/p/[slug]/s/[subjectSlug]` pour afficher les prises de position d’une personnalité sur un sujet
|
||||
- [ ] **Page d’accueil : Limiter le nombre de personnalités actives (thumbnails) sous chaque sujet**
|
||||
- [ ] **Ajouter un évènement Plausible qu’on 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 d’organisation** - 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 l’utilisateur 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
|
||||
35
app/actions/merge-positions-action.ts
Normal file
35
app/actions/merge-positions-action.ts
Normal 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}`)
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}`}
|
||||
/>
|
||||
|
|
|
|||
180
app/s/[slug]/position/[positionId]/page.tsx
Normal file
180
app/s/[slug]/position/[positionId]/page.tsx
Normal 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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
128
app/s/[slug]/position/[positionId]/position-detail.module.css
Normal file
128
app/s/[slug]/position/[positionId]/position-detail.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
98
components/ui/AdminMenu/AdminMenu.module.css
Normal file
98
components/ui/AdminMenu/AdminMenu.module.css
Normal 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;
|
||||
}
|
||||
84
components/ui/AdminMenu/index.tsx
Normal file
84
components/ui/AdminMenu/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
111
domain/use-cases/merge-positions.test.ts
Normal file
111
domain/use-cases/merge-positions.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
54
domain/use-cases/merge-positions.ts
Normal file
54
domain/use-cases/merge-positions.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
766
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue