feat: add related subjects (symmetric links) with tabbed edit page and improved subject detail layout

Related subjects: migration with CHECK(id1 < id2) for uniqueness, repository with dual-query (no string interpolation), link/unlink use cases with 8 tests.
Edit page: Radix Tabs (Contenu / Classification), themes extracted as immediate-save SubjectThemes component, related subjects with chip+remove UI and Combobox search.
Subject detail: themes and related subjects displayed in header metadata area, 'Voir aussi' links inline.
Also extracts mapRowToEntity from subject repo for reuse, removes themes from SubjectForm (now independent).
This commit is contained in:
Jalil Arfaoui 2026-04-05 01:51:14 +02:00
parent a2670ffe31
commit eed063c657
25 changed files with 965 additions and 80 deletions

View file

@ -0,0 +1,54 @@
'use server'
import { Either } from 'effect'
import { createAdminSupabaseClient } from '../../infra/supabase/admin'
import { createSubjectRepository } from '../../infra/database/subject-repository-supabase'
import { createRelatedSubjectsRepository } from '../../infra/database/related-subjects-repository-supabase'
import { linkSubjectsUseCase } from '../../domain/use-cases/link-subjects'
import { unlinkSubjectsUseCase } from '../../domain/use-cases/unlink-subjects'
import { getAuthenticatedContributor } from './get-authenticated-contributor'
export type LinkResult = { success: true } | { success: false; error: string }
export async function linkSubjectsAction(
subjectId1: string,
subjectId2: string,
): Promise<LinkResult> {
const supabase = createAdminSupabaseClient()
const contributor = await getAuthenticatedContributor()
const result = await linkSubjectsUseCase({
contributor,
subjectId1,
subjectId2,
subjectRepo: createSubjectRepository(supabase),
relatedRepo: createRelatedSubjectsRepository(supabase),
})
if (Either.isLeft(result)) {
return { success: false, error: result.left }
}
return { success: true }
}
export async function unlinkSubjectsAction(
subjectId1: string,
subjectId2: string,
): Promise<LinkResult> {
const supabase = createAdminSupabaseClient()
const contributor = await getAuthenticatedContributor()
const result = await unlinkSubjectsUseCase({
contributor,
subjectId1,
subjectId2,
relatedRepo: createRelatedSubjectsRepository(supabase),
})
if (Either.isLeft(result)) {
return { success: false, error: result.left }
}
return { success: true }
}

View file

