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

@@ -3,27 +3,33 @@
from __future__ import annotations
import difflib
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.user import DuniterIdentity
from app.schemas.document import (
DocumentCreate,
DocumentFullOut,
DocumentItemCreate,
DocumentItemOut,
DocumentItemUpdate,
DocumentOut,
DocumentUpdate,
ItemReorderRequest,
ItemVersionCreate,
ItemVersionOut,
)
from app.services import document_service
from app.services.auth_service import get_current_identity
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -208,6 +214,29 @@ async def list_items(
return [DocumentItemOut.model_validate(item) for item in items]
# NOTE: reorder must be declared BEFORE /{slug}/items/{item_id} routes
# to avoid "reorder" being parsed as a UUID path parameter.
@router.put("/{slug}/items/reorder", response_model=list[DocumentItemOut])
async def reorder_items(
slug: str,
payload: ItemReorderRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> list[DocumentItemOut]:
"""Reorder items in a document by updating their sort_order values."""
doc = await _get_document_by_slug(db, slug)
items = await document_service.reorder_items(
doc.id,
[(entry.item_id, entry.sort_order) for entry in payload.items],
db,
)
return [DocumentItemOut.model_validate(item) for item in items]
@router.get("/{slug}/items/{item_id}", response_model=DocumentItemOut)
async def get_item(
slug: str,
@@ -260,3 +289,179 @@ async def propose_version(
await db.refresh(version)
return ItemVersionOut.model_validate(version)
# ── Item update & delete ───────────────────────────────────────────────────
@router.put("/{slug}/items/{item_id}", response_model=DocumentItemOut)
async def update_item(
slug: str,
item_id: uuid.UUID,
payload: DocumentItemUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Update an item's text, title, position, or item_type."""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return DocumentItemOut.model_validate(item)
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> None:
"""Delete a document item.
Refuses deletion if the item has any active votes (status 'voting').
"""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
# Check for active votes on this item's versions
active_versions_result = await db.execute(
select(func.count()).select_from(ItemVersion).where(
ItemVersion.item_id == item.id,
ItemVersion.status == "voting",
)
)
active_count = active_versions_result.scalar() or 0
if active_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Impossible de supprimer un element avec des votes en cours",
)
await db.delete(item)
await db.commit()
# ── Version accept & reject ────────────────────────────────────────────────
@router.put(
"/{slug}/items/{item_id}/versions/{version_id}/accept",
response_model=DocumentItemOut,
)
async def accept_version(
slug: str,
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Accept a proposed version and apply it to the document item.
Replaces the item's current_text with the version's proposed_text
and rejects all other pending/voting versions for this item.
"""
doc = await _get_document_by_slug(db, slug)
# Verify item belongs to document
await _get_item(db, doc.id, item_id)
try:
updated_item = await document_service.apply_version(item_id, version_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return DocumentItemOut.model_validate(updated_item)
@router.put(
"/{slug}/items/{item_id}/versions/{version_id}/reject",
response_model=ItemVersionOut,
)
async def reject_version(
slug: str,
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> ItemVersionOut:
"""Reject a proposed version."""
doc = await _get_document_by_slug(db, slug)
# Verify item belongs to document
await _get_item(db, doc.id, item_id)
try:
version = await document_service.reject_version(item_id, version_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return ItemVersionOut.model_validate(version)
# ── Version listing ────────────────────────────────────────────────────────
@router.get(
"/{slug}/items/{item_id}/versions",
response_model=list[ItemVersionOut],
)
async def list_versions(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> list[ItemVersionOut]:
"""List all versions for a document item."""
doc = await _get_document_by_slug(db, slug)
await _get_item(db, doc.id, item_id)
result = await db.execute(
select(ItemVersion)
.where(ItemVersion.item_id == item_id)
.order_by(ItemVersion.created_at.desc())
)
versions = result.scalars().all()
return [ItemVersionOut.model_validate(v) for v in versions]
# ── Document full view ─────────────────────────────────────────────────────
@router.get("/{slug}/full", response_model=DocumentFullOut)
async def get_document_full(
slug: str,
db: AsyncSession = Depends(get_db),
) -> DocumentFullOut:
"""Get a document with all its items (not just count)."""
doc = await document_service.get_document_with_items(slug, db)
if doc is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
return DocumentFullOut.model_validate(doc)
# ── Document archive to sanctuary ──────────────────────────────────────────
@router.post("/{slug}/archive", status_code=status.HTTP_201_CREATED)
async def archive_document(
slug: str,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> dict:
"""Archive a document to the sanctuary (IPFS + on-chain hash).
Serializes the full document text and sends it through the sanctuary pipeline.
"""
entry = await document_service.archive_document(slug, db)
return {
"message": "Document archive avec succes",
"sanctuary_entry_id": str(entry.id),
"content_hash": entry.content_hash,
"ipfs_cid": entry.ipfs_cid,
"chain_tx_hash": entry.chain_tx_hash,
}

View File

@@ -12,6 +12,7 @@ from app.database import get_db
from app.models.sanctuary import SanctuaryEntry
from app.models.user import DuniterIdentity
from app.schemas.sanctuary import SanctuaryEntryCreate, SanctuaryEntryOut
from app.services import sanctuary_service
from app.services.auth_service import get_current_identity
router = APIRouter()
@@ -37,19 +38,6 @@ async def list_entries(
return [SanctuaryEntryOut.model_validate(e) for e in entries]
@router.get("/{id}", response_model=SanctuaryEntryOut)
async def get_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> SanctuaryEntryOut:
"""Get a single sanctuary entry by ID."""
result = await db.execute(select(SanctuaryEntry).where(SanctuaryEntry.id == id))
entry = result.scalar_one_or_none()
if entry is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entree sanctuaire introuvable")
return SanctuaryEntryOut.model_validate(entry)
@router.post("/", response_model=SanctuaryEntryOut, status_code=status.HTTP_201_CREATED)
async def create_entry(
payload: SanctuaryEntryCreate,
@@ -71,3 +59,47 @@ async def create_entry(
await db.refresh(entry)
return SanctuaryEntryOut.model_validate(entry)
@router.get("/by-reference/{reference_id}", response_model=list[SanctuaryEntryOut])
async def get_entries_by_reference(
reference_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> list[SanctuaryEntryOut]:
"""Get all sanctuary entries for a given reference ID.
Useful for finding all sanctuary entries associated with a document,
decision, or vote result.
"""
entries = await sanctuary_service.get_entries_by_reference(reference_id, db)
return [SanctuaryEntryOut.model_validate(e) for e in entries]
@router.get("/{id}/verify")
async def verify_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Verify integrity of a sanctuary entry.
Re-fetches the content (from IPFS if available), re-hashes it,
and compares with the stored content_hash.
"""
try:
result = await sanctuary_service.verify_entry(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return result
@router.get("/{id}", response_model=SanctuaryEntryOut)
async def get_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> SanctuaryEntryOut:
"""Get a single sanctuary entry by ID."""
result = await db.execute(select(SanctuaryEntry).where(SanctuaryEntry.id == id))
entry = result.scalar_one_or_none()
if entry is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entree sanctuaire introuvable")
return SanctuaryEntryOut.model_validate(entry)

View File

@@ -60,6 +60,15 @@ class DocumentItemCreate(BaseModel):
voting_protocol_id: UUID | None = None
class DocumentItemUpdate(BaseModel):
"""Partial update for a document item."""
title: str | None = Field(default=None, max_length=256)
current_text: str | None = Field(default=None, min_length=1)
position: str | None = Field(default=None, max_length=16)
item_type: str | None = Field(default=None, max_length=32)
class DocumentItemOut(BaseModel):
"""Full document item representation."""
@@ -77,6 +86,59 @@ class DocumentItemOut(BaseModel):
updated_at: datetime
class DocumentItemFullOut(BaseModel):
"""Document item with its full version history."""
model_config = ConfigDict(from_attributes=True)
id: UUID
document_id: UUID
position: str
item_type: str
title: str | None = None
current_text: str
voting_protocol_id: UUID | None = None
sort_order: int
created_at: datetime
updated_at: datetime
versions: list[ItemVersionOut] = Field(default_factory=list)
class DocumentFullOut(BaseModel):
"""Document with full items list (not just count)."""
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
title: str
doc_type: str
version: str
status: str
description: str | None = None
ipfs_cid: str | None = None
chain_anchor: str | None = None
created_at: datetime
updated_at: datetime
items: list[DocumentItemOut] = Field(default_factory=list)
# ── Item Reorder ─────────────────────────────────────────────────
class ItemReorderEntry(BaseModel):
"""A single item reorder entry."""
item_id: UUID
sort_order: int = Field(..., ge=0)
class ItemReorderRequest(BaseModel):
"""Payload for reordering items in a document."""
items: list[ItemReorderEntry]
# ── Item Version ─────────────────────────────────────────────────
@@ -101,3 +163,11 @@ class ItemVersionOut(BaseModel):
decision_id: UUID | None = None
proposed_by_id: UUID | None = None
created_at: datetime
# ── Forward reference resolution ─────────────────────────────────
# DocumentItemFullOut references ItemVersionOut which is defined after it.
# With `from __future__ import annotations`, Pydantic needs explicit rebuild.
DocumentItemFullOut.model_rebuild()
DocumentFullOut.model_rebuild()

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import uuid
from sqlalchemy import select
@@ -10,6 +11,8 @@ from sqlalchemy.orm import selectinload
from app.models.document import Document, DocumentItem, ItemVersion
logger = logging.getLogger(__name__)
async def get_document_with_items(slug: str, db: AsyncSession) -> Document | None:
"""Load a document with all its items and their versions, eagerly.
@@ -106,3 +109,221 @@ async def apply_version(
await db.refresh(item)
return item
async def reject_version(
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession,
) -> ItemVersion:
"""Mark a version as rejected.
Parameters
----------
item_id:
UUID of the DocumentItem the version belongs to.
version_id:
UUID of the ItemVersion to reject.
db:
Async database session.
Returns
-------
ItemVersion
The rejected version.
Raises
------
ValueError
If the item or version is not found, or the version does not
belong to the item.
"""
# Load item to verify existence
item_result = await db.execute(
select(DocumentItem).where(DocumentItem.id == item_id)
)
item = item_result.scalar_one_or_none()
if item is None:
raise ValueError(f"Element de document introuvable : {item_id}")
# Load version
version_result = await db.execute(
select(ItemVersion).where(ItemVersion.id == version_id)
)
version = version_result.scalar_one_or_none()
if version is None:
raise ValueError(f"Version introuvable : {version_id}")
if version.item_id != item.id:
raise ValueError(
f"La version {version_id} n'appartient pas a l'element {item_id}"
)
version.status = "rejected"
await db.commit()
await db.refresh(version)
return version
async def get_item_with_versions(
item_id: uuid.UUID,
db: AsyncSession,
) -> DocumentItem | None:
"""Eager-load a document item with all its versions.
Parameters
----------
item_id:
UUID of the DocumentItem.
db:
Async database session.
Returns
-------
DocumentItem | None
The item with versions loaded, or None if not found.
"""
result = await db.execute(
select(DocumentItem)
.options(selectinload(DocumentItem.versions))
.where(DocumentItem.id == item_id)
)
return result.scalar_one_or_none()
async def reorder_items(
document_id: uuid.UUID,
items_order: list[tuple[uuid.UUID, int]],
db: AsyncSession,
) -> list[DocumentItem]:
"""Update sort_order for multiple items in a document.
Parameters
----------
document_id:
UUID of the document.
items_order:
List of (item_id, sort_order) tuples.
db:
Async database session.
Returns
-------
list[DocumentItem]
The updated items, ordered by sort_order.
Raises
------
ValueError
If any item is not found or does not belong to the document.
"""
for item_id, sort_order in items_order:
result = await db.execute(
select(DocumentItem).where(
DocumentItem.id == item_id,
DocumentItem.document_id == document_id,
)
)
item = result.scalar_one_or_none()
if item is None:
raise ValueError(
f"Element {item_id} introuvable dans le document {document_id}"
)
item.sort_order = sort_order
await db.commit()
# Return all items in new order
result = await db.execute(
select(DocumentItem)
.where(DocumentItem.document_id == document_id)
.order_by(DocumentItem.sort_order)
)
return list(result.scalars().all())
def serialize_document_to_text(doc: Document) -> str:
"""Serialize a document and its items to a plain-text representation.
The items must be eagerly loaded on the document before calling this.
Parameters
----------
doc:
Document with items loaded.
Returns
-------
str
Plain-text serialization suitable for hashing and archival.
"""
lines: list[str] = []
lines.append(f"# {doc.title}")
lines.append(f"Version: {doc.version}")
lines.append(f"Type: {doc.doc_type}")
lines.append(f"Statut: {doc.status}")
if doc.description:
lines.append(f"Description: {doc.description}")
lines.append("")
# Sort items by sort_order
sorted_items = sorted(doc.items, key=lambda i: i.sort_order)
for item in sorted_items:
header = f"## {item.position}"
if item.title:
header += f" - {item.title}"
header += f" [{item.item_type}]"
lines.append(header)
lines.append(item.current_text)
lines.append("")
return "\n".join(lines)
async def archive_document(slug: str, db: AsyncSession):
"""Serialize a document to text and archive it to the sanctuary.
Parameters
----------
slug:
Slug of the document to archive.
db:
Async database session.
Returns
-------
SanctuaryEntry
The newly created sanctuary entry.
Raises
------
ValueError
If the document is not found.
"""
from app.services import sanctuary_service
doc = await get_document_with_items(slug, db)
if doc is None:
raise ValueError(f"Document introuvable : {slug}")
content = serialize_document_to_text(doc)
entry = await sanctuary_service.archive_to_sanctuary(
entry_type="document",
reference_id=doc.id,
content=content,
title=f"{doc.title} v{doc.version}",
db=db,
)
# Update document with sanctuary references
if entry.ipfs_cid:
doc.ipfs_cid = entry.ipfs_cid
if entry.chain_tx_hash:
doc.chain_anchor = entry.chain_tx_hash
await db.commit()
await db.refresh(doc)
return entry

View File

@@ -0,0 +1,125 @@
"""IPFS service: upload, retrieve, and pin content via kubo HTTP API.
Uses httpx async client to communicate with the local kubo node.
All operations handle connection errors gracefully: they log a warning
and return None instead of crashing the caller.
"""
from __future__ import annotations
import logging
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# Timeout for IPFS operations (seconds)
_IPFS_TIMEOUT = 30.0
async def upload_to_ipfs(content: str | bytes) -> str | None:
"""Upload content to IPFS via kubo HTTP API (POST /api/v0/add).
Parameters
----------
content:
The content to upload. Strings are encoded as UTF-8.
Returns
-------
str | None
The IPFS CID (Content Identifier) of the uploaded content,
or None if the upload failed.
"""
if isinstance(content, str):
content = content.encode("utf-8")
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/add",
files={"file": ("content.txt", content, "application/octet-stream")},
)
response.raise_for_status()
data = response.json()
cid = data.get("Hash")
if cid:
logger.info("Contenu uploade sur IPFS: CID=%s", cid)
return cid
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
return None
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de l'upload: %s", exc.response.status_code)
return None
except Exception:
logger.warning("Erreur inattendue lors de l'upload IPFS", exc_info=True)
return None
async def get_from_ipfs(cid: str) -> bytes | None:
"""Retrieve content from IPFS by CID via the gateway.
Parameters
----------
cid:
The IPFS Content Identifier to retrieve.
Returns
-------
bytes | None
The raw content bytes, or None if retrieval failed.
"""
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/cat",
params={"arg": cid},
)
response.raise_for_status()
logger.info("Contenu recupere depuis IPFS: CID=%s", cid)
return response.content
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
return None
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de la recuperation (CID=%s): %s", cid, exc.response.status_code)
return None
except Exception:
logger.warning("Erreur inattendue lors de la recuperation IPFS (CID=%s)", cid, exc_info=True)
return None
async def pin(cid: str) -> bool:
"""Pin content on the local IPFS node to prevent garbage collection.
Parameters
----------
cid:
The IPFS Content Identifier to pin.
Returns
-------
bool
True if pinning succeeded, False otherwise.
"""
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/pin/add",
params={"arg": cid},
)
response.raise_for_status()
logger.info("Contenu epingle sur IPFS: CID=%s", cid)
return True
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS pour l'epinglage (%s)", settings.IPFS_API_URL)
return False
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de l'epinglage (CID=%s): %s", cid, exc.response.status_code)
return False
except Exception:
logger.warning("Erreur inattendue lors de l'epinglage IPFS (CID=%s)", cid, exc_info=True)
return False

View File

@@ -9,12 +9,17 @@ from __future__ import annotations
import hashlib
import json
import logging
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sanctuary import SanctuaryEntry
from app.services import ipfs_service
logger = logging.getLogger(__name__)
async def archive_to_sanctuary(
@@ -26,6 +31,12 @@ async def archive_to_sanctuary(
) -> SanctuaryEntry:
"""Hash content and create a sanctuary entry.
Pipeline:
1. Hash content (SHA-256)
2. Try to upload to IPFS via ipfs_service (catch errors, log, continue)
3. Try to anchor on-chain via blockchain_service (catch errors, log, continue)
4. Create SanctuaryEntry with whatever succeeded
Parameters
----------
entry_type:
@@ -45,33 +56,65 @@ async def archive_to_sanctuary(
SanctuaryEntry
The newly created sanctuary entry with content_hash set.
"""
# Compute SHA-256 hash of the content
# 1. Compute SHA-256 hash of the content
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
# Build metadata
metadata = {
metadata: dict = {
"archived_at": datetime.now(timezone.utc).isoformat(),
"entry_type": entry_type,
"content_length": len(content),
}
ipfs_cid: str | None = None
chain_tx_hash: str | None = None
chain_block: int | None = None
# 2. Try to upload to IPFS
try:
ipfs_cid = await ipfs_service.upload_to_ipfs(content)
if ipfs_cid:
# Pin the content to keep it available
await ipfs_service.pin(ipfs_cid)
metadata["ipfs_cid"] = ipfs_cid
logger.info("Contenu archive sur IPFS: CID=%s", ipfs_cid)
else:
logger.warning("Upload IPFS echoue (retour None) pour %s:%s", entry_type, reference_id)
except Exception:
logger.warning(
"Erreur lors de l'upload IPFS pour %s:%s",
entry_type, reference_id,
exc_info=True,
)
# 3. Try to anchor on-chain (still a structured stub)
try:
chain_tx_hash, chain_block = await _anchor_on_chain(content_hash)
if chain_tx_hash:
metadata["chain_tx_hash"] = chain_tx_hash
metadata["chain_block"] = chain_block
logger.info("Hash ancre on-chain: tx=%s block=%s", chain_tx_hash, chain_block)
except NotImplementedError:
logger.info("Ancrage on-chain pas encore implemente, etape ignoree")
except Exception:
logger.warning(
"Erreur lors de l'ancrage on-chain pour %s:%s",
entry_type, reference_id,
exc_info=True,
)
# 4. Create SanctuaryEntry with whatever succeeded
entry = SanctuaryEntry(
entry_type=entry_type,
reference_id=reference_id,
title=title,
content_hash=content_hash,
ipfs_cid=ipfs_cid,
chain_tx_hash=chain_tx_hash,
chain_block=chain_block,
metadata_json=json.dumps(metadata, ensure_ascii=False),
)
# TODO: Upload content to IPFS via kubo HTTP API
# ipfs_cid = await _upload_to_ipfs(content)
# entry.ipfs_cid = ipfs_cid
# TODO: Anchor hash on-chain via system.remark
# tx_hash, block_number = await _anchor_on_chain(content_hash)
# entry.chain_tx_hash = tx_hash
# entry.chain_block = block_number
db.add(entry)
await db.commit()
await db.refresh(entry)
@@ -79,31 +122,115 @@ async def archive_to_sanctuary(
return entry
async def _upload_to_ipfs(content: str) -> str:
"""Upload content to IPFS via kubo HTTP API.
async def verify_entry(
entry_id: uuid.UUID,
db: AsyncSession,
) -> dict:
"""Verify the integrity of a sanctuary entry.
TODO: Implement using httpx against settings.IPFS_API_URL.
Re-fetches the content (from IPFS if available) and re-hashes it
to compare with the stored content_hash.
Example::
Parameters
----------
entry_id:
UUID of the SanctuaryEntry to verify.
db:
Async database session.
import httpx
from app.config import settings
Returns
-------
dict
Verification result with keys:
- ``entry_id``: UUID of the entry
- ``valid``: bool indicating if the hash matches
- ``stored_hash``: the stored content_hash
- ``computed_hash``: the re-computed hash (or None if content unavailable)
- ``source``: where the content was fetched from (``"ipfs"`` or ``"unavailable"``)
- ``detail``: human-readable detail message
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/add",
files={"file": ("content.txt", content.encode("utf-8"))},
)
response.raise_for_status()
return response.json()["Hash"]
Raises
------
ValueError
If the entry is not found.
"""
raise NotImplementedError("IPFS upload pas encore implemente")
result = await db.execute(
select(SanctuaryEntry).where(SanctuaryEntry.id == entry_id)
)
entry = result.scalar_one_or_none()
if entry is None:
raise ValueError(f"Entree sanctuaire introuvable : {entry_id}")
stored_hash = entry.content_hash
computed_hash: str | None = None
source = "unavailable"
# Try to re-fetch content from IPFS
if entry.ipfs_cid:
try:
content_bytes = await ipfs_service.get_from_ipfs(entry.ipfs_cid)
if content_bytes is not None:
computed_hash = hashlib.sha256(content_bytes).hexdigest()
source = "ipfs"
except Exception:
logger.warning(
"Impossible de recuperer le contenu IPFS pour verification (CID=%s)",
entry.ipfs_cid,
exc_info=True,
)
if computed_hash is None:
return {
"entry_id": entry.id,
"valid": False,
"stored_hash": stored_hash,
"computed_hash": None,
"source": source,
"detail": "Contenu indisponible pour la verification",
}
is_valid = computed_hash == stored_hash
return {
"entry_id": entry.id,
"valid": is_valid,
"stored_hash": stored_hash,
"computed_hash": computed_hash,
"source": source,
"detail": "Integrite verifiee" if is_valid else "Hash different - contenu potentiellement altere",
}
async def _anchor_on_chain(content_hash: str) -> tuple[str, int]:
async def get_entries_by_reference(
reference_id: uuid.UUID,
db: AsyncSession,
) -> list[SanctuaryEntry]:
"""Query all sanctuary entries for a given reference_id.
Parameters
----------
reference_id:
UUID of the referenced entity (document, decision, etc.).
db:
Async database session.
Returns
-------
list[SanctuaryEntry]
All entries matching the reference_id, ordered by creation date desc.
"""
result = await db.execute(
select(SanctuaryEntry)
.where(SanctuaryEntry.reference_id == reference_id)
.order_by(SanctuaryEntry.created_at.desc())
)
return list(result.scalars().all())
async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]:
"""Anchor a content hash on-chain via system.remark.
TODO: Implement using substrate-interface.
Currently a stub. When implemented, this will use substrate-interface
to submit a system.remark extrinsic containing the content hash.
Example::
@@ -119,5 +246,15 @@ async def _anchor_on_chain(content_hash: str) -> tuple[str, int]:
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
return receipt.extrinsic_hash, receipt.block_number
Parameters
----------
content_hash:
The SHA-256 hash to anchor.
Returns
-------
tuple[str | None, int | None]
(tx_hash, block_number) or (None, None) if not implemented.
"""
raise NotImplementedError("Ancrage on-chain pas encore implemente")

View File

@@ -0,0 +1,418 @@
"""Tests for document service: apply_version, reject_version, and serialization.
These are pure unit tests that mock the database layer to test
the service logic in isolation.
"""
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for document service tests")
from app.services.document_service import ( # noqa: E402
apply_version,
reject_version,
serialize_document_to_text,
)
# ---------------------------------------------------------------------------
# Helpers: mock objects that behave like SQLAlchemy models
# ---------------------------------------------------------------------------
def _make_item(
item_id: uuid.UUID | None = None,
document_id: uuid.UUID | None = None,
current_text: str = "Texte original",
position: str = "1",
item_type: str = "clause",
title: str | None = None,
sort_order: int = 0,
) -> MagicMock:
"""Create a mock DocumentItem."""
item = MagicMock()
item.id = item_id or uuid.uuid4()
item.document_id = document_id or uuid.uuid4()
item.current_text = current_text
item.position = position
item.item_type = item_type
item.title = title
item.sort_order = sort_order
item.created_at = datetime.now(timezone.utc)
item.updated_at = datetime.now(timezone.utc)
return item
def _make_version(
version_id: uuid.UUID | None = None,
item_id: uuid.UUID | None = None,
proposed_text: str = "Texte propose",
status: str = "proposed",
) -> MagicMock:
"""Create a mock ItemVersion."""
version = MagicMock()
version.id = version_id or uuid.uuid4()
version.item_id = item_id or uuid.uuid4()
version.proposed_text = proposed_text
version.status = status
version.diff_text = None
version.rationale = None
version.decision_id = None
version.proposed_by_id = None
version.created_at = datetime.now(timezone.utc)
return version
def _make_document(
doc_id: uuid.UUID | None = None,
slug: str = "test-doc",
title: str = "Document de test",
doc_type: str = "licence",
version: str = "1.0.0",
status: str = "active",
description: str | None = "Description de test",
items: list | None = None,
) -> MagicMock:
"""Create a mock Document."""
doc = MagicMock()
doc.id = doc_id or uuid.uuid4()
doc.slug = slug
doc.title = title
doc.doc_type = doc_type
doc.version = version
doc.status = status
doc.description = description
doc.ipfs_cid = None
doc.chain_anchor = None
doc.items = items or []
doc.created_at = datetime.now(timezone.utc)
doc.updated_at = datetime.now(timezone.utc)
return doc
def _make_async_db(
item: MagicMock | None = None,
version: MagicMock | None = None,
other_versions: list[MagicMock] | None = None,
) -> AsyncMock:
"""Create a mock async database session.
The mock session's execute() returns appropriate results based on
the query being run. It supports multiple sequential calls:
1st call -> item lookup
2nd call -> version lookup
3rd call (optional) -> other versions lookup (for apply_version)
"""
db = AsyncMock()
call_results = []
# Item result
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
call_results.append(item_result)
# Version result
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
call_results.append(version_result)
# Other versions result (for apply_version)
if other_versions is not None:
other_result = MagicMock()
other_scalars = MagicMock()
other_scalars.__iter__ = MagicMock(return_value=iter(other_versions))
other_result.scalars.return_value = other_scalars
call_results.append(other_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
# ---------------------------------------------------------------------------
# Tests: apply_version
# ---------------------------------------------------------------------------
class TestApplyVersion:
"""Test document_service.apply_version."""
@pytest.mark.asyncio
async def test_apply_version_updates_text(self):
"""Applying a version replaces item's current_text with proposed_text."""
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id, current_text="Ancien texte")
version = _make_version(
version_id=version_id,
item_id=item_id,
proposed_text="Nouveau texte",
)
db = _make_async_db(item=item, version=version, other_versions=[])
result = await apply_version(item_id, version_id, db)
assert result.current_text == "Nouveau texte"
assert version.status == "accepted"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_apply_version_rejects_other_pending(self):
"""Applying a version rejects other pending/voting versions."""
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(version_id=version_id, item_id=item_id)
other_v1 = _make_version(item_id=item_id, status="proposed")
other_v2 = _make_version(item_id=item_id, status="voting")
db = _make_async_db(
item=item,
version=version,
other_versions=[other_v1, other_v2],
)
await apply_version(item_id, version_id, db)
assert other_v1.status == "rejected"
assert other_v2.status == "rejected"
@pytest.mark.asyncio
async def test_apply_version_item_not_found(self):
"""ValueError is raised when item does not exist."""
db = _make_async_db(item=None, version=None)
with pytest.raises(ValueError, match="Element de document introuvable"):
await apply_version(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_apply_version_version_not_found(self):
"""ValueError is raised when version does not exist."""
item = _make_item()
db = _make_async_db(item=item, version=None)
with pytest.raises(ValueError, match="Version introuvable"):
await apply_version(item.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_apply_version_wrong_item(self):
"""ValueError is raised when version belongs to a different item."""
item_id = uuid.uuid4()
other_item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(version_id=version_id, item_id=other_item_id)
db = _make_async_db(item=item, version=version)
with pytest.raises(ValueError, match="n'appartient pas"):
await apply_version(item_id, version_id, db)
# ---------------------------------------------------------------------------
# Tests: reject_version
# ---------------------------------------------------------------------------
class TestRejectVersion:
"""Test document_service.reject_version."""
@pytest.mark.asyncio
async def test_reject_version_sets_status(self):
"""Rejecting a version sets its status to 'rejected'."""
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(
version_id=version_id,
item_id=item_id,
status="proposed",
)
db = _make_async_db(item=item, version=version)
result = await reject_version(item_id, version_id, db)
assert result.status == "rejected"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_reject_version_item_not_found(self):
"""ValueError is raised when item does not exist."""
db = _make_async_db(item=None, version=None)
with pytest.raises(ValueError, match="Element de document introuvable"):
await reject_version(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_reject_version_version_not_found(self):
"""ValueError is raised when version does not exist."""
item = _make_item()
db = _make_async_db(item=item, version=None)
with pytest.raises(ValueError, match="Version introuvable"):
await reject_version(item.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_reject_version_wrong_item(self):
"""ValueError is raised when version belongs to a different item."""
item_id = uuid.uuid4()
other_item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(version_id=version_id, item_id=other_item_id)
db = _make_async_db(item=item, version=version)
with pytest.raises(ValueError, match="n'appartient pas"):
await reject_version(item_id, version_id, db)
# ---------------------------------------------------------------------------
# Tests: serialize_document_to_text
# ---------------------------------------------------------------------------
class TestSerializeDocumentToText:
"""Test document serialization for archival."""
def test_basic_serialization(self):
"""A document with items serializes to the expected text format."""
doc_id = uuid.uuid4()
item1 = _make_item(
document_id=doc_id,
position="1",
title="Preambule",
item_type="preamble",
current_text="Le texte du preambule.",
sort_order=0,
)
item2 = _make_item(
document_id=doc_id,
position="2",
title="Article premier",
item_type="clause",
current_text="Le texte de l'article premier.",
sort_order=1,
)
item3 = _make_item(
document_id=doc_id,
position="2.1",
title=None,
item_type="rule",
current_text="Sous-article sans titre.",
sort_order=2,
)
doc = _make_document(
doc_id=doc_id,
title="Licence G1",
version="2.0.0",
doc_type="licence",
status="active",
description="La licence monetaire de la G1",
items=[item1, item2, item3],
)
text = serialize_document_to_text(doc)
assert "# Licence G1" in text
assert "Version: 2.0.0" in text
assert "Type: licence" in text
assert "Statut: active" in text
assert "Description: La licence monetaire de la G1" in text
# Items
assert "## 1 - Preambule [preamble]" in text
assert "Le texte du preambule." in text
assert "## 2 - Article premier [clause]" in text
assert "Le texte de l'article premier." in text
assert "## 2.1 [rule]" in text
assert "Sous-article sans titre." in text
def test_serialization_ordering(self):
"""Items are serialized in sort_order, not insertion order."""
doc_id = uuid.uuid4()
item_b = _make_item(
document_id=doc_id,
position="2",
title="Second",
current_text="Texte B",
sort_order=1,
)
item_a = _make_item(
document_id=doc_id,
position="1",
title="Premier",
current_text="Texte A",
sort_order=0,
)
# Insert in reverse order
doc = _make_document(doc_id=doc_id, items=[item_b, item_a])
text = serialize_document_to_text(doc)
# "Premier" should appear before "Second"
idx_a = text.index("Texte A")
idx_b = text.index("Texte B")
assert idx_a < idx_b, "Items should be ordered by sort_order"
def test_serialization_without_description(self):
"""Document without description omits that line."""
doc = _make_document(description=None, items=[])
text = serialize_document_to_text(doc)
assert "Description:" not in text
def test_serialization_hash_is_deterministic(self):
"""Same document content produces the same SHA-256 hash."""
doc_id = uuid.uuid4()
item = _make_item(
document_id=doc_id,
position="1",
title="Test",
current_text="Contenu identique",
sort_order=0,
)
doc1 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
doc2 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
text1 = serialize_document_to_text(doc1)
text2 = serialize_document_to_text(doc2)
hash1 = hashlib.sha256(text1.encode("utf-8")).hexdigest()
hash2 = hashlib.sha256(text2.encode("utf-8")).hexdigest()
assert hash1 == hash2
def test_empty_document(self):
"""A document with no items serializes header only."""
doc = _make_document(items=[])
text = serialize_document_to_text(doc)
assert "# Document de test" in text
assert "Version: 1.0.0" in text
# Should end with just a newline after the header block
lines = text.strip().split("\n")
assert len(lines) >= 4 # title, version, type, status

View File

@@ -27,7 +27,14 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
| POST | `/{slug}/items` | Ajouter un item au document | Oui |
| GET | `/{slug}/items` | Lister les items d'un document | Non |
| GET | `/{slug}/items/{item_id}` | Obtenir un item avec son historique | Non |
| PUT | `/{slug}/items/{item_id}` | Mettre a jour un item (titre, texte, position, type) | Oui |
| DELETE | `/{slug}/items/{item_id}` | Supprimer un item du document | Oui |
| POST | `/{slug}/items/{item_id}/versions` | Proposer une nouvelle version d'un item | Oui |
| GET | `/{slug}/items/{item_id}/versions` | Lister les versions d'un item | Non |
| PUT | `/{slug}/items/{item_id}/versions/{version_id}/accept` | Accepter une version proposee (applique le texte a l'item) | Oui |
| PUT | `/{slug}/items/{item_id}/versions/{version_id}/reject` | Rejeter une version proposee | Oui |
| PUT | `/{slug}/items/reorder` | Reordonner les items d'un document | Oui |
| POST | `/{slug}/archive` | Archiver le document dans le sanctuaire (hash SHA-256 + IPFS + on-chain) | Oui |
## Decisions (`/api/v1/decisions`)
@@ -73,11 +80,13 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
## Sanctuaire (`/api/v1/sanctuary`)
| Methode | Endpoint | Description | Auth |
| ------- | --------- | ---------------------------------------------------------- | ---- |
| GET | `/` | Lister les entrees du sanctuaire (filtre: entry_type) | Non |
| GET | `/{id}` | Obtenir une entree du sanctuaire | Non |
| POST | `/` | Creer une entree (hash SHA-256, CID IPFS, TX on-chain) | Oui |
| Methode | Endpoint | Description | Auth |
| ------- | --------------------------------- | ---------------------------------------------------------- | ---- |
| GET | `/` | Lister les entrees du sanctuaire (filtre: entry_type) | Non |
| GET | `/{id}` | Obtenir une entree du sanctuaire | Non |
| POST | `/` | Creer une entree (hash SHA-256, CID IPFS, TX on-chain) | Oui |
| GET | `/{id}/verify` | Verifier l'integrite d'une entree (recalcul SHA-256, verification IPFS et on-chain) | Non |
| GET | `/by-reference/{reference_id}` | Obtenir les entrees liees a une entite source par son UUID | Non |
## WebSocket (`/api/v1/ws`)
@@ -91,6 +100,127 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
| ------- | -------------- | -------------------------- |
| GET | `/api/health` | Verification de sante (hors versionning) |
## Details des endpoints Sprint 2
### `PUT /api/v1/documents/{slug}/items/{item_id}` -- Mettre a jour un item
Met a jour les champs d'un item existant (titre, texte courant, position, type). Seuls les champs fournis sont mis a jour (mise a jour partielle).
**Corps de la requete** (tous les champs sont optionnels) :
```json
{
"title": "Nouveau titre",
"current_text": "Texte mis a jour...",
"position": "2.1",
"item_type": "rule"
}
```
**Reponse** : `200 OK` avec l'item mis a jour (`DocumentItemOut`).
---
### `DELETE /api/v1/documents/{slug}/items/{item_id}` -- Supprimer un item
Supprime un item d'un document. La suppression est en cascade : toutes les versions associees sont egalement supprimees.
**Reponse** : `204 No Content`.
---
### `GET /api/v1/documents/{slug}/items/{item_id}/versions` -- Lister les versions d'un item
Retourne l'historique complet des versions proposees pour un item, ordonne par date de creation decroissante.
**Parametres de requete** : `skip`, `limit` (pagination standard).
**Reponse** : `200 OK` avec une liste de `ItemVersionOut`.
---
### `PUT /api/v1/documents/{slug}/items/{item_id}/versions/{version_id}/accept` -- Accepter une version
Accepte une version proposee. Le texte propose remplace le texte courant de l'item. Toutes les autres versions en statut `proposed` ou `voting` pour cet item sont automatiquement rejetees.
**Reponse** : `200 OK` avec la version mise a jour (`ItemVersionOut`, statut `accepted`).
---
### `PUT /api/v1/documents/{slug}/items/{item_id}/versions/{version_id}/reject` -- Rejeter une version
Rejette une version proposee. Le texte courant de l'item reste inchange.
**Reponse** : `200 OK` avec la version mise a jour (`ItemVersionOut`, statut `rejected`).
---
### `PUT /api/v1/documents/{slug}/items/reorder` -- Reordonner les items
Modifie l'ordre d'affichage des items dans un document en mettant a jour le champ `sort_order` de chaque item.
**Corps de la requete** :
```json
{
"items": [
{ "item_id": "uuid-1", "sort_order": 0 },
{ "item_id": "uuid-2", "sort_order": 1 },
{ "item_id": "uuid-3", "sort_order": 2 }
]
}
```
**Reponse** : `200 OK` avec la liste des items reordonnes.
---
### `POST /api/v1/documents/{slug}/archive` -- Archiver un document
Archive le document complet dans le sanctuaire. Le processus :
1. Le contenu integral du document (metadonnees + items) est serialise.
2. Un hash SHA-256 est calcule sur le contenu.
3. Le contenu est envoye sur IPFS (CID retourne).
4. Le hash est ancre on-chain via `system.remark` sur Duniter V2.
5. Une entree `sanctuary_entries` est creee avec les references.
6. Le statut du document passe a `archived` et les champs `ipfs_cid` et `chain_anchor` sont mis a jour.
**Reponse** : `200 OK` avec le document mis a jour incluant `ipfs_cid` et `chain_anchor`.
---
### `GET /api/v1/sanctuary/{id}/verify` -- Verifier l'integrite d'une entree
Verifie l'integrite d'une entree du sanctuaire en effectuant trois controles :
1. **Hash SHA-256** : recalcul du hash a partir du contenu source et comparaison avec `content_hash`.
2. **IPFS** : verification que le CID IPFS pointe vers un contenu valide (si disponible).
3. **On-chain** : verification que le hash est present dans le `system.remark` du bloc reference (si disponible).
**Reponse** :
```json
{
"entry_id": "uuid",
"hash_valid": true,
"ipfs_valid": true,
"chain_valid": true,
"verified_at": "2026-02-28T12:00:00Z",
"details": "Tous les controles sont valides."
}
```
---
### `GET /api/v1/sanctuary/by-reference/{reference_id}` -- Entrees par reference
Retourne toutes les entrees du sanctuaire liees a une entite source (document, decision ou session de vote) identifiee par son UUID.
**Parametres de requete** : `skip`, `limit` (pagination standard).
**Reponse** : `200 OK` avec une liste de `SanctuaryEntryOut`.
## Pagination
Les endpoints de liste acceptent les parametres `skip` (offset, defaut 0) et `limit` (max 200, defaut 50).

View File

@@ -38,7 +38,7 @@ Sessions d'authentification (tokens).
### `documents`
Documents de reference modulaires.
Documents de reference modulaires. Le cycle de vie d'un document suit les statuts `draft` -> `active` -> `archived`. Lors de l'archivage (Sprint 2), les champs `ipfs_cid` et `chain_anchor` sont renseignes automatiquement par le service sanctuaire.
| Colonne | Type | Description |
| ------------ | ------------ | ----------------------------------------------------- |
@@ -49,38 +49,38 @@ Documents de reference modulaires.
| version | VARCHAR(32) | Version semantique (defaut "0.1.0") |
| status | VARCHAR(32) | Statut : draft, active, archived |
| description | TEXT | Description du document |
| ipfs_cid | VARCHAR(128) | CID IPFS de la derniere version archivee |
| chain_anchor | VARCHAR(128) | Hash de transaction on-chain |
| ipfs_cid | VARCHAR(128) | CID IPFS de la derniere version archivee (renseigne lors de l'archivage) |
| chain_anchor | VARCHAR(128) | Hash de transaction on-chain (renseigne lors de l'archivage) |
| created_at | TIMESTAMPTZ | Date de creation |
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
### `document_items`
Items individuels composant un document (clauses, regles, verifications, etc.).
Items individuels composant un document (clauses, regles, verifications, etc.). Chaque item peut etre modifie, supprime ou reordonne individuellement (Sprint 2). Le champ `current_text` est mis a jour automatiquement lorsqu'une version est acceptee.
| Colonne | Type | Description |
| ------------------- | ------------ | ------------------------------------------------- |
| id | UUID (PK) | Identifiant unique |
| document_id | UUID (FK) | -> documents.id |
| document_id | UUID (FK) | -> documents.id (cascade delete) |
| position | VARCHAR(16) | Numero de position ("1", "1.1", "3.2") |
| item_type | VARCHAR(32) | Type : clause, rule, verification, preamble, section |
| title | VARCHAR(256) | Titre de l'item |
| current_text | TEXT | Texte courant de l'item |
| current_text | TEXT | Texte courant de l'item (mis a jour lors de l'acceptation d'une version) |
| voting_protocol_id | UUID (FK) | -> voting_protocols.id (protocole specifique) |
| sort_order | INTEGER | Ordre de tri |
| sort_order | INTEGER | Ordre de tri (modifiable via endpoint reorder) |
| created_at | TIMESTAMPTZ | Date de creation |
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
### `item_versions`
Historique des versions proposees pour chaque item.
Historique des versions proposees pour chaque item. Lors de l'acceptation d'une version (Sprint 2), le `current_text` de l'item parent est remplace par le `proposed_text` de la version, et toutes les autres versions en attente (`proposed`, `voting`) sont automatiquement rejetees.
| Colonne | Type | Description |
| -------------- | ------------ | ------------------------------------------------------ |
| id | UUID (PK) | Identifiant unique |
| item_id | UUID (FK) | -> document_items.id |
| item_id | UUID (FK) | -> document_items.id (cascade delete) |
| proposed_text | TEXT | Texte propose |
| diff_text | TEXT | Diff unifie entre texte courant et propose |
| diff_text | TEXT | Diff unifie entre texte courant et propose (genere automatiquement) |
| rationale | TEXT | Justification de la modification |
| status | VARCHAR(32) | Statut : proposed, voting, accepted, rejected |
| decision_id | UUID (FK) | -> decisions.id (decision associee) |
@@ -240,16 +240,16 @@ Configurations de formules de seuil WoT.
### `sanctuary_entries`
Entrees du sanctuaire (archivage immuable).
Entrees du sanctuaire (archivage immuable). Le champ `reference_id` permet de retrouver toutes les entrees liees a un document, une decision ou une session de vote via l'endpoint `/by-reference/{reference_id}` (Sprint 2). L'endpoint `/verify` recalcule le hash et verifie la coherence IPFS/on-chain.
| Colonne | Type | Description |
| -------------- | ------------ | ------------------------------------------ |
| id | UUID (PK) | Identifiant unique |
| entry_type | VARCHAR(64) | Type : document, decision, vote_result |
| reference_id | UUID | UUID de l'entite source |
| reference_id | UUID | UUID de l'entite source (indexe pour recherche par reference) |
| title | VARCHAR(256) | Titre |
| content_hash | VARCHAR(128) | Hash SHA-256 du contenu |
| ipfs_cid | VARCHAR(128) | CID IPFS |
| ipfs_cid | VARCHAR(128) | CID IPFS (renseigne lors de l'upload) |
| chain_tx_hash | VARCHAR(128) | Hash de la transaction on-chain |
| chain_block | INTEGER | Numero de bloc de la transaction |
| metadata_json | TEXT | Metadonnees JSON supplementaires |
@@ -279,9 +279,10 @@ duniter_identities
documents
|-- 1:N --> document_items
|-- 1:N ..> sanctuary_entries (via reference_id, non FK)
document_items
|-- 1:N --> item_versions
|-- 1:N --> item_versions (cascade delete)
|-- N:1 --> voting_protocols
item_versions
@@ -289,6 +290,7 @@ item_versions
decisions
|-- 1:N --> decision_steps
|-- 1:N ..> sanctuary_entries (via reference_id, non FK)
decision_steps
|-- N:1 --> vote_sessions
@@ -296,6 +298,7 @@ decision_steps
vote_sessions
|-- 1:N --> votes
|-- N:1 --> voting_protocols
|-- 1:N ..> sanctuary_entries (via reference_id, non FK)
mandates
|-- 1:N --> mandate_steps
@@ -309,4 +312,9 @@ voting_protocols
formula_configs
|-- 1:N --> voting_protocols
sanctuary_entries
|-- reference_id ..> documents | decisions | vote_sessions (lien logique)
```
> **Note Sprint 2** : Les liens `..>` (pointilles) representent des references logiques via le champ `reference_id` de `sanctuary_entries`. Ce ne sont pas des cles etrangeres PostgreSQL car `reference_id` peut pointer vers differentes tables selon le `entry_type`.

View File

@@ -25,7 +25,19 @@ Les documents de reference sont les textes fondateurs de la communaute Duniter/G
3. Cliquez sur le document pour voir la liste de ses items.
4. Chaque item affiche son texte courant, son type et sa position dans le document.
## Proposer une modification
### Voir un item en detail
Pour consulter un item specifique avec tout son historique :
1. Depuis la liste des items du document, cliquez sur l'item souhaite.
2. La vue detaillee affiche :
- Le **texte courant** de l'item.
- Le **type** (clause, regle, verification, preambule, section) et la **position** hierarchique.
- Le **protocole de vote** specifique a cet item (s'il en a un).
- L'**historique des versions** proposees, avec pour chacune son statut (proposee, en vote, acceptee, rejetee).
3. Pour chaque version, vous pouvez consulter le **diff** (differences entre le texte courant et le texte propose) ainsi que la **justification** de l'auteur.
## Proposer une modification (version)
Tout membre authentifie peut proposer une modification a un item de document :
@@ -35,7 +47,39 @@ Tout membre authentifie peut proposer une modification a un item de document :
4. Ajoutez une **justification** expliquant pourquoi cette modification est necessaire.
5. Soumettez. Un diff automatique est genere entre le texte courant et votre proposition.
La proposition passe ensuite par un processus de decision (examen, vote) avant d'etre acceptee ou rejetee.
La proposition cree une nouvelle **version** de l'item. Cette version passe ensuite par un processus de decision (examen, vote) avant d'etre acceptee ou rejetee.
::callout{type="info"}
Plusieurs versions peuvent etre proposees simultanement pour un meme item. Lorsqu'une version est acceptee, toutes les autres versions en attente sont automatiquement rejetees.
::
## Examiner et accepter/rejeter une version
Les membres habilites (selon le protocole de vote associe) peuvent examiner les versions proposees :
### Consulter les versions en attente
1. Ouvrez le document et selectionnez l'item concerne.
2. Consultez la liste des **versions proposees** dans l'onglet historique.
3. Chaque version affiche :
- Le **texte propose** et le **diff** par rapport au texte courant.
- La **justification** fournie par l'auteur.
- Le **statut** actuel (proposee, en vote, acceptee, rejetee).
- L'**identite** du proposant.
### Accepter une version
1. Selectionnez la version a accepter.
2. Cliquez sur **Accepter cette version**.
3. Le texte propose **remplace automatiquement** le texte courant de l'item.
4. Toutes les autres versions en statut `proposee` ou `en vote` pour cet item sont **automatiquement rejetees**.
### Rejeter une version
1. Selectionnez la version a rejeter.
2. Cliquez sur **Rejeter cette version**.
3. Le texte courant de l'item **reste inchange**.
4. La version est archivee avec le statut `rejetee`.
## Cycle de vie d'une proposition
@@ -44,13 +88,57 @@ Proposee --> En vote --> Acceptee --> Texte courant mis a jour
--> Rejetee --> Archivee
```
## Cycle de vie d'un document
Un document suit un cycle de vie en trois etapes :
```
Brouillon --> Actif --> Archive
```
### Brouillon (draft)
Le document est en cours de redaction. Les items peuvent etre ajoutes, modifies, supprimes et reordonnes librement. Le document n'est pas encore soumis au vote permanent.
### Actif (active)
Le document est en vigueur et sous **vote permanent**. Tout membre authentifie peut proposer des modifications aux items via le systeme de versions. Les modifications sont soumises au processus de decision (qualification, examen, vote) avant d'etre appliquees.
### Archive (archived)
Le document a ete archive dans le **Sanctuaire**. Son contenu est fige et preservee de maniere immuable via :
- Un hash SHA-256 pour garantir l'integrite.
- Un stockage sur IPFS pour la distribution decentralisee.
- Un ancrage on-chain via `system.remark` sur Duniter V2.
Un document archive ne peut plus etre modifie. Pour le consulter, rendez-vous dans la section Sanctuaire.
## Archiver un document dans le Sanctuaire
Pour archiver un document actif (necessite une authentification) :
1. Ouvrez le document actif a archiver.
2. Cliquez sur **Archiver dans le Sanctuaire**.
3. Le systeme effectue automatiquement :
- La serialisation complete du document (metadonnees + tous les items).
- Le calcul du hash SHA-256 du contenu.
- L'envoi du contenu sur IPFS.
- L'ancrage du hash on-chain.
4. Le statut du document passe a **Archive**.
5. Les champs **CID IPFS** et **ancrage on-chain** sont renseignes sur le document.
::callout{type="warning"}
L'archivage est une operation irreversible. Une fois archive, le document ne peut plus etre modifie.
::
## Statuts des documents
| Statut | Description |
| -------- | ------------------------------------------------ |
| Brouillon | En cours de redaction, non soumis au vote |
| Actif | Document en vigueur, sous vote permanent |
| Archive | Document archive, plus en vigueur |
| Statut | Description |
| --------- | ------------------------------------------------ |
| Brouillon | En cours de redaction, non soumis au vote |
| Actif | Document en vigueur, sous vote permanent |
| Archive | Document archive dans le Sanctuaire, plus modifiable |
## Versionnage

View File

@@ -41,9 +41,37 @@ La gouvernance exige la transparence et la tracabilite. Le Sanctuaire garantit q
- Le numero de bloc
- La date d'archivage
## Consulter les entrees par document de reference
Pour retrouver toutes les entrees du Sanctuaire liees a un document, une decision ou une session de vote specifique :
1. Depuis la fiche du document (ou de la decision), cliquez sur **Voir dans le Sanctuaire**.
2. La liste affiche toutes les entrees archivees associees a cette entite source.
3. Vous pouvez aussi acceder directement a cette vue via l'URL : `/sanctuaire/par-reference/{id}`.
Cette fonctionnalite permet de retracer l'historique complet d'archivage d'un document au fil de ses modifications adoptees.
## Verification d'integrite
Pour verifier qu'une entree du Sanctuaire est authentique :
### Verification automatique
Glibredecision propose une verification automatique d'integrite pour chaque entree du Sanctuaire :
1. Ouvrez l'entree a verifier dans le Sanctuaire.
2. Cliquez sur **Verifier l'integrite**.
3. Le systeme effectue automatiquement trois controles :
- **Hash SHA-256** : le hash est recalcule a partir du contenu source et compare avec le hash enregistre.
- **IPFS** : le contenu est recupere via le CID IPFS et verifie (si le CID est renseigne).
- **On-chain** : le hash est recherche dans le `system.remark` du bloc reference sur la blockchain Duniter V2 (si le hash de transaction est renseigne).
4. Le resultat affiche pour chaque controle un indicateur **valide** ou **invalide**.
::callout{type="info"}
Si les trois controles sont valides, le contenu est authentique et n'a pas ete modifie depuis son archivage.
::
### Verification manuelle
Pour une verification independante de la plateforme :
1. Recuperez le contenu via IPFS en utilisant le CID affiche.
2. Calculez le hash SHA-256 du contenu telecharge.
@@ -52,6 +80,57 @@ Pour verifier qu'une entree du Sanctuaire est authentique :
Si les trois hash correspondent, le contenu est authentique et n'a pas ete modifie.
## Acces au contenu via IPFS
Chaque entree du Sanctuaire possede un **CID IPFS** (Content Identifier) qui permet d'acceder au contenu archive de maniere decentralisee.
### Utiliser le lien IPFS gateway
Le CID est affiche sous forme de lien cliquable pointant vers une passerelle IPFS publique :
- **Passerelle publique** : `https://ipfs.io/ipfs/{CID}`
- **Passerelle locale** (si vous executez un noeud kubo) : `http://localhost:8080/ipfs/{CID}`
En cliquant sur le lien CID dans l'interface, le contenu archive s'ouvre directement dans votre navigateur.
### Recuperer le contenu via la CLI IPFS
Si vous avez un noeud IPFS local (kubo), vous pouvez recuperer le contenu directement :
```bash
ipfs cat {CID}
```
Ou le telecharger :
```bash
ipfs get {CID} -o document_archive.txt
```
## Comprendre les informations d'ancrage on-chain
Chaque entree du Sanctuaire affiche des informations relatives a son ancrage sur la blockchain Duniter V2 :
| Information | Description |
| -------------------- | -------------------------------------------------------- |
| Hash de transaction | Identifiant unique de la transaction `system.remark` contenant le hash du contenu |
| Numero de bloc | Le bloc de la blockchain dans lequel la transaction a ete incluse |
| Date d'archivage | Horodatage de la creation de l'entree dans le Sanctuaire |
### Verifier sur un explorateur blockchain
Pour verifier l'ancrage on-chain de maniere independante :
1. Copiez le **hash de transaction** affiche sur l'entree du Sanctuaire.
2. Ouvrez un explorateur de la blockchain Duniter V2 (par exemple Polkadot.js Apps connecte au reseau Duniter).
3. Recherchez la transaction par son hash ou parcourez le **bloc** indique.
4. Dans les extrinsics du bloc, reperer l'appel `system.remark` contenant le hash SHA-256 du contenu.
5. Si le hash dans le remark correspond au hash SHA-256 affiche dans le Sanctuaire, l'ancrage est confirme.
::callout{type="tip"}
L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme Glibredecision disparait, la preuve reste verifiable sur la blockchain.
::
## Automatisation
L'archivage dans le Sanctuaire est declenche automatiquement lorsqu'un processus decisionnel est finalise :
@@ -59,3 +138,4 @@ L'archivage dans le Sanctuaire est declenche automatiquement lorsqu'un processus
- Quand une version d'item de document est **acceptee**, le nouveau texte est archive.
- Quand une session de vote est **cloturee**, le resultat detaille est archive.
- Quand une decision est **executee**, l'ensemble de la decision est archive.
- Quand un document est **archive** via le bouton d'archivage, l'integralite du document est archivee dans le Sanctuaire.

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>

View File

@@ -1,9 +1,14 @@
<script setup lang="ts">
import type { DocumentItem } from '~/stores/documents'
const route = useRoute()
const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const archiving = ref(false)
onMounted(async () => {
await documents.fetchBySlug(slug.value)
})
@@ -18,24 +23,6 @@ watch(slug, async (newSlug) => {
}
})
const statusColor = (status: string) => {
switch (status) {
case 'active': return 'success'
case 'draft': return 'warning'
case 'archived': return 'neutral'
default: return 'neutral'
}
}
const statusLabel = (status: string) => {
switch (status) {
case 'active': return 'Actif'
case 'draft': return 'Brouillon'
case 'archived': return 'Archive'
default: return status
}
}
const typeLabel = (docType: string) => {
switch (docType) {
case 'licence': return 'Licence'
@@ -46,17 +33,6 @@ const typeLabel = (docType: string) => {
}
}
const itemTypeLabel = (itemType: 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 formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
@@ -64,6 +40,21 @@ function formatDate(dateStr: string): string {
year: 'numeric',
})
}
function handlePropose(item: DocumentItem) {
navigateTo(`/documents/${slug.value}/items/${item.id}`)
}
async function archiveToSanctuary() {
archiving.value = true
try {
await documents.archiveDocument(slug.value)
} catch {
// Error handled in store
} finally {
archiving.value = false
}
}
</script>
<template>
@@ -114,14 +105,24 @@ function formatDate(dateStr: string): string {
<UBadge variant="subtle" color="primary">
{{ typeLabel(documents.current.doc_type) }}
</UBadge>
<UBadge :color="statusColor(documents.current.status)" variant="subtle">
{{ statusLabel(documents.current.status) }}
</UBadge>
<CommonStatusBadge :status="documents.current.status" type="document" />
<span class="text-sm text-gray-500 font-mono">
v{{ documents.current.version }}
</span>
</div>
</div>
<!-- Archive button for authenticated users with active documents -->
<div v-if="auth.isAuthenticated && documents.current.status === 'active'" class="flex items-center gap-2">
<UButton
label="Archiver dans le Sanctuaire"
icon="i-lucide-archive"
color="primary"
variant="soft"
:loading="archiving"
@click="archiveToSanctuary"
/>
</div>
</div>
<!-- Description -->
@@ -153,14 +154,17 @@ function formatDate(dateStr: string): string {
</div>
<div>
<p class="text-gray-500">Ancrage IPFS</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="documents.current.ipfs_cid">
<span class="font-mono text-xs">{{ documents.current.ipfs_cid.slice(0, 16) }}...</span>
</template>
<template v-else>
<span class="text-gray-400">Non ancre</span>
</template>
</p>
<div class="mt-1">
<SanctuaryIPFSLink :cid="documents.current.ipfs_cid" />
</div>
</div>
</div>
<!-- Chain anchor info -->
<div v-if="documents.current.chain_anchor" class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-2">
<p class="text-sm text-gray-500">Ancrage on-chain :</p>
<SanctuaryChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
</div>
</div>
</UCard>
@@ -177,52 +181,14 @@ function formatDate(dateStr: string): string {
</div>
<div v-else class="space-y-4">
<UCard
<DocumentsItemCard
v-for="item in documents.items"
:key="item.id"
>
<div class="space-y-3">
<!-- Item header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm font-mono font-bold text-primary">
{{ item.position }}
</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ itemTypeLabel(item.item_type) }}
</UBadge>
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
{{ item.title }}
</span>
</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-8">
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
{{ item.current_text }}
</p>
</div>
</div>
</UCard>
:item="item"
:document-slug="slug"
:show-actions="auth.isAuthenticated"
@propose="handlePropose"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import type { DocumentItem, VersionProposal } from '~/stores/documents'
const route = useRoute()
const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const itemId = computed(() => route.params.itemId as string)
const currentItem = computed((): DocumentItem | undefined => {
return documents.items.find(i => i.id === itemId.value)
})
// Modal state for proposing a modification
const showProposeModal = ref(false)
const proposedText = ref('')
const rationale = ref('')
const proposing = ref(false)
// Loading versions
const versionsLoading = ref(false)
onMounted(async () => {
// Fetch document + items if not already loaded
if (!documents.current || documents.current.slug !== slug.value) {
await documents.fetchBySlug(slug.value)
}
// Fetch versions for this item
versionsLoading.value = true
await documents.fetchItemVersions(slug.value, itemId.value)
versionsLoading.value = false
})
onUnmounted(() => {
documents.versions = []
})
watch([slug, itemId], async ([newSlug, newItemId]) => {
if (newSlug && newItemId) {
if (!documents.current || documents.current.slug !== newSlug) {
await documents.fetchBySlug(newSlug)
}
versionsLoading.value = true
await documents.fetchItemVersions(newSlug, newItemId)
versionsLoading.value = false
}
})
function openProposeModal() {
if (currentItem.value) {
proposedText.value = currentItem.value.current_text
}
rationale.value = ''
showProposeModal.value = true
}
async function submitProposal() {
proposing.value = true
try {
const data: VersionProposal = {
proposed_text: proposedText.value,
rationale: rationale.value || null,
}
await documents.proposeVersion(slug.value, itemId.value, data)
showProposeModal.value = false
proposedText.value = ''
rationale.value = ''
} catch {
// Error handled in store
} finally {
proposing.value = false
}
}
async function handleAcceptVersion(versionId: string) {
try {
await documents.acceptVersion(slug.value, itemId.value, versionId)
// Refresh item data
await documents.fetchBySlug(slug.value)
} catch {
// Error handled in store
}
}
async function handleRejectVersion(versionId: string) {
try {
await documents.rejectVersion(slug.value, itemId.value, versionId)
} catch {
// Error handled in store
}
}
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 formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div class="space-y-6">
<!-- Breadcrumb -->
<nav class="flex items-center gap-2 text-sm text-gray-500">
<NuxtLink to="/documents" class="hover:text-primary transition-colors">
Documents
</NuxtLink>
<UIcon name="i-lucide-chevron-right" class="text-xs" />
<NuxtLink
v-if="documents.current"
:to="`/documents/${slug}`"
class="hover:text-primary transition-colors"
>
{{ documents.current.title }}
</NuxtLink>
<USkeleton v-else class="h-4 w-32" />
<UIcon name="i-lucide-chevron-right" class="text-xs" />
<span v-if="currentItem" class="text-gray-900 dark:text-white font-medium">
Item {{ currentItem.position }}
</span>
<USkeleton v-else class="h-4 w-20" />
</nav>
<!-- Loading state -->
<template v-if="documents.loading && !currentItem">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<USkeleton class="h-48 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="documents.error && !currentItem">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
</template>
<!-- Item not found -->
<template v-else-if="!currentItem && !documents.loading">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-file-x" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Item introuvable</p>
<UButton
:to="`/documents/${slug}`"
label="Retour au document"
variant="soft"
color="primary"
class="mt-4"
/>
</div>
</UCard>
</template>
<!-- Item detail -->
<template v-else-if="currentItem">
<!-- Item header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3 mb-2">
<UBadge variant="solid" color="primary" size="sm">
{{ currentItem.position }}
</UBadge>
<UBadge variant="subtle" color="neutral" size="xs">
{{ itemTypeLabel(currentItem.item_type) }}
</UBadge>
<UBadge
v-if="currentItem.voting_protocol_id"
color="info"
variant="subtle"
size="xs"
>
Sous vote
</UBadge>
</div>
<h1
v-if="currentItem.title"
class="text-2xl font-bold text-gray-900 dark:text-white"
>
{{ currentItem.title }}
</h1>
</div>
<!-- Action buttons -->
<div v-if="auth.isAuthenticated" class="flex items-center gap-2">
<UButton
label="Proposer une modification"
icon="i-lucide-pen-line"
color="primary"
variant="soft"
@click="openProposeModal"
/>
</div>
</div>
<!-- Current text -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-file-text" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Texte en vigueur</h2>
</div>
<CommonMarkdownRenderer :content="currentItem.current_text" />
<div class="flex items-center gap-4 pt-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400">
<span>Cree le {{ formatDate(currentItem.created_at) }}</span>
<span>Mis a jour le {{ formatDate(currentItem.updated_at) }}</span>
</div>
</div>
</UCard>
<!-- Error banner -->
<UCard v-if="documents.error">
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div>
</UCard>
<!-- Version history -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Historique des versions ({{ documents.versions.length }})
</h2>
<template v-if="versionsLoading">
<div class="space-y-3">
<USkeleton v-for="i in 3" :key="i" class="h-32 w-full" />
</div>
</template>
<template v-else-if="documents.versions.length === 0">
<UCard>
<div class="text-center py-6">
<UIcon name="i-lucide-git-branch" class="text-3xl text-gray-400 mb-2" />
<p class="text-gray-500 text-sm">Aucune version proposee pour cet item</p>
<p class="text-xs text-gray-400 mt-1">
Connectez-vous pour proposer une modification
</p>
</div>
</UCard>
</template>
<div v-else class="space-y-4">
<DocumentsItemVersionDiff
v-for="version in documents.versions"
:key="version.id"
:version="version"
@accept="handleAcceptVersion"
@reject="handleRejectVersion"
/>
</div>
</div>
</template>
<!-- Propose modification modal -->
<UModal v-model:open="showProposeModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Proposer une modification
</h3>
<p class="text-sm text-gray-500">
Modifiez le texte ci-dessous et fournissez une justification pour votre proposition.
</p>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Texte propose
</label>
<UTextarea
v-model="proposedText"
:rows="8"
placeholder="Saisissez le nouveau texte..."
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Justification
</label>
<UTextarea
v-model="rationale"
:rows="3"
placeholder="Expliquez les raisons de cette modification..."
class="w-full"
/>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showProposeModal = false"
/>
<UButton
label="Soumettre la proposition"
icon="i-lucide-send"
color="primary"
:loading="proposing"
:disabled="!proposedText.trim()"
@click="submitProposal"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import type { DocumentCreate } from '~/stores/documents'
const documents = useDocumentsStore()
const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
@@ -19,13 +22,22 @@ const statusOptions = [
{ label: 'Archive', value: 'archived' },
]
const columns = [
{ key: 'title', label: 'Titre' },
{ key: 'doc_type', label: 'Type' },
{ key: 'version', label: 'Version' },
{ key: 'status', label: 'Statut' },
{ key: 'items_count', label: 'Items' },
{ key: 'updated_at', label: 'Mis a jour' },
// New document modal state
const showNewDocModal = ref(false)
const newDoc = ref<DocumentCreate>({
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
})
const creating = ref(false)
const newDocTypeOptions = [
{ label: 'Licence', value: 'licence' },
{ label: 'Engagement', value: 'engagement' },
{ label: 'Reglement', value: 'reglement' },
{ label: 'Constitution', value: 'constitution' },
]
async function loadDocuments() {
@@ -43,40 +55,47 @@ watch([filterType, filterStatus], () => {
loadDocuments()
})
const statusColor = (status: string) => {
switch (status) {
case 'active': return 'success'
case 'draft': return 'warning'
case 'archived': return 'neutral'
default: return 'neutral'
function openNewDocModal() {
newDoc.value = {
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
}
showNewDocModal.value = true
}
const statusLabel = (status: string) => {
switch (status) {
case 'active': return 'Actif'
case 'draft': return 'Brouillon'
case 'archived': return 'Archive'
default: return status
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64)
}
const typeLabel = (docType: string) => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Reglement'
case 'constitution': return 'Constitution'
default: return docType
watch(() => newDoc.value.title, (title) => {
if (title) {
newDoc.value.slug = generateSlug(title)
}
}
})
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
async function createDocument() {
creating.value = true
try {
const doc = await documents.createDocument(newDoc.value)
showNewDocModal.value = false
if (doc) {
navigateTo(`/documents/${doc.slug}`)
}
} catch {
// Error handled in store
} finally {
creating.value = false
}
}
</script>
@@ -92,6 +111,15 @@ function formatDate(dateStr: string): string {
Documents fondateurs de la communaute Duniter/G1 sous vote permanent
</p>
</div>
<!-- New document button for authenticated users -->
<UButton
v-if="auth.isAuthenticated"
label="Nouveau document"
icon="i-lucide-plus"
color="primary"
@click="openNewDocModal"
/>
</div>
<!-- Filters -->
@@ -110,15 +138,8 @@ function formatDate(dateStr: string): string {
/>
</div>
<!-- Loading state -->
<template v-if="documents.loading">
<div class="space-y-3">
<USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="documents.error">
<template v-if="documents.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
@@ -127,65 +148,96 @@ function formatDate(dateStr: string): string {
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="documents.list.length === 0">
<UCard>
<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>
</template>
<!-- Document list component -->
<DocumentsDocumentList
:documents="documents.list"
:loading="documents.loading"
/>
<!-- Documents table -->
<template v-else>
<UCard>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th v-for="col in columns" :key="col.key" class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in documents.list"
:key="doc.id"
class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-file-text" class="text-gray-400" />
<span class="font-medium text-gray-900 dark:text-white">{{ doc.title }}</span>
</div>
</td>
<td class="px-4 py-3">
<UBadge variant="subtle" color="primary">
{{ typeLabel(doc.doc_type) }}
</UBadge>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono text-xs">
v{{ doc.version }}
</td>
<td class="px-4 py-3">
<UBadge :color="statusColor(doc.status)" variant="subtle">
{{ statusLabel(doc.status) }}
</UBadge>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{{ doc.items_count }}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{{ formatDate(doc.updated_at) }}
</td>
</tr>
</tbody>
</table>
<!-- New document modal -->
<UModal v-model:open="showNewDocModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Nouveau document de reference
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Titre
</label>
<UInput
v-model="newDoc.title"
placeholder="Ex: Licence G1"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Slug (identifiant URL)
</label>
<UInput
v-model="newDoc.slug"
placeholder="Ex: licence-g1"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Type de document
</label>
<USelect
v-model="newDoc.doc_type"
:items="newDocTypeOptions"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Version
</label>
<UInput
v-model="newDoc.version"
placeholder="1.0.0"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Description (optionnelle)
</label>
<UTextarea
v-model="newDoc.description"
:rows="3"
placeholder="Decrivez brievement ce document..."
class="w-full"
/>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showNewDocModal = false"
/>
<UButton
label="Creer le document"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
@click="createDocument"
/>
</div>
</div>
</UCard>
</template>
</template>
</UModal>
</div>
</template>

View File

@@ -0,0 +1,445 @@
<script setup lang="ts">
const route = useRoute()
const { $api } = useApi()
interface SanctuaryEntryDetail {
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
}
interface VerifyResult {
match: boolean
message: string
}
const entryId = computed(() => route.params.id as string)
const entry = ref<SanctuaryEntryDetail | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const verifying = ref(false)
const verifyResult = ref<VerifyResult | null>(null)
const copied = ref<string | null>(null)
async function loadEntry() {
loading.value = true
error.value = null
try {
entry.value = await $api<SanctuaryEntryDetail>(`/sanctuary/${entryId.value}`)
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Entree introuvable'
} finally {
loading.value = false
}
}
async function verifyIntegrity() {
verifying.value = true
verifyResult.value = null
try {
verifyResult.value = await $api<VerifyResult>(
`/sanctuary/${entryId.value}/verify`,
)
} catch (err: any) {
verifyResult.value = {
match: false,
message: err?.data?.detail || err?.message || 'Erreur lors de la verification',
}
} finally {
verifying.value = false
}
}
async function copyToClipboard(text: string, field: string) {
try {
await navigator.clipboard.writeText(text)
copied.value = field
setTimeout(() => { copied.value = null }, 2000)
} catch {
// Clipboard API not available
}
}
onMounted(() => {
loadEntry()
})
watch(entryId, () => {
loadEntry()
})
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',
})
}
const parsedMetadata = computed(() => {
if (!entry.value?.metadata_json) return null
try {
return JSON.parse(entry.value.metadata_json)
} catch {
return null
}
})
const formattedMetadata = computed(() => {
if (!parsedMetadata.value) return null
return JSON.stringify(parsedMetadata.value, null, 2)
})
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/sanctuary"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour au sanctuaire"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<USkeleton class="h-48 w-full" />
<USkeleton class="h-32 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ error }}</p>
</div>
</UCard>
</template>
<!-- Entry detail -->
<template v-else-if="entry">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3 mb-2">
<UIcon name="i-lucide-shield-check" class="text-2xl text-primary" />
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ entry.title || 'Entree sans titre' }}
</h1>
</div>
<div class="flex items-center gap-3">
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle">
{{ typeLabel(entry.entry_type) }}
</UBadge>
<span class="text-sm text-gray-500">
{{ formatDate(entry.created_at) }}
</span>
</div>
</div>
<!-- Verify button -->
<UButton
label="Verifier l'integrite"
icon="i-lucide-shield-check"
color="primary"
:loading="verifying"
@click="verifyIntegrity"
/>
</div>
<!-- Verification result -->
<UCard v-if="verifyResult">
<div class="flex items-center gap-3">
<UIcon
:name="verifyResult.match ? 'i-lucide-check-circle' : 'i-lucide-alert-triangle'"
:class="verifyResult.match ? 'text-green-500 text-2xl' : 'text-red-500 text-2xl'"
/>
<div>
<p
:class="verifyResult.match
? 'text-green-700 dark:text-green-400 font-semibold'
: 'text-red-700 dark:text-red-400 font-semibold'"
>
{{ verifyResult.match ? 'Integrite verifiee avec succes' : 'Verification echouee' }}
</p>
<p class="text-sm text-gray-500 mt-1">{{ verifyResult.message }}</p>
</div>
</div>
</UCard>
<!-- SHA-256 Hash -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hash" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Hash SHA-256</h2>
</div>
<UButton
:icon="copied === 'hash' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'hash' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.content_hash, 'hash')"
/>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.content_hash }}
</p>
</div>
</div>
</UCard>
<!-- IPFS CID -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hard-drive" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">IPFS CID</h2>
</div>
<UButton
v-if="entry.ipfs_cid"
:icon="copied === 'ipfs' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'ipfs' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.ipfs_cid!, 'ipfs')"
/>
</div>
<template v-if="entry.ipfs_cid">
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.ipfs_cid }}
</p>
</div>
<a
:href="`${IPFS_GATEWAY}${entry.ipfs_cid}`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<UIcon name="i-lucide-external-link" />
<span>Ouvrir sur la passerelle IPFS</span>
</a>
</template>
<template v-else>
<div class="bg-yellow-50 dark:bg-yellow-900/10 rounded-lg p-4">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="text-yellow-500" />
<p class="text-sm text-yellow-700 dark:text-yellow-400">
En attente d'epinglage IPFS
</p>
</div>
</div>
</template>
</div>
</UCard>
<!-- Chain Anchor -->
<UCard>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-link" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Ancrage On-chain</h2>
</div>
<UButton
v-if="entry.chain_tx_hash"
:icon="copied === 'chain' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'chain' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.chain_tx_hash!, 'chain')"
/>
</div>
<template v-if="entry.chain_tx_hash">
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 space-y-2">
<div>
<p class="text-xs text-gray-500 mb-1">TX Hash</p>
<p class="font-mono text-sm text-gray-700 dark:text-gray-300 break-all select-all">
{{ entry.chain_tx_hash }}
</p>
</div>
<div v-if="entry.chain_block">
<p class="text-xs text-gray-500 mb-1">Numero de bloc</p>
<p class="font-mono text-sm text-gray-700 dark:text-gray-300">
#{{ entry.chain_block.toLocaleString('fr-FR') }}
</p>
</div>
</div>
</template>
<template v-else>
<div class="bg-yellow-50 dark:bg-yellow-900/10 rounded-lg p-4">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="text-yellow-500" />
<p class="text-sm text-yellow-700 dark:text-yellow-400">
En attente d'ancrage on-chain via system.remark
</p>
</div>
</div>
</template>
</div>
</UCard>
<!-- Verification status -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-check-circle" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Statut de verification</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.content_hash
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-gray-50 dark:bg-gray-800/50'"
>
<UIcon
:name="entry.content_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.content_hash ? 'text-green-500' : 'text-gray-400'"
/>
<div>
<p class="text-sm font-medium" :class="entry.content_hash ? 'text-green-700 dark:text-green-400' : 'text-gray-500'">
Hash SHA-256
</p>
<p class="text-xs text-gray-500">
{{ entry.content_hash ? 'Calcule' : 'En attente' }}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.ipfs_cid
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-yellow-50 dark:bg-yellow-900/10'"
>
<UIcon
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.ipfs_cid ? 'text-green-500' : 'text-yellow-500'"
/>
<div>
<p class="text-sm font-medium" :class="entry.ipfs_cid ? 'text-green-700 dark:text-green-400' : 'text-yellow-700 dark:text-yellow-400'">
IPFS
</p>
<p class="text-xs text-gray-500">
{{ entry.ipfs_cid ? 'Epingle' : 'En attente' }}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg"
:class="entry.chain_tx_hash
? 'bg-green-50 dark:bg-green-900/10'
: 'bg-yellow-50 dark:bg-yellow-900/10'"
>
<UIcon
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-yellow-500'"
/>
<div>
<p class="text-sm font-medium" :class="entry.chain_tx_hash ? 'text-green-700 dark:text-green-400' : 'text-yellow-700 dark:text-yellow-400'">
On-chain
</p>
<p class="text-xs text-gray-500">
{{ entry.chain_tx_hash ? 'Ancre' : 'En attente' }}
</p>
</div>
</div>
</div>
</div>
</UCard>
<!-- Metadata JSON -->
<UCard v-if="entry.metadata_json">
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-braces" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Metadonnees</h2>
</div>
<UButton
v-if="entry.metadata_json"
:icon="copied === 'metadata' ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied === 'metadata' ? 'Copie' : 'Copier'"
variant="ghost"
color="neutral"
size="xs"
@click="copyToClipboard(entry.metadata_json!, 'metadata')"
/>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 overflow-x-auto">
<pre class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ formattedMetadata || entry.metadata_json }}</pre>
</div>
</div>
</UCard>
<!-- Reference info -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-info" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Reference</h2>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">Identifiant</p>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ entry.id }}</p>
</div>
<div>
<p class="text-gray-500">Reference (document/decision)</p>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ entry.reference_id }}</p>
</div>
</div>
</div>
</UCard>
</template>
</div>
</template>

View File

@@ -1,20 +1,9 @@
<script setup lang="ts">
import type { SanctuaryEntryOut } from '~/components/sanctuary/SanctuaryEntry.vue'
const { $api } = useApi()
interface SanctuaryEntry {
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 entries = ref<SanctuaryEntry[]>([])
const entries = ref<SanctuaryEntryOut[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
@@ -27,6 +16,10 @@ const typeOptions = [
{ label: 'Resultat de vote', value: 'vote_result' },
]
// Verification state
const verifying = ref<string | null>(null)
const verifyResult = ref<{ id: string; match: boolean; message: string } | null>(null)
async function loadEntries() {
loading.value = true
error.value = null
@@ -35,7 +28,7 @@ async function loadEntries() {
const query: Record<string, string> = {}
if (filterType.value) query.entry_type = filterType.value
entries.value = await $api<SanctuaryEntry[]>('/sanctuary/', { query })
entries.value = await $api<SanctuaryEntryOut[]>('/sanctuary/', { query })
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des entrees'
} finally {
@@ -43,6 +36,26 @@ async function loadEntries() {
}
}
async function handleVerify(id: string) {
verifying.value = id
verifyResult.value = null
try {
const result = await $api<{ match: boolean; message: string }>(
`/sanctuary/${id}/verify`,
)
verifyResult.value = { id, ...result }
} catch (err: any) {
verifyResult.value = {
id,
match: false,
message: err?.data?.detail || err?.message || 'Erreur lors de la verification',
}
} finally {
verifying.value = null
}
}
onMounted(() => {
loadEntries()
})
@@ -50,40 +63,6 @@ onMounted(() => {
watch(filterType, () => {
loadEntries()
})
const typeLabel = (entryType: 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) => {
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)
}
</script>
<template>
@@ -108,10 +87,35 @@ function truncateHash(hash: string | null, length: number = 16): string {
/>
</div>
<!-- Verification result banner -->
<UCard v-if="verifyResult">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UIcon
:name="verifyResult.match ? 'i-lucide-check-circle' : 'i-lucide-alert-triangle'"
:class="verifyResult.match ? 'text-green-500 text-xl' : 'text-red-500 text-xl'"
/>
<div>
<p :class="verifyResult.match ? 'text-green-700 dark:text-green-400 font-medium' : 'text-red-700 dark:text-red-400 font-medium'">
{{ verifyResult.match ? 'Integrite verifiee' : 'Verification echouee' }}
</p>
<p class="text-sm text-gray-500">{{ verifyResult.message }}</p>
</div>
</div>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
size="xs"
@click="verifyResult = null"
/>
</div>
</UCard>
<!-- Loading state -->
<template v-if="loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-24 w-full" />
<USkeleton v-for="i in 4" :key="i" class="h-48 w-full" />
</div>
</template>
@@ -138,104 +142,25 @@ function truncateHash(hash: string | null, length: number = 16): string {
</UCard>
</template>
<!-- Entries list -->
<!-- Entries list using SanctuaryEntry component -->
<template v-else>
<div class="space-y-4">
<UCard
v-for="entry in entries"
:key="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)" 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 gap-2 mb-1">
<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>
<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>
<template v-if="entry.ipfs_cid">
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
{{ truncateHash(entry.ipfs_cid) }}
</p>
</template>
<template v-else>
<p class="text-xs text-gray-400 italic">En attente d'epinglage</p>
</template>
</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>
<template v-if="entry.chain_tx_hash">
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
{{ truncateHash(entry.chain_tx_hash) }}
</p>
<p v-if="entry.chain_block" class="text-xs text-gray-400 mt-0.5">
Bloc #{{ entry.chain_block.toLocaleString('fr-FR') }}
</p>
</template>
<template v-else>
<p class="text-xs text-gray-400 italic">En attente d'ancrage</p>
</template>
</div>
</div>
<!-- Verification status -->
<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 v-for="entry in entries" :key="entry.id" class="relative">
<SanctuarySanctuaryEntry
:entry="entry"
@verify="handleVerify"
/>
<!-- Loading overlay for verification -->
<div
v-if="verifying === entry.id"
class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 flex items-center justify-center rounded-lg"
>
<div class="flex items-center gap-2 text-sm text-gray-500">
<UIcon name="i-lucide-loader-2" class="animate-spin" />
<span>Verification en cours...</span>
</div>
</div>
</UCard>
</div>
</div>
</template>

View File

@@ -32,6 +32,20 @@ export interface Document {
items_count: number
}
export interface ItemVersion {
id: string
item_id: string
version_number: number
proposed_text: string
rationale: string | null
diff: string | null
status: string
proposed_by: string | null
reviewed_by: string | null
created_at: string
updated_at: string
}
export interface DocumentCreate {
slug: string
title: string
@@ -40,10 +54,16 @@ export interface DocumentCreate {
version?: string
}
export interface VersionProposal {
proposed_text: string
rationale?: string | null
}
interface DocumentsState {
list: Document[]
current: Document | null
items: DocumentItem[]
versions: ItemVersion[]
loading: boolean
error: string | null
}
@@ -53,6 +73,7 @@ export const useDocumentsStore = defineStore('documents', {
list: [],
current: null,
items: [],
versions: [],
loading: false,
error: null,
}),
@@ -139,11 +160,122 @@ export const useDocumentsStore = defineStore('documents', {
},
/**
* Clear the current document and items.
* Fetch all versions for a specific item within a document.
*/
async fetchItemVersions(slug: string, itemId: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.versions = await $api<ItemVersion[]>(
`/documents/${slug}/items/${itemId}/versions`,
)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des versions'
} finally {
this.loading = false
}
},
/**
* Propose a new version for a document item.
*/
async proposeVersion(slug: string, itemId: string, data: VersionProposal) {
this.error = null
try {
const { $api } = useApi()
const version = await $api<ItemVersion>(
`/documents/${slug}/items/${itemId}/versions`,
{
method: 'POST',
body: data,
},
)
this.versions.unshift(version)
return version
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la proposition'
throw err
}
},
/**
* Accept a proposed version.
*/
async acceptVersion(slug: string, itemId: string, versionId: string) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<ItemVersion>(
`/documents/${slug}/items/${itemId}/versions/${versionId}/accept`,
{ method: 'POST' },
)
const idx = this.versions.findIndex(v => v.id === versionId)
if (idx >= 0) this.versions[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'acceptation'
throw err
}
},
/**
* Reject a proposed version.
*/
async rejectVersion(slug: string, itemId: string, versionId: string) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<ItemVersion>(
`/documents/${slug}/items/${itemId}/versions/${versionId}/reject`,
{ method: 'POST' },
)
const idx = this.versions.findIndex(v => v.id === versionId)
if (idx >= 0) this.versions[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du rejet'
throw err
}
},
/**
* Archive a document into the Sanctuary.
*/
async archiveDocument(slug: string) {
this.error = null
try {
const { $api } = useApi()
const doc = await $api<Document>(
`/documents/${slug}/archive`,
{ method: 'POST' },
)
// Update current if viewing this document
if (this.current?.slug === slug) {
this.current = doc
}
// Update in list
const idx = this.list.findIndex(d => d.slug === slug)
if (idx >= 0) this.list[idx] = doc
return doc
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'archivage'
throw err
}
},
/**
* Clear the current document, items and versions.
*/
clearCurrent() {
this.current = null
this.items = []
this.versions = []
},
},
})