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:
parent
a2670ffe31
commit
eed063c657
25 changed files with 965 additions and 80 deletions
54
app/actions/link-subjects.ts
Normal file
54
app/actions/link-subjects.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
77
app/s/[slug]/RelatedSubjects/RelatedSubjects.module.css
Normal file
77
app/s/[slug]/RelatedSubjects/RelatedSubjects.module.css
Normal 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;
|
||||
}
|
||||
121
app/s/[slug]/RelatedSubjects/index.tsx
Normal file
121
app/s/[slug]/RelatedSubjects/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
app/s/[slug]/RelatedSubjects/types.ts
Normal file
5
app/s/[slug]/RelatedSubjects/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface RelatedSubjectData {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
}
|
||||
19
app/s/[slug]/SubjectThemes/SubjectThemes.module.css
Normal file
19
app/s/[slug]/SubjectThemes/SubjectThemes.module.css
Normal 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;
|
||||
}
|
||||
47
app/s/[slug]/SubjectThemes/index.tsx
Normal file
47
app/s/[slug]/SubjectThemes/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
59
app/s/[slug]/modifier/EditSubjectTabs/index.tsx
Normal file
59
app/s/[slug]/modifier/EditSubjectTabs/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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'] {
|
||||
|
|
|
|||
15
domain/repositories/related-subjects-repository.ts
Normal file
15
domain/repositories/related-subjects-repository.ts
Normal 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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
132
domain/use-cases/link-subjects.test.ts
Normal file
132
domain/use-cases/link-subjects.test.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
48
domain/use-cases/link-subjects.ts
Normal file
48
domain/use-cases/link-subjects.ts
Normal 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)
|
||||
}
|
||||
64
domain/use-cases/unlink-subjects.test.ts
Normal file
64
domain/use-cases/unlink-subjects.test.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
30
domain/use-cases/unlink-subjects.ts
Normal file
30
domain/use-cases/unlink-subjects.ts
Normal 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)
|
||||
}
|
||||
81
infra/database/related-subjects-repository-supabase.ts
Normal file
81
infra/database/related-subjects-repository-supabase.ts
Normal 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),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue