diff --git a/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py b/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py new file mode 100644 index 0000000..55897d0 --- /dev/null +++ b/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 7fee3df..9c5cf4a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app.routers import auth, documents, decisions, votes, mandates, protocols, from app.routers import public from app.routers import organizations from app.routers import qualify +from app.routers import groups # ── 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(organizations.router, prefix="/api/v1/organizations", tags=["organizations"]) app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"]) +app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"]) # ── Health check ───────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d87347d..4892057 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.vote import VoteSession, Vote from app.models.mandate import Mandate, MandateStep from app.models.protocol import VotingProtocol, FormulaConfig from app.models.qualification import QualificationProtocol +from app.models.group import Group, GroupMember from app.models.sanctuary import SanctuaryEntry from app.models.cache import BlockchainCache @@ -18,6 +19,7 @@ __all__ = [ "Mandate", "MandateStep", "VotingProtocol", "FormulaConfig", "QualificationProtocol", + "Group", "GroupMember", "SanctuaryEntry", "BlockchainCache", ] diff --git a/backend/app/models/group.py b/backend/app/models/group.py new file mode 100644 index 0000000..7156a5d --- /dev/null +++ b/backend/app/models/group.py @@ -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") diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py new file mode 100644 index 0000000..0989607 --- /dev/null +++ b/backend/app/routers/groups.py @@ -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() diff --git a/backend/app/schemas/group.py b/backend/app/schemas/group.py new file mode 100644 index 0000000..ae958fd --- /dev/null +++ b/backend/app/schemas/group.py @@ -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 diff --git a/frontend/app/pages/decisions/new.vue b/frontend/app/pages/decisions/new.vue index 8118068..b658d91 100644 --- a/frontend/app/pages/decisions/new.vue +++ b/frontend/app/pages/decisions/new.vue @@ -4,6 +4,7 @@ import type { DecisionCreate } from '~/stores/decisions' const decisions = useDecisionsStore() const protocols = useProtocolsStore() const mandates = useMandatesStore() +const groupsStore = useGroupsStore() const { $api } = useApi() // ── Wizard steps ────────────────────────────────────────────────────────────── @@ -16,16 +17,57 @@ const isStructural = ref(false) const contextDescription = ref('') // Cercles de personnes concernées -const circle1 = ref('') // 1er cercle — nommés explicitement (requis hors mandat) -const circle2 = ref('') // 2e cercle — optionnel -const circle3Open = ref(true) // 3e cercle — toutes personnes se sentant concernées +// Chaque cercle peut être en mode texte libre ou groupe prédéfini +type CircleMode = 'text' | 'group' +const circle1Mode = ref('text') +const circle1Text = ref('') +const circle1GroupId = ref(null) + +const circle2Mode = ref('text') +const circle2Text = ref('') +const circle2GroupId = ref(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 const affectedCountFromCircles = computed(() => { - const c1 = circle1.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length - const c2 = circle2.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length - const base = c1 + c2 - return base >= 2 ? base : (base === 1 ? 2 : null) // minimum 2 + const c1names = circleNames(circle1Mode.value, circle1Text.value, circle1GroupId.value) + const c2names = circleNames(circle2Mode.value, circle2Text.value, circle2GroupId.value) + + 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 ─────────────────────────────────────────────────────────── @@ -75,6 +117,7 @@ const activeMandates = computed(() => onMounted(() => { mandates.fetchAll() + groupsStore.fetchAll() }) // ── Modality metadata ───────────────────────────────────────────────────────── @@ -112,9 +155,8 @@ function modalityMeta(slug: string) { const canQualify = computed(() => { if (withinMandate.value === null) return false if (withinMandate.value === false) { - // Need at least one name in circle 1 - const c1 = circle1.value.trim() - return c1.length > 0 + if (circle1Mode.value === 'text') return circle1Text.value.trim().length > 0 + return circle1GroupId.value !== null } return true }) @@ -206,9 +248,10 @@ async function onSubmit() { if (!formData.value.title.trim()) return submitting.value = true try { + const { c1, c2 } = circlesSummary.value const circlesContext = [ - circle1.value.trim() ? `Cercle 1 (directement concernés) : ${circle1.value.trim()}` : '', - circle2.value.trim() ? `Cercle 2 (indirectement concernés) : ${circle2.value.trim()}` : '', + c1 ? `Cercle 1 (directement concernés) : ${c1}` : '', + c2 ? `Cercle 2 (indirectement concernés) : ${c2}` : '', circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '', ].filter(Boolean).join('\n') @@ -326,13 +369,35 @@ const confidenceLabel: Record = {
1

Premier cercle — personnes directement concernées *

+
+ + +