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
|
from __future__ import annotations
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.document import Document, DocumentItem, ItemVersion
|
from app.models.document import Document, DocumentItem, ItemVersion
|
||||||
from app.models.user import DuniterIdentity
|
from app.models.user import DuniterIdentity
|
||||||
from app.schemas.document import (
|
from app.schemas.document import (
|
||||||
DocumentCreate,
|
DocumentCreate,
|
||||||
|
DocumentFullOut,
|
||||||
DocumentItemCreate,
|
DocumentItemCreate,
|
||||||
DocumentItemOut,
|
DocumentItemOut,
|
||||||
|
DocumentItemUpdate,
|
||||||
DocumentOut,
|
DocumentOut,
|
||||||
DocumentUpdate,
|
DocumentUpdate,
|
||||||
|
ItemReorderRequest,
|
||||||
ItemVersionCreate,
|
ItemVersionCreate,
|
||||||
ItemVersionOut,
|
ItemVersionOut,
|
||||||
)
|
)
|
||||||
|
from app.services import document_service
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -208,6 +214,29 @@ async def list_items(
|
|||||||
return [DocumentItemOut.model_validate(item) for item in 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)
|
@router.get("/{slug}/items/{item_id}", response_model=DocumentItemOut)
|
||||||
async def get_item(
|
async def get_item(
|
||||||
slug: str,
|
slug: str,
|
||||||
@@ -260,3 +289,179 @@ async def propose_version(
|
|||||||
await db.refresh(version)
|
await db.refresh(version)
|
||||||
|
|
||||||
return ItemVersionOut.model_validate(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.sanctuary import SanctuaryEntry
|
||||||
from app.models.user import DuniterIdentity
|
from app.models.user import DuniterIdentity
|
||||||
from app.schemas.sanctuary import SanctuaryEntryCreate, SanctuaryEntryOut
|
from app.schemas.sanctuary import SanctuaryEntryCreate, SanctuaryEntryOut
|
||||||
|
from app.services import sanctuary_service
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -37,19 +38,6 @@ async def list_entries(
|
|||||||
return [SanctuaryEntryOut.model_validate(e) for e in 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)
|
@router.post("/", response_model=SanctuaryEntryOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_entry(
|
async def create_entry(
|
||||||
payload: SanctuaryEntryCreate,
|
payload: SanctuaryEntryCreate,
|
||||||
@@ -71,3 +59,47 @@ async def create_entry(
|
|||||||
await db.refresh(entry)
|
await db.refresh(entry)
|
||||||
|
|
||||||
return SanctuaryEntryOut.model_validate(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
|
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):
|
class DocumentItemOut(BaseModel):
|
||||||
"""Full document item representation."""
|
"""Full document item representation."""
|
||||||
|
|
||||||
@@ -77,6 +86,59 @@ class DocumentItemOut(BaseModel):
|
|||||||
updated_at: datetime
|
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 ─────────────────────────────────────────────────
|
# ── Item Version ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -101,3 +163,11 @@ class ItemVersionOut(BaseModel):
|
|||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
proposed_by_id: UUID | None = None
|
proposed_by_id: UUID | None = None
|
||||||
created_at: datetime
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -10,6 +11,8 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.models.document import Document, DocumentItem, ItemVersion
|
from app.models.document import Document, DocumentItem, ItemVersion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def get_document_with_items(slug: str, db: AsyncSession) -> Document | None:
|
async def get_document_with_items(slug: str, db: AsyncSession) -> Document | None:
|
||||||
"""Load a document with all its items and their versions, eagerly.
|
"""Load a document with all its items and their versions, eagerly.
|
||||||
@@ -106,3 +109,221 @@ async def apply_version(
|
|||||||
await db.refresh(item)
|
await db.refresh(item)
|
||||||
|
|
||||||
return 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 hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.sanctuary import SanctuaryEntry
|
from app.models.sanctuary import SanctuaryEntry
|
||||||
|
from app.services import ipfs_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def archive_to_sanctuary(
|
async def archive_to_sanctuary(
|
||||||
@@ -26,6 +31,12 @@ async def archive_to_sanctuary(
|
|||||||
) -> SanctuaryEntry:
|
) -> SanctuaryEntry:
|
||||||
"""Hash content and create a sanctuary entry.
|
"""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
|
Parameters
|
||||||
----------
|
----------
|
||||||
entry_type:
|
entry_type:
|
||||||
@@ -45,33 +56,65 @@ async def archive_to_sanctuary(
|
|||||||
SanctuaryEntry
|
SanctuaryEntry
|
||||||
The newly created sanctuary entry with content_hash set.
|
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()
|
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# Build metadata
|
# Build metadata
|
||||||
metadata = {
|
metadata: dict = {
|
||||||
"archived_at": datetime.now(timezone.utc).isoformat(),
|
"archived_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"entry_type": entry_type,
|
"entry_type": entry_type,
|
||||||
"content_length": len(content),
|
"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 = SanctuaryEntry(
|
||||||
entry_type=entry_type,
|
entry_type=entry_type,
|
||||||
reference_id=reference_id,
|
reference_id=reference_id,
|
||||||
title=title,
|
title=title,
|
||||||
content_hash=content_hash,
|
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),
|
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)
|
db.add(entry)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(entry)
|
await db.refresh(entry)
|
||||||
@@ -79,31 +122,115 @@ async def archive_to_sanctuary(
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
async def _upload_to_ipfs(content: str) -> str:
|
async def verify_entry(
|
||||||
"""Upload content to IPFS via kubo HTTP API.
|
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
|
Returns
|
||||||
from app.config import settings
|
-------
|
||||||
|
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:
|
Raises
|
||||||
response = await client.post(
|
------
|
||||||
f"{settings.IPFS_API_URL}/api/v0/add",
|
ValueError
|
||||||
files={"file": ("content.txt", content.encode("utf-8"))},
|
If the entry is not found.
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()["Hash"]
|
|
||||||
"""
|
"""
|
||||||
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.
|
"""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::
|
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)
|
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
|
||||||
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
||||||
return receipt.extrinsic_hash, receipt.block_number
|
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")
|
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 |
|
| POST | `/{slug}/items` | Ajouter un item au document | Oui |
|
||||||
| GET | `/{slug}/items` | Lister les items d'un document | Non |
|
| GET | `/{slug}/items` | Lister les items d'un document | Non |
|
||||||
| GET | `/{slug}/items/{item_id}` | Obtenir un item avec son historique | 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 |
|
| 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`)
|
## 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`)
|
## Sanctuaire (`/api/v1/sanctuary`)
|
||||||
|
|
||||||
| Methode | Endpoint | Description | Auth |
|
| Methode | Endpoint | Description | Auth |
|
||||||
| ------- | --------- | ---------------------------------------------------------- | ---- |
|
| ------- | --------------------------------- | ---------------------------------------------------------- | ---- |
|
||||||
| GET | `/` | Lister les entrees du sanctuaire (filtre: entry_type) | Non |
|
| GET | `/` | Lister les entrees du sanctuaire (filtre: entry_type) | Non |
|
||||||
| GET | `/{id}` | Obtenir une entree du sanctuaire | Non |
|
| GET | `/{id}` | Obtenir une entree du sanctuaire | Non |
|
||||||
| POST | `/` | Creer une entree (hash SHA-256, CID IPFS, TX on-chain) | Oui |
|
| 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`)
|
## 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) |
|
| 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
|
## Pagination
|
||||||
|
|
||||||
Les endpoints de liste acceptent les parametres `skip` (offset, defaut 0) et `limit` (max 200, defaut 50).
|
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`
|
||||||
|
|
||||||
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 |
|
| Colonne | Type | Description |
|
||||||
| ------------ | ------------ | ----------------------------------------------------- |
|
| ------------ | ------------ | ----------------------------------------------------- |
|
||||||
@@ -49,38 +49,38 @@ Documents de reference modulaires.
|
|||||||
| version | VARCHAR(32) | Version semantique (defaut "0.1.0") |
|
| version | VARCHAR(32) | Version semantique (defaut "0.1.0") |
|
||||||
| status | VARCHAR(32) | Statut : draft, active, archived |
|
| status | VARCHAR(32) | Statut : draft, active, archived |
|
||||||
| description | TEXT | Description du document |
|
| description | TEXT | Description du document |
|
||||||
| ipfs_cid | VARCHAR(128) | CID IPFS de la derniere version archivee |
|
| 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 |
|
| chain_anchor | VARCHAR(128) | Hash de transaction on-chain (renseigne lors de l'archivage) |
|
||||||
| created_at | TIMESTAMPTZ | Date de creation |
|
| created_at | TIMESTAMPTZ | Date de creation |
|
||||||
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
|
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
|
||||||
|
|
||||||
### `document_items`
|
### `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 |
|
| Colonne | Type | Description |
|
||||||
| ------------------- | ------------ | ------------------------------------------------- |
|
| ------------------- | ------------ | ------------------------------------------------- |
|
||||||
| id | UUID (PK) | Identifiant unique |
|
| 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") |
|
| position | VARCHAR(16) | Numero de position ("1", "1.1", "3.2") |
|
||||||
| item_type | VARCHAR(32) | Type : clause, rule, verification, preamble, section |
|
| item_type | VARCHAR(32) | Type : clause, rule, verification, preamble, section |
|
||||||
| title | VARCHAR(256) | Titre de l'item |
|
| 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) |
|
| 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 |
|
| created_at | TIMESTAMPTZ | Date de creation |
|
||||||
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
|
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
|
||||||
|
|
||||||
### `item_versions`
|
### `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 |
|
| Colonne | Type | Description |
|
||||||
| -------------- | ------------ | ------------------------------------------------------ |
|
| -------------- | ------------ | ------------------------------------------------------ |
|
||||||
| id | UUID (PK) | Identifiant unique |
|
| 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 |
|
| 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 |
|
| rationale | TEXT | Justification de la modification |
|
||||||
| status | VARCHAR(32) | Statut : proposed, voting, accepted, rejected |
|
| status | VARCHAR(32) | Statut : proposed, voting, accepted, rejected |
|
||||||
| decision_id | UUID (FK) | -> decisions.id (decision associee) |
|
| decision_id | UUID (FK) | -> decisions.id (decision associee) |
|
||||||
@@ -240,16 +240,16 @@ Configurations de formules de seuil WoT.
|
|||||||
|
|
||||||
### `sanctuary_entries`
|
### `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 |
|
| Colonne | Type | Description |
|
||||||
| -------------- | ------------ | ------------------------------------------ |
|
| -------------- | ------------ | ------------------------------------------ |
|
||||||
| id | UUID (PK) | Identifiant unique |
|
| id | UUID (PK) | Identifiant unique |
|
||||||
| entry_type | VARCHAR(64) | Type : document, decision, vote_result |
|
| 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 |
|
| title | VARCHAR(256) | Titre |
|
||||||
| content_hash | VARCHAR(128) | Hash SHA-256 du contenu |
|
| 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_tx_hash | VARCHAR(128) | Hash de la transaction on-chain |
|
||||||
| chain_block | INTEGER | Numero de bloc de la transaction |
|
| chain_block | INTEGER | Numero de bloc de la transaction |
|
||||||
| metadata_json | TEXT | Metadonnees JSON supplementaires |
|
| metadata_json | TEXT | Metadonnees JSON supplementaires |
|
||||||
@@ -279,9 +279,10 @@ duniter_identities
|
|||||||
|
|
||||||
documents
|
documents
|
||||||
|-- 1:N --> document_items
|
|-- 1:N --> document_items
|
||||||
|
|-- 1:N ..> sanctuary_entries (via reference_id, non FK)
|
||||||
|
|
||||||
document_items
|
document_items
|
||||||
|-- 1:N --> item_versions
|
|-- 1:N --> item_versions (cascade delete)
|
||||||
|-- N:1 --> voting_protocols
|
|-- N:1 --> voting_protocols
|
||||||
|
|
||||||
item_versions
|
item_versions
|
||||||
@@ -289,6 +290,7 @@ item_versions
|
|||||||
|
|
||||||
decisions
|
decisions
|
||||||
|-- 1:N --> decision_steps
|
|-- 1:N --> decision_steps
|
||||||
|
|-- 1:N ..> sanctuary_entries (via reference_id, non FK)
|
||||||
|
|
||||||
decision_steps
|
decision_steps
|
||||||
|-- N:1 --> vote_sessions
|
|-- N:1 --> vote_sessions
|
||||||
@@ -296,6 +298,7 @@ decision_steps
|
|||||||
vote_sessions
|
vote_sessions
|
||||||
|-- 1:N --> votes
|
|-- 1:N --> votes
|
||||||
|-- N:1 --> voting_protocols
|
|-- N:1 --> voting_protocols
|
||||||
|
|-- 1:N ..> sanctuary_entries (via reference_id, non FK)
|
||||||
|
|
||||||
mandates
|
mandates
|
||||||
|-- 1:N --> mandate_steps
|
|-- 1:N --> mandate_steps
|
||||||
@@ -309,4 +312,9 @@ voting_protocols
|
|||||||
|
|
||||||
formula_configs
|
formula_configs
|
||||||
|-- 1:N --> voting_protocols
|
|-- 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.
|
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.
|
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 :
|
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.
|
4. Ajoutez une **justification** expliquant pourquoi cette modification est necessaire.
|
||||||
5. Soumettez. Un diff automatique est genere entre le texte courant et votre proposition.
|
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
|
## Cycle de vie d'une proposition
|
||||||
|
|
||||||
@@ -44,13 +88,57 @@ Proposee --> En vote --> Acceptee --> Texte courant mis a jour
|
|||||||
--> Rejetee --> Archivee
|
--> 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
|
## Statuts des documents
|
||||||
|
|
||||||
| Statut | Description |
|
| Statut | Description |
|
||||||
| -------- | ------------------------------------------------ |
|
| --------- | ------------------------------------------------ |
|
||||||
| Brouillon | En cours de redaction, non soumis au vote |
|
| Brouillon | En cours de redaction, non soumis au vote |
|
||||||
| Actif | Document en vigueur, sous vote permanent |
|
| Actif | Document en vigueur, sous vote permanent |
|
||||||
| Archive | Document archive, plus en vigueur |
|
| Archive | Document archive dans le Sanctuaire, plus modifiable |
|
||||||
|
|
||||||
## Versionnage
|
## Versionnage
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,37 @@ La gouvernance exige la transparence et la tracabilite. Le Sanctuaire garantit q
|
|||||||
- Le numero de bloc
|
- Le numero de bloc
|
||||||
- La date d'archivage
|
- 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
|
## 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.
|
1. Recuperez le contenu via IPFS en utilisant le CID affiche.
|
||||||
2. Calculez le hash SHA-256 du contenu telecharge.
|
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.
|
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
|
## Automatisation
|
||||||
|
|
||||||
L'archivage dans le Sanctuaire est declenche automatiquement lorsqu'un processus decisionnel est finalise :
|
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 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 session de vote est **cloturee**, le resultat detaille est archive.
|
||||||
- Quand une decision est **executee**, l'ensemble de la decision 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">
|
<script setup lang="ts">
|
||||||
|
import type { DocumentItem } from '~/stores/documents'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const documents = useDocumentsStore()
|
const documents = useDocumentsStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const slug = computed(() => route.params.slug as string)
|
const slug = computed(() => route.params.slug as string)
|
||||||
|
|
||||||
|
const archiving = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await documents.fetchBySlug(slug.value)
|
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) => {
|
const typeLabel = (docType: string) => {
|
||||||
switch (docType) {
|
switch (docType) {
|
||||||
case 'licence': return 'Licence'
|
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 {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -64,6 +40,21 @@ function formatDate(dateStr: string): string {
|
|||||||
year: 'numeric',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -114,14 +105,24 @@ function formatDate(dateStr: string): string {
|
|||||||
<UBadge variant="subtle" color="primary">
|
<UBadge variant="subtle" color="primary">
|
||||||
{{ typeLabel(documents.current.doc_type) }}
|
{{ typeLabel(documents.current.doc_type) }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
<UBadge :color="statusColor(documents.current.status)" variant="subtle">
|
<CommonStatusBadge :status="documents.current.status" type="document" />
|
||||||
{{ statusLabel(documents.current.status) }}
|
|
||||||
</UBadge>
|
|
||||||
<span class="text-sm text-gray-500 font-mono">
|
<span class="text-sm text-gray-500 font-mono">
|
||||||
v{{ documents.current.version }}
|
v{{ documents.current.version }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -153,14 +154,17 @@ function formatDate(dateStr: string): string {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Ancrage IPFS</p>
|
<p class="text-gray-500">Ancrage IPFS</p>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<div class="mt-1">
|
||||||
<template v-if="documents.current.ipfs_cid">
|
<SanctuaryIPFSLink :cid="documents.current.ipfs_cid" />
|
||||||
<span class="font-mono text-xs">{{ documents.current.ipfs_cid.slice(0, 16) }}...</span>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<template v-else>
|
</div>
|
||||||
<span class="text-gray-400">Non ancre</span>
|
|
||||||
</template>
|
<!-- Chain anchor info -->
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
@@ -177,52 +181,14 @@ function formatDate(dateStr: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<UCard
|
<DocumentsItemCard
|
||||||
v-for="item in documents.items"
|
v-for="item in documents.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
>
|
:item="item"
|
||||||
<div class="space-y-3">
|
:document-slug="slug"
|
||||||
<!-- Item header -->
|
:show-actions="auth.isAuthenticated"
|
||||||
<div class="flex items-center justify-between">
|
@propose="handlePropose"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
|
import type { DocumentCreate } from '~/stores/documents'
|
||||||
|
|
||||||
const documents = useDocumentsStore()
|
const documents = useDocumentsStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const filterType = ref<string | undefined>(undefined)
|
const filterType = ref<string | undefined>(undefined)
|
||||||
const filterStatus = ref<string | undefined>(undefined)
|
const filterStatus = ref<string | undefined>(undefined)
|
||||||
@@ -19,13 +22,22 @@ const statusOptions = [
|
|||||||
{ label: 'Archive', value: 'archived' },
|
{ label: 'Archive', value: 'archived' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const columns = [
|
// New document modal state
|
||||||
{ key: 'title', label: 'Titre' },
|
const showNewDocModal = ref(false)
|
||||||
{ key: 'doc_type', label: 'Type' },
|
const newDoc = ref<DocumentCreate>({
|
||||||
{ key: 'version', label: 'Version' },
|
slug: '',
|
||||||
{ key: 'status', label: 'Statut' },
|
title: '',
|
||||||
{ key: 'items_count', label: 'Items' },
|
doc_type: 'licence',
|
||||||
{ key: 'updated_at', label: 'Mis a jour' },
|
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() {
|
async function loadDocuments() {
|
||||||
@@ -43,40 +55,47 @@ watch([filterType, filterStatus], () => {
|
|||||||
loadDocuments()
|
loadDocuments()
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusColor = (status: string) => {
|
function openNewDocModal() {
|
||||||
switch (status) {
|
newDoc.value = {
|
||||||
case 'active': return 'success'
|
slug: '',
|
||||||
case 'draft': return 'warning'
|
title: '',
|
||||||
case 'archived': return 'neutral'
|
doc_type: 'licence',
|
||||||
default: return 'neutral'
|
description: null,
|
||||||
|
version: '1.0.0',
|
||||||
}
|
}
|
||||||
|
showNewDocModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
function generateSlug(title: string): string {
|
||||||
switch (status) {
|
return title
|
||||||
case 'active': return 'Actif'
|
.toLowerCase()
|
||||||
case 'draft': return 'Brouillon'
|
.normalize('NFD')
|
||||||
case 'archived': return 'Archive'
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
default: return status
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
}
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.slice(0, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeLabel = (docType: string) => {
|
watch(() => newDoc.value.title, (title) => {
|
||||||
switch (docType) {
|
if (title) {
|
||||||
case 'licence': return 'Licence'
|
newDoc.value.slug = generateSlug(title)
|
||||||
case 'engagement': return 'Engagement'
|
|
||||||
case 'reglement': return 'Reglement'
|
|
||||||
case 'constitution': return 'Constitution'
|
|
||||||
default: return docType
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
async function createDocument() {
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
creating.value = true
|
||||||
day: 'numeric',
|
try {
|
||||||
month: 'short',
|
const doc = await documents.createDocument(newDoc.value)
|
||||||
year: 'numeric',
|
showNewDocModal.value = false
|
||||||
})
|
if (doc) {
|
||||||
|
navigateTo(`/documents/${doc.slug}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -92,6 +111,15 @@ function formatDate(dateStr: string): string {
|
|||||||
Documents fondateurs de la communaute Duniter/G1 sous vote permanent
|
Documents fondateurs de la communaute Duniter/G1 sous vote permanent
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New document button for authenticated users -->
|
||||||
|
<UButton
|
||||||
|
v-if="auth.isAuthenticated"
|
||||||
|
label="Nouveau document"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
color="primary"
|
||||||
|
@click="openNewDocModal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -110,15 +138,8 @@ function formatDate(dateStr: string): string {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Error state -->
|
||||||
<template v-else-if="documents.error">
|
<template v-if="documents.error">
|
||||||
<UCard>
|
<UCard>
|
||||||
<div class="flex items-center gap-3 text-red-500">
|
<div class="flex items-center gap-3 text-red-500">
|
||||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||||
@@ -127,65 +148,96 @@ function formatDate(dateStr: string): string {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Document list component -->
|
||||||
<template v-else-if="documents.list.length === 0">
|
<DocumentsDocumentList
|
||||||
<UCard>
|
:documents="documents.list"
|
||||||
<div class="text-center py-8">
|
:loading="documents.loading"
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Documents table -->
|
<!-- New document modal -->
|
||||||
<template v-else>
|
<UModal v-model:open="showNewDocModal">
|
||||||
<UCard>
|
<template #content>
|
||||||
<div class="overflow-x-auto">
|
<div class="p-6 space-y-4">
|
||||||
<table class="w-full text-sm">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<thead>
|
Nouveau document de reference
|
||||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
</h3>
|
||||||
<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 }}
|
<div class="space-y-4">
|
||||||
</th>
|
<div class="space-y-2">
|
||||||
</tr>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
</thead>
|
Titre
|
||||||
<tbody>
|
</label>
|
||||||
<tr
|
<UInput
|
||||||
v-for="doc in documents.list"
|
v-model="newDoc.title"
|
||||||
:key="doc.id"
|
placeholder="Ex: Licence G1"
|
||||||
class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
class="w-full"
|
||||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
/>
|
||||||
>
|
</div>
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="space-y-2">
|
||||||
<UIcon name="i-lucide-file-text" class="text-gray-400" />
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ doc.title }}</span>
|
Slug (identifiant URL)
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
v-model="newDoc.slug"
|
||||||
|
placeholder="Ex: licence-g1"
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Type de document
|
||||||
|
</label>
|
||||||
|
<USelect
|
||||||
|
v-model="newDoc.doc_type"
|
||||||
|
:items="newDocTypeOptions"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Version
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
v-model="newDoc.version"
|
||||||
|
placeholder="1.0.0"
|
||||||
|
class="w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Description (optionnelle)
|
||||||
|
</label>
|
||||||
|
<UTextarea
|
||||||
|
v-model="newDoc.description"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="Decrivez brievement ce document..."
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<UButton
|
||||||
|
label="Annuler"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
@click="showNewDocModal = false"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
label="Creer le document"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
color="primary"
|
||||||
|
:loading="creating"
|
||||||
|
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
|
||||||
|
@click="createDocument"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</UCard>
|
|
||||||
</template>
|
</template>
|
||||||
|
</UModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
|
import type { SanctuaryEntryOut } from '~/components/sanctuary/SanctuaryEntry.vue'
|
||||||
|
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
|
|
||||||
interface SanctuaryEntry {
|
const entries = ref<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 entries = ref<SanctuaryEntry[]>([])
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -27,6 +16,10 @@ const typeOptions = [
|
|||||||
{ label: 'Resultat de vote', value: 'vote_result' },
|
{ 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() {
|
async function loadEntries() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -35,7 +28,7 @@ async function loadEntries() {
|
|||||||
const query: Record<string, string> = {}
|
const query: Record<string, string> = {}
|
||||||
if (filterType.value) query.entry_type = filterType.value
|
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) {
|
} catch (err: any) {
|
||||||
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des entrees'
|
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des entrees'
|
||||||
} finally {
|
} 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(() => {
|
onMounted(() => {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
@@ -50,40 +63,6 @@ onMounted(() => {
|
|||||||
watch(filterType, () => {
|
watch(filterType, () => {
|
||||||
loadEntries()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -108,10 +87,35 @@ function truncateHash(hash: string | null, length: number = 16): string {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Loading state -->
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<div class="space-y-3">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -138,104 +142,25 @@ function truncateHash(hash: string | null, length: number = 16): string {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Entries list -->
|
<!-- Entries list using SanctuaryEntry component -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UCard
|
<div v-for="entry in entries" :key="entry.id" class="relative">
|
||||||
v-for="entry in entries"
|
<SanctuarySanctuaryEntry
|
||||||
:key="entry.id"
|
: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">
|
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<!-- Entry header -->
|
<UIcon name="i-lucide-loader-2" class="animate-spin" />
|
||||||
<div class="flex items-start justify-between">
|
<span>Verification en cours...</span>
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ entry.title || 'Entree sans titre' }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{ formatDate(entry.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UBadge :color="typeColor(entry.entry_type)" variant="subtle" size="xs">
|
|
||||||
{{ typeLabel(entry.entry_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hashes and anchors -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<!-- Content hash -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
|
||||||
{{ truncateHash(entry.content_hash) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IPFS CID -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
|
||||||
</div>
|
|
||||||
<template v-if="entry.ipfs_cid">
|
|
||||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
|
||||||
{{ truncateHash(entry.ipfs_cid) }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p class="text-xs text-gray-400 italic">En attente d'epinglage</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chain anchor -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
|
||||||
</div>
|
|
||||||
<template v-if="entry.chain_tx_hash">
|
|
||||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
|
||||||
{{ truncateHash(entry.chain_tx_hash) }}
|
|
||||||
</p>
|
|
||||||
<p v-if="entry.chain_block" class="text-xs text-gray-400 mt-0.5">
|
|
||||||
Bloc #{{ entry.chain_block.toLocaleString('fr-FR') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p class="text-xs text-gray-400 italic">En attente d'ancrage</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verification status -->
|
|
||||||
<div class="flex items-center gap-4 text-xs">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UIcon
|
|
||||||
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
|
||||||
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
|
|
||||||
/>
|
|
||||||
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
|
|
||||||
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UIcon
|
|
||||||
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
|
||||||
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
|
|
||||||
/>
|
|
||||||
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
|
|
||||||
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ export interface Document {
|
|||||||
items_count: number
|
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 {
|
export interface DocumentCreate {
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
@@ -40,10 +54,16 @@ export interface DocumentCreate {
|
|||||||
version?: string
|
version?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionProposal {
|
||||||
|
proposed_text: string
|
||||||
|
rationale?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface DocumentsState {
|
interface DocumentsState {
|
||||||
list: Document[]
|
list: Document[]
|
||||||
current: Document | null
|
current: Document | null
|
||||||
items: DocumentItem[]
|
items: DocumentItem[]
|
||||||
|
versions: ItemVersion[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
@@ -53,6 +73,7 @@ export const useDocumentsStore = defineStore('documents', {
|
|||||||
list: [],
|
list: [],
|
||||||
current: null,
|
current: null,
|
||||||
items: [],
|
items: [],
|
||||||
|
versions: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
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() {
|
clearCurrent() {
|
||||||
this.current = null
|
this.current = null
|
||||||
this.items = []
|
this.items = []
|
||||||
|
this.versions = []
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user