From eed063c65771d8ed680e19920be6e119b0976749 Mon Sep 17 00:00:00 2001 From: Jalil Arfaoui Date: Sun, 5 Apr 2026 01:51:14 +0200 Subject: [PATCH] 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). --- app/actions/link-subjects.ts | 54 +++++++ app/actions/update-subject.ts | 27 +--- .../RelatedSubjects.module.css | 77 ++++++++++ app/s/[slug]/RelatedSubjects/index.tsx | 121 ++++++++++++++++ app/s/[slug]/RelatedSubjects/types.ts | 5 + .../SubjectThemes/SubjectThemes.module.css | 19 +++ app/s/[slug]/SubjectThemes/index.tsx | 47 +++++++ .../EditSubjectTabs.module.css | 56 ++++++++ .../[slug]/modifier/EditSubjectTabs/index.tsx | 59 ++++++++ app/s/[slug]/modifier/page.tsx | 18 ++- app/s/[slug]/page.tsx | 32 ++++- app/s/[slug]/subject-detail.module.css | 36 ++++- components/subjects/EditSubjectForm/index.tsx | 10 +- components/subjects/SubjectForm/index.tsx | 29 +--- .../ui/ThemeSelector/ThemeSelector.module.css | 6 +- .../related-subjects-repository.ts | 15 ++ domain/reputation/permissions.ts | 1 + domain/use-cases/link-subjects.test.ts | 132 ++++++++++++++++++ domain/use-cases/link-subjects.ts | 48 +++++++ domain/use-cases/unlink-subjects.test.ts | 64 +++++++++ domain/use-cases/unlink-subjects.ts | 30 ++++ .../related-subjects-repository-supabase.ts | 81 +++++++++++ infra/database/subject-repository-supabase.ts | 4 +- ...05100000_create_related_subjects_table.sql | 17 +++ types/database.types.ts | 57 ++++++++ 25 files changed, 965 insertions(+), 80 deletions(-) create mode 100644 app/actions/link-subjects.ts create mode 100644 app/s/[slug]/RelatedSubjects/RelatedSubjects.module.css create mode 100644 app/s/[slug]/RelatedSubjects/index.tsx create mode 100644 app/s/[slug]/RelatedSubjects/types.ts create mode 100644 app/s/[slug]/SubjectThemes/SubjectThemes.module.css create mode 100644 app/s/[slug]/SubjectThemes/index.tsx create mode 100644 app/s/[slug]/modifier/EditSubjectTabs/EditSubjectTabs.module.css create mode 100644 app/s/[slug]/modifier/EditSubjectTabs/index.tsx create mode 100644 domain/repositories/related-subjects-repository.ts create mode 100644 domain/use-cases/link-subjects.test.ts create mode 100644 domain/use-cases/link-subjects.ts create mode 100644 domain/use-cases/unlink-subjects.test.ts create mode 100644 domain/use-cases/unlink-subjects.ts create mode 100644 infra/database/related-subjects-repository-supabase.ts create mode 100644 supabase/migrations/20260405100000_create_related_subjects_table.sql diff --git a/app/actions/link-subjects.ts b/app/actions/link-subjects.ts new file mode 100644 index 0000000..7d7c724 --- /dev/null +++ b/app/actions/link-subjects.ts @@ -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 { + 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 { + 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 } +} diff --git a/app/actions/update-subject.ts b/app/actions/update-subject.ts index 671bbb4..cfb613c 100644 --- a/app/actions/update-subject.ts +++ b/app/actions/update-subject.ts @@ -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 { 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 } } diff --git a/app/s/[slug]/RelatedSubjects/RelatedSubjects.module.css b/app/s/[slug]/RelatedSubjects/RelatedSubjects.module.css new file mode 100644 index 0000000..dfc01c1 --- /dev/null +++ b/app/s/[slug]/RelatedSubjects/RelatedSubjects.module.css @@ -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; +} diff --git a/app/s/[slug]/RelatedSubjects/index.tsx b/app/s/[slug]/RelatedSubjects/index.tsx new file mode 100644 index 0000000..f30a3f8 --- /dev/null +++ b/app/s/[slug]/RelatedSubjects/index.tsx @@ -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(null) + const [error, setError] = useState() + + 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 ( +
+ + + {error && } + + {related.length === 0 && canManage && ( +

+ Aucun sujet connexe. Utilisez la recherche ci-dessous pour lier des sujets entre eux. +

+ )} + + {related.length > 0 && ( +
+ {related.map((r) => ( + + {r.title} + {canManage && ( + + )} + + ))} +
+ )} + + {canManage && ( +
+ +
+ )} +
+ ) +} diff --git a/app/s/[slug]/RelatedSubjects/types.ts b/app/s/[slug]/RelatedSubjects/types.ts new file mode 100644 index 0000000..17c9090 --- /dev/null +++ b/app/s/[slug]/RelatedSubjects/types.ts @@ -0,0 +1,5 @@ +export interface RelatedSubjectData { + id: string + title: string + slug: string +} diff --git a/app/s/[slug]/SubjectThemes/SubjectThemes.module.css b/app/s/[slug]/SubjectThemes/SubjectThemes.module.css new file mode 100644 index 0000000..b7e895c --- /dev/null +++ b/app/s/[slug]/SubjectThemes/SubjectThemes.module.css @@ -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; +} diff --git a/app/s/[slug]/SubjectThemes/index.tsx b/app/s/[slug]/SubjectThemes/index.tsx new file mode 100644 index 0000000..586f5d9 --- /dev/null +++ b/app/s/[slug]/SubjectThemes/index.tsx @@ -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() + + 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 ( +
+ + {error && } + +

+ Sélectionnez une ou plusieurs thématiques. Les changements sont enregistrés immédiatement. +

+
+ ) +} diff --git a/app/s/[slug]/modifier/EditSubjectTabs/EditSubjectTabs.module.css b/app/s/[slug]/modifier/EditSubjectTabs/EditSubjectTabs.module.css new file mode 100644 index 0000000..f4a9e61 --- /dev/null +++ b/app/s/[slug]/modifier/EditSubjectTabs/EditSubjectTabs.module.css @@ -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; +} diff --git a/app/s/[slug]/modifier/EditSubjectTabs/index.tsx b/app/s/[slug]/modifier/EditSubjectTabs/index.tsx new file mode 100644 index 0000000..df114dc --- /dev/null +++ b/app/s/[slug]/modifier/EditSubjectTabs/index.tsx @@ -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 ( + + + + Contenu + + + Classification + + + + + + + + +
+ {availableThemes.length > 0 && ( + + )} + + +
+
+
+ ) +} diff --git a/app/s/[slug]/modifier/page.tsx b/app/s/[slug]/modifier/page.tsx index 35a69dc..680bee3 100644 --- a/app/s/[slug]/modifier/page.tsx +++ b/app/s/[slug]/modifier/page.tsx @@ -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 ( @@ -50,16 +55,21 @@ export default async function EditSubjectPage({ params }: PageProps) { subtitle={subject.title} /> - 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, + }))} /> ) diff --git a/app/s/[slug]/page.tsx b/app/s/[slug]/page.tsx index 8b0e47f..6729b3f 100644 --- a/app/s/[slug]/page.tsx +++ b/app/s/[slug]/page.tsx @@ -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) { /> )} - {themes.length > 0 && ( -
- {themes.map((t) => ( - - ))} + {(themes.length > 0 || relatedSubjects.length > 0) && ( +
+ {themes.length > 0 && ( +
+ {themes.map((t) => ( + + ))} +
+ )} + {relatedSubjects.length > 0 && ( +
+ Voir aussi + {relatedSubjects.map((s, i) => ( + + {i > 0 && · } + + {s.title} + + + ))} +
+ )}
)}

{subject.presentation}

diff --git a/app/s/[slug]/subject-detail.module.css b/app/s/[slug]/subject-detail.module.css index cf34a77..72b0a17 100644 --- a/app/s/[slug]/subject-detail.module.css +++ b/app/s/[slug]/subject-detail.module.css @@ -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 { diff --git a/components/subjects/EditSubjectForm/index.tsx b/components/subjects/EditSubjectForm/index.tsx index a5185a5..9554e8f 100644 --- a/components/subjects/EditSubjectForm/index.tsx +++ b/components/subjects/EditSubjectForm/index.tsx @@ -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} /> ) } diff --git a/components/subjects/SubjectForm/index.tsx b/components/subjects/SubjectForm/index.tsx index 367fcf5..8c11fcd 100644 --- a/components/subjects/SubjectForm/index.tsx +++ b/components/subjects/SubjectForm/index.tsx @@ -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(subject?.themeIds ?? []) const [error, setError] = useState() const [fieldErrors, setFieldErrors] = useState() 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({
- {availableThemes.length > 0 && ( -
-
- - -
-
-

Conseils

-

- Sélectionnez une ou plusieurs thématiques pour aider les visiteurs à trouver ce sujet. - Si aucune thématique ne correspond, laissez vide. -

-
-
- )} -
{cancelHref && ( diff --git a/components/ui/ThemeSelector/ThemeSelector.module.css b/components/ui/ThemeSelector/ThemeSelector.module.css index d000720..031db9b 100644 --- a/components/ui/ThemeSelector/ThemeSelector.module.css +++ b/components/ui/ThemeSelector/ThemeSelector.module.css @@ -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'] { diff --git a/domain/repositories/related-subjects-repository.ts b/domain/repositories/related-subjects-repository.ts new file mode 100644 index 0000000..d47bc4e --- /dev/null +++ b/domain/repositories/related-subjects-repository.ts @@ -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 + + link( + subjectId1: string, + subjectId2: string, + createdBy: string, + ): Effect.Effect + + unlink(subjectId1: string, subjectId2: string): Effect.Effect +} diff --git a/domain/reputation/permissions.ts b/domain/reputation/permissions.ts index a88ab07..daae4f3 100644 --- a/domain/reputation/permissions.ts +++ b/domain/reputation/permissions.ts @@ -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, diff --git a/domain/use-cases/link-subjects.test.ts b/domain/use-cases/link-subjects.test.ts new file mode 100644 index 0000000..12a1b1a --- /dev/null +++ b/domain/use-cases/link-subjects.test.ts @@ -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' }]) + }) +}) diff --git a/domain/use-cases/link-subjects.ts b/domain/use-cases/link-subjects.ts new file mode 100644 index 0000000..4dd4130 --- /dev/null +++ b/domain/use-cases/link-subjects.ts @@ -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> { + 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) +} diff --git a/domain/use-cases/unlink-subjects.test.ts b/domain/use-cases/unlink-subjects.test.ts new file mode 100644 index 0000000..232963c --- /dev/null +++ b/domain/use-cases/unlink-subjects.test.ts @@ -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' }]) + }) +}) diff --git a/domain/use-cases/unlink-subjects.ts b/domain/use-cases/unlink-subjects.ts new file mode 100644 index 0000000..c73c06d --- /dev/null +++ b/domain/use-cases/unlink-subjects.ts @@ -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> { + 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) +} diff --git a/infra/database/related-subjects-repository-supabase.ts b/infra/database/related-subjects-repository-supabase.ts new file mode 100644 index 0000000..cad38be --- /dev/null +++ b/infra/database/related-subjects-repository-supabase.ts @@ -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), + }), + } +} diff --git a/infra/database/subject-repository-supabase.ts b/infra/database/subject-repository-supabase.ts index 8b3e7a1..a702bfc 100644 --- a/infra/database/subject-repository-supabase.ts +++ b/infra/database/subject-repository-supabase.ts @@ -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), diff --git a/supabase/migrations/20260405100000_create_related_subjects_table.sql b/supabase/migrations/20260405100000_create_related_subjects_table.sql new file mode 100644 index 0000000..32347d0 --- /dev/null +++ b/supabase/migrations/20260405100000_create_related_subjects_table.sql @@ -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; diff --git a/types/database.types.ts b/types/database.types.ts index 1411ca8..3dad830 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -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