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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
125
backend/app/services/ipfs_service.py
Normal file
125
backend/app/services/ipfs_service.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
418
backend/app/tests/test_documents.py
Normal file
418
backend/app/tests/test_documents.py
Normal 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
|
||||
@@ -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`)
|
||||
|
||||
@@ -74,10 +81,12 @@ 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 |
|
||||
| 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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
| Archive | Document archive dans le Sanctuaire, plus modifiable |
|
||||
|
||||
## Versionnage
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
51
frontend/app/components/common/DiffView.vue
Normal file
51
frontend/app/components/common/DiffView.vue
Normal 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>
|
||||
67
frontend/app/components/common/MarkdownRenderer.vue
Normal file
67
frontend/app/components/common/MarkdownRenderer.vue
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// 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>
|
||||
61
frontend/app/components/common/StatusBadge.vue
Normal file
61
frontend/app/components/common/StatusBadge.vue
Normal 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>
|
||||
88
frontend/app/components/documents/DocumentList.vue
Normal file
88
frontend/app/components/documents/DocumentList.vue
Normal 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>
|
||||
89
frontend/app/components/documents/ItemCard.vue
Normal file
89
frontend/app/components/documents/ItemCard.vue
Normal 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>
|
||||
95
frontend/app/components/documents/ItemVersionDiff.vue
Normal file
95
frontend/app/components/documents/ItemVersionDiff.vue
Normal 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>
|
||||
33
frontend/app/components/sanctuary/ChainAnchor.vue
Normal file
33
frontend/app/components/sanctuary/ChainAnchor.vue
Normal 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>
|
||||
38
frontend/app/components/sanctuary/IPFSLink.vue
Normal file
38
frontend/app/components/sanctuary/IPFSLink.vue
Normal 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>
|
||||
171
frontend/app/components/sanctuary/SanctuaryEntry.vue
Normal file
171
frontend/app/components/sanctuary/SanctuaryEntry.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
328
frontend/app/pages/documents/[slug]/items/[itemId].vue
Normal file
328
frontend/app/pages/documents/[slug]/items/[itemId].vue
Normal 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>
|
||||
@@ -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>
|
||||
<!-- 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>
|
||||
</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>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
445
frontend/app/pages/sanctuary/[id].vue
Normal file
445
frontend/app/pages/sanctuary/[id].vue
Normal 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>
|
||||
@@ -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 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="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 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>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user