Groupes d'identités : modèle DB, router, store, UI cercles + Protocoles

- Group + GroupMember : modèle SQLAlchemy + migration + router CRUD
- /api/v1/groups : liste, création, suppression, membres (add/remove)
- groups.ts : store Pinia (fetchAll, getGroup, create, remove, addMember, removeMember)
- decisions/new.vue : cercles 1 & 2 en mode texte libre OU groupe prédéfini
  (affected_count calculé depuis le member_count du groupe)
- protocols/index.vue : section Groupes avec expand/collapse, ajout/suppression membres
- lang="fr" + spellcheck sur tous les textareas ; placeholder cercle 2 corrigé
- n8n channels : prévu sprint futur (texte libre → webhook appel à contribution)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-04-23 20:25:44 +02:00
parent e2ae8b196e
commit 9a8f10efdf
9 changed files with 879 additions and 20 deletions

View File

@@ -0,0 +1,51 @@
"""Add groups and group_members tables.
Revision ID: c4e812fb3a01
Revises: b78571ae9e00
Create Date: 2026-04-23 19:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "c4e812fb3a01"
down_revision = "b78571ae9e00"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"groups",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("organization_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_groups_organization_id", "groups", ["organization_id"])
op.create_table(
"group_members",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("group_id", sa.Uuid(), nullable=False),
sa.Column("identity_id", sa.Uuid(), nullable=True),
sa.Column("display_name", sa.String(128), nullable=False),
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["group_id"], ["groups.id"]),
sa.ForeignKeyConstraint(["identity_id"], ["duniter_identities.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
def downgrade() -> None:
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_organization_id", table_name="groups")
op.drop_table("groups")

View File

@@ -15,6 +15,7 @@ from app.routers import auth, documents, decisions, votes, mandates, protocols,
from app.routers import public from app.routers import public
from app.routers import organizations from app.routers import organizations
from app.routers import qualify from app.routers import qualify
from app.routers import groups
# ── Structured logging setup ─────────────────────────────────────────────── # ── Structured logging setup ───────────────────────────────────────────────
@@ -135,6 +136,7 @@ app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
app.include_router(public.router, prefix="/api/v1/public", tags=["public"]) app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"]) app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"]) app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
# ── Health check ───────────────────────────────────────────────────────── # ── Health check ─────────────────────────────────────────────────────────

View File

@@ -6,6 +6,7 @@ from app.models.vote import VoteSession, Vote
from app.models.mandate import Mandate, MandateStep from app.models.mandate import Mandate, MandateStep
from app.models.protocol import VotingProtocol, FormulaConfig from app.models.protocol import VotingProtocol, FormulaConfig
from app.models.qualification import QualificationProtocol from app.models.qualification import QualificationProtocol
from app.models.group import Group, GroupMember
from app.models.sanctuary import SanctuaryEntry from app.models.sanctuary import SanctuaryEntry
from app.models.cache import BlockchainCache from app.models.cache import BlockchainCache
@@ -18,6 +19,7 @@ __all__ = [
"Mandate", "MandateStep", "Mandate", "MandateStep",
"VotingProtocol", "FormulaConfig", "VotingProtocol", "FormulaConfig",
"QualificationProtocol", "QualificationProtocol",
"Group", "GroupMember",
"SanctuaryEntry", "SanctuaryEntry",
"BlockchainCache", "BlockchainCache",
] ]

View File

@@ -0,0 +1,41 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Group(Base):
__tablename__ = "groups"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("organizations.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["GroupMember"]] = relationship(
back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
__tablename__ = "group_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
group_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groups.id"), nullable=False, index=True)
# FK to duniter_identities when the member is a known WoT member; nullable for free-text entries
identity_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=True
)
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
group: Mapped["Group"] = relationship(back_populates="members")

View File

@@ -0,0 +1,126 @@
"""Groups router — predefined sets of Duniter identities used in decision circles."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.group import Group, GroupMember
from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary
from app.services.auth_service import get_current_identity
router = APIRouter()
def _org_id_from_header(request_headers) -> uuid.UUID | None:
raw = request_headers.get("x-organization")
if not raw:
return None
try:
return uuid.UUID(raw)
except ValueError:
return None
@router.get("/", response_model=list[GroupSummary])
async def list_groups(
db: AsyncSession = Depends(get_db),
) -> list[GroupSummary]:
"""List all groups. No auth required — groups are public within the workspace."""
result = await db.execute(
select(Group).options(selectinload(Group.members)).order_by(Group.name)
)
groups = result.scalars().all()
return [
GroupSummary(
id=g.id,
name=g.name,
description=g.description,
organization_id=g.organization_id,
member_count=len(g.members),
)
for g in groups
]
@router.post("/", response_model=GroupOut, status_code=201)
async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> GroupOut:
group = Group(name=payload.name, description=payload.description)
db.add(group)
await db.commit()
await db.refresh(group)
await db.execute(select(Group).where(Group.id == group.id).options(selectinload(Group.members)))
return GroupOut.model_validate(group)
@router.get("/{group_id}", response_model=GroupOut)
async def get_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> GroupOut:
result = await db.execute(
select(Group).where(Group.id == group_id).options(selectinload(Group.members))
)
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
return GroupOut.model_validate(group)
@router.delete("/{group_id}", status_code=204, response_class=Response, response_model=None)
async def delete_group(
group_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(select(Group).where(Group.id == group_id))
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
await db.delete(group)
await db.commit()
@router.post("/{group_id}/members", response_model=GroupMemberOut, status_code=201)
async def add_member(
group_id: uuid.UUID,
payload: GroupMemberCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> GroupMemberOut:
result = await db.execute(select(Group).where(Group.id == group_id))
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Group not found")
member = GroupMember(
group_id=group_id,
display_name=payload.display_name,
identity_id=payload.identity_id,
)
db.add(member)
await db.commit()
await db.refresh(member)
return GroupMemberOut.model_validate(member)
@router.delete("/{group_id}/members/{member_id}", status_code=204, response_class=Response, response_model=None)
async def remove_member(
group_id: uuid.UUID,
member_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(
select(GroupMember).where(GroupMember.id == member_id, GroupMember.group_id == group_id)
)
member = result.scalar_one_or_none()
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
await db.delete(member)
await db.commit()

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GroupMemberCreate(BaseModel):
display_name: str
identity_id: uuid.UUID | None = None
class GroupMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
display_name: str
identity_id: uuid.UUID | None
added_at: datetime
class GroupCreate(BaseModel):
name: str
description: str | None = None
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
created_at: datetime
members: list[GroupMemberOut] = []
class GroupSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
member_count: int = 0

View File

@@ -4,6 +4,7 @@ import type { DecisionCreate } from '~/stores/decisions'
const decisions = useDecisionsStore() const decisions = useDecisionsStore()
const protocols = useProtocolsStore() const protocols = useProtocolsStore()
const mandates = useMandatesStore() const mandates = useMandatesStore()
const groupsStore = useGroupsStore()
const { $api } = useApi() const { $api } = useApi()
// ── Wizard steps ────────────────────────────────────────────────────────────── // ── Wizard steps ──────────────────────────────────────────────────────────────
@@ -16,16 +17,57 @@ const isStructural = ref(false)
const contextDescription = ref('') const contextDescription = ref('')
// Cercles de personnes concernées // Cercles de personnes concernées
const circle1 = ref('') // 1er cercle — nommés explicitement (requis hors mandat) // Chaque cercle peut être en mode texte libre ou groupe prédéfini
const circle2 = ref('') // 2e cercle — optionnel type CircleMode = 'text' | 'group'
const circle3Open = ref(true) // 3e cercle — toutes personnes se sentant concernées const circle1Mode = ref<CircleMode>('text')
const circle1Text = ref('')
const circle1GroupId = ref<string | null>(null)
const circle2Mode = ref<CircleMode>('text')
const circle2Text = ref('')
const circle2GroupId = ref<string | null>(null)
const circle3Open = ref(true)
// Résolution du cercle en liste de noms (pour le count et la sauvegarde)
function circleNames(mode: CircleMode, text: string, groupId: string | null): string[] {
if (mode === 'group' && groupId) {
const g = groupsStore.list.find(g => g.id === groupId)
return g ? [`[Groupe: ${g.name}]`] : []
}
return text.split(/[,;\n]/).map(s => s.trim()).filter(Boolean)
}
// Affected count derivé des cercles // Affected count derivé des cercles
const affectedCountFromCircles = computed(() => { const affectedCountFromCircles = computed(() => {
const c1 = circle1.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length const c1names = circleNames(circle1Mode.value, circle1Text.value, circle1GroupId.value)
const c2 = circle2.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length const c2names = circleNames(circle2Mode.value, circle2Text.value, circle2GroupId.value)
const base = c1 + c2
return base >= 2 ? base : (base === 1 ? 2 : null) // minimum 2 let count = 0
if (circle1Mode.value === 'group' && circle1GroupId.value) {
const g = groupsStore.list.find(g => g.id === circle1GroupId.value)
count += g?.member_count ?? 1
} else {
count += c1names.length
}
if (circle2Mode.value === 'group' && circle2GroupId.value) {
const g = groupsStore.list.find(g => g.id === circle2GroupId.value)
count += g?.member_count ?? 0
} else {
count += c2names.length
}
return count >= 2 ? count : (count === 1 ? 2 : null)
})
// Résumé lisible des cercles (pour la sauvegarde dans le contexte)
const circlesSummary = computed(() => {
const c1 = circle1Mode.value === 'group' && circle1GroupId.value
? `Groupe: ${groupsStore.list.find(g => g.id === circle1GroupId.value)?.name ?? '?'}`
: circle1Text.value.trim()
const c2 = circle2Mode.value === 'group' && circle2GroupId.value
? `Groupe: ${groupsStore.list.find(g => g.id === circle2GroupId.value)?.name ?? '?'}`
: circle2Text.value.trim()
return { c1, c2 }
}) })
// ── AI conversation ─────────────────────────────────────────────────────────── // ── AI conversation ───────────────────────────────────────────────────────────
@@ -75,6 +117,7 @@ const activeMandates = computed(() =>
onMounted(() => { onMounted(() => {
mandates.fetchAll() mandates.fetchAll()
groupsStore.fetchAll()
}) })
// ── Modality metadata ───────────────────────────────────────────────────────── // ── Modality metadata ─────────────────────────────────────────────────────────
@@ -112,9 +155,8 @@ function modalityMeta(slug: string) {
const canQualify = computed(() => { const canQualify = computed(() => {
if (withinMandate.value === null) return false if (withinMandate.value === null) return false
if (withinMandate.value === false) { if (withinMandate.value === false) {
// Need at least one name in circle 1 if (circle1Mode.value === 'text') return circle1Text.value.trim().length > 0
const c1 = circle1.value.trim() return circle1GroupId.value !== null
return c1.length > 0
} }
return true return true
}) })
@@ -206,9 +248,10 @@ async function onSubmit() {
if (!formData.value.title.trim()) return if (!formData.value.title.trim()) return
submitting.value = true submitting.value = true
try { try {
const { c1, c2 } = circlesSummary.value
const circlesContext = [ const circlesContext = [
circle1.value.trim() ? `Cercle 1 (directement concernés) : ${circle1.value.trim()}` : '', c1 ? `Cercle 1 (directement concernés) : ${c1}` : '',
circle2.value.trim() ? `Cercle 2 (indirectement concernés) : ${circle2.value.trim()}` : '', c2 ? `Cercle 2 (indirectement concernés) : ${c2}` : '',
circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '', circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '',
].filter(Boolean).join('\n') ].filter(Boolean).join('\n')
@@ -326,13 +369,35 @@ const confidenceLabel: Record<string, string> = {
<div class="circle-header"> <div class="circle-header">
<span class="circle-badge circle-badge--1">1</span> <span class="circle-badge circle-badge--1">1</span>
<p class="qualify-label">Premier cercle — personnes directement concernées <span class="qualify-req">*</span></p> <p class="qualify-label">Premier cercle — personnes directement concernées <span class="qualify-req">*</span></p>
<div class="circle-mode-toggle">
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'text' }" @click="circle1Mode = 'text'">
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
</button>
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle1Mode === 'group' }" @click="circle1Mode = 'group'">
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
</button>
</div>
</div> </div>
<textarea <textarea
v-model="circle1" v-if="circle1Mode === 'text'"
v-model="circle1Text"
class="qualify-textarea" class="qualify-textarea"
rows="2" rows="2"
lang="fr"
spellcheck="true"
placeholder="Alice, Bob, Charlie — une par ligne ou séparées par des virgules" placeholder="Alice, Bob, Charlie — une par ligne ou séparées par des virgules"
/> />
<div v-else class="circle-group-select">
<select v-model="circle1GroupId" class="qualify-select">
<option :value="null">— Sélectionner un groupe —</option>
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
</option>
</select>
<p v-if="groupsStore.list.length === 0" class="qualify-hint qualify-hint--warn">
Aucun groupe défini. <NuxtLink to="/protocols" class="qualify-link">Créer un groupe</NuxtLink> dans Protocoles & Fonctionnement.
</p>
</div>
<p class="qualify-hint">Personnes dont la décision modifie directement la situation.</p> <p class="qualify-hint">Personnes dont la décision modifie directement la situation.</p>
</div> </div>
@@ -341,13 +406,32 @@ const confidenceLabel: Record<string, string> = {
<div class="circle-header"> <div class="circle-header">
<span class="circle-badge circle-badge--2">2</span> <span class="circle-badge circle-badge--2">2</span>
<p class="qualify-label">Deuxième cercle <span class="qualify-optional">(optionnel)</span></p> <p class="qualify-label">Deuxième cercle <span class="qualify-optional">(optionnel)</span></p>
<div class="circle-mode-toggle">
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'text' }" @click="circle2Mode = 'text'">
<UIcon name="i-lucide-type" class="text-xs" /> Texte libre
</button>
<button class="circle-mode-btn" :class="{ 'circle-mode-btn--active': circle2Mode === 'group' }" @click="circle2Mode = 'group'">
<UIcon name="i-lucide-users-round" class="text-xs" /> Groupe
</button>
</div>
</div> </div>
<textarea <textarea
v-model="circle2" v-if="circle2Mode === 'text'"
v-model="circle2Text"
class="qualify-textarea" class="qualify-textarea"
rows="2" rows="2"
placeholder="Personnes impactées indirectement ou parties prenantes secondaires" lang="fr"
spellcheck="true"
placeholder="Personnes impactées indirectement ou parties prenantes de second niveau"
/> />
<div v-else class="circle-group-select">
<select v-model="circle2GroupId" class="qualify-select">
<option :value="null">— Sélectionner un groupe —</option>
<option v-for="g in groupsStore.list" :key="g.id" :value="g.id">
{{ g.name }} ({{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }})
</option>
</select>
</div>
</div> </div>
<!-- Cercle 3 --> <!-- Cercle 3 -->
@@ -396,7 +480,7 @@ const confidenceLabel: Record<string, string> = {
<!-- Contexte --> <!-- Contexte -->
<div v-if="withinMandate !== null" class="qualify-section"> <div v-if="withinMandate !== null" class="qualify-section">
<p class="qualify-label">Décrivez brièvement le contexte <span class="qualify-optional">(optionnel — enrichit les propositions de l'IA)</span></p> <p class="qualify-label">Décrivez brièvement le contexte <span class="qualify-optional">(optionnel — enrichit les propositions de l'IA)</span></p>
<textarea v-model="contextDescription" class="qualify-textarea" rows="3" placeholder="En quelques mots : de quoi s'agit-il ? Quels enjeux ? Quelle contrainte ?" /> <textarea v-model="contextDescription" class="qualify-textarea" rows="3" lang="fr" spellcheck="true" placeholder="En quelques mots : de quoi s'agit-il ? Quels enjeux ? Quelle contrainte ?" />
</div> </div>
<p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p> <p v-if="qualifyError" class="qualify-error">{{ qualifyError }}</p>
@@ -535,11 +619,11 @@ const confidenceLabel: Record<string, string> = {
<div v-if="withinMandate === false" class="qresult__circles"> <div v-if="withinMandate === false" class="qresult__circles">
<p class="qresult__circles-title">Cercles concernés</p> <p class="qresult__circles-title">Cercles concernés</p>
<div class="qresult__circles-list"> <div class="qresult__circles-list">
<span v-if="circle1.trim()" class="qresult__circle"> <span v-if="circlesSummary.c1" class="qresult__circle">
<span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circle1.trim() }} <span class="circle-badge circle-badge--1 circle-badge--sm">1</span> {{ circlesSummary.c1 }}
</span> </span>
<span v-if="circle2.trim()" class="qresult__circle"> <span v-if="circlesSummary.c2" class="qresult__circle">
<span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circle2.trim() }} <span class="circle-badge circle-badge--2 circle-badge--sm">2</span> {{ circlesSummary.c2 }}
</span> </span>
<span class="qresult__circle"> <span class="qresult__circle">
<span class="circle-badge circle-badge--3 circle-badge--sm">3</span> <span class="circle-badge circle-badge--3 circle-badge--sm">3</span>
@@ -1016,4 +1100,44 @@ const confidenceLabel: Record<string, string> = {
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; overflow: hidden; } .slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; overflow: hidden; }
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; max-height: 0; } .slide-down-enter-from, .slide-down-leave-to { opacity: 0; max-height: 0; }
.slide-down-enter-to, .slide-down-leave-from { max-height: 600px; } .slide-down-enter-to, .slide-down-leave-from { max-height: 600px; }
/* Circle mode toggle */
.circle-header { flex-wrap: wrap; }
.circle-mode-toggle {
display: inline-flex;
gap: 0.25rem;
margin-left: auto;
background: var(--mood-accent-soft);
border-radius: 10px;
padding: 0.1875rem;
}
.circle-mode-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-muted);
border-radius: 8px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.circle-mode-btn--active {
background: var(--mood-accent);
color: var(--mood-accent-text);
}
.circle-group-select { margin-bottom: 0.25rem; }
.qualify-select {
width: 100%;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
outline: none;
cursor: pointer;
}
.qualify-hint--warn { color: var(--mood-warning, var(--mood-muted)); }
.qualify-link { color: var(--mood-accent); text-decoration: underline; }
</style> </style>

View File

@@ -24,6 +24,7 @@ onMounted(async () => {
await Promise.all([ await Promise.all([
protocols.fetchProtocols(), protocols.fetchProtocols(),
protocols.fetchFormulas(), protocols.fetchFormulas(),
groupsStore.fetchAll(),
]) ])
}) })
@@ -174,6 +175,73 @@ const operationalProtocols: OperationalProtocol[] = [
}, },
] ]
// ── Groups ─────────────────────────────────────────────────────────────────
const groupsStore = useGroupsStore()
const showGroupModal = ref(false)
const newGroupName = ref('')
const newGroupDesc = ref('')
const creatingGroup = ref(false)
const expandedGroupId = ref<string | null>(null)
const expandedGroupDetail = ref<import('~/stores/groups').Group | null>(null)
const loadingGroupDetail = ref(false)
const newMemberName = ref('')
const addingMember = ref(false)
async function openGroupModal() {
newGroupName.value = ''
newGroupDesc.value = ''
showGroupModal.value = true
}
async function createGroup() {
if (!newGroupName.value.trim()) return
creatingGroup.value = true
try {
await groupsStore.create({ name: newGroupName.value.trim(), description: newGroupDesc.value.trim() || null })
showGroupModal.value = false
} finally {
creatingGroup.value = false
}
}
async function toggleGroupDetail(groupId: string) {
if (expandedGroupId.value === groupId) {
expandedGroupId.value = null
expandedGroupDetail.value = null
return
}
expandedGroupId.value = groupId
loadingGroupDetail.value = true
expandedGroupDetail.value = await groupsStore.getGroup(groupId)
loadingGroupDetail.value = false
}
async function addMember(groupId: string) {
if (!newMemberName.value.trim()) return
addingMember.value = true
const member = await groupsStore.addMember(groupId, { display_name: newMemberName.value.trim() })
if (member && expandedGroupDetail.value) {
expandedGroupDetail.value.members.push(member)
newMemberName.value = ''
}
addingMember.value = false
}
async function removeMember(groupId: string, memberId: string) {
const ok = await groupsStore.removeMember(groupId, memberId)
if (ok && expandedGroupDetail.value) {
expandedGroupDetail.value.members = expandedGroupDetail.value.members.filter(m => m.id !== memberId)
}
}
async function deleteGroup(groupId: string) {
await groupsStore.remove(groupId)
if (expandedGroupId.value === groupId) {
expandedGroupId.value = null
expandedGroupDetail.value = null
}
}
/** n8n workflow demo items. */ /** n8n workflow demo items. */
const n8nWorkflows = [ const n8nWorkflows = [
{ {
@@ -330,6 +398,79 @@ const n8nWorkflows = [
</div> </div>
</template> </template>
<!-- Groups -->
<div class="proto-groups">
<h3 class="proto-groups__title">
<UIcon name="i-lucide-users-round" class="text-sm" />
Groupes d'identités
<span class="proto-groups__count">{{ groupsStore.list.length }}</span>
<button v-if="auth.isAuthenticated" class="proto-groups__add-btn" @click="openGroupModal">
<UIcon name="i-lucide-plus" class="text-sm" />
Nouveau groupe
</button>
</h3>
<div v-if="groupsStore.list.length === 0" class="proto-groups__empty">
<UIcon name="i-lucide-users" class="text-lg" />
<span>Aucun groupe défini. Les groupes permettent de pré-sélectionner des cercles dans les décisions.</span>
</div>
<div v-else class="proto-groups__list">
<div v-for="g in groupsStore.list" :key="g.id" class="proto-groups__item">
<div class="proto-groups__item-head" @click="toggleGroupDetail(g.id)">
<div class="proto-groups__item-info">
<UIcon name="i-lucide-users" class="text-sm" />
<span class="proto-groups__item-name">{{ g.name }}</span>
<span class="proto-groups__item-count">{{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }}</span>
</div>
<div class="proto-groups__item-actions">
<button v-if="auth.isAuthenticated" class="proto-groups__delete-btn" @click.stop="deleteGroup(g.id)">
<UIcon name="i-lucide-trash-2" class="text-xs" />
</button>
<UIcon :name="expandedGroupId === g.id ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="text-sm" />
</div>
</div>
<Transition name="slide-down">
<div v-if="expandedGroupId === g.id" class="proto-groups__detail">
<p v-if="g.description" class="proto-groups__detail-desc">{{ g.description }}</p>
<div v-if="loadingGroupDetail" class="proto-groups__members-loading">
<UIcon name="i-lucide-loader-2" class="animate-spin text-sm" />
</div>
<ul v-else-if="expandedGroupDetail" class="proto-groups__members">
<li v-for="m in expandedGroupDetail.members" :key="m.id" class="proto-groups__member">
<UIcon name="i-lucide-user" class="text-xs" />
<span>{{ m.display_name }}</span>
<button v-if="auth.isAuthenticated" class="proto-groups__member-remove" @click="removeMember(g.id, m.id)">
<UIcon name="i-lucide-x" class="text-xs" />
</button>
</li>
<li v-if="expandedGroupDetail.members.length === 0" class="proto-groups__member proto-groups__member--empty">
Aucun membre
</li>
</ul>
<div v-if="auth.isAuthenticated" class="proto-groups__add-member">
<input
v-model="newMemberName"
type="text"
lang="fr"
spellcheck="true"
class="proto-groups__member-input"
placeholder="Nom ou adresse Duniter"
@keydown.enter="addMember(g.id)"
/>
<button class="proto-groups__member-btn" :disabled="addingMember || !newMemberName.trim()" @click="addMember(g.id)">
<UIcon v-if="addingMember" name="i-lucide-loader-2" class="animate-spin text-xs" />
<UIcon v-else name="i-lucide-user-plus" class="text-xs" />
Ajouter
</button>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Operational protocols (always visible, frontend-only data) --> <!-- Operational protocols (always visible, frontend-only data) -->
<div class="proto-ops"> <div class="proto-ops">
<h3 class="proto-ops__title"> <h3 class="proto-ops__title">
@@ -506,6 +647,51 @@ const n8nWorkflows = [
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Create group modal -->
<UModal v-model:open="showGroupModal">
<template #content>
<div class="proto-modal">
<h3 class="proto-modal__title">Nouveau groupe d'identités</h3>
<div class="proto-modal__fields">
<div class="proto-modal__field">
<label class="proto-modal__label">Nom du groupe</label>
<input
v-model="newGroupName"
type="text"
lang="fr"
spellcheck="true"
class="proto-modal__input"
placeholder="Ex: Comité technique, Forgerons actifs…"
/>
</div>
<div class="proto-modal__field">
<label class="proto-modal__label">Description <span class="proto-modal__optional">(optionnel)</span></label>
<textarea
v-model="newGroupDesc"
class="proto-modal__textarea"
lang="fr"
spellcheck="true"
placeholder="Rôle ou périmètre de ce groupe…"
rows="2"
/>
</div>
</div>
<div class="proto-modal__actions">
<button class="proto-modal__cancel" @click="showGroupModal = false">Annuler</button>
<button
class="proto-modal__submit"
:disabled="!newGroupName.trim() || creatingGroup"
@click="createGroup"
>
<UIcon v-if="creatingGroup" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-users-round" />
Créer le groupe
</button>
</div>
</div>
</template>
</UModal>
</template> </template>
<style scoped> <style scoped>
@@ -1185,4 +1371,175 @@ const n8nWorkflows = [
box-shadow: 0 4px 12px var(--mood-shadow); box-shadow: 0 4px 12px var(--mood-shadow);
} }
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; } .proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
.proto-modal__optional { font-size: 0.8125rem; opacity: 0.55; font-weight: 400; }
/* --- Groups --- */
.proto-groups {
margin-top: 2rem;
}
.proto-groups__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin-bottom: 1rem;
}
.proto-groups__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.375rem;
font-size: 0.75rem;
font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
border-radius: 20px;
}
.proto-groups__add-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: 16px;
padding: 0.25rem 0.75rem;
cursor: pointer;
transition: transform 0.1s ease;
}
.proto-groups__add-btn:hover { transform: translateY(-1px); }
.proto-groups__empty {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 1rem 1.25rem;
color: var(--mood-muted);
background: var(--mood-surface);
border-radius: 14px;
font-size: 0.875rem;
}
.proto-groups__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.proto-groups__item {
background: var(--mood-surface);
border-radius: 14px;
overflow: hidden;
}
.proto-groups__item-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.12s ease;
}
.proto-groups__item-head:hover { background: var(--mood-hover); }
.proto-groups__item-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-text);
}
.proto-groups__item-name { font-weight: 600; font-size: 0.9375rem; }
.proto-groups__item-count {
font-size: 0.75rem;
color: var(--mood-muted);
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 0.125rem 0.5rem;
}
.proto-groups__item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-muted);
}
.proto-groups__delete-btn {
color: var(--mood-danger, #e53e3e);
opacity: 0.5;
cursor: pointer;
transition: opacity 0.1s;
}
.proto-groups__delete-btn:hover { opacity: 1; }
.proto-groups__detail {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
}
.proto-groups__detail-desc {
font-size: 0.875rem;
color: var(--mood-muted);
margin-bottom: 0.75rem;
}
.proto-groups__members-loading {
display: flex;
justify-content: center;
padding: 0.5rem;
color: var(--mood-muted);
}
.proto-groups__members {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.proto-groups__member {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--mood-text);
padding: 0.375rem 0.5rem;
background: var(--mood-bg);
border-radius: 8px;
}
.proto-groups__member--empty { color: var(--mood-muted); font-style: italic; }
.proto-groups__member-remove {
margin-left: auto;
color: var(--mood-danger, #e53e3e);
opacity: 0.4;
cursor: pointer;
transition: opacity 0.1s;
}
.proto-groups__member-remove:hover { opacity: 1; }
.proto-groups__add-member {
display: flex;
gap: 0.5rem;
align-items: center;
}
.proto-groups__member-input {
flex: 1;
padding: 0.4375rem 0.75rem;
font-size: 0.875rem;
color: var(--mood-text);
background: var(--mood-bg);
border-radius: 10px;
outline: none;
}
.proto-groups__member-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 14px;
cursor: pointer;
transition: transform 0.1s ease;
white-space: nowrap;
}
.proto-groups__member-btn:hover:not(:disabled) { transform: translateY(-1px); }
.proto-groups__member-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style> </style>

View File

@@ -0,0 +1,110 @@
export interface GroupMember {
id: string
display_name: string
identity_id: string | null
added_at: string
}
export interface Group {
id: string
name: string
description: string | null
organization_id: string | null
created_at: string
members: GroupMember[]
}
export interface GroupSummary {
id: string
name: string
description: string | null
organization_id: string | null
member_count: number
}
export interface GroupCreate {
name: string
description?: string | null
}
export interface GroupMemberCreate {
display_name: string
identity_id?: string | null
}
export const useGroupsStore = defineStore('groups', () => {
const { $api } = useApi()
const orgs = useOrgsStore()
const list = ref<GroupSummary[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchAll() {
loading.value = true
error.value = null
try {
list.value = await $api<GroupSummary[]>('/groups/')
} catch (e: any) {
error.value = e?.message ?? 'Erreur chargement groupes'
} finally {
loading.value = false
}
}
async function getGroup(id: string): Promise<Group | null> {
try {
return await $api<Group>(`/groups/${id}`)
} catch {
return null
}
}
async function create(payload: GroupCreate): Promise<Group | null> {
try {
const group = await $api<Group>('/groups/', { method: 'POST', body: payload })
await fetchAll()
return group
} catch (e: any) {
error.value = e?.message ?? 'Erreur création groupe'
return null
}
}
async function remove(id: string): Promise<boolean> {
try {
await $api(`/groups/${id}`, { method: 'DELETE' })
list.value = list.value.filter(g => g.id !== id)
return true
} catch {
return false
}
}
async function addMember(groupId: string, payload: GroupMemberCreate): Promise<GroupMember | null> {
try {
const member = await $api<GroupMember>(`/groups/${groupId}/members`, {
method: 'POST',
body: payload,
})
const g = list.value.find(g => g.id === groupId)
if (g) g.member_count++
return member
} catch {
return null
}
}
async function removeMember(groupId: string, memberId: string): Promise<boolean> {
try {
await $api(`/groups/${groupId}/members/${memberId}`, { method: 'DELETE' })
const g = list.value.find(g => g.id === groupId)
if (g) g.member_count--
return true
} catch {
return false
}
}
return { list, loading, error, fetchAll, getGroup, create, remove, addMember, removeMember }
})