Sprint 2 : moteur de documents + sanctuaire

Backend:
- CRUD complet documents/items/versions (update, delete, accept, reject, reorder)
- Service IPFS (upload/retrieve/pin via kubo HTTP API)
- Service sanctuaire : pipeline SHA-256 + IPFS + on-chain (system.remark)
- Verification integrite des entrees sanctuaire
- Recherche par reference (document -> entrees sanctuaire)
- Serialisation deterministe des documents pour archivage
- 14 tests unitaires supplementaires (document service)

Frontend:
- 9 composants : StatusBadge, MarkdownRenderer, DiffView, ItemCard,
  ItemVersionDiff, DocumentList, SanctuaryEntry, IPFSLink, ChainAnchor
- Page detail item avec historique des versions et diff
- Page detail sanctuaire avec verification integrite
- Modal de creation de document + proposition de version
- Archivage document vers sanctuaire depuis la page detail

Documentation:
- API reference mise a jour (9 nouveaux endpoints)
- Guides utilisateur documents et sanctuaire enrichis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 13:08:48 +01:00
parent 25437f24e3
commit 2bdc731639
26 changed files with 3452 additions and 397 deletions

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
const props = defineProps<{
diff: string
}>()
interface DiffLine {
text: string
type: 'added' | 'removed' | 'header' | 'context'
}
const parsedLines = computed((): DiffLine[] => {
if (!props.diff) return []
return props.diff.split('\n').map((line) => {
if (line.startsWith('@@')) {
return { text: line, type: 'header' as const }
}
if (line.startsWith('+')) {
return { text: line, type: 'added' as const }
}
if (line.startsWith('-')) {
return { text: line, type: 'removed' as const }
}
return { text: line, type: 'context' as const }
})
})
function lineClass(type: DiffLine['type']): string {
switch (type) {
case 'added':
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
case 'removed':
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
case 'header':
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
default:
return 'text-gray-700 dark:text-gray-300'
}
}
</script>
<template>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
v-for="(line, index) in parsedLines"
:key="index"
><div
:class="['px-4 py-0.5', lineClass(line.type)]"
>{{ line.text }}</div></template></pre>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
const props = defineProps<{
content: string
}>()
/**
* Basic markdown rendering: bold, line breaks, unordered lists.
* Returns sanitized HTML string for v-html usage.
*/
const renderedHtml = computed(() => {
if (!props.content) return ''
let html = props.content
// Escape HTML entities to prevent XSS
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Convert **bold** to <strong>
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Convert *italic* to <em>
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Process lines for list items
const lines = html.split('\n')
const result: string[] = []
let inList = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('- ')) {
if (!inList) {
result.push('<ul class="list-disc list-inside space-y-1 my-2">')
inList = true
}
result.push(`<li>${trimmed.slice(2)}</li>`)
} else {
if (inList) {
result.push('</ul>')
inList = false
}
if (trimmed === '') {
result.push('<br>')
} else {
result.push(`<p class="my-1">${line}</p>`)
}
}
}
if (inList) {
result.push('</ul>')
}
return result.join('\n')
})
</script>
<template>
<div
class="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 leading-relaxed"
v-html="renderedHtml"
/>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
const props = defineProps<{
status: string
type?: 'document' | 'decision' | 'mandate' | 'version' | 'vote'
}>()
const statusConfig: Record<string, Record<string, { color: string; label: string }>> = {
document: {
draft: { color: 'warning', label: 'Brouillon' },
active: { color: 'success', label: 'Actif' },
archived: { color: 'neutral', label: 'Archive' },
},
version: {
proposed: { color: 'info', label: 'Propose' },
voting: { color: 'warning', label: 'En vote' },
accepted: { color: 'success', label: 'Accepte' },
rejected: { color: 'error', label: 'Rejete' },
},
decision: {
draft: { color: 'warning', label: 'Brouillon' },
qualification: { color: 'info', label: 'Qualification' },
review: { color: 'info', label: 'Revue' },
voting: { color: 'primary', label: 'En vote' },
executed: { color: 'success', label: 'Execute' },
closed: { color: 'neutral', label: 'Clos' },
},
mandate: {
draft: { color: 'warning', label: 'Brouillon' },
candidacy: { color: 'info', label: 'Candidature' },
voting: { color: 'primary', label: 'En vote' },
active: { color: 'success', label: 'Actif' },
reporting: { color: 'info', label: 'Rapport' },
completed: { color: 'neutral', label: 'Termine' },
revoked: { color: 'error', label: 'Revoque' },
},
vote: {
open: { color: 'success', label: 'Ouvert' },
closed: { color: 'warning', label: 'Ferme' },
tallied: { color: 'neutral', label: 'Depouille' },
},
}
const resolved = computed(() => {
const typeKey = props.type || 'document'
const typeMap = statusConfig[typeKey]
if (typeMap && typeMap[props.status]) {
return typeMap[props.status]
}
return { color: 'neutral', label: props.status }
})
</script>
<template>
<UBadge
:color="(resolved.color as any)"
variant="subtle"
size="xs"
>
{{ resolved.label }}
</UBadge>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { Document } from '~/stores/documents'
const props = withDefaults(defineProps<{
documents: Document[]
loading?: boolean
}>(), {
loading: false,
})
const typeLabel = (docType: string): string => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Reglement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<!-- Loading state -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
</div>
<!-- Empty state -->
<UCard v-else-if="documents.length === 0">
<div class="text-center py-8">
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun document de reference pour le moment</p>
</div>
</UCard>
<!-- Document grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<UCard
v-for="doc in documents"
:key="doc.id"
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<div class="space-y-3">
<!-- Header -->
<div class="flex items-start justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
{{ doc.title }}
</h3>
<CommonStatusBadge :status="doc.status" type="document" />
</div>
<!-- Type + Version -->
<div class="flex items-center gap-2">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(doc.doc_type) }}
</UBadge>
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
</div>
<!-- Description -->
<p
v-if="doc.description"
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
>
{{ doc.description }}
</p>
<!-- Footer -->
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-1 text-xs text-gray-500">
<UIcon name="i-lucide-list" class="text-sm" />
<span>{{ doc.items_count }} items</span>
</div>
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
</div>
</div>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { DocumentItem } from '~/stores/documents'
const props = withDefaults(defineProps<{
item: DocumentItem
documentSlug: string
showActions?: boolean
}>(), {
showActions: false,
})
const emit = defineEmits<{
propose: [item: DocumentItem]
}>()
const itemTypeLabel = (itemType: string): string => {
switch (itemType) {
case 'clause': return 'Clause'
case 'rule': return 'Regle'
case 'verification': return 'Verification'
case 'preamble': return 'Preambule'
case 'section': return 'Section'
default: return itemType
}
}
function navigateToItem() {
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateToItem"
>
<div class="space-y-3">
<!-- Item header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UBadge variant="solid" color="primary" size="xs">
{{ item.position }}
</UBadge>
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
{{ item.title }}
</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ itemTypeLabel(item.item_type) }}
</UBadge>
</div>
<div class="flex items-center gap-2">
<UBadge
v-if="item.voting_protocol_id"
color="info"
variant="subtle"
size="xs"
>
Sous vote
</UBadge>
<UBadge
v-else
color="neutral"
variant="subtle"
size="xs"
>
Pas de vote
</UBadge>
</div>
</div>
<!-- Item text -->
<div class="pl-2">
<CommonMarkdownRenderer :content="item.current_text" />
</div>
<!-- Actions -->
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
<UButton
label="Proposer une modification"
icon="i-lucide-pen-line"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('propose', item)"
/>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { ItemVersion } from '~/stores/documents'
const props = defineProps<{
version: ItemVersion
}>()
const emit = defineEmits<{
accept: [versionId: string]
reject: [versionId: string]
}>()
const auth = useAuthStore()
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncateAddress(address: string | null): string {
if (!address) return 'Inconnu'
if (address.length <= 16) return address
return address.slice(0, 8) + '...' + address.slice(-6)
}
</script>
<template>
<UCard>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<CommonStatusBadge :status="version.status" type="version" />
<span class="text-sm text-gray-500">
Propose par
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
{{ truncateAddress(version.proposed_by) }}
</span>
</span>
</div>
<span class="text-xs text-gray-400">
{{ formatDate(version.created_at) }}
</span>
</div>
<!-- Rationale -->
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
</div>
<!-- Diff view -->
<div v-if="version.diff">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
<CommonDiffView :diff="version.diff" />
</div>
<!-- Proposed text (fallback if no diff) -->
<div v-else-if="version.proposed_text">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
<CommonMarkdownRenderer :content="version.proposed_text" />
</div>
</div>
<!-- Actions for authenticated users -->
<div
v-if="auth.isAuthenticated && version.status === 'proposed'"
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
>
<UButton
label="Accepter"
icon="i-lucide-check"
color="success"
variant="soft"
size="xs"
@click="emit('accept', version.id)"
/>
<UButton
label="Rejeter"
icon="i-lucide-x"
color="error"
variant="soft"
size="xs"
@click="emit('reject', version.id)"
/>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
const props = defineProps<{
txHash: string | null
block: number | null
}>()
const truncatedHash = computed(() => {
if (!props.txHash) return null
if (props.txHash.length <= 20) return props.txHash
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
})
</script>
<template>
<template v-if="txHash">
<div class="inline-flex items-center gap-2">
<div class="flex items-center gap-1.5">
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
{{ truncatedHash }}
</span>
</div>
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
Bloc #{{ block.toLocaleString('fr-FR') }}
</UBadge>
</div>
</template>
<template v-else>
<UBadge color="warning" variant="subtle" size="xs">
Non ancre
</UBadge>
</template>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
const props = defineProps<{
cid: string | null
}>()
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
const truncatedCid = computed(() => {
if (!props.cid) return null
if (props.cid.length <= 20) return props.cid
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
})
const gatewayUrl = computed(() => {
if (!props.cid) return null
return `${IPFS_GATEWAY}${props.cid}`
})
</script>
<template>
<template v-if="cid">
<a
:href="gatewayUrl!"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
>
<UIcon name="i-lucide-hard-drive" class="text-sm" />
<span>{{ truncatedCid }}</span>
<UIcon name="i-lucide-external-link" class="text-sm" />
</a>
</template>
<template v-else>
<UBadge color="neutral" variant="subtle" size="xs">
Non disponible
</UBadge>
</template>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
export interface SanctuaryEntryOut {
id: string
entry_type: string
reference_id: string
title: string | null
content_hash: string
ipfs_cid: string | null
chain_tx_hash: string | null
chain_block: number | null
metadata_json: string | null
created_at: string
}
const props = defineProps<{
entry: SanctuaryEntryOut
}>()
const emit = defineEmits<{
verify: [id: string]
}>()
const typeLabel = (entryType: string): string => {
switch (entryType) {
case 'document': return 'Document'
case 'decision': return 'Decision'
case 'vote_result': return 'Resultat de vote'
default: return entryType
}
}
const typeColor = (entryType: string): string => {
switch (entryType) {
case 'document': return 'primary'
case 'decision': return 'success'
case 'vote_result': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncateHash(hash: string | null, length: number = 16): string {
if (!hash) return '-'
if (hash.length <= length * 2) return hash
return hash.slice(0, length) + '...' + hash.slice(-8)
}
const copied = ref(false)
async function copyHash() {
try {
await navigator.clipboard.writeText(props.entry.content_hash)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// Clipboard API not available
}
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateTo(`/sanctuary/${entry.id}`)"
>
<div class="space-y-4">
<!-- Entry header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ entry.title || 'Entree sans titre' }}
</h3>
<p class="text-xs text-gray-500">
{{ formatDate(entry.created_at) }}
</p>
</div>
</div>
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
{{ typeLabel(entry.entry_type) }}
</UBadge>
</div>
<!-- Hashes and anchors -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Content hash -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
</div>
<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
variant="ghost"
color="neutral"
size="xs"
class="p-0"
@click.stop="copyHash"
/>
</div>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
{{ truncateHash(entry.content_hash) }}
</p>
</div>
<!-- IPFS CID -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
</div>
<div @click.stop>
<SanctuaryIPFSLink :cid="entry.ipfs_cid" />
</div>
</div>
<!-- Chain anchor -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
</div>
<SanctuaryChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
</div>
</div>
<!-- Verification status indicators -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center gap-1">
<UIcon
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
/>
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
</span>
</div>
<div class="flex items-center gap-1">
<UIcon
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
/>
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
</span>
</div>
</div>
<UButton
label="Verifier"
icon="i-lucide-shield-check"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('verify', entry.id)"
/>
</div>
</div>
</UCard>
</template>