@ -1,13 +1,10 @@
'use server'
import { Either } from 'effect'
import { ThemeId } from '../../domain/entities/theme'
import { createAdminSupabaseClient } from '../../infra/supabase/admin'
import { createSubjectRepository } from '../../infra/database/subject-repository-supabase'
import { createReputationRepository } from '../../infra/database/reputation-repository-supabase'
import { createThemeRepository } from '../../infra/database/theme-repository-supabase'
import { updateSubjectUseCase } from '../../domain/use-cases/update-subject'
import { setSubjectThemesUseCase } from '../../domain/use-cases/set-subject-themes'
import { getAuthenticatedContributor } from './get-authenticated-contributor'
export type ActionResult = { success: true; slug: string } | { success: false; error: string }
@ -18,8 +15,6 @@ export async function updateSubjectAction(
): Promise<ActionResult> {
const supabase = createAdminSupabaseClient()
const contributor = await getAuthenticatedContributor()
const subjectRepo = createSubjectRepository(supabase)
const themeRepo = createThemeRepository(supabase)
const result = await updateSubjectUseCase({
contributor,
@ -27,7 +22,7 @@ export async function updateSubjectAction(
title: String(formData.get('title') ?? ''),
presentation: String(formData.get('presentation') ?? ''),
problem: String(formData.get('problem') ?? ''),
subjectRepo,
subjectRepo: createSubjectRepository(supabase),
reputationRepo: createReputationRepository(supabase),
})
@ -35,25 +30,5 @@ export async function updateSubjectAction(
return { success: false, error: result.left }
}
let themeIdsRaw: string[]
try {
themeIdsRaw = JSON.parse(String(formData.get('themeIds') ?? '[]'))
if (!Array.isArray(themeIdsRaw)) themeIdsRaw = []
} catch {
themeIdsRaw = []
}
const themesResult = await setSubjectThemesUseCase({
contributor,
subjectId,
themeIds: themeIdsRaw.map((id) => ThemeId.make(id)),
themeRepo,
subjectRepo,
})
if (Either.isLeft(themesResult)) {
return { success: false, error: themesResult.left }
}
return { success: true, slug: result.right.slug }
}

View file

@ -0,0 +1,77 @@
.container {
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
font-family: var(--font-gotham-bold);
font-size: 0.8em;
color: var(--text-dark);
margin: 0;
}
.empty {
font-family: var(--font-gotham-book);
font-size: 0.8em;
color: #999;
margin: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0;
font-family: var(--font-gotham-book);
font-size: 0.8em;
color: var(--debats-red);
background: white;
border: 1px solid #e0dcd3;
border-radius: 4px;
overflow: hidden;
transition: opacity 0.15s;
}
.chipRemoving {
opacity: 0.4;
}
.chipLabel {
padding: 5px 10px;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 100%;
padding: 5px 0;
background: none;
border: none;
border-left: 1px solid #e0dcd3;
color: #ccc;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.chipRemove:hover {
background: var(--debats-red);
color: white;
}
.chipRemove:disabled {
cursor: not-allowed;
}
.searchRow {
max-width: 350px;
}

View file

@ -0,0 +1,121 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { linkSubjectsAction, unlinkSubjectsAction } from '../../../actions/link-subjects'
import { searchSubjects } from '../../../actions/search-subjects'
import Combobox from '../../../../components/ui/Combobox'
import FormError from '../../../../components/ui/FormError'
import styles from './RelatedSubjects.module.css'
import { RelatedSubjectData } from './types'
interface RelatedSubjectsProps {
subjectId: string
related: RelatedSubjectData[]
canManage: boolean
}
export default function RelatedSubjects({ subjectId, related, canManage }: RelatedSubjectsProps) {
const router = useRouter()
const [comboboxKey, setComboboxKey] = useState(0)
const [removingId, setRemovingId] = useState<string | null>(null)
const [error, setError] = useState<string>()
const handleSearch = useCallback(
async (query: string) => {
const results = await searchSubjects(query)
const excludeIds = new Set([subjectId, ...related.map((r) => r.id)])
return results.filter((r) => !excludeIds.has(r.id)).map((r) => ({ id: r.id, label: r.title }))
},
[subjectId, related],
)
const handleSelect = useCallback(
async (selectedId: string) => {
if (!selectedId) return
setError(undefined)
const result = await linkSubjectsAction(subjectId, selectedId)
if (result.success) {
setComboboxKey((k) => k + 1)
router.refresh()
} else {
setError(result.error)
}
},
[subjectId, router],
)
const handleUnlink = useCallback(
async (relatedId: string) => {
setError(undefined)
setRemovingId(relatedId)
const result = await unlinkSubjectsAction(subjectId, relatedId)
setRemovingId(null)
if (result.success) {
router.refresh()
} else {
setError(result.error)
}
},
[subjectId, router],
)
return (
<div className={styles.container}>
<label className={styles.title}>Sujets connexes</label>
{error && <FormError message={error} />}
{related.length === 0 && canManage && (
<p className={styles.empty}>
Aucun sujet connexe. Utilisez la recherche ci-dessous pour lier des sujets entre eux.
</p>
)}
{related.length > 0 && (
<div className={styles.chips}>
{related.map((r) => (
<span
key={r.id}
className={`${styles.chip} ${removingId === r.id ? styles.chipRemoving : ''}`}
>
<span className={styles.chipLabel}>{r.title}</span>
{canManage && (
<button
type="button"
className={styles.chipRemove}
onClick={() => handleUnlink(r.id)}
disabled={removingId === r.id}
aria-label={`Retirer le lien avec ${r.title}`}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path
d="M4 4l6 6M10 4l-6 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</span>
))}
</div>
)}
{canManage && (
<div className={styles.searchRow}>
<Combobox
key={comboboxKey}
label="Lier un sujet"
id="related-subject"
name="related-subject"
placeholder="Rechercher un sujet à lier..."
onSearch={handleSearch}
onSelect={handleSelect}
/>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,5 @@
export interface RelatedSubjectData {
id: string
title: string
slug: string
}

View file

@ -0,0 +1,19 @@
.container {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-family: var(--font-gotham-bold);
font-size: 0.8em;
color: var(--text-dark);
margin: 0;
}
.hint {
font-family: var(--font-gotham-book);
font-size: 0.75em;
color: #999;
margin: 0;
}

View file

@ -0,0 +1,47 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { setSubjectThemesAction } from '../../../actions/set-subject-themes'
import ThemeSelector, { ThemeOption } from '../../../../components/ui/ThemeSelector'
import FormError from '../../../../components/ui/FormError'
import styles from './SubjectThemes.module.css'
interface SubjectThemesProps {
subjectId: string
availableThemes: ThemeOption[]
selectedThemeIds: string[]
}
export default function SubjectThemes({
subjectId,
availableThemes,
selectedThemeIds,
}: SubjectThemesProps) {
const router = useRouter()
const [error, setError] = useState<string>()
const handleChange = useCallback(
async (themeIds: string[]) => {
setError(undefined)
const result = await setSubjectThemesAction(subjectId, themeIds)
if (result.success) {
router.refresh()
} else {
setError(result.error)
}
},
[subjectId, router],
)
return (
<div className={styles.container}>
<label className={styles.label}>Thématiques</label>
{error && <FormError message={error} />}
<ThemeSelector themes={availableThemes} value={selectedThemeIds} onChange={handleChange} />
<p className={styles.hint}>
Sélectionnez une ou plusieurs thématiques. Les changements sont enregistrés immédiatement.
</p>
</div>
)
}

View file

@ -0,0 +1,56 @@
.tabList {
display: flex;
gap: 0;
border-bottom: 2px solid #f0f0f0;
margin-bottom: 28px;
}
.tabTrigger {
font-family: var(--font-gotham-bold);
font-size: 0.8em;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-light);
background: none;
border: none;
padding: 10px 20px 12px;
cursor: pointer;
position: relative;
transition: color 0.15s;
}
.tabTrigger:hover {
color: var(--text-dark);
}
.tabTrigger[data-state='active'] {
color: var(--debats-red);
}
.tabTrigger[data-state='active']::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: var(--debats-red);
}
.tabContent {
outline: none;
}
.tabContent[data-state='inactive'] {
display: none;
}
.classificationPanel {
display: flex;
flex-direction: column;
gap: 28px;
background: #f8f6f0;
border-left: 3px solid var(--debats-red);
border-radius: 0 6px 6px 0;
padding: 24px 28px;
}

View file

@ -0,0 +1,59 @@
'use client'
import { Tabs } from 'radix-ui'
import EditSubjectForm from '../../../../../components/subjects/EditSubjectForm'
import { SubjectFormValues } from '../../../../../components/subjects/SubjectForm'
import { ThemeOption } from '../../../../../components/ui/ThemeSelector'
import SubjectThemes from '../../SubjectThemes'
import RelatedSubjects from '../../RelatedSubjects'
import { RelatedSubjectData } from '../../RelatedSubjects/types'
import styles from './EditSubjectTabs.module.css'
interface EditSubjectTabsProps {
subjectId: string
subjectSlug: string
subject: SubjectFormValues
availableThemes: ThemeOption[]
selectedThemeIds: string[]
relatedSubjects: RelatedSubjectData[]
}
export default function EditSubjectTabs({
subjectId,
subjectSlug,
subject,
availableThemes,
selectedThemeIds,
relatedSubjects,
}: EditSubjectTabsProps) {
return (
<Tabs.Root defaultValue="content">
<Tabs.List className={styles.tabList} aria-label="Sections du sujet">
<Tabs.Trigger value="content" className={styles.tabTrigger}>
Contenu
</Tabs.Trigger>
<Tabs.Trigger value="classification" className={styles.tabTrigger}>
Classification
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="content" className={styles.tabContent}>
<EditSubjectForm subjectId={subjectId} subjectSlug={subjectSlug} subject={subject} />
</Tabs.Content>
<Tabs.Content value="classification" className={styles.tabContent}>
<div className={styles.classificationPanel}>
{availableThemes.length > 0 && (
<SubjectThemes
subjectId={subjectId}
availableThemes={availableThemes}
selectedThemeIds={selectedThemeIds}
/>
)}
<RelatedSubjects subjectId={subjectId} related={relatedSubjects} canManage={true} />
</div>
</Tabs.Content>
</Tabs.Root>
)
}

View file

@ -4,11 +4,12 @@ import { Effect } from 'effect'
import { createAdminSupabaseClient } from '../../../../infra/supabase/admin'
import { createSubjectRepository } from '../../../../infra/database/subject-repository-supabase'
import { createThemeRepository } from '../../../../infra/database/theme-repository-supabase'
import { createRelatedSubjectsRepository } from '../../../../infra/database/related-subjects-repository-supabase'
import { getAuthenticatedContributor } from '../../../actions/get-authenticated-contributor'
import { canPerform } from '../../../../domain/reputation/permissions'
import ContentWithSidebar from '../../../../components/layout/ContentWithSidebar'
import FormPageHeader from '../../../../components/layout/FormPageHeader'
import EditSubjectForm from '../../../../components/subjects/EditSubjectForm'
import EditSubjectTabs from './EditSubjectTabs'
interface PageProps {
params: Promise<{ slug: string }>
@ -31,6 +32,7 @@ export default async function EditSubjectPage({ params }: PageProps) {
const supabase = createAdminSupabaseClient()
const subjectRepo = createSubjectRepository(supabase)
const themeRepo = createThemeRepository(supabase)
const relatedRepo = createRelatedSubjectsRepository(supabase)
const [subject, allThemes] = await Promise.all([
Effect.runPromise(subjectRepo.findBySlug(slug)),
@ -39,7 +41,10 @@ export default async function EditSubjectPage({ params }: PageProps) {
if (!subject) notFound()
const subjectThemes = await Effect.runPromise(themeRepo.findBySubjectId(subject.id))
const [subjectThemes, relatedSubjects] = await Promise.all([
Effect.runPromise(themeRepo.findBySubjectId(subject.id)),
Effect.runPromise(relatedRepo.findRelated(subject.id)),
])
return (
<ContentWithSidebar topMargin>
@ -50,16 +55,21 @@ export default async function EditSubjectPage({ params }: PageProps) {
subtitle={subject.title}
/>
<EditSubjectForm
<EditSubjectTabs
subjectId={subject.id}
subjectSlug={subject.slug}
subject={{
title: subject.title,
presentation: subject.presentation,
problem: subject.problem,
themeIds: subjectThemes.map((t) => t.id),
}}
availableThemes={allThemes.map((t) => ({ id: t.id, name: t.name }))}
selectedThemeIds={subjectThemes.map((t) => t.id)}
relatedSubjects={relatedSubjects.map((s) => ({
id: s.id,
title: s.title,
slug: s.slug,
}))}
/>
</ContentWithSidebar>
)

View file

@ -5,6 +5,7 @@ import { Effect } from 'effect'
import { createAdminSupabaseClient } from '../../../infra/supabase/admin'
import { createSubjectRepository } from '../../../infra/database/subject-repository-supabase'
import { createThemeRepository } from '../../../infra/database/theme-repository-supabase'
import { createRelatedSubjectsRepository } from '../../../infra/database/related-subjects-repository-supabase'
import { getSubjectPositionsSummary } from '../../../infra/queries/subject-positions-summary'
import { isMajorSubject } from '../../../domain/entities/subject'
import { canPerform } from '../../../domain/reputation/permissions'
@ -62,12 +63,14 @@ export default async function SubjectDetailPage({ params }: PageProps) {
if (!subject) notFound()
const themeRepo = createThemeRepository(supabase)
const relatedRepo = createRelatedSubjectsRepository(supabase)
const [positions, stats, contributor, themes] = await Promise.all([
const [positions, stats, contributor, themes, relatedSubjects] = await Promise.all([
Effect.runPromise(getSubjectPositionsSummary(supabase, subject.id)),
Effect.runPromise(subjectRepo.getStats(subject.id)),
getAuthenticatedContributor(),
Effect.runPromise(themeRepo.findBySubjectId(subject.id)),
Effect.runPromise(relatedRepo.findRelated(subject.id)),
])
const totalFigures = stats.publicFiguresCount
@ -106,11 +109,28 @@ export default async function SubjectDetailPage({ params }: PageProps) {
/>
)}
</div>
{themes.length > 0 && (
<div className={styles.themes}>
{themes.map((t) => (
<ThemeBadge key={t.id} name={t.name} slug={t.slug} />
))}
{(themes.length > 0 || relatedSubjects.length > 0) && (
<div className={styles.metadata}>
{themes.length > 0 && (
<div className={styles.metadataRow}>
{themes.map((t) => (
<ThemeBadge key={t.id} name={t.name} slug={t.slug} />
))}
</div>
)}
{relatedSubjects.length > 0 && (
<div className={styles.metadataRow}>
<span className={styles.metadataLabel}>Voir aussi</span>
{relatedSubjects.map((s, i) => (
<span key={s.id}>
{i > 0 && <span className={styles.relatedSeparator}> · </span>}
<Link href={`/s/${s.slug}`} className={styles.relatedLink}>
{s.title}
</Link>
</span>
))}
</div>
)}
</div>
)}
<p className={styles.presentation}>{subject.presentation}</p>

View file

@ -16,11 +16,43 @@
margin: 0 0 15px 0;
}
.themes {
.metadata {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.metadataRow {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
margin-bottom: 12px;
}
.metadataLabel {
font-family: var(--font-gotham-book);
font-size: 0.85em;
color: var(--text-light);
}
.relatedLink {
font-family: var(--font-gotham-bold);
font-size: 0.95em;
color: var(--debats-red);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.relatedLink:hover {
text-decoration-thickness: 2px;
}
.relatedSeparator {
font-family: var(--font-gotham-book);
font-size: 0.8em;
color: #ccc;
}
.presentation {

View file

@ -3,21 +3,14 @@
import { useCallback } from 'react'
import { updateSubjectAction } from '../../../app/actions/update-subject'
import SubjectForm, { SubjectFormValues } from '../SubjectForm'
import { ThemeOption } from '../../ui/ThemeSelector'
interface EditSubjectFormProps {
subjectId: string
subjectSlug: string
subject: SubjectFormValues
availableThemes: ThemeOption[]
}
export default function EditSubjectForm({
subjectId,
subjectSlug,
subject,
availableThemes,
}: EditSubjectFormProps) {
export default function EditSubjectForm({ subjectId, subjectSlug, subject }: EditSubjectFormProps) {
const handleSubmit = useCallback(
(formData: FormData) => updateSubjectAction(subjectId, formData),
[subjectId],
@ -30,7 +23,6 @@ export default function EditSubjectForm({
pendingLabel="Enregistrement..."
cancelHref={`/s/${subjectSlug}`}
subject={subject}
availableThemes={availableThemes}
/>
)
}

View file

@ -9,7 +9,6 @@ import TextArea from '../../ui/TextArea'
import Button from '../../ui/Button'
import FormError from '../../ui/FormError'
import GuideExample from '../../ui/GuideExample'
import ThemeSelector, { ThemeOption } from '../../ui/ThemeSelector'
import styles from '../../ui/form-with-guide.module.css'
type SubmitResult =
@ -21,7 +20,6 @@ export interface SubjectFormValues {
title: string
presentation: string
problem: string
themeIds: string[]
}
interface SubjectFormProps {
@ -30,7 +28,6 @@ interface SubjectFormProps {
pendingLabel: string
cancelHref?: string
subject?: SubjectFormValues
availableThemes?: ThemeOption[]
onSuccess?: (result: { slug: string; title: string }) => void
}
@ -40,14 +37,12 @@ export default function SubjectForm({
pendingLabel,
cancelHref,
subject,
availableThemes = [],
onSuccess,
}: SubjectFormProps) {
const router = useRouter()
const [title, setTitle] = useState(subject?.title ?? '')
const [presentation, setPresentation] = useState(subject?.presentation ?? '')
const [problem, setProblem] = useState(subject?.problem ?? '')
const [selectedThemeIds, setSelectedThemeIds] = useState<string[]>(subject?.themeIds ?? [])
const [error, setError] = useState<string>()
const [fieldErrors, setFieldErrors] = useState<FieldErrors>()
const [isPending, setIsPending] = useState(false)
@ -63,8 +58,6 @@ export default function SubjectForm({
formData.set('title', title)
formData.set('presentation', presentation)
formData.set('problem', problem)
formData.set('themeIds', JSON.stringify(selectedThemeIds))
try {
const result = await onSubmit(formData)
@ -88,7 +81,7 @@ export default function SubjectForm({
setIsPending(false)
}
},
[title, presentation, problem, selectedThemeIds, onSubmit, onSuccess, router],
[title, presentation, problem, onSubmit, onSuccess, router],
)
return (
@ -159,26 +152,6 @@ export default function SubjectForm({
</div>
</div>
{availableThemes.length > 0 && (
<div className={styles.fieldGroup}>
<div>
<label className={styles.label}>Thématiques</label>
<ThemeSelector
themes={availableThemes}
value={selectedThemeIds}
onChange={setSelectedThemeIds}
/>
</div>
<div className={styles.guide}>
<p className={styles.guideTitle}>Conseils</p>
<p className={styles.guideText}>
Sélectionnez une ou plusieurs thématiques pour aider les visiteurs à trouver ce sujet.
Si aucune thématique ne correspond, laissez vide.
</p>
</div>
</div>
)}
<div className={styles.actions}>
<Button type="submit">{isPending ? pendingLabel : submitLabel}</Button>
{cancelHref && (

View file

@ -8,8 +8,8 @@
font-family: var(--font-gotham-book);
font-size: 0.85em;
color: var(--text-light);
background: #f5f5f5;
border: 1px solid #e0e0e0;
background: white;
border: 1px solid #e0dcd3;
border-radius: 3px;
padding: 5px 12px;
cursor: pointer;
@ -21,7 +21,7 @@
}
.item:hover {
background: #eee;
background: #f0ede6;
}
.item[data-state='on'] {

View file

@ -0,0 +1,15 @@
import { Effect } from 'effect'
import { Subject } from '../entities/subject'
import { DatabaseError } from './errors'
export interface RelatedSubjectsRepository {
findRelated(subjectId: string): Effect.Effect<Subject[], DatabaseError>
link(
subjectId1: string,
subjectId2: string,
createdBy: string,
): Effect.Effect<void, DatabaseError>
unlink(subjectId1: string, subjectId2: string): Effect.Effect<void, DatabaseError>
}

View file

@ -36,6 +36,7 @@ const ACTIONS = {
// Éloquent (1000+)
assign_theme: Rank.Eloquent,
link_subjects: Rank.Eloquent,
add_argument: Rank.Eloquent,
add_subject: Rank.Eloquent,
add_personality: Rank.Eloquent,

View file

@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest'
import { Either, Effect } from 'effect'
import { linkSubjectsUseCase } from './link-subjects'
import { Subject, SubjectId, SubjectTitle, SubjectSlug } from '../entities/subject'
function fakeSubject(id: string, title: string): Subject {
return Subject.make({
id: SubjectId.make(id),
title: SubjectTitle.make(title),
slug: SubjectSlug.make(title.toLowerCase().replace(/ /g, '-')),
presentation: 'Présentation suffisamment longue',
problem: 'Problème suffisamment long',
createdBy: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
})
}
const subjectA = fakeSubject('subject-a', 'Sujet Alpha')
const subjectB = fakeSubject('subject-b', 'Sujet Bravo')
const fakeSubjectRepo = {
findAll: () => Effect.succeed([]),
findBySlug: () => Effect.succeed(null),
findById: (id: string) =>
Effect.succeed(id === 'subject-a' ? subjectA : id === 'subject-b' ? subjectB : null),
create: () => Effect.succeed(null as never),
update: () => Effect.succeed(null as never),
delete: () => Effect.succeed(undefined as void),
getStats: () =>
Effect.succeed({ subjectId: '', positionsCount: 0, publicFiguresCount: 0, statementsCount: 0 }),
findSummariesByActivity: () => Effect.succeed([]),
findSummariesByCreatedAt: () => Effect.succeed([]),
findSummaryById: () => Effect.succeed(null),
findAllIds: () => Effect.succeed([]),
}
function fakeRelatedRepo() {
const linked: Array<{ id1: string; id2: string }> = []
return {
repo: {
findRelated: () => Effect.succeed([]),
link: (id1: string, id2: string) => {
linked.push({ id1, id2 })
return Effect.succeed(undefined as void)
},
unlink: () => Effect.succeed(undefined as void),
},
linked,
}
}
describe('linkSubjectsUseCase', () => {
it('should fail when contributor is null', async () => {
const { repo } = fakeRelatedRepo()
const result = await linkSubjectsUseCase({
contributor: null,
subjectId1: 'subject-a',
subjectId2: 'subject-b',
subjectRepo: fakeSubjectRepo,
relatedRepo: repo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left).toContain('connecté')
}
})
it('should fail when contributor lacks reputation', async () => {
const { repo } = fakeRelatedRepo()
const result = await linkSubjectsUseCase({
contributor: { id: 'abc', reputation: 0 },
subjectId1: 'subject-a',
subjectId2: 'subject-b',
subjectRepo: fakeSubjectRepo,
relatedRepo: repo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left).toContain('Éloquent')
}
})
it('should fail when linking a subject to itself', async () => {
const { repo } = fakeRelatedRepo()
const result = await linkSubjectsUseCase({
contributor: { id: 'abc', reputation: 1000 },
subjectId1: 'subject-a',
subjectId2: 'subject-a',
subjectRepo: fakeSubjectRepo,
relatedRepo: repo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left).toContain('même sujet')
}
})
it('should fail when a subject does not exist', async () => {
const { repo } = fakeRelatedRepo()
const result = await linkSubjectsUseCase({
contributor: { id: 'abc', reputation: 1000 },
subjectId1: 'subject-a',
subjectId2: 'nonexistent',
subjectRepo: fakeSubjectRepo,
relatedRepo: repo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left).toContain('introuvable')
}
})
it('should link two subjects on success', async () => {
const { repo, linked } = fakeRelatedRepo()
const result = await linkSubjectsUseCase({
contributor: { id: 'abc', reputation: 1000 },
subjectId1: 'subject-a',
subjectId2: 'subject-b',
subjectRepo: fakeSubjectRepo,
relatedRepo: repo,
})
expect(Either.isRight(result)).toBe(true)
expect(linked).toEqual([{ id1: 'subject-a', id2: 'subject-b' }])
})
})

View file

@ -0,0 +1,48 @@
import { Either, Effect } from 'effect'
import { SubjectRepository } from '../repositories/subject-repository'
import { RelatedSubjectsRepository } from '../repositories/related-subjects-repository'
import { canPerform, requiredRank } from '../reputation/permissions'
import { ContributorIdentity } from './types'
type LinkSubjectsParams = {
contributor: ContributorIdentity | null
subjectId1: string
subjectId2: string
subjectRepo: SubjectRepository
relatedRepo: RelatedSubjectsRepository
}
export async function linkSubjectsUseCase(
params: LinkSubjectsParams,
): Promise<Either.Either<void, string>> {
const { contributor, subjectId1, subjectId2, subjectRepo, relatedRepo } = params
if (!contributor) {
return Either.left('Vous devez être connecté·e.')
}
if (!canPerform(contributor.reputation, 'link_subjects')) {
const rank = requiredRank('link_subjects')
return Either.left(`Vous devez être ${rank} pour lier des sujets.`)
}
if (subjectId1 === subjectId2) {
return Either.left('Impossible de lier un sujet au même sujet.')
}
const [subject1, subject2] = await Promise.all([
Effect.runPromise(subjectRepo.findById(subjectId1)),
Effect.runPromise(subjectRepo.findById(subjectId2)),
])
if (!subject1) {
return Either.left(`Le sujet ${subjectId1} est introuvable.`)
}
if (!subject2) {
return Either.left(`Le sujet ${subjectId2} est introuvable.`)
}
await Effect.runPromise(relatedRepo.link(subjectId1, subjectId2, contributor.id))
return Either.right(undefined)
}

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { Either, Effect } from 'effect'
import { unlinkSubjectsUseCase } from './unlink-subjects'
function fakeRelatedRepo() {
const unlinked: Array<{ id1: string; id2: string }> = []
return {
repo: {
findRelated: () => Effect.succeed([]),
link: () => Effect.succeed(undefined as void),
unlink: (id1: string, id2: string) => {
unlinked.push({ id1, id2 })
return Effect.succeed(undefined as void)
},
},
unlinked,
}
}
describe('unlinkSubjectsUseCase', () => {
it('should fail when contributor is null', async () => {
const { repo } = fakeRelatedRepo()
const result = await unlinkSubjectsUseCase({
contributor: null,
subjectId1: 'subject-a',
subjectId2: 'subject-b',
relatedRepo: repo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left).toContain('connecté')
}
})
it('should fail when contributor lacks reputation', async () => {
const { repo } = fakeRelatedRepo()
const result = await unlinkSubjectsUseCase({
contributor: { id: 'abc', reputation: 0 },
subjectId1: 'subject-a',
subjectId2: 'subject-b',
relatedRepo: repo,
})
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left).toContain('Éloquent')
}
})
it('should unlink two subjects on success', async () => {
const { repo, unlinked } = fakeRelatedRepo()
const result = await unlinkSubjectsUseCase({
contributor: { id: 'abc', reputation: 1000 },
subjectId1: 'subject-a',
subjectId2: 'subject-b',
relatedRepo: repo,
})
expect(Either.isRight(result)).toBe(true)
expect(unlinked).toEqual([{ id1: 'subject-a', id2: 'subject-b' }])
})
})

View file

@ -0,0 +1,30 @@
import { Either, Effect } from 'effect'
import { RelatedSubjectsRepository } from '../repositories/related-subjects-repository'
import { canPerform, requiredRank } from '../reputation/permissions'
import { ContributorIdentity } from './types'
type UnlinkSubjectsParams = {
contributor: ContributorIdentity | null
subjectId1: string
subjectId2: string
relatedRepo: RelatedSubjectsRepository
}
export async function unlinkSubjectsUseCase(
params: UnlinkSubjectsParams,
): Promise<Either.Either<void, string>> {
const { contributor, subjectId1, subjectId2, relatedRepo } = params
if (!contributor) {
return Either.left('Vous devez être connecté·e.')
}
if (!canPerform(contributor.reputation, 'link_subjects')) {
const rank = requiredRank('link_subjects')
return Either.left(`Vous devez être ${rank} pour modifier les liens entre sujets.`)
}
await Effect.runPromise(relatedRepo.unlink(subjectId1, subjectId2))
return Either.right(undefined)
}

View file

@ -0,0 +1,81 @@
import * as Sentry from '@sentry/nextjs'
import { Effect } from 'effect'
import { SupabaseClient } from '@supabase/supabase-js'
import { DatabaseError } from '../../domain/repositories/errors'
import { RelatedSubjectsRepository } from '../../domain/repositories/related-subjects-repository'
import { mapRowToEntity } from './subject-repository-supabase'
function dbError(message: string, error: unknown): DatabaseError {
const msg = `${message}: ${error instanceof Error ? error.message : JSON.stringify(error)}`
Sentry.captureException(error, { extra: { message } })
return new DatabaseError(msg)
}
function orderIds(id1: string, id2: string): [string, string] {
return id1 < id2 ? [id1, id2] : [id2, id1]
}
export function createRelatedSubjectsRepository(
supabase: SupabaseClient,
): RelatedSubjectsRepository {
return {
findRelated: (subjectId: string) =>
Effect.tryPromise({
try: async () => {
const [asFirst, asSecond] = await Promise.all([
supabase.from('related_subjects').select('subject_id_2').eq('subject_id_1', subjectId),
supabase.from('related_subjects').select('subject_id_1').eq('subject_id_2', subjectId),
])
if (asFirst.error) throw asFirst.error
if (asSecond.error) throw asSecond.error
const relatedIds = [
...asFirst.data.map((l) => l.subject_id_2),
...asSecond.data.map((l) => l.subject_id_1),
]
if (relatedIds.length === 0) return []
const { data, error } = await supabase
.from('subjects')
.select('*')
.in('id', relatedIds)
.is('deleted_at', null)
.order('title')
if (error) throw error
return data.map(mapRowToEntity)
},
catch: (error) => dbError('Failed to fetch related subjects', error),
}),
link: (subjectId1: string, subjectId2: string, createdBy: string) =>
Effect.tryPromise({
try: async () => {
const [id1, id2] = orderIds(subjectId1, subjectId2)
const { error } = await supabase
.from('related_subjects')
.upsert({ subject_id_1: id1, subject_id_2: id2, created_by: createdBy })
if (error) throw error
},
catch: (error) => dbError('Failed to link subjects', error),
}),
unlink: (subjectId1: string, subjectId2: string) =>
Effect.tryPromise({
try: async () => {
const [id1, id2] = orderIds(subjectId1, subjectId2)
const { error } = await supabase
.from('related_subjects')
.delete()
.eq('subject_id_1', id1)
.eq('subject_id_2', id2)
if (error) throw error
},
catch: (error) => dbError('Failed to unlink subjects', error),
}),
}
}

View file

@ -16,9 +16,9 @@ function dbError(message: string, error: unknown): DatabaseError {
return new DatabaseError(msg)
}
type SubjectRow = Database['public']['Tables']['subjects']['Row']
export type SubjectRow = Database['public']['Tables']['subjects']['Row']
const mapRowToEntity = (row: SubjectRow) =>
export const mapRowToEntity = (row: SubjectRow) =>
Subject.make({
id: SubjectId.make(row.id),
title: SubjectTitle.make(row.title),

View file

@ -0,0 +1,17 @@
-- Related subjects: symmetric links between subjects
-- Convention: subject_id_1 < subject_id_2 to prevent duplicate pairs
CREATE TABLE related_subjects (
subject_id_1 UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
subject_id_2 UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES contributors(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (subject_id_1, subject_id_2),
CONSTRAINT related_subjects_ordered CHECK (subject_id_1 < subject_id_2),
CONSTRAINT related_subjects_not_self CHECK (subject_id_1 <> subject_id_2)
);
CREATE INDEX idx_related_subjects_2 ON related_subjects (subject_id_2);
-- RLS: all access goes through admin/service client
ALTER TABLE related_subjects ENABLE ROW LEVEL SECURITY;

View file

@ -359,6 +359,63 @@ export type Database = {
},
]
}
related_subjects: {
Row: {
created_at: string
created_by: string
subject_id_1: string
subject_id_2: string
}
Insert: {
created_at?: string
created_by: string
subject_id_1: string
subject_id_2: string
}
Update: {
created_at?: string
created_by?: string
subject_id_1?: string
subject_id_2?: string
}
Relationships: [
{
foreignKeyName: "related_subjects_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "contributors"
referencedColumns: ["id"]
},
{
foreignKeyName: "related_subjects_subject_id_1_fkey"
columns: ["subject_id_1"]
isOneToOne: false
referencedRelation: "subjects"
referencedColumns: ["id"]
},
{
foreignKeyName: "related_subjects_subject_id_1_fkey"
columns: ["subject_id_1"]
isOneToOne: false
referencedRelation: "v_subject_activity_summary"
referencedColumns: ["id"]
},
{
foreignKeyName: "related_subjects_subject_id_2_fkey"
columns: ["subject_id_2"]
isOneToOne: false
referencedRelation: "subjects"
referencedColumns: ["id"]
},
{
foreignKeyName: "related_subjects_subject_id_2_fkey"
columns: ["subject_id_2"]
isOneToOne: false
referencedRelation: "v_subject_activity_summary"
referencedColumns: ["id"]
},
]
}
reputation_events: {
Row: {
action: string