From 4f8bf6621841ebdb033cf7b566400fe4d1f31e6d Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:14:25 -0300 Subject: [PATCH 01/12] feat: create conference/committee/conference_members mapping on supabase --- .../20260719224327_create_initial_tables.sql | 99 ++++++++++++++----- 1 file changed, 76 insertions(+), 23 deletions(-) diff --git a/supabase/migrations/20260719224327_create_initial_tables.sql b/supabase/migrations/20260719224327_create_initial_tables.sql index ce34ead..ea57bc0 100644 --- a/supabase/migrations/20260719224327_create_initial_tables.sql +++ b/supabase/migrations/20260719224327_create_initial_tables.sql @@ -1,21 +1,91 @@ +------------------------------------------------------ +-- ENUMS AND TYPES +------------------------------------------------------ create type activity_status as enum ('active', 'closed', 'planned', 'cancelled'); -create type committee_role as enum ('chair', 'delegate'); create type asset_type as enum ('preset', 'custom'); +create type conference_role as enum ( + 'admin', -- Secretary general + 'chair', -- Director / Moderator + 'press', -- Press / Media team + 'staff', -- General logistics / Crisis backroom + 'participant' -- Participant representation +) +------------------------------------------------------ +-- CORE CONFERENCE HIERARCHY +------------------------------------------------------ create table conferences ( id bigint primary key generated always as identity, name varchar(255) not null, + slug varchar(100), status activity_status not null default 'planned', - owner_id uuid not null references auth.users on delete cascade + owner_id uuid not null references auth.users on delete cascade, + + location varchar(255), + logo varchar(1000), + color varchar(7) not null default '#0f172a', + start_date timestamptz not null, + end_date timestamptz not null, + + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), ); create table committees ( id bigint primary key generated always as identity, conference_id bigint not null references conferences(id), - name varchar(64) not null, - status activity_status not null default 'planned' + + name varchar(128) not null, -- full name + code varchar(64) not null, -- code name + logo varchar(1000), + topic TEXT, + status activity_status not null default 'planned', + + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), +); + +create table sessions ( + id bigint primary key generated always as identity, + committee_id bigint not null references committees(id) on delete cascade, + name varchar(64), + status activity_status not null default 'planned', + started_at timestamptz, + ended_at timestamptz, + state_snapshot JSONB +); + +------------------------------------------------------ +--- CONFERENCE MEMBERS +------------------------------------------------------ + +create table conference_assignments ( + id bigint primary key generated always as identity, + conference_id bigint not null references conferences(id) on delete cascade, + + user_id uuid references auth.users(id) on delete set null, + name varchar(255) not null, + email varchar(255) not null, + institution varchar(255), + + -- Assign role and permissions + role conference_role not null default 'participant', + + -- null if conference_role is admin/press/... + committee_id bigint references committees(id) on delete set null, + + -- null if not participant/delegation + representation_id bigint null references representations(id) on delete set null, + + created_at timestamptz not null default now(), + unique (conference_id, email, committee_id) ); + +------------------------------------------------------ +--- SESSION THINGS +------------------------------------------------------ + -- This table references preset/custom representations that we might work with. We can perhaps separate the two of them later create table representations ( id bigint primary key generated always as identity, @@ -54,22 +124,5 @@ create table committee_seats ( primary key (committee_id, representation_id) ); -create table committee_assignments ( - user_id uuid not null references auth.users(id) on delete cascade, - committee_id bigint not null references committees(id) on delete cascade, - role committee_role not null, - representation_id bigint null references representations(id), - - primary key (user_id, committee_id) -); - -create table sessions ( - id bigint primary key generated always as identity, - committee_id bigint not null references committees(id) on delete cascade, - name varchar(64), - status activity_status not null default 'planned', - started_at timestamptz, - ended_at timestamptz, - state_snapshot JSONB -); - +create index idx_conf_members_user on conference_members(user_id); +create index idx_conf_members_comm on conference_members(committee_id); From 55ed3708a4943d62808373269c76076045ec5be1 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:14:50 -0300 Subject: [PATCH 02/12] feat(backend): initialize conference/ module --- backend/app/conference/repository.py | 44 +++++++++++++++++++++ backend/app/conference/schemas.py | 29 ++++++++++++++ backend/app/conference/service.py | 25 ++++++++++++ backend/app/conference/views.py | 59 ++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 backend/app/conference/repository.py create mode 100644 backend/app/conference/schemas.py create mode 100644 backend/app/conference/service.py create mode 100644 backend/app/conference/views.py diff --git a/backend/app/conference/repository.py b/backend/app/conference/repository.py new file mode 100644 index 0000000..9c5dbcf --- /dev/null +++ b/backend/app/conference/repository.py @@ -0,0 +1,44 @@ +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.conference import schemas +from app.core.database import RepositoryError + + +async def create_conference( + session: AsyncSession, user_id: UUID, payload: schemas.ConferenceCreate +) -> int: + query = text(""" + INSERT INTO public.conferences + (name, owner_id, location, color, start_date, end_date) + VALUES ( + name = :name, + owner_id = :owner_id, + location = :location, + color = :color, + start_date = :start_date, + end_date = :end_date + ) + RETURNING id + """) + + try: + result = await session.execute( + query, + { + "name": payload.name, + "owner_id": user_id, + "location": payload.location, + "color": payload.color, + "start_date": payload.start_date, + "end_date": payload.end_date, + }, + ) + row = result.mappings().one() + return row.get("id", -1) + + except SQLAlchemyError: + raise RepositoryError("Could not create conference") diff --git a/backend/app/conference/schemas.py b/backend/app/conference/schemas.py new file mode 100644 index 0000000..5b38465 --- /dev/null +++ b/backend/app/conference/schemas.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ConferenceCreate(BaseModel): + """Schema for conference creation""" + + name: str + location: str | None = None + color: str = "#0f172a" + start_date: datetime + end_date: datetime + + +class EnrollMember(BaseModel): + """Schema for enrolling a user into a conference""" + + name: str + email: str + role: str = "delegate" + committee_id: int | None = None + representation_id: int | None = None + + +class CommitteeCreate(BaseModel): + """Schema for creating a committee""" + + ... diff --git a/backend/app/conference/service.py b/backend/app/conference/service.py new file mode 100644 index 0000000..2eca109 --- /dev/null +++ b/backend/app/conference/service.py @@ -0,0 +1,25 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +import app.conference.repository as repository +import app.conference.schemas as schemas + + +async def create_conference( + session: AsyncSession, user_id: UUID, payload: schemas.ConferenceCreate +) -> int: + session_id = await repository.create_conference( + session=session, user_id=user_id, payload=payload + ) + + return session_id + + +async def get_user_conferences(session: AsyncSession, user_id: UUID): ... + + +async def get_conference_info(session: AsyncSession, id: int): ... + + +async def create_committee(session: AsyncSession, payload: schemas.CommitteeCreate): ... diff --git a/backend/app/conference/views.py b/backend/app/conference/views.py new file mode 100644 index 0000000..0a74680 --- /dev/null +++ b/backend/app/conference/views.py @@ -0,0 +1,59 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +import app.conference.schemas as schemas +import app.conference.service as service +from app.auth.dep import get_current_user +from app.auth.service import AuthUser +from app.core.database import get_db_session + +router = APIRouter() + + +@router.post("/") +async def create_conference( + payload: schemas.ConferenceCreate, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): + """Endpoint to create a new conference""" + id = await service.create_conference( + session=db_session, user_id=current_user.user_id, payload=payload + ) + return {"id": id, "status": "Created"} + + +@router.get("/") +async def get_user_conferences( + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): ... + + +@router.get("/{id}") +async def get_conference_info( + id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): ... + + +@router.post("/{id}/committees") +async def create_committee( + committee: schemas.CommitteeCreate, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): ... + + +@router.post("/{conference_id}") +async def enroll_member( + member: schemas.EnrollMember, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): ... + + +# From 5ddcc2584843859996c92a5f6a13d3a95e6d31e9 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:15:25 -0300 Subject: [PATCH 03/12] refactor: move RepositoryError to core/ --- backend/app/core/database.py | 6 ++++++ backend/app/session/repository.py | 7 +------ backend/app/session/service.py | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index dea867f..78c9cf8 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -11,6 +11,12 @@ from app.core.config import Settings +class RepositoryError(Exception): + """Base exception for all repository issues""" + + pass + + def create_db( settings: Settings, ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py index c69648d..282508c 100644 --- a/backend/app/session/repository.py +++ b/backend/app/session/repository.py @@ -5,15 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.access.models import CommitteeAssignment +from app.core.database import RepositoryError from app.session.models import DelegationContext, StoredSession -class RepositoryError(Exception): - """Base exception for all repository issues""" - - pass - - async def create_session( session: AsyncSession, committee_id: int, diff --git a/backend/app/session/service.py b/backend/app/session/service.py index ec8818f..b2e5ba1 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -13,6 +13,7 @@ import app.session.repository as repository import app.session.schemas as schemas from app.access.models import CommitteeAssignment +from app.core.database import RepositoryError from app.session.engine import SessionEngine from .manager import ConnectionManager @@ -138,7 +139,7 @@ async def activate_session( try: await repository.update_session_info(session=session, session_info=updated) - except repository.RepositoryError: + except RepositoryError: raise SessionUpdateError("Could not update session info") from None await session.commit() From 0335db8d12570d0b3b6357df12c2466aab0e07eb Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:12:15 -0300 Subject: [PATCH 04/12] refactor: add initial global exception handlers --- backend/app/core/exceptions.py | 24 ++++++++++++++++++++++++ backend/app/main.py | 18 +++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 backend/app/core/exceptions.py diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..5ed5035 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,24 @@ +class AppException(Exception): + """Base exception""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class NotFoundError(AppException): + """Resource does not exist""" + + pass + + +class AccessDeniedError(AppException): + """User does not have permission""" + + pass + + +class ConflictError(AppException): + """Duplicate entity or invalid state transition""" + + pass diff --git a/backend/app/main.py b/backend/app/main.py index 191882c..5d59ee5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request, status, from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi @@ -11,6 +11,7 @@ from app.session.engine import SessionEngine from app.session.manager import ConnectionManager from app.session.views import router as session_router +import app.core.exceptions as exceptions # Startup and shutdown logic for shared variables, such as @@ -37,7 +38,17 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# CORS config for Vite +# --- Exception Handlers + +@app.exception_handler(exceptions.NotFoundError) +async def not_found_handler(request: Request, exc: exceptions.NotFoundError): + return { + "status_code": status.HTTP_404_NOT_FOUND, + "message": exc.message + } + +# --- Middlewares + app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], @@ -45,7 +56,8 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) -# include commitees here? +# --- Routes + app.include_router(session_router, prefix="/committees", tags=["committees"]) app.include_router(access_router, prefix="/access", tags=["access"]) From 9e6761b0a47b501cb69acf2d59317182632fa9ed Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:50:35 -0300 Subject: [PATCH 05/12] feat(conference): implement conference models, queries, and services Co-authored-by: Antigravity --- backend/app/access/repository.py | 16 +- backend/app/conference/models.py | 18 + backend/app/conference/repository.py | 335 ++++++++++++++++-- backend/app/conference/schemas.py | 90 ++++- backend/app/conference/service.py | 177 ++++++++- backend/app/conference/views.py | 72 +++- backend/app/main.py | 34 +- backend/app/session/repository.py | 26 -- backend/app/session/service.py | 4 +- .../20260719224327_create_initial_tables.sql | 83 +++-- supabase/seed.sql | 13 +- 11 files changed, 734 insertions(+), 134 deletions(-) create mode 100644 backend/app/conference/models.py diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py index d37ee65..935697a 100644 --- a/backend/app/access/repository.py +++ b/backend/app/access/repository.py @@ -17,7 +17,7 @@ async def get_committee_assignment( ca.role, ca.representation_id FROM public.committees c - JOIN public.committee_assignments ca + JOIN public.conference_assignments ca ON c.id = ca.committee_id WHERE c.id = :committee_id AND ca.user_id = :user_id @@ -31,10 +31,14 @@ async def get_committee_assignment( if row is None: return None + role = row["role"] + if role == "participant": + role = "delegate" + return CommitteeAssignment( user_id=row["user_id"], committee_id=row["committee_id"], - role=row["role"], + role=role, representation_id=row["representation_id"], ) @@ -50,7 +54,7 @@ async def get_session_assignment( ca.role, ca.representation_id FROM public.sessions s - JOIN public.committee_assignments ca + JOIN public.conference_assignments ca ON ca.committee_id = s.committee_id WHERE s.id = :session_id AND ca.user_id = :user_id @@ -63,9 +67,13 @@ async def get_session_assignment( if row is None: return None + role = row["role"] + if role == "participant": + role = "delegate" + return CommitteeAssignment( user_id=row["user_id"], committee_id=row["committee_id"], - role=row["role"], + role=role, representation_id=row["representation_id"], ) diff --git a/backend/app/conference/models.py b/backend/app/conference/models.py new file mode 100644 index 0000000..8900706 --- /dev/null +++ b/backend/app/conference/models.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class ConferenceAssignment: + """Holds information about a user's role and representation in a conference/committee.""" + + user_id: UUID + role: str + conference_id: int | None = None + committee_id: int | None = None + representation_id: int | None = None + + +# Alias for backward compatibility if needed by session engine +CommitteeAssignment = ConferenceAssignment + diff --git a/backend/app/conference/repository.py b/backend/app/conference/repository.py index 9c5dbcf..c474ffc 100644 --- a/backend/app/conference/repository.py +++ b/backend/app/conference/repository.py @@ -1,44 +1,331 @@ +import json +from typing import Any from uuid import UUID from sqlalchemy import text -from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.conference import schemas -from app.core.database import RepositoryError +from app.conference.models import ConferenceAssignment async def create_conference( session: AsyncSession, user_id: UUID, payload: schemas.ConferenceCreate ) -> int: + """Create a new conference and return its id.""" query = text(""" INSERT INTO public.conferences - (name, owner_id, location, color, start_date, end_date) + (name, slug, owner_id, location, logo, color, start_date, end_date) VALUES ( - name = :name, - owner_id = :owner_id, - location = :location, - color = :color, - start_date = :start_date, - end_date = :end_date + :name, + :slug, + :owner_id, + :location, + :logo, + :color, + :start_date, + :end_date ) RETURNING id """) - try: - result = await session.execute( - query, - { - "name": payload.name, - "owner_id": user_id, - "location": payload.location, - "color": payload.color, - "start_date": payload.start_date, - "end_date": payload.end_date, - }, + result = await session.execute( + query, + { + "name": payload.name, + "slug": payload.slug, + "owner_id": user_id, + "location": payload.location, + "logo": payload.logo, + "color": payload.color, + "start_date": payload.start_date, + "end_date": payload.end_date, + }, + ) + row = result.mappings().one() + return int(row["id"]) + + +async def get_user_conferences( + session: AsyncSession, + user_id: UUID, +) -> list[int]: + """Return all conference IDs where the user is an owner or assigned member.""" + query = text(""" + SELECT DISTINCT c.id + FROM public.conferences c + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = c.id AND ca.user_id = :user_id + WHERE c.owner_id = :user_id OR ca.user_id = :user_id + ORDER BY c.id ASC + """) + + result = await session.execute(query, {"user_id": user_id}) + return [int(r["id"]) for r in result.mappings().all()] + + +async def get_conference_by_id( + session: AsyncSession, + conference_id: int, +) -> dict[str, Any] | None: + """Fetch basic conference data by ID.""" + query = text(""" + SELECT id, name, slug, status, owner_id, location, logo, color, start_date, end_date + FROM public.conferences + WHERE id = :conference_id + """) + result = await session.execute(query, {"conference_id": conference_id}) + row = result.mappings().one_or_none() + return dict(row) if row else None + + +async def list_committees_for_conference( + session: AsyncSession, + conference_id: int, +) -> list[dict[str, Any]]: + """Fetch all committees belonging to a conference.""" + query = text(""" + SELECT id, conference_id, name, code, logo, topic, status, created_at + FROM public.committees + WHERE conference_id = :conference_id + ORDER BY id ASC + """) + result = await session.execute(query, {"conference_id": conference_id}) + return [dict(r) for r in result.mappings().all()] + + +async def check_conference_exists( + session: AsyncSession, + conference_id: int, +) -> bool: + """Check if a conference exists by id.""" + query = text("SELECT 1 FROM public.conferences WHERE id = :conference_id") + result = await session.execute(query, {"conference_id": conference_id}) + return result.scalar() is not None + + +async def get_user_conference_role( + session: AsyncSession, + user_id: UUID, + conference_id: int, +) -> str | None: + """Return the user's role in the conference ('owner', 'admin', 'chair', etc.) or None if unauthorized.""" + query = text(""" + SELECT + CASE + WHEN c.owner_id = :user_id THEN 'owner' + ELSE ca.role::text + END AS role + FROM public.conferences c + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = c.id AND ca.user_id = :user_id + WHERE c.id = :conference_id + AND (c.owner_id = :user_id OR ca.user_id = :user_id) + LIMIT 1 + """) + result = await session.execute( + query, {"conference_id": conference_id, "user_id": user_id} + ) + row = result.mappings().one_or_none() + return row["role"] if row else None + + +async def get_user_committee_role( + session: AsyncSession, + user_id: UUID, + committee_id: int, +) -> str | None: + """Return the user's role for a specific committee (allowing conference owner/admin override).""" + query = text(""" + SELECT + CASE + WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' + WHEN ca.role = 'chair' THEN 'chair' + ELSE 'delegate' + END AS role + FROM public.committees c + JOIN public.conferences conf ON conf.id = c.conference_id + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = conf.id + AND ca.user_id = :user_id + AND (ca.committee_id = c.id OR ca.committee_id IS NULL) + WHERE c.id = :committee_id + AND (conf.owner_id = :user_id OR ca.user_id = :user_id) + ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC + LIMIT 1 + """) + result = await session.execute( + query, {"committee_id": committee_id, "user_id": user_id} + ) + row = result.mappings().one_or_none() + return row["role"] if row else None + + +async def create_committee( + session: AsyncSession, + conference_id: int, + payload: schemas.CommitteeCreate, +) -> dict[str, Any]: + """Create a new committee inside a conference.""" + query = text(""" + INSERT INTO public.committees + (conference_id, name, code, logo, topic, status) + VALUES + (:conference_id, :name, :code, :logo, :topic, :status) + RETURNING id, conference_id, name, code, logo, topic, status, created_at + """) + + result = await session.execute( + query, + { + "conference_id": conference_id, + "name": payload.name, + "code": payload.code, + "logo": payload.logo, + "topic": payload.topic, + "status": payload.status, + }, + ) + return dict(result.mappings().one()) + + +async def enroll_member( + session: AsyncSession, + conference_id: int, + payload: schemas.EnrollMember, +) -> dict[str, Any]: + """Enroll or assign a member to a conference and auto-link user_id if matching email exists.""" + query = text(""" + INSERT INTO public.conference_assignments + (conference_id, user_id, name, email, institution, role, committee_id, representation_id) + VALUES ( + :conference_id, + (SELECT id FROM auth.users WHERE email = :email LIMIT 1), + :name, + :email, + :institution, + :role::conference_role, + :committee_id, + :representation_id ) - row = result.mappings().one() - return row.get("id", -1) + RETURNING id, conference_id, user_id, name, email, institution, role, committee_id, representation_id, created_at + """) + + result = await session.execute( + query, + { + "conference_id": conference_id, + "name": payload.name, + "email": payload.email, + "institution": payload.institution, + "role": payload.role, + "committee_id": payload.committee_id, + "representation_id": payload.representation_id, + }, + ) + return dict(result.mappings().one()) + + +async def list_conference_members( + session: AsyncSession, + conference_id: int, +) -> list[dict[str, Any]]: + """List all members/assignments enrolled in a conference.""" + query = text(""" + SELECT + id, conference_id, user_id, name, email, institution, role, + committee_id, representation_id, created_at + FROM public.conference_assignments + WHERE conference_id = :conference_id + ORDER BY created_at ASC + """) + result = await session.execute(query, {"conference_id": conference_id}) + return [dict(r) for r in result.mappings().all()] + + +async def get_conference_assignment( + session: AsyncSession, user_id: UUID, committee_id: int +) -> ConferenceAssignment | None: + """Fetch assignment for a user in a committee (allows conference owner/admin chair override).""" + query = text(""" + SELECT + :user_id AS user_id, + c.id AS committee_id, + conf.id AS conference_id, + CASE + WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' + WHEN ca.role = 'chair' THEN 'chair' + ELSE 'delegate' + END AS role, + ca.representation_id + FROM public.committees c + JOIN public.conferences conf ON conf.id = c.conference_id + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = conf.id + AND ca.user_id = :user_id + AND (ca.committee_id = c.id OR ca.committee_id IS NULL) + WHERE c.id = :committee_id + AND (conf.owner_id = :user_id OR ca.user_id = :user_id) + ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC + LIMIT 1 + """) + + result = await session.execute( + query, {"committee_id": committee_id, "user_id": user_id} + ) + row = result.mappings().one_or_none() + if row is None: + return None + + return ConferenceAssignment( + user_id=row["user_id"], + conference_id=row["conference_id"], + committee_id=row["committee_id"], + role=row["role"], + representation_id=row["representation_id"], + ) + + +async def get_session_assignment( + session: AsyncSession, user_id: UUID, session_id: int +) -> ConferenceAssignment | None: + """Fetch assignment for a user in the committee that owns a session.""" + query = text(""" + SELECT + :user_id AS user_id, + s.committee_id, + conf.id AS conference_id, + CASE + WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' + WHEN ca.role = 'chair' THEN 'chair' + ELSE 'delegate' + END AS role, + ca.representation_id + FROM public.sessions s + JOIN public.committees c ON c.id = s.committee_id + JOIN public.conferences conf ON conf.id = c.conference_id + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = conf.id + AND ca.user_id = :user_id + AND (ca.committee_id = c.id OR ca.committee_id IS NULL) + WHERE s.id = :session_id + AND (conf.owner_id = :user_id OR ca.user_id = :user_id) + ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC + LIMIT 1 + """) + + result = await session.execute( + query, {"session_id": session_id, "user_id": user_id} + ) + row = result.mappings().one_or_none() + if row is None: + return None + + return ConferenceAssignment( + user_id=row["user_id"], + conference_id=row["conference_id"], + committee_id=row["committee_id"], + role=row["role"], + representation_id=row["representation_id"], + ) - except SQLAlchemyError: - raise RepositoryError("Could not create conference") diff --git a/backend/app/conference/schemas.py b/backend/app/conference/schemas.py index 5b38465..1007930 100644 --- a/backend/app/conference/schemas.py +++ b/backend/app/conference/schemas.py @@ -1,29 +1,107 @@ from datetime import datetime +from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class ConferenceCreate(BaseModel): """Schema for conference creation""" name: str + slug: str | None = None location: str | None = None + logo: str | None = None color: str = "#0f172a" start_date: datetime end_date: datetime +class ConferenceSummary(BaseModel): + """Lightweight conference summary for lists and switchers""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + slug: str | None = None + status: str + location: str | None = None + logo: str | None = None + color: str + start_date: datetime + end_date: datetime + owner_id: UUID + user_role: str | None = None + total_committees: int = 0 + + +class CommitteeCreate(BaseModel): + """Schema for creating a committee""" + + name: str + code: str + logo: str | None = None + topic: str | None = None + status: str = "planned" + + +class CommitteeResponse(BaseModel): + """Schema for returning committee details""" + + model_config = ConfigDict(from_attributes=True) + + id: int + conference_id: int + name: str + code: str + logo: str | None = None + topic: str | None = None + status: str + created_at: datetime | None = None + + +class ConferenceDetail(BaseModel): + """Detailed conference information including its committees""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + slug: str | None = None + status: str + owner_id: UUID + location: str | None = None + logo: str | None = None + color: str + start_date: datetime + end_date: datetime + caller_role: str | None = None + committees: list[CommitteeResponse] = [] + + class EnrollMember(BaseModel): - """Schema for enrolling a user into a conference""" + """Schema for enrolling/assigning a user into a conference""" name: str email: str - role: str = "delegate" + institution: str | None = None + role: str = "participant" committee_id: int | None = None representation_id: int | None = None -class CommitteeCreate(BaseModel): - """Schema for creating a committee""" +class AssignmentResponse(BaseModel): + """Schema for returning conference assignment details""" + + model_config = ConfigDict(from_attributes=True) - ... + id: int + conference_id: int + user_id: UUID | None = None + name: str + email: str + institution: str | None = None + role: str + committee_id: int | None = None + representation_id: int | None = None + created_at: datetime | None = None diff --git a/backend/app/conference/service.py b/backend/app/conference/service.py index 2eca109..2ea41f3 100644 --- a/backend/app/conference/service.py +++ b/backend/app/conference/service.py @@ -1,25 +1,192 @@ +from typing import Any, Literal from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession import app.conference.repository as repository import app.conference.schemas as schemas +from app.conference.models import ConferenceAssignment +from app.core.exceptions import AccessDeniedError, NotFoundError async def create_conference( session: AsyncSession, user_id: UUID, payload: schemas.ConferenceCreate ) -> int: - session_id = await repository.create_conference( + """Create a new conference.""" + conference_id = await repository.create_conference( session=session, user_id=user_id, payload=payload ) + return conference_id - return session_id +async def get_user_conferences( + session: AsyncSession, user_id: UUID +) -> list[int]: + """Get all conference IDs for a user.""" + return await repository.get_user_conferences(session=session, user_id=user_id) -async def get_user_conferences(session: AsyncSession, user_id: UUID): ... +async def get_conference_info( + session: AsyncSession, user_id: UUID, conference_id: int +) -> dict[str, Any]: + """Get conference detail using split verification and fetch queries.""" + # Query 1: Verification + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role is None: + exists = await repository.check_conference_exists( + session=session, conference_id=conference_id + ) + if exists: + raise AccessDeniedError("You do not have access to this conference") + raise NotFoundError(f"Conference with id {conference_id} not found") + + # Query 2: Fetch Conference & Committees + conf = await repository.get_conference_by_id( + session=session, conference_id=conference_id + ) + if conf is None: + raise NotFoundError(f"Conference with id {conference_id} not found") + + committees = await repository.list_committees_for_conference( + session=session, conference_id=conference_id + ) + + conf["caller_role"] = user_role + conf["committees"] = committees + return conf + + +async def create_committee( + session: AsyncSession, + user_id: UUID, + conference_id: int, + payload: schemas.CommitteeCreate, +) -> dict[str, Any]: + """Create a committee within a conference (requires owner/admin role).""" + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role is None: + exists = await repository.check_conference_exists( + session=session, conference_id=conference_id + ) + if not exists: + raise NotFoundError(f"Conference with id {conference_id} not found") + raise AccessDeniedError("You do not have access to this conference") + + if user_role not in ("owner", "admin"): + raise AccessDeniedError( + "Only conference owners and admins can create committees" + ) + + return await repository.create_committee( + session=session, conference_id=conference_id, payload=payload + ) + + +async def enroll_member( + session: AsyncSession, + user_id: UUID, + conference_id: int, + payload: schemas.EnrollMember, +) -> dict[str, Any]: + """Enroll a member or delegate into a conference (requires owner/admin role).""" + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role is None: + exists = await repository.check_conference_exists( + session=session, conference_id=conference_id + ) + if not exists: + raise NotFoundError(f"Conference with id {conference_id} not found") + raise AccessDeniedError("You do not have access to this conference") + + if user_role not in ("owner", "admin"): + raise AccessDeniedError( + "Only conference owners and admins can enroll members" + ) + + return await repository.enroll_member( + session=session, conference_id=conference_id, payload=payload + ) + + +async def list_conference_members( + session: AsyncSession, + user_id: UUID, + conference_id: int, +) -> list[dict[str, Any]]: + """List all enrolled members in a conference for an authorized user.""" + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role is None: + exists = await repository.check_conference_exists( + session=session, conference_id=conference_id + ) + if not exists: + raise NotFoundError(f"Conference with id {conference_id} not found") + raise AccessDeniedError("You do not have access to this conference") + + return await repository.list_conference_members( + session=session, conference_id=conference_id + ) + + +async def resolve_conference_assignment( + session: AsyncSession, + user_id: UUID, + committee_id: int, +) -> ConferenceAssignment: + """Resolve a user's assignment in a committee.""" + assignment = await repository.get_conference_assignment( + session=session, user_id=user_id, committee_id=committee_id + ) + if assignment is None: + raise AccessDeniedError("User has no assignment for this committee") + + if assignment.role == "delegate" and assignment.representation_id is None: + raise AccessDeniedError("Delegate role has no delegation id") + + return assignment + + +async def resolve_session_assignment( + session: AsyncSession, + user_id: UUID, + session_id: int, +) -> ConferenceAssignment: + """Resolve a user's assignment for a session.""" + assignment = await repository.get_session_assignment( + session=session, user_id=user_id, session_id=session_id + ) + if assignment is None: + raise AccessDeniedError("User has no assignment for this session") + + if assignment.role == "delegate" and assignment.representation_id is None: + raise AccessDeniedError("Delegate role has no delegation id") + + return assignment + + +async def verify_user_role( + session: AsyncSession, + user_id: UUID, + committee_id: int, + required_role: Literal["chair", "delegate"], +) -> ConferenceAssignment: + """Verify and require that a user has a specific role for a committee.""" + assignment = await resolve_conference_assignment( + session=session, user_id=user_id, committee_id=committee_id + ) -async def get_conference_info(session: AsyncSession, id: int): ... + if assignment.role != required_role: + raise AccessDeniedError( + f"User requires the {required_role} role for this committee" + ) + return assignment -async def create_committee(session: AsyncSession, payload: schemas.CommitteeCreate): ... diff --git a/backend/app/conference/views.py b/backend/app/conference/views.py index 0a74680..bc2b050 100644 --- a/backend/app/conference/views.py +++ b/backend/app/conference/views.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, status from sqlalchemy.ext.asyncio import AsyncSession import app.conference.schemas as schemas @@ -12,48 +12,94 @@ router = APIRouter() -@router.post("/") +@router.post("/", status_code=status.HTTP_201_CREATED) async def create_conference( payload: schemas.ConferenceCreate, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], ): """Endpoint to create a new conference""" - id = await service.create_conference( + conference_id = await service.create_conference( session=db_session, user_id=current_user.user_id, payload=payload ) - return {"id": id, "status": "Created"} + return {"id": conference_id, "status": "Created"} -@router.get("/") +@router.get("/", response_model=list[int]) async def get_user_conferences( db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): ... +): + """Endpoint to get list of conference ids for a user""" + return await service.get_user_conferences( + session=db_session, user_id=current_user.user_id + ) -@router.get("/{id}") +@router.get("/{id}", response_model=schemas.ConferenceDetail) async def get_conference_info( id: int, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): ... +): + """Endpoint to get detailed conference information and committees""" + return await service.get_conference_info( + session=db_session, user_id=current_user.user_id, conference_id=id + ) -@router.post("/{id}/committees") +@router.post( + "/{id}/committees", + response_model=schemas.CommitteeResponse, + status_code=status.HTTP_201_CREATED, +) async def create_committee( + id: int, committee: schemas.CommitteeCreate, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): ... +): + """Endpoint to create a new committee in a conference""" + return await service.create_committee( + session=db_session, + user_id=current_user.user_id, + conference_id=id, + payload=committee, + ) -@router.post("/{conference_id}") +@router.post( + "/{conference_id}/members", + response_model=schemas.AssignmentResponse, + status_code=status.HTTP_201_CREATED, +) async def enroll_member( + conference_id: int, member: schemas.EnrollMember, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): ... +): + """Endpoint to enroll/assign a member to a conference""" + return await service.enroll_member( + session=db_session, + user_id=current_user.user_id, + conference_id=conference_id, + payload=member, + ) -# +@router.get( + "/{conference_id}/members", + response_model=list[schemas.AssignmentResponse], +) +async def list_conference_members( + conference_id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): + """Endpoint to list all members assigned to a conference""" + return await service.list_conference_members( + session=db_session, + user_id=current_user.user_id, + conference_id=conference_id, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 5d59ee5..2b83b7c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,17 +1,20 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Request, status, +from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi +from fastapi.responses import JSONResponse + +import app.core.exceptions as exceptions from app.access.views import router as access_router +from app.conference.views import router as conference_router from app.core.config import get_settings from app.core.database import create_db from app.core.openapi import add_websocket_message_schemas from app.session.engine import SessionEngine from app.session.manager import ConnectionManager from app.session.views import router as session_router -import app.core.exceptions as exceptions # Startup and shutdown logic for shared variables, such as @@ -40,12 +43,30 @@ async def lifespan(app: FastAPI): # --- Exception Handlers + @app.exception_handler(exceptions.NotFoundError) async def not_found_handler(request: Request, exc: exceptions.NotFoundError): - return { - "status_code": status.HTTP_404_NOT_FOUND, - "message": exc.message - } + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": exc.message}, + ) + + +@app.exception_handler(exceptions.AccessDeniedError) +async def access_denied_handler(request: Request, exc: exceptions.AccessDeniedError): + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": exc.message}, + ) + + +@app.exception_handler(exceptions.ConflictError) +async def conflict_handler(request: Request, exc: exceptions.ConflictError): + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={"detail": exc.message}, + ) + # --- Middlewares @@ -58,6 +79,7 @@ async def not_found_handler(request: Request, exc: exceptions.NotFoundError): # --- Routes +app.include_router(conference_router, prefix="/conferences", tags=["conferences"]) app.include_router(session_router, prefix="/committees", tags=["committees"]) app.include_router(access_router, prefix="/access", tags=["access"]) diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py index 282508c..c3800cb 100644 --- a/backend/app/session/repository.py +++ b/backend/app/session/repository.py @@ -4,7 +4,6 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.access.models import CommitteeAssignment from app.core.database import RepositoryError from app.session.models import DelegationContext, StoredSession @@ -93,31 +92,6 @@ async def update_session_info( raise RepositoryError("Session update failed") from exc -async def bulk_insert_assignments( - session: AsyncSession, delegations: list[CommitteeAssignment] -): - """Inserts via bulk all (uuid, delegation, session_id) rows""" - params = [ - { - "user_id": d.user_id, - "session_id": d.committee_id, - "role": d.role, - "delegation_id": d.representation_id, - } - for d in delegations - ] - - query = text(""" - INSERT INTO public.committee_assignments (user_id, committee_id, role, representation_id) - VALUES (:user_id, :committee_id, :role, :representation_id) - """) - - await session.execute( - query, - params, - ) - - # TODO: pass this out to conferences/ domain async def bulk_get_delegation_context( session: AsyncSession, diff --git a/backend/app/session/service.py b/backend/app/session/service.py index b2e5ba1..308dc31 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -12,7 +12,7 @@ import app.session.enums as enums import app.session.repository as repository import app.session.schemas as schemas -from app.access.models import CommitteeAssignment +from app.conference.models import ConferenceAssignment from app.core.database import RepositoryError from app.session.engine import SessionEngine @@ -168,7 +168,7 @@ async def prepare_session_connect( session: AsyncSession, manager: ConnectionManager, committee_session_id: int, - assignment: CommitteeAssignment, + assignment: ConferenceAssignment, ) -> SessionActor: """Service that prepares for session connect. Primarily used as a fallback in case the SessionLiveState is not in manager diff --git a/supabase/migrations/20260719224327_create_initial_tables.sql b/supabase/migrations/20260719224327_create_initial_tables.sql index ea57bc0..9ed359b 100644 --- a/supabase/migrations/20260719224327_create_initial_tables.sql +++ b/supabase/migrations/20260719224327_create_initial_tables.sql @@ -4,12 +4,12 @@ create type activity_status as enum ('active', 'closed', 'planned', 'cancelled'); create type asset_type as enum ('preset', 'custom'); create type conference_role as enum ( - 'admin', -- Secretary general - 'chair', -- Director / Moderator - 'press', -- Press / Media team - 'staff', -- General logistics / Crisis backroom - 'participant' -- Participant representation -) + 'admin', -- Secretary general / Organizer + 'chair', -- Director / Moderator + 'press', -- Press / Media team + 'staff', -- General logistics / Crisis backroom + 'participant' -- Delegate / Delegation +); ------------------------------------------------------ -- CORE CONFERENCE HIERARCHY @@ -28,12 +28,12 @@ create table conferences ( end_date timestamptz not null, created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), + updated_at timestamptz not null default now() ); create table committees ( id bigint primary key generated always as identity, - conference_id bigint not null references conferences(id), + conference_id bigint not null references conferences(id) on delete cascade, name varchar(128) not null, -- full name code varchar(64) not null, -- code name @@ -42,7 +42,7 @@ create table committees ( status activity_status not null default 'planned', created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), + updated_at timestamptz not null default now() ); create table sessions ( @@ -56,34 +56,7 @@ create table sessions ( ); ------------------------------------------------------ ---- CONFERENCE MEMBERS ------------------------------------------------------- - -create table conference_assignments ( - id bigint primary key generated always as identity, - conference_id bigint not null references conferences(id) on delete cascade, - - user_id uuid references auth.users(id) on delete set null, - name varchar(255) not null, - email varchar(255) not null, - institution varchar(255), - - -- Assign role and permissions - role conference_role not null default 'participant', - - -- null if conference_role is admin/press/... - committee_id bigint references committees(id) on delete set null, - - -- null if not participant/delegation - representation_id bigint null references representations(id) on delete set null, - - created_at timestamptz not null default now(), - unique (conference_id, email, committee_id) -); - - ------------------------------------------------------- ---- SESSION THINGS +-- REPRESENTATIONS & LAYOUTS ------------------------------------------------------ -- This table references preset/custom representations that we might work with. We can perhaps separate the two of them later @@ -93,8 +66,7 @@ create table representations ( rep_type asset_type not null default 'preset', code varchar(10), identifier varchar(255) not null, -- this can be either an url if the rep_type is custom, or a code (like 'br') - conference_id bigint null -- if set references a specific conference. if we delete that conference entry, we might setup a custom handler on the - -- backend + conference_id bigint null references conferences(id) on delete cascade ); -- preset layouts for conferences @@ -119,10 +91,37 @@ create table layout_seats ( create table committee_seats ( committee_id bigint not null references committees(id) on delete cascade, representation_id bigint not null references representations(id) on delete cascade, - seat_label varchar(3), + seat_label varchar(3), primary key (committee_id, representation_id) ); -create index idx_conf_members_user on conference_members(user_id); -create index idx_conf_members_comm on conference_members(committee_id); +------------------------------------------------------ +--- CONFERENCE MEMBERS & ASSIGNMENTS +------------------------------------------------------ + +create table conference_assignments ( + id bigint primary key generated always as identity, + conference_id bigint not null references conferences(id) on delete cascade, + + user_id uuid references auth.users(id) on delete set null, + name varchar(255) not null, + email varchar(255) not null, + institution varchar(255), + + -- Assign role and permissions + role conference_role not null default 'participant', + + -- null if conference_role is admin/press/staff + committee_id bigint references committees(id) on delete set null, + + -- null if not participant/delegation + representation_id bigint null references representations(id) on delete set null, + + created_at timestamptz not null default now(), + unique (conference_id, email, committee_id) +); + +create index idx_conf_assignments_user on conference_assignments(user_id); +create index idx_conf_assignments_comm on conference_assignments(committee_id); +create index idx_conf_assignments_conf on conference_assignments(conference_id); diff --git a/supabase/seed.sql b/supabase/seed.sql index 9b27847..feaabc2 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -88,16 +88,17 @@ values (1, (select id from representations where code = 'tw'), '1-1'), (1, (select id from representations where code = 'tr'), '2-6'); -insert into public.committee_assignments - (user_id, committee_id, role, representation_id) +insert into public.conference_assignments + (conference_id, user_id, name, email, role, committee_id, representation_id) values - ('11111111-1111-1111-1111-111111111111', 1, 'chair', null), - ('22222222-2222-2222-2222-222222222222', 1, 'delegate', (select id from representations where code = 'al')), - ('33333333-3333-3333-3333-333333333333', 1, 'delegate', (select id from representations where code = 'de')), - ('44444444-4444-4444-4444-444444444444', 1, 'delegate', (select id from representations where code = 'br')); + ((select id from conferences where name = 'I WebMUN'), '11111111-1111-1111-1111-111111111111', 'Chair Person', 'chair@codelab.usp.br', 'chair', 1, null), + ((select id from conferences where name = 'I WebMUN'), '22222222-2222-2222-2222-222222222222', 'Delegate Albania', 'albania@codelab.usp.br', 'participant', 1, (select id from representations where code = 'al')), + ((select id from conferences where name = 'I WebMUN'), '33333333-3333-3333-3333-333333333333', 'Delegate Germany', 'alemanha@codelab.usp.br', 'participant', 1, (select id from representations where code = 'de')), + ((select id from conferences where name = 'I WebMUN'), '44444444-4444-4444-4444-444444444444', 'Delegate Brazil', 'brazil@codelab.usp.br', 'participant', 1, (select id from representations where code = 'br')); insert into public.sessions (committee_id) values (1); + From c59420916a758702e50ad74fc8ba18fad3589358 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:54:30 -0300 Subject: [PATCH 06/12] refactor(conference): move session access endpoint and role checks to conference Co-authored-by: Antigravity --- backend/app/conference/schemas.py | 10 ++++++++++ backend/app/conference/views.py | 20 ++++++++++++++++++++ backend/app/main.py | 2 -- backend/app/session/views.py | 12 +++--------- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/backend/app/conference/schemas.py b/backend/app/conference/schemas.py index 1007930..b4c5e76 100644 --- a/backend/app/conference/schemas.py +++ b/backend/app/conference/schemas.py @@ -105,3 +105,13 @@ class AssignmentResponse(BaseModel): committee_id: int | None = None representation_id: int | None = None created_at: datetime | None = None + + +class SessionRepresentation(BaseModel): + """Schema for returning user's role and representation for a session""" + + model_config = ConfigDict(from_attributes=True) + + role: str + representation_id: int | None = None + diff --git a/backend/app/conference/views.py b/backend/app/conference/views.py index bc2b050..3aa0b8f 100644 --- a/backend/app/conference/views.py +++ b/backend/app/conference/views.py @@ -103,3 +103,23 @@ async def list_conference_members( user_id=current_user.user_id, conference_id=conference_id, ) + + +@router.get( + "/sessions/{session_id}/me", + response_model=schemas.SessionRepresentation, +) +async def get_my_session_access( + session_id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): + """Return the authenticated user's actor context for a session.""" + assignment = await service.resolve_session_assignment( + session=db_session, user_id=current_user.user_id, session_id=session_id + ) + return schemas.SessionRepresentation( + role=assignment.role, + representation_id=assignment.representation_id, + ) + diff --git a/backend/app/main.py b/backend/app/main.py index 2b83b7c..aeab316 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,6 @@ from fastapi.responses import JSONResponse import app.core.exceptions as exceptions -from app.access.views import router as access_router from app.conference.views import router as conference_router from app.core.config import get_settings from app.core.database import create_db @@ -81,7 +80,6 @@ async def conflict_handler(request: Request, exc: exceptions.ConflictError): app.include_router(conference_router, prefix="/conferences", tags=["conferences"]) app.include_router(session_router, prefix="/committees", tags=["committees"]) -app.include_router(access_router, prefix="/access", tags=["access"]) def custom_openapi(): diff --git a/backend/app/session/views.py b/backend/app/session/views.py index fbea56a..3d01324 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -16,10 +16,9 @@ from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession -import app.access.service as access +import app.conference.service as conference_service import app.session.repository as repository import app.session.service as service -from app.access.service import AccessDenied from app.auth.dep import get_current_user from app.auth.service import ( AuthUser, @@ -55,7 +54,7 @@ async def create_session_endpoint( ): """POST endpoint to create a new session""" try: - await access.verify_user_role( + await conference_service.verify_user_role( session=session, user_id=current_user.user_id, committee_id=session_schema.committee_id, @@ -68,11 +67,6 @@ async def create_session_endpoint( ) return {"id": res, "status": "Created"} - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc except service.SessionCreationError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -155,7 +149,7 @@ async def websocket_endpoint( # The session determines its committee; never accept it from the client. session_factory = websocket.app.state.db_session_factory async with session_factory() as db: - assignment = await access.resolve_session_assignment( + assignment = await conference_service.resolve_session_assignment( session=db, user_id=auth_user.user_id, session_id=session_id ) From 4715a16246960448d27077d4f21051a0e725a313 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:57:17 -0300 Subject: [PATCH 07/12] refactor(access): remove obsolete access module and migrate tests to conference Co-authored-by: Antigravity --- backend/app/access/enums.py | 6 -- backend/app/access/models.py | 14 ---- backend/app/access/repository.py | 79 ------------------- backend/app/access/schemas.py | 8 -- backend/app/access/service.py | 64 --------------- backend/app/access/views.py | 35 -------- .../tests/{access => conference}/__init__.py | 0 .../{access => conference}/test_service.py | 45 ++++++----- backend/app/tests/session/test_service.py | 13 ++- 9 files changed, 32 insertions(+), 232 deletions(-) delete mode 100644 backend/app/access/enums.py delete mode 100644 backend/app/access/models.py delete mode 100644 backend/app/access/repository.py delete mode 100644 backend/app/access/schemas.py delete mode 100644 backend/app/access/service.py delete mode 100644 backend/app/access/views.py rename backend/app/tests/{access => conference}/__init__.py (100%) rename backend/app/tests/{access => conference}/test_service.py (55%) diff --git a/backend/app/access/enums.py b/backend/app/access/enums.py deleted file mode 100644 index ef6dd71..0000000 --- a/backend/app/access/enums.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import StrEnum - - -class SessionRoles(StrEnum): - CHAIR = "chair" - DELEGATION = "delegate" diff --git a/backend/app/access/models.py b/backend/app/access/models.py deleted file mode 100644 index 8881a43..0000000 --- a/backend/app/access/models.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - -from . import enums - - -@dataclass(frozen=True) -class CommitteeAssignment: - """Object that holds info about an UUID to a commitee and Delegation / Chair""" - - user_id: UUID - committee_id: int # TODO: remove this to map out to committees/conferences - role: enums.SessionRoles - representation_id: int | None diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py deleted file mode 100644 index 935697a..0000000 --- a/backend/app/access/repository.py +++ /dev/null @@ -1,79 +0,0 @@ -from uuid import UUID - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from .models import CommitteeAssignment - - -# TODO: pass this to a conference/ domain -async def get_committee_assignment( - session: AsyncSession, user_id: UUID, committee_id: int -) -> CommitteeAssignment | None: - query = text(""" - SELECT - ca.user_id, - ca.committee_id, - ca.role, - ca.representation_id - FROM public.committees c - JOIN public.conference_assignments ca - ON c.id = ca.committee_id - WHERE c.id = :committee_id AND - ca.user_id = :user_id - """) - - result = await session.execute( - query, {"committee_id": committee_id, "user_id": user_id} - ) - - row = result.mappings().one_or_none() - if row is None: - return None - - role = row["role"] - if role == "participant": - role = "delegate" - - return CommitteeAssignment( - user_id=row["user_id"], - committee_id=row["committee_id"], - role=role, - representation_id=row["representation_id"], - ) - - -async def get_session_assignment( - session: AsyncSession, user_id: UUID, session_id: int -) -> CommitteeAssignment | None: - """Get a user's assignment for the committee that owns a session.""" - query = text(""" - SELECT - ca.user_id, - ca.committee_id, - ca.role, - ca.representation_id - FROM public.sessions s - JOIN public.conference_assignments ca - ON ca.committee_id = s.committee_id - WHERE s.id = :session_id - AND ca.user_id = :user_id - """) - - result = await session.execute( - query, {"session_id": session_id, "user_id": user_id} - ) - row = result.mappings().one_or_none() - if row is None: - return None - - role = row["role"] - if role == "participant": - role = "delegate" - - return CommitteeAssignment( - user_id=row["user_id"], - committee_id=row["committee_id"], - role=role, - representation_id=row["representation_id"], - ) diff --git a/backend/app/access/schemas.py b/backend/app/access/schemas.py deleted file mode 100644 index 864dfb0..0000000 --- a/backend/app/access/schemas.py +++ /dev/null @@ -1,8 +0,0 @@ -from pydantic import BaseModel - -from . import enums - - -class SessionRepresentation(BaseModel): - role: enums.SessionRoles - representation_id: int | None diff --git a/backend/app/access/service.py b/backend/app/access/service.py deleted file mode 100644 index 12e4fc4..0000000 --- a/backend/app/access/service.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Literal -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.access.models import CommitteeAssignment - -from .repository import get_committee_assignment, get_session_assignment - - -class AccessDenied(Exception): ... - - -async def resolve_committee_assignment( - session: AsyncSession, - user_id: UUID, - committee_id: int, -) -> CommitteeAssignment: - assignment: CommitteeAssignment | None = await get_committee_assignment( - session, user_id, committee_id - ) - - if assignment is None: - raise AccessDenied("User has no committee assignment") - - if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDenied("Delegate role has no delegation id") - - return assignment - - -async def resolve_session_assignment( - session: AsyncSession, - user_id: UUID, - session_id: int, -) -> CommitteeAssignment: - """Resolve the assignment for a session without trusting client committee data.""" - assignment = await get_session_assignment(session, user_id, session_id) - - if assignment is None: - raise AccessDenied("User has no assignment for this session") - - if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDenied("Delegate role has no delegation id") - - return assignment - - -async def verify_user_role( - session: AsyncSession, - user_id: UUID, - committee_id: int, - required_role: Literal["chair", "delegate"], -) -> CommitteeAssignment: - """Require a user's role within one specific committee.""" - assignment = await get_committee_assignment(session, user_id, committee_id) - - if assignment is None: - raise AccessDenied("User has no committee assignment") - - if assignment.role != required_role: - raise AccessDenied(f"User requires the {required_role} role for this committee") - - return assignment diff --git a/backend/app/access/views.py b/backend/app/access/views.py deleted file mode 100644 index 91975ac..0000000 --- a/backend/app/access/views.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.dep import get_current_user -from app.auth.service import AuthUser -from app.core.database import get_db_session - -from .schemas import SessionRepresentation -from .service import AccessDenied, resolve_session_assignment - -router = APIRouter() - - -@router.get("/sessions/{session_id}/me") -async def get_my_session_access( - session_id: int, - db_session: Annotated[AsyncSession, Depends(get_db_session)], - current_user: Annotated[AuthUser, Depends(get_current_user)], -) -> SessionRepresentation: - """Return the authenticated user's actor context for a session.""" - try: - assignment = await resolve_session_assignment( - db_session, current_user.user_id, session_id - ) - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc - - return SessionRepresentation( - role=assignment.role, representation_id=assignment.representation_id - ) diff --git a/backend/app/tests/access/__init__.py b/backend/app/tests/conference/__init__.py similarity index 100% rename from backend/app/tests/access/__init__.py rename to backend/app/tests/conference/__init__.py diff --git a/backend/app/tests/access/test_service.py b/backend/app/tests/conference/test_service.py similarity index 55% rename from backend/app/tests/access/test_service.py rename to backend/app/tests/conference/test_service.py index e3ab09d..f375683 100644 --- a/backend/app/tests/access/test_service.py +++ b/backend/app/tests/conference/test_service.py @@ -2,12 +2,12 @@ import pytest -from app.access.models import CommitteeAssignment -from app.access.service import ( - AccessDenied, - resolve_committee_assignment, +from app.conference.models import ConferenceAssignment +from app.conference.service import ( + resolve_conference_assignment, verify_user_role, ) +from app.core.exceptions import AccessDeniedError @pytest.mark.anyio @@ -15,16 +15,18 @@ async def test_denies_user_without_assignment(monkeypatch): async def no_assignment(*_args, **_kwargs): return None - monkeypatch.setattr("app.access.service.get_committee_assignment", no_assignment) + monkeypatch.setattr( + "app.conference.service.repository.get_conference_assignment", no_assignment + ) - with pytest.raises(AccessDenied, match="no committee assignment"): - await resolve_committee_assignment(object(), uuid4(), 1) + with pytest.raises(AccessDeniedError, match="no assignment"): + await resolve_conference_assignment(object(), uuid4(), 1) @pytest.mark.anyio async def test_denies_delegate_without_delegation(monkeypatch): async def invalid_assignment(*_args, **_kwargs): - return CommitteeAssignment( + return ConferenceAssignment( user_id=uuid4(), committee_id=1, role="delegate", @@ -32,16 +34,16 @@ async def invalid_assignment(*_args, **_kwargs): ) monkeypatch.setattr( - "app.access.service.get_committee_assignment", invalid_assignment + "app.conference.service.repository.get_conference_assignment", invalid_assignment ) - with pytest.raises(AccessDenied, match="no delegation id"): - await resolve_committee_assignment(object(), uuid4(), 1) + with pytest.raises(AccessDeniedError, match="no delegation id"): + await resolve_conference_assignment(object(), uuid4(), 1) @pytest.mark.anyio async def test_returns_valid_assignment(monkeypatch): - assignment = CommitteeAssignment( + assignment = ConferenceAssignment( user_id=uuid4(), committee_id=1, role="chair", @@ -51,16 +53,18 @@ async def test_returns_valid_assignment(monkeypatch): async def valid_assignment(*_args, **_kwargs): return assignment - monkeypatch.setattr("app.access.service.get_committee_assignment", valid_assignment) + monkeypatch.setattr( + "app.conference.service.repository.get_conference_assignment", valid_assignment + ) - result = await resolve_committee_assignment(object(), assignment.user_id, 1) + result = await resolve_conference_assignment(object(), assignment.user_id, 1) assert result is assignment @pytest.mark.anyio async def test_role_check_denies_a_delegate_when_a_chair_is_required(monkeypatch): - assignment = CommitteeAssignment( + assignment = ConferenceAssignment( user_id=uuid4(), committee_id=1, role="delegate", @@ -71,16 +75,16 @@ async def delegate_assignment(*_args, **_kwargs): return assignment monkeypatch.setattr( - "app.access.service.get_committee_assignment", delegate_assignment + "app.conference.service.repository.get_conference_assignment", delegate_assignment ) - with pytest.raises(AccessDenied, match="requires the chair role"): + with pytest.raises(AccessDeniedError, match="requires the chair role"): await verify_user_role(object(), assignment.user_id, 1, "chair") @pytest.mark.anyio async def test_role_check_returns_matching_assignment(monkeypatch): - assignment = CommitteeAssignment( + assignment = ConferenceAssignment( user_id=uuid4(), committee_id=1, role="chair", @@ -90,8 +94,11 @@ async def test_role_check_returns_matching_assignment(monkeypatch): async def chair_assignment(*_args, **_kwargs): return assignment - monkeypatch.setattr("app.access.service.get_committee_assignment", chair_assignment) + monkeypatch.setattr( + "app.conference.service.repository.get_conference_assignment", chair_assignment + ) assert ( await verify_user_role(object(), assignment.user_id, 1, "chair") is assignment ) + diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index ba63b6d..ca0cbc2 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -5,8 +5,7 @@ import pytest -from app.access.enums import SessionRoles -from app.access.models import CommitteeAssignment +from app.conference.models import ConferenceAssignment from app.session import enums from app.session.enums import SessionRole from app.session.manager import ConnectionManager @@ -23,10 +22,10 @@ @pytest.fixture def brazil_assignment(): - return CommitteeAssignment( + return ConferenceAssignment( user_id=UUID("44444444-4444-4444-4444-444444444444"), committee_id=0, - role=SessionRoles.DELEGATION, + role="delegate", representation_id=0, ) @@ -100,7 +99,7 @@ def test_cannot_build_actor_with_nonexistent_delegation( async def test_prepare_connect_without_db( connection_manager: ConnectionManager, session_state: SessionLiveState, - brazil_assignment: CommitteeAssignment, + brazil_assignment: ConferenceAssignment, ) -> None: connection_manager.room_states[0] = session_state mock_session = None @@ -120,7 +119,7 @@ async def test_prepare_connect_without_db( @pytest.mark.anyio async def test_prepare_connect_fetches_db( connection_manager: ConnectionManager, - brazil_assignment: CommitteeAssignment, + brazil_assignment: ConferenceAssignment, monkeypatch, ) -> None: mock_session = MagicMock @@ -156,7 +155,7 @@ async def test_prepare_connect_fetches_db( @pytest.mark.anyio async def test_cant_prepare_connect_storedlive_missing( connection_manager: ConnectionManager, - brazil_assignment: CommitteeAssignment, + brazil_assignment: ConferenceAssignment, monkeypatch, ) -> None: with pytest.raises(SessionFetchError, match="Could not fetch session info"): From 59d3a11fdb0a2baaa853b21c4a4774564fa15adb Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:45:38 -0300 Subject: [PATCH 08/12] refactor(conference): streamline conference domain, queries, and unified ConferenceAssignment Co-authored-by: Antigravity --- backend/app/conference/models.py | 22 ++-- backend/app/conference/repository.py | 104 +++---------------- backend/app/conference/schemas.py | 46 -------- backend/app/conference/service.py | 66 +++--------- backend/app/conference/views.py | 43 +++----- backend/app/session/views.py | 3 +- backend/app/tests/conference/test_service.py | 19 ++-- 7 files changed, 71 insertions(+), 232 deletions(-) diff --git a/backend/app/conference/models.py b/backend/app/conference/models.py index 8900706..75fe81f 100644 --- a/backend/app/conference/models.py +++ b/backend/app/conference/models.py @@ -1,18 +1,24 @@ -from dataclasses import dataclass +from datetime import datetime from uuid import UUID +from pydantic import BaseModel, ConfigDict -@dataclass(frozen=True) -class ConferenceAssignment: - """Holds information about a user's role and representation in a conference/committee.""" - user_id: UUID - role: str +class ConferenceAssignment(BaseModel): + """Holds information about a user's role, enrollment, and representation.""" + + model_config = ConfigDict(from_attributes=True) + + id: int | None = None conference_id: int | None = None + user_id: UUID | None = None + name: str | None = None + email: str | None = None + institution: str | None = None + role: str committee_id: int | None = None representation_id: int | None = None + created_at: datetime | None = None -# Alias for backward compatibility if needed by session engine -CommitteeAssignment = ConferenceAssignment diff --git a/backend/app/conference/repository.py b/backend/app/conference/repository.py index c474ffc..13144ad 100644 --- a/backend/app/conference/repository.py +++ b/backend/app/conference/repository.py @@ -94,16 +94,6 @@ async def list_committees_for_conference( return [dict(r) for r in result.mappings().all()] -async def check_conference_exists( - session: AsyncSession, - conference_id: int, -) -> bool: - """Check if a conference exists by id.""" - query = text("SELECT 1 FROM public.conferences WHERE id = :conference_id") - result = await session.execute(query, {"conference_id": conference_id}) - return result.scalar() is not None - - async def get_user_conference_role( session: AsyncSession, user_id: UUID, @@ -130,37 +120,6 @@ async def get_user_conference_role( return row["role"] if row else None -async def get_user_committee_role( - session: AsyncSession, - user_id: UUID, - committee_id: int, -) -> str | None: - """Return the user's role for a specific committee (allowing conference owner/admin override).""" - query = text(""" - SELECT - CASE - WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' - WHEN ca.role = 'chair' THEN 'chair' - ELSE 'delegate' - END AS role - FROM public.committees c - JOIN public.conferences conf ON conf.id = c.conference_id - LEFT JOIN public.conference_assignments ca - ON ca.conference_id = conf.id - AND ca.user_id = :user_id - AND (ca.committee_id = c.id OR ca.committee_id IS NULL) - WHERE c.id = :committee_id - AND (conf.owner_id = :user_id OR ca.user_id = :user_id) - ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC - LIMIT 1 - """) - result = await session.execute( - query, {"committee_id": committee_id, "user_id": user_id} - ) - row = result.mappings().one_or_none() - return row["role"] if row else None - - async def create_committee( session: AsyncSession, conference_id: int, @@ -243,10 +202,13 @@ async def list_conference_members( return [dict(r) for r in result.mappings().all()] -async def get_conference_assignment( - session: AsyncSession, user_id: UUID, committee_id: int +async def get_assignment( + session: AsyncSession, + user_id: UUID, + committee_id: int | None = None, + session_id: int | None = None, ) -> ConferenceAssignment | None: - """Fetch assignment for a user in a committee (allows conference owner/admin chair override).""" + """Fetch assignment for a user in a committee or session context.""" query = text(""" SELECT :user_id AS user_id, @@ -260,18 +222,24 @@ async def get_conference_assignment( ca.representation_id FROM public.committees c JOIN public.conferences conf ON conf.id = c.conference_id + LEFT JOIN public.sessions s ON s.committee_id = c.id LEFT JOIN public.conference_assignments ca ON ca.conference_id = conf.id AND ca.user_id = :user_id AND (ca.committee_id = c.id OR ca.committee_id IS NULL) - WHERE c.id = :committee_id - AND (conf.owner_id = :user_id OR ca.user_id = :user_id) + WHERE (:committee_id IS NOT NULL AND c.id = :committee_id) + OR (:session_id IS NOT NULL AND s.id = :session_id) ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC LIMIT 1 """) result = await session.execute( - query, {"committee_id": committee_id, "user_id": user_id} + query, + { + "committee_id": committee_id, + "session_id": session_id, + "user_id": user_id, + }, ) row = result.mappings().one_or_none() if row is None: @@ -286,46 +254,4 @@ async def get_conference_assignment( ) -async def get_session_assignment( - session: AsyncSession, user_id: UUID, session_id: int -) -> ConferenceAssignment | None: - """Fetch assignment for a user in the committee that owns a session.""" - query = text(""" - SELECT - :user_id AS user_id, - s.committee_id, - conf.id AS conference_id, - CASE - WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' - WHEN ca.role = 'chair' THEN 'chair' - ELSE 'delegate' - END AS role, - ca.representation_id - FROM public.sessions s - JOIN public.committees c ON c.id = s.committee_id - JOIN public.conferences conf ON conf.id = c.conference_id - LEFT JOIN public.conference_assignments ca - ON ca.conference_id = conf.id - AND ca.user_id = :user_id - AND (ca.committee_id = c.id OR ca.committee_id IS NULL) - WHERE s.id = :session_id - AND (conf.owner_id = :user_id OR ca.user_id = :user_id) - ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC - LIMIT 1 - """) - - result = await session.execute( - query, {"session_id": session_id, "user_id": user_id} - ) - row = result.mappings().one_or_none() - if row is None: - return None - - return ConferenceAssignment( - user_id=row["user_id"], - conference_id=row["conference_id"], - committee_id=row["committee_id"], - role=row["role"], - representation_id=row["representation_id"], - ) diff --git a/backend/app/conference/schemas.py b/backend/app/conference/schemas.py index b4c5e76..272e361 100644 --- a/backend/app/conference/schemas.py +++ b/backend/app/conference/schemas.py @@ -16,25 +16,6 @@ class ConferenceCreate(BaseModel): end_date: datetime -class ConferenceSummary(BaseModel): - """Lightweight conference summary for lists and switchers""" - - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - slug: str | None = None - status: str - location: str | None = None - logo: str | None = None - color: str - start_date: datetime - end_date: datetime - owner_id: UUID - user_role: str | None = None - total_committees: int = 0 - - class CommitteeCreate(BaseModel): """Schema for creating a committee""" @@ -88,30 +69,3 @@ class EnrollMember(BaseModel): role: str = "participant" committee_id: int | None = None representation_id: int | None = None - - -class AssignmentResponse(BaseModel): - """Schema for returning conference assignment details""" - - model_config = ConfigDict(from_attributes=True) - - id: int - conference_id: int - user_id: UUID | None = None - name: str - email: str - institution: str | None = None - role: str - committee_id: int | None = None - representation_id: int | None = None - created_at: datetime | None = None - - -class SessionRepresentation(BaseModel): - """Schema for returning user's role and representation for a session""" - - model_config = ConfigDict(from_attributes=True) - - role: str - representation_id: int | None = None - diff --git a/backend/app/conference/service.py b/backend/app/conference/service.py index 2ea41f3..0e47869 100644 --- a/backend/app/conference/service.py +++ b/backend/app/conference/service.py @@ -35,11 +35,6 @@ async def get_conference_info( session=session, user_id=user_id, conference_id=conference_id ) if user_role is None: - exists = await repository.check_conference_exists( - session=session, conference_id=conference_id - ) - if exists: - raise AccessDeniedError("You do not have access to this conference") raise NotFoundError(f"Conference with id {conference_id} not found") # Query 2: Fetch Conference & Committees @@ -68,14 +63,6 @@ async def create_committee( user_role = await repository.get_user_conference_role( session=session, user_id=user_id, conference_id=conference_id ) - if user_role is None: - exists = await repository.check_conference_exists( - session=session, conference_id=conference_id - ) - if not exists: - raise NotFoundError(f"Conference with id {conference_id} not found") - raise AccessDeniedError("You do not have access to this conference") - if user_role not in ("owner", "admin"): raise AccessDeniedError( "Only conference owners and admins can create committees" @@ -96,14 +83,6 @@ async def enroll_member( user_role = await repository.get_user_conference_role( session=session, user_id=user_id, conference_id=conference_id ) - if user_role is None: - exists = await repository.check_conference_exists( - session=session, conference_id=conference_id - ) - if not exists: - raise NotFoundError(f"Conference with id {conference_id} not found") - raise AccessDeniedError("You do not have access to this conference") - if user_role not in ("owner", "admin"): raise AccessDeniedError( "Only conference owners and admins can enroll members" @@ -124,47 +103,28 @@ async def list_conference_members( session=session, user_id=user_id, conference_id=conference_id ) if user_role is None: - exists = await repository.check_conference_exists( - session=session, conference_id=conference_id - ) - if not exists: - raise NotFoundError(f"Conference with id {conference_id} not found") - raise AccessDeniedError("You do not have access to this conference") + raise NotFoundError(f"Conference with id {conference_id} not found") return await repository.list_conference_members( session=session, conference_id=conference_id ) -async def resolve_conference_assignment( +async def resolve_assignment( session: AsyncSession, user_id: UUID, - committee_id: int, + committee_id: int | None = None, + session_id: int | None = None, ) -> ConferenceAssignment: - """Resolve a user's assignment in a committee.""" - assignment = await repository.get_conference_assignment( - session=session, user_id=user_id, committee_id=committee_id + """Resolve a user's assignment in a committee or session context.""" + assignment = await repository.get_assignment( + session=session, + user_id=user_id, + committee_id=committee_id, + session_id=session_id, ) if assignment is None: - raise AccessDeniedError("User has no assignment for this committee") - - if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDeniedError("Delegate role has no delegation id") - - return assignment - - -async def resolve_session_assignment( - session: AsyncSession, - user_id: UUID, - session_id: int, -) -> ConferenceAssignment: - """Resolve a user's assignment for a session.""" - assignment = await repository.get_session_assignment( - session=session, user_id=user_id, session_id=session_id - ) - if assignment is None: - raise AccessDeniedError("User has no assignment for this session") + raise AccessDeniedError("User has no assignment for this context") if assignment.role == "delegate" and assignment.representation_id is None: raise AccessDeniedError("Delegate role has no delegation id") @@ -179,7 +139,7 @@ async def verify_user_role( required_role: Literal["chair", "delegate"], ) -> ConferenceAssignment: """Verify and require that a user has a specific role for a committee.""" - assignment = await resolve_conference_assignment( + assignment = await resolve_assignment( session=session, user_id=user_id, committee_id=committee_id ) @@ -190,3 +150,5 @@ async def verify_user_role( return assignment + + diff --git a/backend/app/conference/views.py b/backend/app/conference/views.py index 3aa0b8f..c688c18 100644 --- a/backend/app/conference/views.py +++ b/backend/app/conference/views.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends, status from sqlalchemy.ext.asyncio import AsyncSession @@ -7,6 +7,7 @@ import app.conference.service as service from app.auth.dep import get_current_user from app.auth.service import AuthUser +from app.conference.models import ConferenceAssignment from app.core.database import get_db_session router = APIRouter() @@ -17,7 +18,7 @@ async def create_conference( payload: schemas.ConferenceCreate, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): +) -> dict[str, Any]: """Endpoint to create a new conference""" conference_id = await service.create_conference( session=db_session, user_id=current_user.user_id, payload=payload @@ -25,23 +26,23 @@ async def create_conference( return {"id": conference_id, "status": "Created"} -@router.get("/", response_model=list[int]) +@router.get("/") async def get_user_conferences( db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): +) -> list[int]: """Endpoint to get list of conference ids for a user""" return await service.get_user_conferences( session=db_session, user_id=current_user.user_id ) -@router.get("/{id}", response_model=schemas.ConferenceDetail) +@router.get("/{id}") async def get_conference_info( id: int, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): +) -> schemas.ConferenceDetail: """Endpoint to get detailed conference information and committees""" return await service.get_conference_info( session=db_session, user_id=current_user.user_id, conference_id=id @@ -50,7 +51,6 @@ async def get_conference_info( @router.post( "/{id}/committees", - response_model=schemas.CommitteeResponse, status_code=status.HTTP_201_CREATED, ) async def create_committee( @@ -58,7 +58,7 @@ async def create_committee( committee: schemas.CommitteeCreate, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): +) -> schemas.CommitteeResponse: """Endpoint to create a new committee in a conference""" return await service.create_committee( session=db_session, @@ -70,7 +70,6 @@ async def create_committee( @router.post( "/{conference_id}/members", - response_model=schemas.AssignmentResponse, status_code=status.HTTP_201_CREATED, ) async def enroll_member( @@ -78,7 +77,7 @@ async def enroll_member( member: schemas.EnrollMember, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): +) -> ConferenceAssignment: """Endpoint to enroll/assign a member to a conference""" return await service.enroll_member( session=db_session, @@ -88,15 +87,12 @@ async def enroll_member( ) -@router.get( - "/{conference_id}/members", - response_model=list[schemas.AssignmentResponse], -) +@router.get("/{conference_id}/members") async def list_conference_members( conference_id: int, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): +) -> list[ConferenceAssignment]: """Endpoint to list all members assigned to a conference""" return await service.list_conference_members( session=db_session, @@ -105,21 +101,16 @@ async def list_conference_members( ) -@router.get( - "/sessions/{session_id}/me", - response_model=schemas.SessionRepresentation, -) +@router.get("/sessions/{session_id}/me") async def get_my_session_access( session_id: int, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -): - """Return the authenticated user's actor context for a session.""" - assignment = await service.resolve_session_assignment( +) -> ConferenceAssignment: + """Return the authenticated user's assignment context for a session.""" + return await service.resolve_assignment( session=db_session, user_id=current_user.user_id, session_id=session_id ) - return schemas.SessionRepresentation( - role=assignment.role, - representation_id=assignment.representation_id, - ) + + diff --git a/backend/app/session/views.py b/backend/app/session/views.py index 3d01324..e3ab892 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -146,10 +146,9 @@ async def websocket_endpoint( settings=settings, token=validated_auth_data.access_token ) - # The session determines its committee; never accept it from the client. session_factory = websocket.app.state.db_session_factory async with session_factory() as db: - assignment = await conference_service.resolve_session_assignment( + assignment = await conference_service.resolve_assignment( session=db, user_id=auth_user.user_id, session_id=session_id ) diff --git a/backend/app/tests/conference/test_service.py b/backend/app/tests/conference/test_service.py index f375683..a97be3f 100644 --- a/backend/app/tests/conference/test_service.py +++ b/backend/app/tests/conference/test_service.py @@ -4,7 +4,7 @@ from app.conference.models import ConferenceAssignment from app.conference.service import ( - resolve_conference_assignment, + resolve_assignment, verify_user_role, ) from app.core.exceptions import AccessDeniedError @@ -16,11 +16,11 @@ async def no_assignment(*_args, **_kwargs): return None monkeypatch.setattr( - "app.conference.service.repository.get_conference_assignment", no_assignment + "app.conference.service.repository.get_assignment", no_assignment ) with pytest.raises(AccessDeniedError, match="no assignment"): - await resolve_conference_assignment(object(), uuid4(), 1) + await resolve_assignment(object(), uuid4(), committee_id=1) @pytest.mark.anyio @@ -34,11 +34,11 @@ async def invalid_assignment(*_args, **_kwargs): ) monkeypatch.setattr( - "app.conference.service.repository.get_conference_assignment", invalid_assignment + "app.conference.service.repository.get_assignment", invalid_assignment ) with pytest.raises(AccessDeniedError, match="no delegation id"): - await resolve_conference_assignment(object(), uuid4(), 1) + await resolve_assignment(object(), uuid4(), committee_id=1) @pytest.mark.anyio @@ -54,10 +54,10 @@ async def valid_assignment(*_args, **_kwargs): return assignment monkeypatch.setattr( - "app.conference.service.repository.get_conference_assignment", valid_assignment + "app.conference.service.repository.get_assignment", valid_assignment ) - result = await resolve_conference_assignment(object(), assignment.user_id, 1) + result = await resolve_assignment(object(), assignment.user_id, committee_id=1) assert result is assignment @@ -75,7 +75,7 @@ async def delegate_assignment(*_args, **_kwargs): return assignment monkeypatch.setattr( - "app.conference.service.repository.get_conference_assignment", delegate_assignment + "app.conference.service.repository.get_assignment", delegate_assignment ) with pytest.raises(AccessDeniedError, match="requires the chair role"): @@ -95,10 +95,11 @@ async def chair_assignment(*_args, **_kwargs): return assignment monkeypatch.setattr( - "app.conference.service.repository.get_conference_assignment", chair_assignment + "app.conference.service.repository.get_assignment", chair_assignment ) assert ( await verify_user_role(object(), assignment.user_id, 1, "chair") is assignment ) + From a76db67ac7b64321a229d74bac2ef3d31c382c25 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:09:18 -0300 Subject: [PATCH 09/12] Fix session access migration Co-authored-by: Codex --- backend/app/session/views.py | 14 ++++++++------ backend/app/tests/session/test_views.py | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/app/session/views.py b/backend/app/session/views.py index 622bec0..74e7da7 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -29,6 +29,7 @@ from app.core.config import Settings, get_settings from app.core.database import get_db_session from app.core.dep import get_connection_manager, get_logger, get_session_engine +from app.core.exceptions import AccessDeniedError from app.session.engine import EventRejectedError, SessionEngine from app.session.enums import EventErrorCode from app.session.manager import ConnectionManager @@ -97,7 +98,7 @@ async def activate_session_endpoint( detail="Session not found", ) - await access.verify_user_role( + await conference_service.verify_user_role( session=db_session, user_id=current_user.user_id, committee_id=stored.committee_id, @@ -107,7 +108,7 @@ async def activate_session_endpoint( await service.activate_session( session=db_session, manager=manager, committee_session_id=session_id ) - except AccessDenied as exc: + except AccessDeniedError as exc: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(exc), @@ -169,9 +170,10 @@ async def websocket_endpoint( data = await websocket.receive_json() try: validated_event = EventMessage.model_validate(data) - except ValidationError as exc: + except ValidationError: message = EventRejectedMessage( - code=EventErrorCode.INVALID_MESSAGE, message=str(exc) + code=EventErrorCode.INVALID_MESSAGE, + message="Invalid event message", ) await manager.send_message( session_id=session_id, message=message, websocket=websocket @@ -214,7 +216,7 @@ async def websocket_endpoint( except ( TokenExpiredError, TokenInvalidError, - AccessDenied, + AccessDeniedError, service.ActorResolutionError, service.SessionFetchError, ValidationError, @@ -223,7 +225,7 @@ async def websocket_endpoint( reason = "token_expired" elif isinstance(exc, TokenInvalidError): reason = "token_invalid" - elif isinstance(exc, AccessDenied): + elif isinstance(exc, AccessDeniedError): reason = "access_denied" elif isinstance(exc, service.SessionFetchError): reason = "session_unavailable" diff --git a/backend/app/tests/session/test_views.py b/backend/app/tests/session/test_views.py index 0893b85..3fc8a4f 100644 --- a/backend/app/tests/session/test_views.py +++ b/backend/app/tests/session/test_views.py @@ -67,8 +67,8 @@ def authenticated_websocket_dependencies(monkeypatch, chair_actor: SessionActor) ), ) monkeypatch.setattr( - views.access, - "resolve_session_assignment", + views.conference_service, + "resolve_assignment", AsyncMock(return_value=MagicMock()), ) prepare_connect = AsyncMock(return_value=chair_actor) From 6086ab27f83a3cf5749c281bf4059149c8b4f58b Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:16:10 -0300 Subject: [PATCH 10/12] refactor(session): centralize HTTP error handling Co-authored-by: Codex --- backend/app/core/exceptions.py | 22 ++++++++++ backend/app/main.py | 24 ++--------- backend/app/session/service.py | 41 ++++++++++-------- backend/app/session/views.py | 79 ++++++++++------------------------ 4 files changed, 72 insertions(+), 94 deletions(-) diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index 5ed5035..5f8b39d 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -1,6 +1,8 @@ class AppException(Exception): """Base exception""" + status_code = 500 + def __init__(self, message: str): self.message = message super().__init__(message) @@ -9,16 +11,36 @@ def __init__(self, message: str): class NotFoundError(AppException): """Resource does not exist""" + status_code = 404 + pass class AccessDeniedError(AppException): """User does not have permission""" + status_code = 403 + pass class ConflictError(AppException): """Duplicate entity or invalid state transition""" + status_code = 409 + + pass + + +class BadRequest(AppException): + """Request payload or requested operation is invalid.""" + + status_code = 400 + + pass + + +class InternalServerError(AppException): + """An application operation failed unexpectedly.""" + pass diff --git a/backend/app/main.py b/backend/app/main.py index aeab316..c4b7973 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Request, status +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi @@ -43,26 +43,10 @@ async def lifespan(app: FastAPI): # --- Exception Handlers -@app.exception_handler(exceptions.NotFoundError) -async def not_found_handler(request: Request, exc: exceptions.NotFoundError): +@app.exception_handler(exceptions.AppException) +async def app_exception_handler(request: Request, exc: exceptions.AppException): return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={"detail": exc.message}, - ) - - -@app.exception_handler(exceptions.AccessDeniedError) -async def access_denied_handler(request: Request, exc: exceptions.AccessDeniedError): - return JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={"detail": exc.message}, - ) - - -@app.exception_handler(exceptions.ConflictError) -async def conflict_handler(request: Request, exc: exceptions.ConflictError): - return JSONResponse( - status_code=status.HTTP_409_CONFLICT, + status_code=exc.status_code, content={"detail": exc.message}, ) diff --git a/backend/app/session/service.py b/backend/app/session/service.py index 63d1f4a..c0804b3 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -14,6 +14,12 @@ import app.session.schemas as schemas from app.conference.models import ConferenceAssignment from app.core.database import RepositoryError +from app.core.exceptions import ( + BadRequest, + ConflictError, + InternalServerError, + NotFoundError, +) from app.session.engine import EventRejectedError, SessionEngine from .manager import ConnectionManager @@ -29,18 +35,10 @@ class ActorResolutionError(Exception): pass -class SessionCreationError(Exception): - pass - - class SessionFetchError(Exception): pass -class SessionUpdateError(Exception): - pass - - def build_actor( user_id: UUID, manager: ConnectionManager, @@ -92,34 +90,41 @@ async def create_session_service( ) if session_id is None: - raise SessionCreationError("Could not create session with given schema") + raise BadRequest("Could not create session with given schema") await session.commit() return session_id +async def get_session_for_activation( + session: AsyncSession, committee_session_id: int +): + """Fetch a session to authorize and activate, or report that it is absent.""" + stored = await repository.get_session_info( + session=session, committee_session_id=committee_session_id + ) + if stored is None: + raise NotFoundError("Session not found") + return stored + + async def activate_session( session: AsyncSession, manager: ConnectionManager, committee_session_id: int, ): """Activate a planned session""" - stored = await repository.get_session_info( - session=session, committee_session_id=committee_session_id - ) - - if stored is None: - raise SessionFetchError("Could not fetch session info") + stored = await get_session_for_activation(session, committee_session_id) if stored.status != "planned": - raise SessionFetchError("Session already started") + raise ConflictError("Session already started") delegations = await repository.bulk_get_delegation_context( session=session, committee_id=stored.committee_id ) if delegations is None: - raise SessionFetchError("Could not fetch session delegations info") + raise ConflictError("Session delegations are unavailable") live_state = SessionLiveState( session_id=stored.id, @@ -140,7 +145,7 @@ async def activate_session( try: await repository.update_session_info(session=session, session_info=updated) except RepositoryError: - raise SessionUpdateError("Could not update session info") from None + raise InternalServerError("Could not update session info") from None await session.commit() diff --git a/backend/app/session/views.py b/backend/app/session/views.py index 74e7da7..ebf3fb6 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -12,12 +12,10 @@ WebSocketDisconnect, status, ) -from fastapi.exceptions import HTTPException from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession import app.conference.service as conference_service -import app.session.repository as repository import app.session.service as service from app.auth.dep import get_current_user from app.auth.service import ( @@ -58,25 +56,17 @@ async def create_session_endpoint( current_user: Annotated[AuthUser, Depends(get_current_user)], ): """POST endpoint to create a new session""" - try: - await conference_service.verify_user_role( - session=session, - user_id=current_user.user_id, - committee_id=session_schema.committee_id, - required_role="chair", - ) - - res = await service.create_session_service( - session=session, - session_schema=session_schema, - ) - return {"id": res, "status": "Created"} - - except service.SessionCreationError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) from exc + await conference_service.verify_user_role( + session=session, + user_id=current_user.user_id, + committee_id=session_schema.committee_id, + required_role="chair", + ) + session_id = await service.create_session_service( + session=session, + session_schema=session_schema, + ) + return {"id": session_id, "status": "Created"} @router.post("/{session_id}/activate", status_code=status.HTTP_204_NO_CONTENT) @@ -87,41 +77,18 @@ async def activate_session_endpoint( current_user: Annotated[AuthUser, Depends(get_current_user)], ): """Endpoint to activate a planned session""" - try: - stored = await repository.get_session_info( - session=db_session, - committee_session_id=session_id, - ) - if stored is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Session not found", - ) - - await conference_service.verify_user_role( - session=db_session, - user_id=current_user.user_id, - committee_id=stored.committee_id, - required_role="chair", - ) - - await service.activate_session( - session=db_session, manager=manager, committee_session_id=session_id - ) - except AccessDeniedError as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc - except service.SessionFetchError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) from exc - except service.SessionUpdateError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) - ) from exc + stored = await service.get_session_for_activation( + session=db_session, committee_session_id=session_id + ) + await conference_service.verify_user_role( + session=db_session, + user_id=current_user.user_id, + committee_id=stored.committee_id, + required_role="chair", + ) + await service.activate_session( + session=db_session, manager=manager, committee_session_id=session_id + ) @router.websocket("/ws/{session_id}") From 89b29e3cf439d2591e798dbb0542b7c07bf2a6f2 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:09:30 -0300 Subject: [PATCH 11/12] feat(conference): expose dashboard and session access data Co-authored-by: Codex --- backend/app/conference/repository.py | 115 +++-- backend/app/conference/schemas.py | 11 + backend/app/conference/service.py | 58 ++- backend/app/conference/views.py | 16 +- backend/app/session/schemas.py | 15 + backend/app/session/views.py | 2 +- backend/app/tests/conference/test_service.py | 221 +++++++- backend/app/tests/session/test_views.py | 2 +- frontend/src/context/SessionContext.tsx | 7 +- frontend/src/schemas/types.gen.ts | 499 ++++++++++++++++++- 10 files changed, 853 insertions(+), 93 deletions(-) diff --git a/backend/app/conference/repository.py b/backend/app/conference/repository.py index 13144ad..f8e4a33 100644 --- a/backend/app/conference/repository.py +++ b/backend/app/conference/repository.py @@ -1,4 +1,3 @@ -import json from typing import Any from uuid import UUID @@ -49,19 +48,39 @@ async def create_conference( async def get_user_conferences( session: AsyncSession, user_id: UUID, -) -> list[int]: - """Return all conference IDs where the user is an owner or assigned member.""" +) -> list[dict[str, Any]]: + """Return summaries of conferences where the user is an owner or member.""" query = text(""" - SELECT DISTINCT c.id + SELECT + c.id, + c.name, + c.logo, + c.color, + c.status, + CASE + WHEN c.owner_id = :user_id THEN 'owner' + ELSE ( + SELECT ca.role::text + FROM public.conference_assignments ca + WHERE ca.conference_id = c.id AND ca.user_id = :user_id + ORDER BY + (ca.committee_id IS NULL) DESC, + ca.created_at ASC + LIMIT 1 + ) + END AS caller_role FROM public.conferences c - LEFT JOIN public.conference_assignments ca - ON ca.conference_id = c.id AND ca.user_id = :user_id - WHERE c.owner_id = :user_id OR ca.user_id = :user_id + WHERE c.owner_id = :user_id + OR EXISTS ( + SELECT 1 + FROM public.conference_assignments ca + WHERE ca.conference_id = c.id AND ca.user_id = :user_id + ) ORDER BY c.id ASC """) result = await session.execute(query, {"user_id": user_id}) - return [int(r["id"]) for r in result.mappings().all()] + return [dict(row) for row in result.mappings().all()] async def get_conference_by_id( @@ -102,7 +121,7 @@ async def get_user_conference_role( """Return the user's role in the conference ('owner', 'admin', 'chair', etc.) or None if unauthorized.""" query = text(""" SELECT - CASE + CASE WHEN c.owner_id = :user_id THEN 'owner' ELSE ca.role::text END AS role @@ -202,19 +221,28 @@ async def list_conference_members( return [dict(r) for r in result.mappings().all()] -async def get_assignment( +def _assignment_from_row(row: Any) -> ConferenceAssignment: + return ConferenceAssignment( + user_id=row["user_id"], + conference_id=row["conference_id"], + committee_id=row["committee_id"], + role=row["role"], + representation_id=row["representation_id"], + ) + + +async def get_committee_assignment( session: AsyncSession, user_id: UUID, - committee_id: int | None = None, - session_id: int | None = None, + committee_id: int, ) -> ConferenceAssignment | None: - """Fetch assignment for a user in a committee or session context.""" + """Fetch a user's assignment for one committee.""" query = text(""" SELECT :user_id AS user_id, c.id AS committee_id, conf.id AS conference_id, - CASE + CASE WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' WHEN ca.role = 'chair' THEN 'chair' ELSE 'delegate' @@ -222,13 +250,11 @@ async def get_assignment( ca.representation_id FROM public.committees c JOIN public.conferences conf ON conf.id = c.conference_id - LEFT JOIN public.sessions s ON s.committee_id = c.id - LEFT JOIN public.conference_assignments ca - ON ca.conference_id = conf.id - AND ca.user_id = :user_id + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = conf.id + AND ca.user_id = :user_id AND (ca.committee_id = c.id OR ca.committee_id IS NULL) - WHERE (:committee_id IS NOT NULL AND c.id = :committee_id) - OR (:session_id IS NOT NULL AND s.id = :session_id) + WHERE c.id = :committee_id ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC LIMIT 1 """) @@ -237,7 +263,6 @@ async def get_assignment( query, { "committee_id": committee_id, - "session_id": session_id, "user_id": user_id, }, ) @@ -245,13 +270,47 @@ async def get_assignment( if row is None: return None - return ConferenceAssignment( - user_id=row["user_id"], - conference_id=row["conference_id"], - committee_id=row["committee_id"], - role=row["role"], - representation_id=row["representation_id"], - ) + return _assignment_from_row(row) +async def get_session_assignment( + session: AsyncSession, + user_id: UUID, + session_id: int, +) -> ConferenceAssignment | None: + """Fetch a user's assignment for one committee session.""" + query = text(""" + SELECT + :user_id AS user_id, + c.id AS committee_id, + conf.id AS conference_id, + CASE + WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' + WHEN ca.role = 'chair' THEN 'chair' + ELSE 'delegate' + END AS role, + ca.representation_id + FROM public.sessions s + JOIN public.committees c ON c.id = s.committee_id + JOIN public.conferences conf ON conf.id = c.conference_id + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = conf.id + AND ca.user_id = :user_id + AND (ca.committee_id = c.id OR ca.committee_id IS NULL) + WHERE s.id = :session_id + ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC + LIMIT 1 + """) + + result = await session.execute( + query, + { + "session_id": session_id, + "user_id": user_id, + }, + ) + row = result.mappings().one_or_none() + if row is None: + return None + return _assignment_from_row(row) diff --git a/backend/app/conference/schemas.py b/backend/app/conference/schemas.py index 272e361..3a3d1ba 100644 --- a/backend/app/conference/schemas.py +++ b/backend/app/conference/schemas.py @@ -41,6 +41,17 @@ class CommitteeResponse(BaseModel): created_at: datetime | None = None +class ConferenceSummary(BaseModel): + """Conference data needed to select a conference in the dashboard.""" + + id: int + name: str + logo: str | None = None + color: str + status: str + caller_role: str + + class ConferenceDetail(BaseModel): """Detailed conference information including its committees""" diff --git a/backend/app/conference/service.py b/backend/app/conference/service.py index 0e47869..51421cf 100644 --- a/backend/app/conference/service.py +++ b/backend/app/conference/service.py @@ -16,13 +16,14 @@ async def create_conference( conference_id = await repository.create_conference( session=session, user_id=user_id, payload=payload ) + await session.commit() return conference_id async def get_user_conferences( session: AsyncSession, user_id: UUID -) -> list[int]: - """Get all conference IDs for a user.""" +) -> list[dict[str, Any]]: + """Get dashboard summaries for all conferences available to a user.""" return await repository.get_user_conferences(session=session, user_id=user_id) @@ -68,9 +69,11 @@ async def create_committee( "Only conference owners and admins can create committees" ) - return await repository.create_committee( + committee = await repository.create_committee( session=session, conference_id=conference_id, payload=payload ) + await session.commit() + return committee async def enroll_member( @@ -88,9 +91,11 @@ async def enroll_member( "Only conference owners and admins can enroll members" ) - return await repository.enroll_member( + assignment = await repository.enroll_member( session=session, conference_id=conference_id, payload=payload ) + await session.commit() + return assignment async def list_conference_members( @@ -110,26 +115,45 @@ async def list_conference_members( ) -async def resolve_assignment( +def _require_valid_assignment( + assignment: ConferenceAssignment | None, +) -> ConferenceAssignment: + """Validate that an assignment grants a usable committee identity.""" + if assignment is None: + raise AccessDeniedError("User has no assignment for this context") + + if assignment.role == "delegate" and assignment.representation_id is None: + raise AccessDeniedError("Delegate role has no delegation id") + + return assignment + + +async def resolve_committee_assignment( session: AsyncSession, user_id: UUID, - committee_id: int | None = None, - session_id: int | None = None, + committee_id: int, ) -> ConferenceAssignment: - """Resolve a user's assignment in a committee or session context.""" - assignment = await repository.get_assignment( + """Resolve a user's assignment for one committee.""" + assignment = await repository.get_committee_assignment( session=session, user_id=user_id, committee_id=committee_id, - session_id=session_id, ) - if assignment is None: - raise AccessDeniedError("User has no assignment for this context") + return _require_valid_assignment(assignment) - if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDeniedError("Delegate role has no delegation id") - return assignment +async def resolve_session_assignment( + session: AsyncSession, + user_id: UUID, + session_id: int, +) -> ConferenceAssignment: + """Resolve a user's assignment for one committee session.""" + assignment = await repository.get_session_assignment( + session=session, + user_id=user_id, + session_id=session_id, + ) + return _require_valid_assignment(assignment) async def verify_user_role( @@ -139,7 +163,7 @@ async def verify_user_role( required_role: Literal["chair", "delegate"], ) -> ConferenceAssignment: """Verify and require that a user has a specific role for a committee.""" - assignment = await resolve_assignment( + assignment = await resolve_committee_assignment( session=session, user_id=user_id, committee_id=committee_id ) @@ -150,5 +174,3 @@ async def verify_user_role( return assignment - - diff --git a/backend/app/conference/views.py b/backend/app/conference/views.py index c688c18..9847d6b 100644 --- a/backend/app/conference/views.py +++ b/backend/app/conference/views.py @@ -5,6 +5,7 @@ import app.conference.schemas as schemas import app.conference.service as service +import app.session.schemas as session_schemas from app.auth.dep import get_current_user from app.auth.service import AuthUser from app.conference.models import ConferenceAssignment @@ -30,8 +31,8 @@ async def create_conference( async def get_user_conferences( db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -) -> list[int]: - """Endpoint to get list of conference ids for a user""" +) -> list[schemas.ConferenceSummary]: + """Return dashboard summaries for the authenticated user's conferences.""" return await service.get_user_conferences( session=db_session, user_id=current_user.user_id ) @@ -106,11 +107,12 @@ async def get_my_session_access( session_id: int, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -) -> ConferenceAssignment: +) -> session_schemas.SessionRepresentation: """Return the authenticated user's assignment context for a session.""" - return await service.resolve_assignment( + assignment = await service.resolve_session_assignment( session=db_session, user_id=current_user.user_id, session_id=session_id ) - - - + return session_schemas.SessionRepresentation( + role=assignment.role, + representation_id=assignment.representation_id, + ) diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index e4ee663..04fe0a9 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -1,3 +1,4 @@ +from enum import StrEnum from typing import Annotated, Literal from uuid import UUID @@ -15,6 +16,20 @@ class SessionCreationSchema(BaseModel): name: str | None = None +class SessionRoles(StrEnum): + """Roles available to a participant in an active committee session.""" + + CHAIR = "chair" + DELEGATE = "delegate" + + +class SessionRepresentation(BaseModel): + """Session-specific access context for the authenticated user.""" + + role: SessionRoles + representation_id: int | None = None + + class MotionPayload(BaseModel): """General motion payload. Used on Delegate and Chair payloads""" diff --git a/backend/app/session/views.py b/backend/app/session/views.py index ebf3fb6..a2b4341 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -120,7 +120,7 @@ async def websocket_endpoint( session_factory = websocket.app.state.db_session_factory async with session_factory() as db: - assignment = await conference_service.resolve_assignment( + assignment = await conference_service.resolve_session_assignment( session=db, user_id=auth_user.user_id, session_id=session_id ) diff --git a/backend/app/tests/conference/test_service.py b/backend/app/tests/conference/test_service.py index a97be3f..d50f12e 100644 --- a/backend/app/tests/conference/test_service.py +++ b/backend/app/tests/conference/test_service.py @@ -1,13 +1,18 @@ +from unittest.mock import AsyncMock from uuid import uuid4 +from datetime import datetime import pytest +import app.conference.service as conference_service from app.conference.models import ConferenceAssignment +from app.conference.schemas import CommitteeCreate, ConferenceCreate, EnrollMember from app.conference.service import ( - resolve_assignment, + resolve_committee_assignment, + resolve_session_assignment, verify_user_role, ) -from app.core.exceptions import AccessDeniedError +from app.core.exceptions import AccessDeniedError, NotFoundError @pytest.mark.anyio @@ -16,11 +21,11 @@ async def no_assignment(*_args, **_kwargs): return None monkeypatch.setattr( - "app.conference.service.repository.get_assignment", no_assignment + "app.conference.service.repository.get_committee_assignment", no_assignment ) with pytest.raises(AccessDeniedError, match="no assignment"): - await resolve_assignment(object(), uuid4(), committee_id=1) + await resolve_committee_assignment(object(), uuid4(), committee_id=1) @pytest.mark.anyio @@ -34,11 +39,11 @@ async def invalid_assignment(*_args, **_kwargs): ) monkeypatch.setattr( - "app.conference.service.repository.get_assignment", invalid_assignment + "app.conference.service.repository.get_committee_assignment", invalid_assignment ) with pytest.raises(AccessDeniedError, match="no delegation id"): - await resolve_assignment(object(), uuid4(), committee_id=1) + await resolve_committee_assignment(object(), uuid4(), committee_id=1) @pytest.mark.anyio @@ -54,14 +59,41 @@ async def valid_assignment(*_args, **_kwargs): return assignment monkeypatch.setattr( - "app.conference.service.repository.get_assignment", valid_assignment + "app.conference.service.repository.get_committee_assignment", valid_assignment ) - result = await resolve_assignment(object(), assignment.user_id, committee_id=1) + result = await resolve_committee_assignment( + object(), assignment.user_id, committee_id=1 + ) assert result is assignment +@pytest.mark.anyio +async def test_resolves_assignment_for_a_session(monkeypatch): + assignment = ConferenceAssignment( + user_id=uuid4(), + committee_id=1, + role="chair", + representation_id=None, + ) + get_session_assignment = AsyncMock(return_value=assignment) + monkeypatch.setattr( + "app.conference.service.repository.get_session_assignment", + get_session_assignment, + ) + session = object() + + result = await resolve_session_assignment( + session, assignment.user_id, session_id=9 + ) + + assert result is assignment + get_session_assignment.assert_awaited_once_with( + session=session, user_id=assignment.user_id, session_id=9 + ) + + @pytest.mark.anyio async def test_role_check_denies_a_delegate_when_a_chair_is_required(monkeypatch): assignment = ConferenceAssignment( @@ -75,7 +107,7 @@ async def delegate_assignment(*_args, **_kwargs): return assignment monkeypatch.setattr( - "app.conference.service.repository.get_assignment", delegate_assignment + "app.conference.service.repository.get_committee_assignment", delegate_assignment ) with pytest.raises(AccessDeniedError, match="requires the chair role"): @@ -95,7 +127,7 @@ async def chair_assignment(*_args, **_kwargs): return assignment monkeypatch.setattr( - "app.conference.service.repository.get_assignment", chair_assignment + "app.conference.service.repository.get_committee_assignment", chair_assignment ) assert ( @@ -103,3 +135,172 @@ async def chair_assignment(*_args, **_kwargs): ) +@pytest.mark.anyio +async def test_get_conference_info_denies_an_unassigned_user(monkeypatch): + get_role = AsyncMock(return_value=None) + get_conference = AsyncMock() + monkeypatch.setattr( + conference_service.repository, "get_user_conference_role", get_role + ) + monkeypatch.setattr( + conference_service.repository, "get_conference_by_id", get_conference + ) + + with pytest.raises(NotFoundError, match="Conference with id 7 not found"): + await conference_service.get_conference_info(object(), uuid4(), 7) + + get_conference.assert_not_awaited() + + +@pytest.mark.anyio +async def test_get_conference_info_combines_role_and_committees(monkeypatch): + user_id = uuid4() + conference = {"id": 7, "name": "WebMUN", "status": "planned"} + committees = [{"id": 3, "name": "Security Council"}] + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="chair"), + ) + monkeypatch.setattr( + conference_service.repository, + "get_conference_by_id", + AsyncMock(return_value=conference), + ) + monkeypatch.setattr( + conference_service.repository, + "list_committees_for_conference", + AsyncMock(return_value=committees), + ) + + result = await conference_service.get_conference_info(object(), user_id, 7) + + assert result == { + "id": 7, + "name": "WebMUN", + "status": "planned", + "caller_role": "chair", + "committees": committees, + } + + +@pytest.mark.anyio +async def test_create_committee_requires_a_conference_manager(monkeypatch): + create_committee = AsyncMock() + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="delegate"), + ) + monkeypatch.setattr( + conference_service.repository, "create_committee", create_committee + ) + + with pytest.raises(AccessDeniedError, match="owners and admins"): + await conference_service.create_committee( + object(), uuid4(), 7, CommitteeCreate(name="Security Council", code="SC") + ) + + create_committee.assert_not_awaited() + + +@pytest.mark.anyio +async def test_create_committee_forwards_an_authorized_request(monkeypatch): + session = AsyncMock() + user_id = uuid4() + payload = CommitteeCreate(name="Security Council", code="SC") + created = {"id": 3, "conference_id": 7, "name": payload.name} + create_committee = AsyncMock(return_value=created) + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="admin"), + ) + monkeypatch.setattr( + conference_service.repository, "create_committee", create_committee + ) + + result = await conference_service.create_committee(session, user_id, 7, payload) + + assert result == created + create_committee.assert_awaited_once_with( + session=session, conference_id=7, payload=payload + ) + session.commit.assert_awaited_once() + + +@pytest.mark.anyio +async def test_enroll_member_forwards_an_authorized_request(monkeypatch): + session = AsyncMock() + user_id = uuid4() + payload = EnrollMember( + name="Ada Lovelace", email="ada@example.com", role="delegate" + ) + enrolled = {"id": 4, "email": payload.email, "role": payload.role} + enroll_member = AsyncMock(return_value=enrolled) + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="owner"), + ) + monkeypatch.setattr( + conference_service.repository, "enroll_member", enroll_member + ) + + result = await conference_service.enroll_member(session, user_id, 7, payload) + + assert result == enrolled + enroll_member.assert_awaited_once_with( + session=session, conference_id=7, payload=payload + ) + session.commit.assert_awaited_once() + + +@pytest.mark.anyio +async def test_create_conference_commits_after_creating_it(monkeypatch): + session = AsyncMock() + user_id = uuid4() + payload = ConferenceCreate( + name="WebMUN", + start_date=datetime.now(), + end_date=datetime.now(), + ) + create_conference = AsyncMock(return_value=7) + monkeypatch.setattr( + conference_service.repository, "create_conference", create_conference + ) + + conference_id = await conference_service.create_conference( + session, user_id, payload + ) + + assert conference_id == 7 + create_conference.assert_awaited_once_with( + session=session, user_id=user_id, payload=payload + ) + session.commit.assert_awaited_once() + + +@pytest.mark.anyio +async def test_get_user_conferences_returns_dashboard_summaries(monkeypatch): + session = object() + user_id = uuid4() + summaries = [ + { + "id": 7, + "name": "WebMUN", + "logo": None, + "color": "#0f172a", + "status": "planned", + "caller_role": "owner", + } + ] + get_user_conferences = AsyncMock(return_value=summaries) + monkeypatch.setattr( + conference_service.repository, "get_user_conferences", get_user_conferences + ) + + result = await conference_service.get_user_conferences(session, user_id) + + assert result == summaries + get_user_conferences.assert_awaited_once_with(session=session, user_id=user_id) diff --git a/backend/app/tests/session/test_views.py b/backend/app/tests/session/test_views.py index 3fc8a4f..15ceabf 100644 --- a/backend/app/tests/session/test_views.py +++ b/backend/app/tests/session/test_views.py @@ -68,7 +68,7 @@ def authenticated_websocket_dependencies(monkeypatch, chair_actor: SessionActor) ) monkeypatch.setattr( views.conference_service, - "resolve_assignment", + "resolve_session_assignment", AsyncMock(return_value=MagicMock()), ) prepare_connect = AsyncMock(return_value=chair_actor) diff --git a/frontend/src/context/SessionContext.tsx b/frontend/src/context/SessionContext.tsx index 2e37bd1..1103f5e 100644 --- a/frontend/src/context/SessionContext.tsx +++ b/frontend/src/context/SessionContext.tsx @@ -84,7 +84,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { const [representation_id, setRepresentation_id] = useState(null) useEffect(() => { - fetch(`${import.meta.env.VITE_API_URL}/access/sessions/${parsedSessionId}/me`, + fetch(`${import.meta.env.VITE_API_URL}/conferences/sessions/${parsedSessionId}/me`, { method: "GET", headers: { @@ -95,7 +95,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { ).then((response) => { if (!response.ok) throw new Error("Error when getting role") return response.json() - }).then((data: SessionRepresentation) => { setRole(data.role); setRepresentation_id(data.representation_id); }) + }).then((data: SessionRepresentation) => { + setRole(data.role); + setRepresentation_id(data.representation_id ?? null); + }) }, [parsedSessionId, token]) const value = { diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index 571a06b..194c8c0 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -4,6 +4,278 @@ export type ClientOptions = { baseUrl: 'http://localhost:8000' | (string & {}); }; +/** + * CommitteeCreate + * + * Schema for creating a committee + */ +export type CommitteeCreate = { + /** + * Name + */ + name: string; + /** + * Code + */ + code: string; + /** + * Logo + */ + logo?: string | null; + /** + * Topic + */ + topic?: string | null; + /** + * Status + */ + status?: string; +}; + +/** + * CommitteeResponse + * + * Schema for returning committee details + */ +export type CommitteeResponse = { + /** + * Id + */ + id: number; + /** + * Conference Id + */ + conference_id: number; + /** + * Name + */ + name: string; + /** + * Code + */ + code: string; + /** + * Logo + */ + logo?: string | null; + /** + * Topic + */ + topic?: string | null; + /** + * Status + */ + status: string; + /** + * Created At + */ + created_at?: string | null; +}; + +/** + * ConferenceAssignment + * + * Holds information about a user's role, enrollment, and representation. + */ +export type ConferenceAssignment = { + /** + * Id + */ + id?: number | null; + /** + * Conference Id + */ + conference_id?: number | null; + /** + * User Id + */ + user_id?: string | null; + /** + * Name + */ + name?: string | null; + /** + * Email + */ + email?: string | null; + /** + * Institution + */ + institution?: string | null; + /** + * Role + */ + role: string; + /** + * Committee Id + */ + committee_id?: number | null; + /** + * Representation Id + */ + representation_id?: number | null; + /** + * Created At + */ + created_at?: string | null; +}; + +/** + * ConferenceCreate + * + * Schema for conference creation + */ +export type ConferenceCreate = { + /** + * Name + */ + name: string; + /** + * Slug + */ + slug?: string | null; + /** + * Location + */ + location?: string | null; + /** + * Logo + */ + logo?: string | null; + /** + * Color + */ + color?: string; + /** + * Start Date + */ + start_date: string; + /** + * End Date + */ + end_date: string; +}; + +/** + * ConferenceDetail + * + * Detailed conference information including its committees + */ +export type ConferenceDetail = { + /** + * Id + */ + id: number; + /** + * Name + */ + name: string; + /** + * Slug + */ + slug?: string | null; + /** + * Status + */ + status: string; + /** + * Owner Id + */ + owner_id: string; + /** + * Location + */ + location?: string | null; + /** + * Logo + */ + logo?: string | null; + /** + * Color + */ + color: string; + /** + * Start Date + */ + start_date: string; + /** + * End Date + */ + end_date: string; + /** + * Caller Role + */ + caller_role?: string | null; + /** + * Committees + */ + committees?: Array; +}; + +/** + * ConferenceSummary + * + * Conference data needed to select a conference in the dashboard. + */ +export type ConferenceSummary = { + /** + * Id + */ + id: number; + /** + * Name + */ + name: string; + /** + * Logo + */ + logo?: string | null; + /** + * Color + */ + color: string; + /** + * Status + */ + status: string; + /** + * Caller Role + */ + caller_role: string; +}; + +/** + * EnrollMember + * + * Schema for enrolling/assigning a user into a conference + */ +export type EnrollMember = { + /** + * Name + */ + name: string; + /** + * Email + */ + email: string; + /** + * Institution + */ + institution?: string | null; + /** + * Role + */ + role?: string; + /** + * Committee Id + */ + committee_id?: number | null; + /** + * Representation Id + */ + representation_id?: number | null; +}; + /** * HTTPValidationError */ @@ -32,22 +304,28 @@ export type SessionCreationSchema = { /** * SessionRepresentation + * + * Session-specific access context for the authenticated user. */ export type SessionRepresentation = { role: SessionRoles; /** * Representation Id */ - representation_id: number | null; + representation_id?: number | null; }; /** * SessionRoles + * + * Roles available to a participant in an active committee session. */ export const SessionRoles = { CHAIR: 'chair', DELEGATE: 'delegate' } as const; /** * SessionRoles + * + * Roles available to a participant in an active committee session. */ export type SessionRoles = typeof SessionRoles[keyof typeof SessionRoles]; @@ -1320,44 +1598,176 @@ export type ServerSessionMessage = ({ type: 'dispatch_result'; } & DispatchResultMessage); -export type HealthCommitteesHealthGetData = { +export type GetUserConferencesConferencesGetData = { body?: never; path?: never; query?: never; - url: '/committees/health'; + url: '/conferences/'; }; -export type HealthCommitteesHealthGetResponses = { +export type GetUserConferencesConferencesGetResponses = { /** + * Response Get User Conferences Conferences Get + * * Successful Response */ - 200: unknown; + 200: Array; }; -export type CreateSessionEndpointCommitteesPostData = { - body: SessionCreationSchema; +export type GetUserConferencesConferencesGetResponse = GetUserConferencesConferencesGetResponses[keyof GetUserConferencesConferencesGetResponses]; + +export type CreateConferenceConferencesPostData = { + body: ConferenceCreate; path?: never; query?: never; - url: '/committees/'; + url: '/conferences/'; }; -export type CreateSessionEndpointCommitteesPostErrors = { +export type CreateConferenceConferencesPostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type CreateSessionEndpointCommitteesPostError = CreateSessionEndpointCommitteesPostErrors[keyof CreateSessionEndpointCommitteesPostErrors]; +export type CreateConferenceConferencesPostError = CreateConferenceConferencesPostErrors[keyof CreateConferenceConferencesPostErrors]; -export type CreateSessionEndpointCommitteesPostResponses = { +export type CreateConferenceConferencesPostResponses = { /** + * Response Create Conference Conferences Post + * * Successful Response */ - 200: unknown; + 201: { + [key: string]: unknown; + }; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostData = { +export type CreateConferenceConferencesPostResponse = CreateConferenceConferencesPostResponses[keyof CreateConferenceConferencesPostResponses]; + +export type GetConferenceInfoConferencesIdGetData = { + body?: never; + path: { + /** + * Id + */ + id: number; + }; + query?: never; + url: '/conferences/{id}'; +}; + +export type GetConferenceInfoConferencesIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetConferenceInfoConferencesIdGetError = GetConferenceInfoConferencesIdGetErrors[keyof GetConferenceInfoConferencesIdGetErrors]; + +export type GetConferenceInfoConferencesIdGetResponses = { + /** + * Successful Response + */ + 200: ConferenceDetail; +}; + +export type GetConferenceInfoConferencesIdGetResponse = GetConferenceInfoConferencesIdGetResponses[keyof GetConferenceInfoConferencesIdGetResponses]; + +export type CreateCommitteeConferencesIdCommitteesPostData = { + body: CommitteeCreate; + path: { + /** + * Id + */ + id: number; + }; + query?: never; + url: '/conferences/{id}/committees'; +}; + +export type CreateCommitteeConferencesIdCommitteesPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateCommitteeConferencesIdCommitteesPostError = CreateCommitteeConferencesIdCommitteesPostErrors[keyof CreateCommitteeConferencesIdCommitteesPostErrors]; + +export type CreateCommitteeConferencesIdCommitteesPostResponses = { + /** + * Successful Response + */ + 201: CommitteeResponse; +}; + +export type CreateCommitteeConferencesIdCommitteesPostResponse = CreateCommitteeConferencesIdCommitteesPostResponses[keyof CreateCommitteeConferencesIdCommitteesPostResponses]; + +export type ListConferenceMembersConferencesConferenceIdMembersGetData = { + body?: never; + path: { + /** + * Conference Id + */ + conference_id: number; + }; + query?: never; + url: '/conferences/{conference_id}/members'; +}; + +export type ListConferenceMembersConferencesConferenceIdMembersGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListConferenceMembersConferencesConferenceIdMembersGetError = ListConferenceMembersConferencesConferenceIdMembersGetErrors[keyof ListConferenceMembersConferencesConferenceIdMembersGetErrors]; + +export type ListConferenceMembersConferencesConferenceIdMembersGetResponses = { + /** + * Response List Conference Members Conferences Conference Id Members Get + * + * Successful Response + */ + 200: Array; +}; + +export type ListConferenceMembersConferencesConferenceIdMembersGetResponse = ListConferenceMembersConferencesConferenceIdMembersGetResponses[keyof ListConferenceMembersConferencesConferenceIdMembersGetResponses]; + +export type EnrollMemberConferencesConferenceIdMembersPostData = { + body: EnrollMember; + path: { + /** + * Conference Id + */ + conference_id: number; + }; + query?: never; + url: '/conferences/{conference_id}/members'; +}; + +export type EnrollMemberConferencesConferenceIdMembersPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type EnrollMemberConferencesConferenceIdMembersPostError = EnrollMemberConferencesConferenceIdMembersPostErrors[keyof EnrollMemberConferencesConferenceIdMembersPostErrors]; + +export type EnrollMemberConferencesConferenceIdMembersPostResponses = { + /** + * Successful Response + */ + 201: ConferenceAssignment; +}; + +export type EnrollMemberConferencesConferenceIdMembersPostResponse = EnrollMemberConferencesConferenceIdMembersPostResponses[keyof EnrollMemberConferencesConferenceIdMembersPostResponses]; + +export type GetMySessionAccessConferencesSessionsSessionIdMeGetData = { body?: never; path: { /** @@ -1366,28 +1776,65 @@ export type ActivateSessionEndpointCommitteesSessionIdActivatePostData = { session_id: number; }; query?: never; - url: '/committees/{session_id}/activate'; + url: '/conferences/sessions/{session_id}/me'; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostErrors = { +export type GetMySessionAccessConferencesSessionsSessionIdMeGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostError = ActivateSessionEndpointCommitteesSessionIdActivatePostErrors[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostErrors]; +export type GetMySessionAccessConferencesSessionsSessionIdMeGetError = GetMySessionAccessConferencesSessionsSessionIdMeGetErrors[keyof GetMySessionAccessConferencesSessionsSessionIdMeGetErrors]; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponses = { +export type GetMySessionAccessConferencesSessionsSessionIdMeGetResponses = { /** * Successful Response */ - 204: void; + 200: SessionRepresentation; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponse = ActivateSessionEndpointCommitteesSessionIdActivatePostResponses[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostResponses]; +export type GetMySessionAccessConferencesSessionsSessionIdMeGetResponse = GetMySessionAccessConferencesSessionsSessionIdMeGetResponses[keyof GetMySessionAccessConferencesSessionsSessionIdMeGetResponses]; -export type GetMySessionAccessAccessSessionsSessionIdMeGetData = { +export type HealthCommitteesHealthGetData = { + body?: never; + path?: never; + query?: never; + url: '/committees/health'; +}; + +export type HealthCommitteesHealthGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type CreateSessionEndpointCommitteesPostData = { + body: SessionCreationSchema; + path?: never; + query?: never; + url: '/committees/'; +}; + +export type CreateSessionEndpointCommitteesPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateSessionEndpointCommitteesPostError = CreateSessionEndpointCommitteesPostErrors[keyof CreateSessionEndpointCommitteesPostErrors]; + +export type CreateSessionEndpointCommitteesPostResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ActivateSessionEndpointCommitteesSessionIdActivatePostData = { body?: never; path: { /** @@ -1396,23 +1843,23 @@ export type GetMySessionAccessAccessSessionsSessionIdMeGetData = { session_id: number; }; query?: never; - url: '/access/sessions/{session_id}/me'; + url: '/committees/{session_id}/activate'; }; -export type GetMySessionAccessAccessSessionsSessionIdMeGetErrors = { +export type ActivateSessionEndpointCommitteesSessionIdActivatePostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type GetMySessionAccessAccessSessionsSessionIdMeGetError = GetMySessionAccessAccessSessionsSessionIdMeGetErrors[keyof GetMySessionAccessAccessSessionsSessionIdMeGetErrors]; +export type ActivateSessionEndpointCommitteesSessionIdActivatePostError = ActivateSessionEndpointCommitteesSessionIdActivatePostErrors[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostErrors]; -export type GetMySessionAccessAccessSessionsSessionIdMeGetResponses = { +export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponses = { /** * Successful Response */ - 200: SessionRepresentation; + 204: void; }; -export type GetMySessionAccessAccessSessionsSessionIdMeGetResponse = GetMySessionAccessAccessSessionsSessionIdMeGetResponses[keyof GetMySessionAccessAccessSessionsSessionIdMeGetResponses]; +export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponse = ActivateSessionEndpointCommitteesSessionIdActivatePostResponses[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostResponses]; From fd6dfa720aafc3be207d6a24717e01bf14094f59 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:09:37 -0300 Subject: [PATCH 12/12] fix(supabase): repair reset-only development seed Co-authored-by: Codex --- supabase/seed.sql | 118 ++++++++++++++++++---------------------------- 1 file changed, 47 insertions(+), 71 deletions(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index feaabc2..56667c8 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,16 +1,26 @@ --- default conference +-- Reset-only local development seed. +-- This file assumes `supabase db reset`: generated public-table IDs start at 1. +-- Do not run it against an existing database. + +begin; insert into public.conferences - (name, status, owner_id) -values - ('I WebMUN', 'active', '11111111-1111-1111-1111-111111111111'); + (name, status, owner_id, start_date, end_date) +values + ( + 'I WebMUN', + 'active', + '11111111-1111-1111-1111-111111111111', + timestamptz '2026-08-01 09:00:00+00', + timestamptz '2026-08-03 18:00:00+00' + ); -insert into public.committees - (conference_id, name, status) -values - ((select id from conferences where name = 'I WebMUN'), 'CSNU', 'planned'); +-- Conference ID 1 +insert into public.committees (conference_id, name, code, status) +values (1, 'CSNU', 'CSNU', 'planned'); -insert into public.representations (name, code, identifier) values +-- Representation IDs 1–21, in the same order as the seat maps below. +insert into public.representations (name, code, identifier) values ('Albânia', 'al', 'al'), ('Alemanha', 'de', 'de'), ('Austrália', 'au', 'au'), @@ -33,72 +43,38 @@ insert into public.representations (name, code, identifier) values ('Taiwan', 'tw', 'tw'), ('Turquia', 'tr', 'tr'); -insert into layouts - (name, conference_id, committee_id) -values - ('Standard 21 Room', null, null); +-- Layout ID 1 +insert into public.layouts (name, conference_id, committee_id) +values ('Standard 21 Room', null, null); -insert into layout_seats - (layout_id, representation_id, seat_label) -values - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'al'), '3-4'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'de'), '2-5'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'au'), '2-4'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'br'), '3-2'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'cn'), '1-6'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'kr'), '3-5'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ae'), '3-9'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'us'), '1-2'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ph'), '2-2'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'fr'), '1-4'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'gt'), '3-7'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'hk'), '3-1'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'in'), '3-6'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'id'), '3-3'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'jp'), '2-1'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'my'), '2-3'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'gb'), '1-3'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ru'), '1-5'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ch'), '3-8'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'tw'), '1-1'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'tr'), '2-6'); +insert into public.layout_seats (layout_id, representation_id, seat_label) values + (1, 1, '3-4'), (1, 2, '2-5'), (1, 3, '2-4'), (1, 4, '3-2'), + (1, 5, '1-6'), (1, 6, '3-5'), (1, 7, '3-9'), (1, 8, '1-2'), + (1, 9, '2-2'), (1, 10, '1-4'), (1, 11, '3-7'), (1, 12, '3-1'), + (1, 13, '3-6'), (1, 14, '3-3'), (1, 15, '2-1'), (1, 16, '2-3'), + (1, 17, '1-3'), (1, 18, '1-5'), (1, 19, '3-8'), (1, 20, '1-1'), + (1, 21, '2-6'); -insert into committee_seats - (committee_id, representation_id, seat_label) -values - (1, (select id from representations where code = 'al'), '3-4'), - (1, (select id from representations where code = 'de'), '2-5'), - (1, (select id from representations where code = 'au'), '2-4'), - (1, (select id from representations where code = 'br'), '3-2'), - (1, (select id from representations where code = 'cn'), '1-6'), - (1, (select id from representations where code = 'kr'), '3-5'), - (1, (select id from representations where code = 'ae'), '3-9'), - (1, (select id from representations where code = 'us'), '1-2'), - (1, (select id from representations where code = 'ph'), '2-2'), - (1, (select id from representations where code = 'fr'), '1-4'), - (1, (select id from representations where code = 'gt'), '3-7'), - (1, (select id from representations where code = 'hk'), '3-1'), - (1, (select id from representations where code = 'in'), '3-6'), - (1, (select id from representations where code = 'id'), '3-3'), - (1, (select id from representations where code = 'jp'), '2-1'), - (1, (select id from representations where code = 'my'), '2-3'), - (1, (select id from representations where code = 'gb'), '1-3'), - (1, (select id from representations where code = 'ru'), '1-5'), - (1, (select id from representations where code = 'ch'), '3-8'), - (1, (select id from representations where code = 'tw'), '1-1'), - (1, (select id from representations where code = 'tr'), '2-6'); - -insert into public.conference_assignments - (conference_id, user_id, name, email, role, committee_id, representation_id) +-- Committee ID 1 +insert into public.committee_seats + (committee_id, representation_id, seat_label) values - ((select id from conferences where name = 'I WebMUN'), '11111111-1111-1111-1111-111111111111', 'Chair Person', 'chair@codelab.usp.br', 'chair', 1, null), - ((select id from conferences where name = 'I WebMUN'), '22222222-2222-2222-2222-222222222222', 'Delegate Albania', 'albania@codelab.usp.br', 'participant', 1, (select id from representations where code = 'al')), - ((select id from conferences where name = 'I WebMUN'), '33333333-3333-3333-3333-333333333333', 'Delegate Germany', 'alemanha@codelab.usp.br', 'participant', 1, (select id from representations where code = 'de')), - ((select id from conferences where name = 'I WebMUN'), '44444444-4444-4444-4444-444444444444', 'Delegate Brazil', 'brazil@codelab.usp.br', 'participant', 1, (select id from representations where code = 'br')); + (1, 1, '3-4'), (1, 2, '2-5'), (1, 3, '2-4'), (1, 4, '3-2'), + (1, 5, '1-6'), (1, 6, '3-5'), (1, 7, '3-9'), (1, 8, '1-2'), + (1, 9, '2-2'), (1, 10, '1-4'), (1, 11, '3-7'), (1, 12, '3-1'), + (1, 13, '3-6'), (1, 14, '3-3'), (1, 15, '2-1'), (1, 16, '2-3'), + (1, 17, '1-3'), (1, 18, '1-5'), (1, 19, '3-8'), (1, 20, '1-1'), + (1, 21, '2-6'); -insert into public.sessions - (committee_id) +insert into public.conference_assignments + (conference_id, user_id, name, email, role, committee_id, representation_id) values - (1); + (1, '11111111-1111-1111-1111-111111111111', 'Chair Person', 'chair@codelab.usp.br', 'chair', 1, null), + (1, '22222222-2222-2222-2222-222222222222', 'Delegate Albania', 'albania@codelab.usp.br', 'participant', 1, 1), + (1, '33333333-3333-3333-3333-333333333333', 'Delegate Germany', 'alemanha@codelab.usp.br', 'participant', 1, 2), + (1, '44444444-4444-4444-4444-444444444444', 'Delegate Brazil', 'brazil@codelab.usp.br', 'participant', 1, 4); +-- Session ID 1 +insert into public.sessions (committee_id) values (1); +commit;