From 38189537d7f39ae614d333ea287399711c9df093 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:31:13 -0300 Subject: [PATCH 1/8] refactor(backend): add WebSocket message outcomes --- backend/app/session/engine.py | 112 +++++++++++--------- backend/app/session/enums.py | 4 + backend/app/session/manager.py | 22 ++-- backend/app/session/models.py | 11 ++ backend/app/session/schemas.py | 46 +++++++- backend/app/session/service.py | 19 ++-- backend/app/session/views.py | 21 +++- backend/app/tests/conftest.py | 3 +- backend/app/tests/session/test_engine.py | 62 ++++++----- backend/app/tests/session/test_service.py | 14 ++- backend/app/tests/session/test_websocket.py | 45 ++++---- 11 files changed, 226 insertions(+), 133 deletions(-) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index e1705cc..ecd5c22 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -20,10 +20,12 @@ AgendaItem, DebateContext, DelegationContext, + DispatchOutcome, MotionContext, QuestionContext, RollCallContext, SessionActor, + SessionEffect, SessionLiveState, VotingContext, ) @@ -253,7 +255,7 @@ def require_chair(actor: SessionActor) -> None: # -------------- HANDLERS -------------- def handle_submit_motion( state: SessionLiveState, event: schemas.SubmitMotionEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: """Handles/Maps all possible states through a motion""" # Extract payload (as DelegateMotionSchema) payload = event.payload @@ -286,12 +288,12 @@ def handle_submit_motion( ) state.submitted_motions.append(context) - return state + return DispatchOutcome(state=state) def handle_submit_question( state: SessionLiveState, event: schemas.SubmitQuestionEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: # TODO: change this so Chair can also catalog motions for delegations require_delegate(actor) @@ -306,12 +308,12 @@ def handle_submit_question( details=payload.details, ) state.submitted_questions.append(context) - return state + return DispatchOutcome(state=state) def handle_join_queue( state: SessionLiveState, event: schemas.JoinQueueEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: delegate = require_delegate(actor) if state.current_state != States.OPEN_GSL: @@ -322,12 +324,12 @@ def handle_join_queue( raise InvalidProceduralMove("Already in Queue") state.gsl_queue.append(delegate.id) - return state + return DispatchOutcome(state=state) def handle_leave_queue( state: SessionLiveState, event: schemas.LeaveQueueEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: delegate = require_delegate(actor) if state.current_state != States.OPEN_GSL: @@ -337,12 +339,12 @@ def handle_leave_queue( raise InvalidProceduralMove("Not in Queue") state.gsl_queue.remove(delegate.id) - return state + return DispatchOutcome(state=state) def handle_cast_vote( state: SessionLiveState, event: schemas.CastVoteEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: delegate = require_delegate(actor) voting_context = state.voting @@ -357,25 +359,25 @@ def handle_cast_vote( # register vote on voting context voting_context.voting_registry[delegate.id] = event.payload.vote - return state + return DispatchOutcome(state=state) def handle_answer_roll_call( state: SessionLiveState, event: schemas.AnswerRollCallEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: delegate = require_delegate(actor) if state.current_state != States.ROLL_CALL or state.roll_call is None: raise InvalidProceduralMove("Roll call not available now") state.roll_call.registry[delegate.id] = event.payload.choice - return state + return DispatchOutcome(state=state) # Chair events def handle_open_session( state: SessionLiveState, event: schemas.OpenSessionEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) if state.current_state != States.SETUP: @@ -391,12 +393,12 @@ def handle_open_session( state.timer_expiration = None state.timer_remaining_seconds = 0 - return state + return DispatchOutcome(state=state) def handle_close_session( state: SessionLiveState, event: schemas.CloseSessionEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) @@ -414,13 +416,13 @@ def handle_close_session( state.timer_expiration = None state.timer_remaining_seconds = 0 - return state + return DispatchOutcome(state=state) # TODO: create helpers for timers -> stop_timer, set_timer, pause_timer, etc def handle_toggle_timer( state: SessionLiveState, event: schemas.ToggleTimerEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) # uses utc for now @@ -443,12 +445,12 @@ def handle_toggle_timer( state.timer_is_running = True state.timer_expiration = now + timedelta(seconds=state.timer_remaining_seconds) - return state + return DispatchOutcome(state=state) def handle_increase_timer( state: SessionLiveState, event: schemas.IncreaseTimerEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) now = datetime.now(UTC) @@ -461,12 +463,12 @@ def handle_increase_timer( else: state.timer_remaining_seconds += event.payload.seconds - return state + return DispatchOutcome(state=state) def handle_open_informal_voting( state: SessionLiveState, event: schemas.OpenInformalVotingEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) if state.current_state == States.VOTING_EXECUTION: @@ -483,14 +485,14 @@ def handle_open_informal_voting( state.current_state = States.VOTING_EXECUTION - return state + return DispatchOutcome(state=state) def handle_close_informal_voting( state: SessionLiveState, event: schemas.CloseInformalVotingEvent, actor: SessionActor, -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) if state.voting is None: @@ -507,14 +509,14 @@ def handle_close_informal_voting( state.voting = None - return state + return DispatchOutcome(state=state) def handle_close_procedural_voting( state: SessionLiveState, event: schemas.CloseProceduralVotingEvent, actor: SessionActor, -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) if state.voting is None: @@ -533,6 +535,20 @@ def handle_close_procedural_voting( present = count_present_delegations(state) passed = tally_votes(state.voting, present) + effect = SessionEffect( + type=enums.SessionEffectType.VOTE_CLOSED, + data={ + "present": present, + "favour": len( + [ + True + for _, vote in state.voting.voting_registry.items() + if vote == enums.VotingChoice.FAVOUR + ] + ), + "passed": passed, + }, + ) # TODO: pass everything here into a helper "apply_passed_motion" and "apply_change_debate" if passed: @@ -638,13 +654,13 @@ def handle_close_procedural_voting( # clear state voting state.voting = None - return state + return DispatchOutcome(state=state, effect=effect) # handles setting state into VOTING_EXECUTION or rejecting the motion def handle_resolve_motion( state: SessionLiveState, event: schemas.ResolveMotionEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: # TODO: check how to resolve INTRODUCE_RESOLUTION_PROPOSAL and INTRODUCE_AMENDMENT_PROPOSAL motions separately from procedural motions require_chair(actor) @@ -669,17 +685,17 @@ def handle_resolve_motion( state.submitted_motions.remove(motion) - return state + return DispatchOutcome(state=state) def handle_set_agenda( state: SessionLiveState, event: schemas.SetAgendaEvent, actor: SessionActor -) -> SessionLiveState: ... +) -> DispatchOutcome: ... def handle_mark_agenda_item( state: SessionLiveState, event: schemas.MarkAgendaItemEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: if event.payload.discussed is not None: state.agenda_topics[ event.payload.index @@ -689,38 +705,38 @@ def handle_mark_agenda_item( state.active_topic_index = event.payload.index else: state.active_topic_index = None - return state + return DispatchOutcome(state=state) def handle_set_agenda_item( state: SessionLiveState, event: schemas.SetAgendaItemEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: item = AgendaItem( index=event.payload.index, topic=event.payload.topic, already_discussed=False ) state.agenda_topics[event.payload.index] = item - return state + return DispatchOutcome(state=state) def handle_delete_agenda_item( state: SessionLiveState, event: schemas.DeleteAgendaItemEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: state.agenda_topics.pop(event.payload.index) if state.active_topic_index == event.payload.index: state.active_topic_index = None - return state + return DispatchOutcome(state=state) def handle_manual_phase_set( state: SessionLiveState, event: schemas.SetPhaseEvent, actor: SessionActor -) -> SessionLiveState: ... +) -> DispatchOutcome: ... def handle_choose_speaker( state: SessionLiveState, event: schemas.SpeakerEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) seconds = event.payload.seconds or get_default_speaker_seconds(state) @@ -737,12 +753,12 @@ def handle_choose_speaker( state.timer_expiration = None # will be calculated when timer is toggled state.timer_remaining_seconds = seconds or 60 # default to something - return state + return DispatchOutcome(state=state) def handle_mark_roll_call( state: SessionLiveState, event: schemas.MarkRollCallEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) if state.current_state != States.ROLL_CALL or state.roll_call is None: raise InvalidProceduralMove("Cannot mark roll call right now") @@ -752,12 +768,12 @@ def handle_mark_roll_call( state.roll_call.registry[event.payload.delegation_id] = event.payload.choice - return state + return DispatchOutcome(state=state) def handle_mark_roll_call_bulk( state: SessionLiveState, event: schemas.MarkRollCallBulkEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) if state.current_state != States.ROLL_CALL or state.roll_call is None: raise InvalidProceduralMove("Cannot mark roll call right now") @@ -768,12 +784,12 @@ def handle_mark_roll_call_bulk( state.roll_call.registry.update(event.payload.Rollcalls) - return state + return DispatchOutcome(state=state) def handle_close_roll_call( state: SessionLiveState, event: schemas.CloseRollCallEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) if state.current_state != States.ROLL_CALL or state.roll_call is None: raise InvalidProceduralMove("Cannot close roll call right now") @@ -792,25 +808,25 @@ def handle_close_roll_call( for delegation_id, choice in state.roll_call.registry.items() if choice in {RollCallChoice.PRESENT, RollCallChoice.PRESENT_AND_VOTING} } - return state + return DispatchOutcome(state=state) def handle_insert_queue( state: SessionLiveState, event: schemas.ChairInsertQueueEvent, actor: SessionActor -) -> SessionLiveState: +) -> DispatchOutcome: require_chair(actor) del_id: int = event.payload.target delegate = state.delegations.get(del_id) if delegate is None: raise InvalidProceduralMove("Delegate not found") state.gsl_queue.append(delegate.id) - return state + return DispatchOutcome(state=state) # Signature for events/handlers, uses legacy(ish) 3.11 TypeAlias EventHandler: TypeAlias = Callable[ [SessionLiveState, Any, SessionActor], # overall signature - SessionLiveState, # Return type + DispatchOutcome, # Return type ] # TODO: check if list has all events, since it's generated by codex @@ -846,7 +862,7 @@ class SessionEngine: # function to calculate new state over old one def dispatch( self, state: SessionLiveState, event: schemas.SessionEvent, actor: SessionActor - ) -> SessionLiveState: + ) -> DispatchOutcome: handler = EVENT_HANDLERS.get(event.type) if handler is None: diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py index aaa0116..cf6a383 100644 --- a/backend/app/session/enums.py +++ b/backend/app/session/enums.py @@ -1,6 +1,10 @@ from enum import StrEnum +class SessionEffectType(StrEnum): + VOTE_CLOSED = "Vote Closed" + + # --- States --- class States(StrEnum): # Normal flow of states diff --git a/backend/app/session/manager.py b/backend/app/session/manager.py index b099405..c63c63a 100644 --- a/backend/app/session/manager.py +++ b/backend/app/session/manager.py @@ -4,6 +4,8 @@ from fastapi import WebSocket +from app.session.schemas import ServerSessionMessage, StateSnapshotMessage + from .models import SessionActor, SessionLiveState @@ -17,11 +19,11 @@ def __init__(self): async def connect(self, websocket: WebSocket, session_id: int, actor: SessionActor): self.active_connections.setdefault(session_id, {})[websocket] = actor - # when someone connects, send current state as SessionLiveState + # Send the initial state through the same typed protocol as later updates. if session_id in self.room_states: - # TODO: check if it's better to create with mode='json' or model_dump_json() + state = self.room_states[session_id] await websocket.send_json( - self.room_states[session_id].model_dump(mode="json") + StateSnapshotMessage(state=state).model_dump(mode="json") ) def disconnect(self, websocket: WebSocket, session_id: int): @@ -40,14 +42,12 @@ def count_present_delegations(self, session_id: int) -> int: {actor.delegation.id for actor in actors if actor.delegation is not None} ) - # More things from connection manager here - async def broadcast_state(self, session_id: int): + async def broadcast_message(self, session_id: int, message: ServerSessionMessage): """Sends current state to all clients in the room""" - state = self.room_states.get(session_id) - if not state: - return - for connection in self.active_connections[session_id]: - await connection.send_json(state.model_dump(mode="json")) + await connection.send_json(message.model_dump(mode="json")) - # TODO: add broadcast_event so we send only the event + deltas (fields changed)/event only, or keep broadcasting entire state + async def send_message(self, session_id: int, message: ServerSessionMessage, websocket: WebSocket): + """Sends a ServerSessionMessage to connected socket""" + if self.active_connections.get(session_id, {}).get(websocket): + await websocket.send_json(message.model_dump(mode="json")) diff --git a/backend/app/session/models.py b/backend/app/session/models.py index e539e16..487da89 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -2,6 +2,7 @@ # Even though it's internal, some things may be sent out to public (TODO:like SessionLiveState) from dataclasses import dataclass from datetime import datetime +from typing import Any from uuid import UUID from pydantic import BaseModel @@ -140,3 +141,13 @@ class SessionLiveState(BaseModel): # Additional config has_veto_power: bool = False + + +class SessionEffect(BaseModel): + type: enums.SessionEffectType + data: dict[str, Any] = {} + + +class DispatchOutcome(BaseModel): + state: SessionLiveState + effect: SessionEffect | None = None diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index 239a93c..57e53b2 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -1,12 +1,13 @@ from typing import Annotated, Literal +from uuid import UUID from pydantic import BaseModel, Field import app.session.enums as enums - -# --- General Schemas --- +import app.session.models as models +# --- General Schemas --- class SessionCreationSchema(BaseModel): """Schema to create a session. Follows a DB schema + extra configs format""" @@ -260,3 +261,44 @@ class DeleteAgendaItemEvent(BaseModel): | MarkRollCallBulkEvent, Field(discriminator="type"), ] + + +# --- Messages --- +class AuthenticateMessage(BaseModel): + type: Literal["authenticate"] = "authenticate" + access_token: str + + +class EventMessage(BaseModel): + type: Literal["event"] = "event" + request_id: UUID + event: SessionEvent + + +# Server sent messages +class StateSnapshotMessage(BaseModel): + type: Literal["state_snapshot"] = "state_snapshot" + state: models.SessionLiveState + + +class DispatchResultMessage(BaseModel): + type: Literal["dispatch_result"] = "dispatch_result" + state: models.SessionLiveState + effect: models.SessionEffect | None = None + + +class EventResultMessage(BaseModel): + type: Literal["event_result"] = "event_result" + request_id: UUID + ok: bool + # TODO: error: EventError | None = None + + +ClientSessionMessage = Annotated[ + AuthenticateMessage | EventMessage, Field(discriminator="type") +] + +ServerSessionMessage = Annotated[ + StateSnapshotMessage | EventResultMessage | DispatchResultMessage, + Field(discriminator="type"), +] diff --git a/backend/app/session/service.py b/backend/app/session/service.py index 414aa77..0e6a6b8 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -7,7 +7,6 @@ from datetime import datetime from uuid import UUID -from pydantic import TypeAdapter from sqlalchemy.ext.asyncio import AsyncSession import app.session.enums as enums @@ -209,19 +208,17 @@ async def handle_client_messages( logger: logging.Logger, session_id: int, actor: SessionActor, - data, + data: schemas.EventMessage, ): - adapter = TypeAdapter(schemas.SessionEvent) - - # if schema is None: - # raise ValueError("Unsupported event type") - - event = adapter.validate_json(data) + event = data.event state = manager.room_states[session_id] logger.info(event) # Debugging - new_state = engine.dispatch(state, event, actor) - manager.room_states[session_id] = new_state + dispatch_outcome = engine.dispatch(state, event, actor) + message = schemas.DispatchResultMessage( + state=dispatch_outcome.state, effect=dispatch_outcome.effect + ) + manager.room_states[session_id] = dispatch_outcome.state - await manager.broadcast_state(session_id) + await manager.broadcast_message(session_id, message) diff --git a/backend/app/session/views.py b/backend/app/session/views.py index ab3fb2a..7774add 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -13,6 +13,7 @@ status, ) from fastapi.exceptions import HTTPException +from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession import app.access.service as access @@ -33,7 +34,12 @@ from app.session.enums import ChairEvents, DelegateEvents from app.session.manager import ConnectionManager from app.session.models import SessionLiveState -from app.session.schemas import SessionCreationSchema, SessionEvent +from app.session.schemas import ( + AuthenticateMessage, + EventMessage, + SessionCreationSchema, + SessionEvent, +) router = APIRouter() @@ -153,9 +159,11 @@ async def websocket_endpoint( await websocket.accept() try: auth_message = await websocket.receive_json() + validated_auth_data = AuthenticateMessage.model_validate(auth_message) + # we use pure verify_jwt_token due to websocket not handling bearer-header support auth_user = verify_jwt_token( - settings=settings, token=auth_message["access_token"] + settings=settings, token=validated_auth_data.access_token ) # The session determines its committee; never accept it from the client. @@ -175,14 +183,16 @@ async def websocket_endpoint( await manager.connect(websocket, session_id, actor) try: while True: - data = await websocket.receive_text() + data = await websocket.receive_json() + validated_event = EventMessage.model_validate(data) + await service.handle_client_messages( manager=manager, engine=engine, logger=logger, session_id=session_id, actor=actor, - data=data, + data=validated_event, ) except WebSocketDisconnect: @@ -196,6 +206,7 @@ async def websocket_endpoint( AccessDenied, service.ActorResolutionError, service.SessionFetchError, + ValidationError, ) as exc: if isinstance(exc, WebSocketDisconnect): reason = "websocket_disconnect" @@ -207,6 +218,8 @@ async def websocket_endpoint( reason = "access_denied" elif isinstance(exc, service.SessionFetchError): reason = "session_unavailable" + elif isinstance(exc, ValidationError): + reason = "invalid_json" else: reason = "actor_resolution_error" diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 13cc326..9fc51e1 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -13,6 +13,7 @@ from app.session.manager import ConnectionManager from app.session.models import ( DelegationContext, + DispatchOutcome, RollCallContext, SessionActor, SessionLiveState, @@ -77,7 +78,7 @@ def dispatch(self, state, event, actor): "actor": actor, } state.current_state = States.OPEN_GSL - return state + return DispatchOutcome(state=state, effect=None) @pytest.fixture diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index 295b7e6..75ae043 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -254,7 +254,9 @@ def test_delegate_can_submit_motion_in_open_gsl( submit_debate_motion_event: sch.SubmitMotionEvent, delegate_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, submit_debate_motion_event, delegate_actor) + state = engine.dispatch( + open_gsl_state, submit_debate_motion_event, delegate_actor + ).state assert len(state.submitted_motions) == 1 assert state.submitted_motions[0].id == 1 @@ -292,7 +294,7 @@ def test_delegate_can_submit_question( submit_question_event: sch.SubmitQuestionEvent, delegate_actor: md.SessionActor, ) -> None: - state = engine.dispatch(session_state, submit_question_event, delegate_actor) + state = engine.dispatch(session_state, submit_question_event, delegate_actor).state assert len(state.submitted_questions) == 1 assert state.submitted_questions[0].id == 1 @@ -306,7 +308,7 @@ def test_delegate_can_join_queue( join_queue_event: sch.JoinQueueEvent, delegate_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, join_queue_event, delegate_actor) + state = engine.dispatch(open_gsl_state, join_queue_event, delegate_actor).state assert state.gsl_queue == [0] @@ -351,7 +353,7 @@ def test_delegate_can_leave_queue( ) -> None: open_gsl_state.gsl_queue.append(0) - state = engine.dispatch(open_gsl_state, leave_queue_event, delegate_actor) + state = engine.dispatch(open_gsl_state, leave_queue_event, delegate_actor).state assert state.gsl_queue == [] @@ -372,7 +374,7 @@ def test_delegate_can_cast_vote( cast_vote_event: sch.CastVoteEvent, delegate_actor: md.SessionActor, ) -> None: - state = engine.dispatch(voting_state, cast_vote_event, delegate_actor) + state = engine.dispatch(voting_state, cast_vote_event, delegate_actor).state assert state.voting is not None assert state.voting.voting_registry == {0: enums.VotingChoice.FAVOUR} @@ -408,7 +410,7 @@ def test_delegate_can_answer_roll_call( ) -> None: session_state.current_state = enums.States.ROLL_CALL - state = engine.dispatch(session_state, answer_roll_call_event, delegate_actor) + state = engine.dispatch(session_state, answer_roll_call_event, delegate_actor).state assert state.roll_call.registry == {0: enums.RollCallChoice.PRESENT} @@ -426,7 +428,7 @@ def test_chair_can_close_roll_call( 3: enums.RollCallChoice.ABSENT, } - state = engine.dispatch(session_state, close_roll_call_event, chair_actor) + state = engine.dispatch(session_state, close_roll_call_event, chair_actor).state assert state.current_state == enums.States.OPEN_GSL assert state.voting_choice == { @@ -455,7 +457,7 @@ def test_chair_can_toggle_timer( ) -> None: session_state.timer_remaining_seconds = 30 - state = engine.dispatch(session_state, toggle_timer_event, chair_actor) + state = engine.dispatch(session_state, toggle_timer_event, chair_actor).state assert state.timer_is_running is True assert state.timer_expiration is not None @@ -479,7 +481,7 @@ def test_chair_can_increase_paused_timer( ) -> None: session_state.timer_remaining_seconds = 30 - state = engine.dispatch(session_state, increase_timer_event, chair_actor) + state = engine.dispatch(session_state, increase_timer_event, chair_actor).state assert state.timer_remaining_seconds == 45 assert state.timer_is_running is False @@ -501,7 +503,9 @@ def test_chair_can_open_informal_voting( open_informal_voting_event: sch.OpenInformalVotingEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, open_informal_voting_event, chair_actor) + state = engine.dispatch( + open_gsl_state, open_informal_voting_event, chair_actor + ).state assert state.current_state == enums.States.VOTING_EXECUTION assert state.voting is not None @@ -526,7 +530,9 @@ def test_chair_can_close_informal_voting( close_informal_voting_event: sch.CloseInformalVotingEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(voting_state, close_informal_voting_event, chair_actor) + state = engine.dispatch( + voting_state, close_informal_voting_event, chair_actor + ).state assert state.current_state == enums.States.OPEN_GSL assert state.voting is None @@ -551,7 +557,7 @@ def test_chair_can_resolve_motion_into_procedural_voting( ) -> None: open_gsl_state.submitted_motions.append(close_speakers_list_motion) - state = engine.dispatch(open_gsl_state, resolve_motion_event, chair_actor) + state = engine.dispatch(open_gsl_state, resolve_motion_event, chair_actor).state assert state.current_state == enums.States.VOTING_EXECUTION assert state.voting is not None @@ -572,7 +578,7 @@ def test_chair_can_deny_motion_without_opening_vote( payload=sch.ChairResolveMotionPayload(motion_id=1, action=False), ) - state = engine.dispatch(open_gsl_state, event, chair_actor) + state = engine.dispatch(open_gsl_state, event, chair_actor).state assert state.current_state == enums.States.OPEN_GSL assert state.voting is None @@ -610,14 +616,16 @@ def test_chair_can_close_passed_procedural_vote( 2: enums.VotingChoice.AGAINST, } - state = engine.dispatch( + result = engine.dispatch( procedural_voting_state, close_procedural_voting_event, chair_actor, ) - assert state.current_state == enums.States.CLOSED_GSL - assert state.voting is None + assert result.state.current_state == enums.States.CLOSED_GSL + assert result.state.voting is None + assert result.effect is not None + assert result.effect.data.get("passed") is True def test_chair_can_close_failed_procedural_vote( @@ -638,14 +646,16 @@ def test_chair_can_close_failed_procedural_vote( 2: enums.VotingChoice.AGAINST, } - state = engine.dispatch( + result = engine.dispatch( procedural_voting_state, close_procedural_voting_event, chair_actor, ) - assert state.current_state == enums.States.OPEN_GSL - assert state.voting is None + assert result.state.current_state == enums.States.OPEN_GSL + assert result.state.voting is None + assert result.effect is not None + assert result.effect.data.get("passed") is False def test_delegate_cannot_close_procedural_vote( @@ -707,7 +717,7 @@ def test_chair_can_choose_speaker( choose_speaker_event: sch.SpeakerEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, choose_speaker_event, chair_actor) + state = engine.dispatch(open_gsl_state, choose_speaker_event, chair_actor).state assert state.current_speaker == 1 assert state.timer_is_running is False @@ -733,7 +743,7 @@ def test_chair_can_mark_roll_call( ) -> None: session_state.current_state = enums.States.ROLL_CALL - state = engine.dispatch(session_state, mark_roll_call_event, chair_actor) + state = engine.dispatch(session_state, mark_roll_call_event, chair_actor).state assert state.roll_call.registry == {1: enums.RollCallChoice.PRESENT_AND_VOTING} @@ -764,7 +774,7 @@ def test_chair_can_mark_roll_call_bulk( ) -> None: session_state.current_state = enums.States.ROLL_CALL - state = engine.dispatch(session_state, mark_roll_call_bulk_event, chair_actor) + state = engine.dispatch(session_state, mark_roll_call_bulk_event, chair_actor).state assert state.roll_call.registry == { 1: enums.RollCallChoice.PRESENT, @@ -800,7 +810,7 @@ def test_chair_insert_queue_uses_delegation_id( insert_queue_event: sch.ChairInsertQueueEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, insert_queue_event, chair_actor) + state = engine.dispatch(open_gsl_state, insert_queue_event, chair_actor).state assert state.gsl_queue == [0] @@ -814,7 +824,7 @@ def test_chair_open_session_starts_roll_call( session_state.current_state = enums.States.SETUP session_state.roll_call.registry = {1: enums.RollCallChoice.PRESENT} - state = engine.dispatch(session_state, open_session_event, chair_actor) + state = engine.dispatch(session_state, open_session_event, chair_actor).state assert state.current_state == enums.States.ROLL_CALL assert state.roll_call.registry == {} @@ -827,7 +837,7 @@ def test_chair_can_set_agenda( set_agenda_event: sch.SetAgendaEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(session_state, set_agenda_event, chair_actor) + state = engine.dispatch(session_state, set_agenda_event, chair_actor).state assert state.agenda_topics == [("Topic A", True), ("Topic B", True)] assert state.active_topic_index == 0 @@ -840,6 +850,6 @@ def test_chair_can_manually_set_phase( manual_phase_set_event: sch.SetPhaseEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(session_state, manual_phase_set_event, chair_actor) + state = engine.dispatch(session_state, manual_phase_set_event, chair_actor).state assert state.current_state == enums.States.OPEN_GSL diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index a1a9659..ba63b6d 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -1,7 +1,7 @@ # Test suite for service layer import json from unittest.mock import AsyncMock, MagicMock -from uuid import UUID +from uuid import UUID, uuid4 import pytest @@ -11,6 +11,7 @@ from app.session.enums import SessionRole from app.session.manager import ConnectionManager from app.session.models import SessionActor, SessionLiveState +from app.session.schemas import EventMessage, JoinQueueEvent from app.session.service import ( ActorResolutionError, SessionFetchError, @@ -186,12 +187,13 @@ async def test_handle_client_messages_dispatches_and_broadcasts( broadcasts = [] - async def fake_broadcast_state(session_id: int): + async def fake_broadcast_message(session_id: int, message): broadcasts.append(session_id) # replaces real broadcast state with this one - connection_manager.broadcast_state = fake_broadcast_state + connection_manager.broadcast_message = fake_broadcast_message + # data sent by the websocket data = json.dumps( { "type": enums.DelegateEvents.JOIN_QUEUE, @@ -199,13 +201,17 @@ async def fake_broadcast_state(session_id: int): } ) + client_message = EventMessage( + request_id=uuid4(), event=JoinQueueEvent.model_validate_json(data) + ) + await handle_client_messages( manager=connection_manager, engine=fake_engine, logger=__import__("logging").getLogger("test"), session_id=session_state.session_id, actor=delegate_actor, - data=data, + data=client_message, ) # tests if engine dispatched the message diff --git a/backend/app/tests/session/test_websocket.py b/backend/app/tests/session/test_websocket.py index 27f199d..2b3fe79 100644 --- a/backend/app/tests/session/test_websocket.py +++ b/backend/app/tests/session/test_websocket.py @@ -1,7 +1,8 @@ import pytest from app.session.manager import ConnectionManager, SessionLiveState -from app.session.models import SessionActor +from app.session.models import DispatchOutcome, SessionActor +from app.session.schemas import DispatchResultMessage, StateSnapshotMessage class FakeWebSocket: @@ -29,10 +30,10 @@ async def test_connect_stores_actor( ) -> None: websocket = FakeWebSocket() - await connection_manager.connect(websocket, session_id=1, actor=chair_actor) + await connection_manager.connect(websocket, session_id=1, actor=chair_actor) # type:ignore - assert connection_manager.active_connections[1][websocket] == chair_actor - assert connection_manager.get_actor(websocket, 1) == chair_actor + assert connection_manager.active_connections[1][websocket] == chair_actor # type:ignore + assert connection_manager.get_actor(websocket, 1) == chair_actor # type:ignore assert connection_manager.count_connected(1) == 1 @@ -46,12 +47,14 @@ async def test_connect_sends_existing_room_state( connection_manager.room_states[session_state.session_id] = session_state await connection_manager.connect( - websocket, + websocket, # type:ignore session_id=session_state.session_id, actor=chair_actor, ) - assert websocket.sent_json == [session_state.model_dump(mode="json")] + expected_json = StateSnapshotMessage(state=session_state).model_dump(mode="json") + + assert websocket.sent_json[-1] == expected_json @pytest.mark.anyio @@ -60,9 +63,9 @@ async def test_disconnect_removes_socket( chair_actor: SessionActor, ) -> None: websocket = FakeWebSocket() - await connection_manager.connect(websocket, session_id=1, actor=chair_actor) + await connection_manager.connect(websocket, session_id=1, actor=chair_actor) # type:ignore - connection_manager.disconnect(websocket, session_id=1) + connection_manager.disconnect(websocket, session_id=1) # type:ignore assert connection_manager.active_connections[1] == {} assert connection_manager.count_connected(1) == 0 @@ -79,12 +82,12 @@ async def test_broadcast_state_sends_snapshot_to_all_connections( delegate_socket = FakeWebSocket() connection_manager.room_states[session_state.session_id] = session_state await connection_manager.connect( - chair_socket, + chair_socket, # type:ignore session_state.session_id, chair_actor, ) await connection_manager.connect( - delegate_socket, + delegate_socket, # type:ignore session_state.session_id, delegate_actor, ) @@ -92,26 +95,16 @@ async def test_broadcast_state_sends_snapshot_to_all_connections( chair_socket.sent_json.clear() delegate_socket.sent_json.clear() - await connection_manager.broadcast_state(session_state.session_id) + outcome = DispatchOutcome(state=session_state) + msg = DispatchResultMessage(state=outcome.state, effect=outcome.effect) + + await connection_manager.broadcast_message(session_state.session_id, msg) - expected = session_state.model_dump(mode="json") + expected = msg.model_dump(mode="json") assert chair_socket.sent_json == [expected] assert delegate_socket.sent_json == [expected] -@pytest.mark.anyio -async def test_broadcast_state_without_room_state_is_noop( - connection_manager: ConnectionManager, - chair_actor: SessionActor, -) -> None: - websocket = FakeWebSocket() - await connection_manager.connect(websocket, session_id=1, actor=chair_actor) - - await connection_manager.broadcast_state(session_id=1) - - assert websocket.sent_json == [] - - @pytest.mark.xfail( strict=True, reason="broadcast_state indexes active_connections when state exists.", @@ -123,4 +116,4 @@ async def test_broadcast_state_without_connections_is_noop( ) -> None: connection_manager.room_states[session_state.session_id] = session_state - await connection_manager.broadcast_state(session_state.session_id) + await connection_manager.broadcast_message(session_state.session_id, state=None) # type:ignore From ca5fe70aca176ce711d416ac8533040143c154e4 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:31:43 -0300 Subject: [PATCH 2/8] feat(openapi): export WebSocket protocol schemas Co-authored-by: Codex --- backend/app/core/openapi.py | 36 + backend/app/main.py | 20 + backend/app/session/views.py | 14 - backend/app/tests/test_openapi.py | 15 + frontend/openapi-ts.config.ts | 6 +- frontend/package-lock.json | 275 +++----- frontend/package.json | 4 +- frontend/src/context/SessionContext.tsx | 14 +- frontend/src/schemas/types.gen.ts | 881 ++++++++++++++---------- 9 files changed, 707 insertions(+), 558 deletions(-) create mode 100644 backend/app/core/openapi.py create mode 100644 backend/app/tests/test_openapi.py diff --git a/backend/app/core/openapi.py b/backend/app/core/openapi.py new file mode 100644 index 0000000..f381c8b --- /dev/null +++ b/backend/app/core/openapi.py @@ -0,0 +1,36 @@ +from typing import Any + +from pydantic import TypeAdapter + +from app.session.enums import ChairEvents, DelegateEvents +from app.session.schemas import ( + ClientSessionMessage, + ServerSessionMessage, + SessionEvent, +) + + +def add_websocket_message_schemas(openapi_schema: dict[str, Any]) -> None: + """Add WebSocket-only message contracts to the generated OpenAPI document. + + FastAPI only discovers schemas referenced by HTTP routes. These contracts are + validated on the WebSocket endpoint, so export them explicitly instead of + maintaining a fake HTTP route solely for schema generation. + """ + components = openapi_schema.setdefault("components", {}).setdefault( + "schemas", {} + ) + + for name, message_type in { + "ChairEvents": ChairEvents, + "DelegateEvents": DelegateEvents, + "SessionEvent": SessionEvent, + "ClientSessionMessage": ClientSessionMessage, + "ServerSessionMessage": ServerSessionMessage, + }.items(): + schema = TypeAdapter(message_type).json_schema( + ref_template="#/components/schemas/{model}" + ) + definitions = schema.pop("$defs", {}) + components.update(definitions) + components[name] = schema diff --git a/backend/app/main.py b/backend/app/main.py index 0dcacd9..191882c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,10 +2,12 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.utils import get_openapi from app.access.views import router as access_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 @@ -46,3 +48,21 @@ async def lifespan(app: FastAPI): # include commitees here? app.include_router(session_router, prefix="/committees", tags=["committees"]) app.include_router(access_router, prefix="/access", tags=["access"]) + + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + add_websocket_message_schemas(openapi_schema) + app.openapi_schema = openapi_schema + return openapi_schema + + +app.openapi = custom_openapi diff --git a/backend/app/session/views.py b/backend/app/session/views.py index 7774add..fbea56a 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -31,30 +31,16 @@ from app.core.database import get_db_session from app.core.dep import get_connection_manager, get_logger, get_session_engine from app.session.engine import SessionEngine -from app.session.enums import ChairEvents, DelegateEvents from app.session.manager import ConnectionManager -from app.session.models import SessionLiveState from app.session.schemas import ( AuthenticateMessage, EventMessage, SessionCreationSchema, - SessionEvent, ) router = APIRouter() -@router.get("/dummy", status_code=status.HTTP_404_NOT_FOUND) -async def dummy( - types: SessionEvent, - schemas: SessionLiveState, - enum1: DelegateEvents, - enum2: ChairEvents, -): - """Dummy workaround to make FastAPI add all schemas to the OpenAPI file""" - return Response(status_code=status.HTTP_404_NOT_FOUND) - - @router.get("/health", status_code=status.HTTP_200_OK) async def health(): """Healthcheck route""" diff --git a/backend/app/tests/test_openapi.py b/backend/app/tests/test_openapi.py new file mode 100644 index 0000000..e99c433 --- /dev/null +++ b/backend/app/tests/test_openapi.py @@ -0,0 +1,15 @@ +from app.main import app + + +def test_openapi_exports_websocket_contracts_without_dummy_route() -> None: + app.openapi_schema = None + + schema = app.openapi() + components = schema["components"]["schemas"] + + assert "/committees/dummy" not in schema["paths"] + assert components["ChairEvents"]["type"] == "string" + assert components["DelegateEvents"]["type"] == "string" + assert components["SessionEvent"]["discriminator"]["propertyName"] == "type" + assert components["ClientSessionMessage"]["discriminator"]["propertyName"] == "type" + assert components["ServerSessionMessage"]["discriminator"]["propertyName"] == "type" diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts index 4d793e2..e03d2b5 100644 --- a/frontend/openapi-ts.config.ts +++ b/frontend/openapi-ts.config.ts @@ -1,6 +1,6 @@ /* -Configuração para geração automática do types.gen.ts para Schemas na frontend -*/ + * Configuration for generating frontend OpenAPI types. + */ export default { input: process.env.OPENAPI_URL ?? 'http://localhost:8000/openapi.json', output: { @@ -12,5 +12,5 @@ export default { enums: true, name: '@hey-api/typescript', }, - ] + ], }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d29df9d..551b761 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@base-ui/react": "^1.5.0", "@fontsource-variable/geist": "^5.2.8", - "@hey-api/openapi-ts": "^0.99.0", + "@hey-api/openapi-ts": "^0.97.0", "@supabase/supabase-js": "^2.110.8", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-table": "^8.21.3", @@ -105,13 +105,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -313,12 +313,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -435,17 +435,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -453,9 +453,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -941,9 +941,9 @@ } }, "node_modules/@hey-api/codegen-core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.1.tgz", - "integrity": "sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.8.2.tgz", + "integrity": "sha512-R2NMf3wq97rh1mjz33WJQU8svz3F0RYUjvx/QzXucjpSqQ3O5huTdDjErG4fMxSr1X+X56NuDrqtGfHmo1TRUQ==", "license": "MIT", "dependencies": { "@hey-api/types": "0.1.4", @@ -952,51 +952,51 @@ "color-support": "1.1.3" }, "engines": { - "node": ">=22.18.0" + "node": ">=22.13.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" } }, "node_modules/@hey-api/json-schema-ref-parser": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.4.tgz", - "integrity": "sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.2.tgz", + "integrity": "sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q==", "license": "MIT", "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", - "js-yaml": "4.2.0" + "js-yaml": "4.1.1" }, "engines": { - "node": ">=22.18.0" + "node": ">=22.13.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.99.0.tgz", - "integrity": "sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==", + "version": "0.97.3", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.97.3.tgz", + "integrity": "sha512-4sR6/E/POuy7aPZW9DDjhObzZCq7eSJWiW0+epXeKNczoTWEwdOyWFy9Ca/CnXYlZ3oJsrv0ZD0OO+YuczT7CA==", "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "0.9.1", - "@hey-api/json-schema-ref-parser": "1.4.4", - "@hey-api/shared": "0.5.0", + "@hey-api/codegen-core": "0.8.2", + "@hey-api/json-schema-ref-parser": "1.4.2", + "@hey-api/shared": "0.4.5", "@hey-api/spec-types": "0.2.0", "@hey-api/types": "0.1.4", "@lukeed/ms": "2.0.2", "ansi-colors": "4.1.3", "color-support": "1.1.3", - "commander": "15.0.0", + "commander": "14.0.3", "get-tsconfig": "4.14.0" }, "bin": { "openapi-ts": "bin/run.js" }, "engines": { - "node": ">=22.18.0" + "node": ">=22.13.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" @@ -1005,41 +1005,32 @@ "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" } }, - "node_modules/@hey-api/openapi-ts/node_modules/commander": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", - "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", - "license": "MIT", - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@hey-api/shared": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.5.0.tgz", - "integrity": "sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.4.5.tgz", + "integrity": "sha512-au4eHpBXAe1du0iMp6ESYuEaMS2jsoEyrbcT246btRhI9rMeQFEs7ZjtcMGXGsxhpaR38A8cPGNHx7QOrWAdMw==", "license": "MIT", "dependencies": { - "@hey-api/codegen-core": "0.9.1", - "@hey-api/json-schema-ref-parser": "1.4.4", + "@hey-api/codegen-core": "0.8.2", + "@hey-api/json-schema-ref-parser": "1.4.2", "@hey-api/spec-types": "0.2.0", "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", - "semver": "7.8.4" + "semver": "7.7.4" }, "engines": { - "node": ">=22.18.0" + "node": ">=22.13.0" }, "funding": { "url": "https://github.com/sponsors/hey-api" } }, "node_modules/@hey-api/shared/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3654,42 +3645,6 @@ "path-browserify": "^1.0.1" } }, - "node_modules/@ts-morph/common/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -3937,42 +3892,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4244,9 +4163,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4317,9 +4236,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -4336,11 +4255,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -4440,9 +4359,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "funding": [ { "type": "opencollective", @@ -4980,9 +4899,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.393", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", - "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "version": "1.5.413", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz", + "integrity": "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -5435,9 +5354,9 @@ } }, "node_modules/exsolve": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", - "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -5806,9 +5725,9 @@ } }, "node_modules/giget": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", - "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", "license": "MIT", "bin": { "giget": "dist/cli.mjs" @@ -5899,9 +5818,9 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", + "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6002,9 +5921,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -6835,9 +6754,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -6868,9 +6787,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "license": "MIT", "engines": { "node": ">=18" @@ -6935,9 +6854,9 @@ } }, "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", "license": "MIT" }, "node_modules/on-finished": { @@ -7332,9 +7251,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -7351,7 +7270,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7780,9 +7699,9 @@ } }, "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -8608,9 +8527,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -8654,9 +8573,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "funding": [ { "type": "opencollective", diff --git a/frontend/package.json b/frontend/package.json index bcf7111..34221fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "type": "module", "scripts": { "dev": "vite", - "generate:schema": "openapi-ts", + "generate:schema": "openapi-ts --file openapi-ts.config.ts", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" @@ -16,7 +16,7 @@ "dependencies": { "@base-ui/react": "^1.5.0", "@fontsource-variable/geist": "^5.2.8", - "@hey-api/openapi-ts": "^0.99.0", + "@hey-api/openapi-ts": "^0.97.0", "@supabase/supabase-js": "^2.110.8", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/src/context/SessionContext.tsx b/frontend/src/context/SessionContext.tsx index 8027598..2c10ab3 100644 --- a/frontend/src/context/SessionContext.tsx +++ b/frontend/src/context/SessionContext.tsx @@ -3,7 +3,7 @@ import { createContext, useEffect, useState } from 'react'; import { useAuth } from './AuthContext'; import { useParams } from 'react-router-dom'; import { UpdateStore } from '@/store/useCommitteeStore'; -import {type SessionRepresentation,type BodyDummyCommitteesDummyGet as Types} from '@/schemas/types.gen'; +import {type SessionEvent, type SessionRepresentation} from '@/schemas/types.gen'; interface SessionContextType{ @@ -37,7 +37,7 @@ export function SessionProvider({ children }: { children: ReactNode }) socket = ws ws.onopen = () => { - ws?.send(JSON.stringify({ access_token: token })); + ws?.send(JSON.stringify({ type: 'authenticate', access_token: token })); setStatus("Connected"); } @@ -82,10 +82,14 @@ export function SessionProvider({ children }: { children: ReactNode }) return {children}; } -export function sendMessage(data: Types["types"]) { +export function sendMessage(event: SessionEvent) { if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(data)); + socket.send(JSON.stringify({ + type: 'event', + request_id: crypto.randomUUID(), + event, + })); } else { @@ -93,4 +97,4 @@ export function sendMessage(data: Types["types"]) { } } -export const useSession = () => useContext(SessionContext) \ No newline at end of file +export const useSession = () => useContext(SessionContext) diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index 6723ddf..0666f50 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -1,68 +1,82 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: 'http://localhost:8000' | (string & {}); + baseUrl: `${string}://${string}` | (string & {}); }; /** - * AgendaItem + * HTTPValidationError */ -export type AgendaItem = { +export type HttpValidationError = { /** - * Index + * Detail */ - index: string; + detail?: Array; +}; + +/** + * SessionCreationSchema + * + * Schema to create a session. Follows a DB schema + extra configs format + */ +export type SessionCreationSchema = { /** - * Topic + * Committee Id */ - topic: string; + committee_id: number; /** - * Already Discussed + * Name */ - already_discussed: boolean; + name?: string | null; }; /** - * AnswerRollCallEvent + * SessionRepresentation */ -export type AnswerRollCallEvent = { +export type SessionRepresentation = { + role: SessionRoles; /** - * Type + * Representation Id */ - type: 'AnswerRollCallEvent'; - payload: AnswerRollCallPayload; + representation_id: number | null; }; /** - * AnswerRollCallPayload + * SessionRoles */ -export type AnswerRollCallPayload = { - /** - * Choice - */ - choice: 'Present' | 'Present and Voting'; -}; +export const SessionRoles = { CHAIR: 'chair', DELEGATE: 'delegate' } as const; /** - * Body_dummy_committees_dummy_get + * SessionRoles */ -export type BodyDummyCommitteesDummyGet = { - /** - * Types - */ - types: SubmitMotionEvent | SubmitQuestionEvent | CastVoteEvent | AnswerRollCallEvent | JoinQueueEvent | LeaveQueueEvent | OpenSessionEvent | CloseSessionEvent | IncreaseTimerEvent | ToggleTimerEvent | OpenInformalVotingEvent | CloseProceduralVotingEvent | CloseInformalVotingEvent | ResolveMotionEvent | SpeakerEvent | SetAgendaEvent | SetAgendaItemEvent | MarkAgendaItemEvent | DeleteAgendaItemEvent | SetPhaseEvent | MarkRollCallEvent | CloseRollCallEvent | ChairInsertQueueEvent | MarkRollCallBulkEvent; - schemas: SessionLiveState; -}; +export type SessionRoles = typeof SessionRoles[keyof typeof SessionRoles]; /** - * CastVoteEvent + * ValidationError */ -export type CastVoteEvent = { +export type ValidationError = { /** - * Type + * Location */ - type: 'CastVoteEvent'; - payload: DelegateVotingPayload; + loc: Array; + /** + * Message + */ + msg: string; + /** + * Error Type + */ + type: string; + /** + * Input + */ + input?: unknown; + /** + * Context + */ + ctx?: { + [key: string]: unknown; + }; }; /** @@ -94,6 +108,56 @@ export const ChairEvents = { */ export type ChairEvents = typeof ChairEvents[keyof typeof ChairEvents]; +/** + * DelegateEvents + */ +export const DelegateEvents = { + SUBMIT_MOTION_EVENT: 'SubmitMotionEvent', + SUBMIT_QUESTION_EVENT: 'SubmitQuestionEvent', + JOIN_QUEUE_EVENT: 'JoinQueueEvent', + LEAVE_QUEUE_EVENT: 'LeaveQueueEvent', + CAST_VOTE_EVENT: 'CastVoteEvent', + YIELD_EVENT: 'YieldEvent', + ANSWER_ROLL_CALL_EVENT: 'AnswerRollCallEvent' +} as const; + +/** + * DelegateEvents + */ +export type DelegateEvents = typeof DelegateEvents[keyof typeof DelegateEvents]; + +/** + * AnswerRollCallEvent + */ +export type AnswerRollCallEvent = { + /** + * Type + */ + type: 'AnswerRollCallEvent'; + payload: AnswerRollCallPayload; +}; + +/** + * AnswerRollCallPayload + */ +export type AnswerRollCallPayload = { + /** + * Choice + */ + choice: 'Present' | 'Present and Voting'; +}; + +/** + * CastVoteEvent + */ +export type CastVoteEvent = { + /** + * Type + */ + type: 'CastVoteEvent'; + payload: DelegateVotingPayload; +}; + /** * ChairForceSpeakerPayload */ @@ -239,34 +303,6 @@ export type CloseSessionEvent = { payload: EmptyPayload; }; -/** - * DebateContext - */ -export type DebateContext = { - debate_type: DebateTypes; - return_state: States; - /** - * Total Duration Seconds - */ - total_duration_seconds?: number | null; - /** - * Total Speeches - */ - total_speeches?: number | null; - /** - * Per Speaker Seconds - */ - per_speaker_seconds?: number | null; - /** - * Expires At - */ - expires_at?: string | null; - /** - * Topic - */ - topic?: string | null; -}; - /** * DebateTypes */ @@ -281,24 +317,6 @@ export const DebateTypes = { */ export type DebateTypes = typeof DebateTypes[keyof typeof DebateTypes]; -/** - * DelegateEvents - */ -export const DelegateEvents = { - SUBMIT_MOTION_EVENT: 'SubmitMotionEvent', - SUBMIT_QUESTION_EVENT: 'SubmitQuestionEvent', - JOIN_QUEUE_EVENT: 'JoinQueueEvent', - LEAVE_QUEUE_EVENT: 'LeaveQueueEvent', - CAST_VOTE_EVENT: 'CastVoteEvent', - YIELD_EVENT: 'YieldEvent', - ANSWER_ROLL_CALL_EVENT: 'AnswerRollCallEvent' -} as const; - -/** - * DelegateEvents - */ -export type DelegateEvents = typeof DelegateEvents[keyof typeof DelegateEvents]; - /** * DelegateMotionPayload */ @@ -349,28 +367,6 @@ export type DelegateVotingPayload = { vote: VotingChoice; }; -/** - * DelegationContext - */ -export type DelegationContext = { - /** - * Id - */ - id: number; - /** - * Name - */ - name: string; - /** - * Seat - */ - seat: string; - /** - * Code - */ - code: string; -}; - /** * DeleteAgendaItemEvent */ @@ -399,16 +395,6 @@ export type EmptyPayload = { [key: string]: unknown; }; -/** - * HTTPValidationError - */ -export type HttpValidationError = { - /** - * Detail - */ - detail?: Array; -}; - /** * IncreaseTimerEvent */ @@ -540,42 +526,6 @@ export type MarkRollCallPayload = { choice: RollCallChoice; }; -/** - * MotionContext - */ -export type MotionContext = { - /** - * Id - */ - id?: number | null; - /** - * Priority - */ - priority?: number; - type: Motions; - /** - * Delegate Id - */ - delegate_id?: number | null; - debate_type?: DebateTypes | null; - /** - * Total Duration Minutes - */ - total_duration_minutes?: number | null; - /** - * Per Speaker Seconds - */ - per_speaker_seconds?: number | null; - /** - * Target Topic - */ - target_topic?: string | null; - /** - * Details - */ - details?: string | null; -}; - /** * Motions */ @@ -625,30 +575,7 @@ export type OpenSessionEvent = { }; /** - * QuestionContext - */ -export type QuestionContext = { - /** - * Id - */ - id?: number | null; - /** - * Priority - */ - priority?: number; - type: Questions; - /** - * Delegate Id - */ - delegate_id?: number | null; - /** - * Details - */ - details?: string | null; -}; - -/** - * Questions + * Questions */ export const Questions = { ORDER: 'Order', @@ -686,143 +613,6 @@ export const RollCallChoice = { */ export type RollCallChoice = typeof RollCallChoice[keyof typeof RollCallChoice]; -/** - * RollCallContext - */ -export type RollCallContext = { - /** - * Registry - */ - registry?: { - [key: string]: RollCallChoice; - }; - /** - * Current Delegation - */ - current_delegation?: number | null; -}; - -/** - * SessionCreationSchema - * - * Schema to create a session. Follows a DB schema + extra configs format - */ -export type SessionCreationSchema = { - /** - * Committee Id - */ - committee_id: number; - /** - * Name - */ - name?: string | null; -}; - -/** - * SessionLiveState - */ -export type SessionLiveState = { - /** - * Session Id - */ - session_id: number; - /** - * Start Time - */ - start_time: string; - /** - * Delegations - */ - delegations: { - [key: string]: DelegationContext; - }; - current_state?: States; - /** - * Timer Is Running - */ - timer_is_running?: boolean; - /** - * Timer Expiration - */ - timer_expiration?: string | null; - /** - * Timer Remaining Seconds - */ - timer_remaining_seconds?: number; - /** - * Current Speaker - */ - current_speaker?: number | null; - /** - * Gsl Queue - */ - gsl_queue?: Array; - /** - * Can Set Motion - */ - can_set_motion?: boolean; - /** - * Gsl Default Time Seconds - */ - gsl_default_time_seconds?: number; - /** - * Caucus List - */ - caucus_list?: Array; - debate?: DebateContext | null; - /** - * Submitted Motions - */ - submitted_motions?: Array; - /** - * Submitted Questions - */ - submitted_questions?: Array; - /** - * Agenda Topics - */ - agenda_topics?: { - [key: string]: AgendaItem; - }; - /** - * Active Topic Index - */ - active_topic_index?: string | null; - voting?: VotingContext | null; - /** - * Voting Choice - */ - voting_choice?: { - [key: string]: RollCallChoice; - } | null; - roll_call: RollCallContext; - /** - * Has Veto Power - */ - has_veto_power?: boolean; -}; - -/** - * SessionRepresentation - */ -export type SessionRepresentation = { - role: SessionRoles; - /** - * Representation Id - */ - representation_id: number | null; -}; - -/** - * SessionRoles - */ -export const SessionRoles = { CHAIR: 'chair', DELEGATE: 'delegate' } as const; - -/** - * SessionRoles - */ -export type SessionRoles = typeof SessionRoles[keyof typeof SessionRoles]; - /** * SetAgendaEvent */ @@ -938,46 +728,445 @@ export type ToggleTimerEvent = { }; /** - * ValidationError + * VotingChoice */ -export type ValidationError = { +export const VotingChoice = { + FAVOUR: 'Favour', + AGAINST: 'Against', + ABSTAIN: 'Abstain' +} as const; + +/** + * VotingChoice + */ +export type VotingChoice = typeof VotingChoice[keyof typeof VotingChoice]; + +export type SessionEvent = ({ + type: 'SubmitMotionEvent'; +} & SubmitMotionEvent) | ({ + type: 'SubmitQuestionEvent'; +} & SubmitQuestionEvent) | ({ + type: 'CastVoteEvent'; +} & CastVoteEvent) | ({ + type: 'AnswerRollCallEvent'; +} & AnswerRollCallEvent) | ({ + type: 'JoinQueueEvent'; +} & JoinQueueEvent) | ({ + type: 'LeaveQueueEvent'; +} & LeaveQueueEvent) | ({ + type: 'OpenSessionEvent'; +} & OpenSessionEvent) | ({ + type: 'CloseSessionEvent'; +} & CloseSessionEvent) | ({ + type: 'IncreaseTimerEvent'; +} & IncreaseTimerEvent) | ({ + type: 'ToggleTimerEvent'; +} & ToggleTimerEvent) | ({ + type: 'OpenInformalVotingEvent'; +} & OpenInformalVotingEvent) | ({ + type: 'CloseProceduralVotingEvent'; +} & CloseProceduralVotingEvent) | ({ + type: 'CloseInformalVotingEvent'; +} & CloseInformalVotingEvent) | ({ + type: 'ResolveMotionEvent'; +} & ResolveMotionEvent) | ({ + type: 'SpeakerEvent'; +} & SpeakerEvent) | ({ + type: 'SetAgenda'; +} & SetAgendaEvent) | ({ + type: 'SetAgendaItemEvent'; +} & SetAgendaItemEvent) | ({ + type: 'MarkAgendaItemEvent'; +} & MarkAgendaItemEvent) | ({ + type: 'DeleteAgendaItemEvent'; +} & DeleteAgendaItemEvent) | ({ + type: 'SetPhaseEvent'; +} & SetPhaseEvent) | ({ + type: 'MarkRollCallEvent'; +} & MarkRollCallEvent) | ({ + type: 'CloseRollCallEvent'; +} & CloseRollCallEvent) | ({ + type: 'InsertQueueEvent'; +} & ChairInsertQueueEvent) | ({ + type: 'MarkRollCallBulkEvent'; +} & MarkRollCallBulkEvent); + +/** + * AuthenticateMessage + */ +export type AuthenticateMessage = { /** - * Location + * Type */ - loc: Array; + type?: 'authenticate'; /** - * Message + * Access Token */ - msg: string; + access_token: string; +}; + +/** + * EventMessage + */ +export type EventMessage = { /** - * Error Type + * Type */ - type: string; + type?: 'event'; + /** + * Request Id + */ + request_id: string; + /** + * Event + */ + event: ({ + type: 'SubmitMotionEvent'; + } & SubmitMotionEvent) | ({ + type: 'SubmitQuestionEvent'; + } & SubmitQuestionEvent) | ({ + type: 'CastVoteEvent'; + } & CastVoteEvent) | ({ + type: 'AnswerRollCallEvent'; + } & AnswerRollCallEvent) | ({ + type: 'JoinQueueEvent'; + } & JoinQueueEvent) | ({ + type: 'LeaveQueueEvent'; + } & LeaveQueueEvent) | ({ + type: 'OpenSessionEvent'; + } & OpenSessionEvent) | ({ + type: 'CloseSessionEvent'; + } & CloseSessionEvent) | ({ + type: 'IncreaseTimerEvent'; + } & IncreaseTimerEvent) | ({ + type: 'ToggleTimerEvent'; + } & ToggleTimerEvent) | ({ + type: 'OpenInformalVotingEvent'; + } & OpenInformalVotingEvent) | ({ + type: 'CloseProceduralVotingEvent'; + } & CloseProceduralVotingEvent) | ({ + type: 'CloseInformalVotingEvent'; + } & CloseInformalVotingEvent) | ({ + type: 'ResolveMotionEvent'; + } & ResolveMotionEvent) | ({ + type: 'SpeakerEvent'; + } & SpeakerEvent) | ({ + type: 'SetAgenda'; + } & SetAgendaEvent) | ({ + type: 'SetAgendaItemEvent'; + } & SetAgendaItemEvent) | ({ + type: 'MarkAgendaItemEvent'; + } & MarkAgendaItemEvent) | ({ + type: 'DeleteAgendaItemEvent'; + } & DeleteAgendaItemEvent) | ({ + type: 'SetPhaseEvent'; + } & SetPhaseEvent) | ({ + type: 'MarkRollCallEvent'; + } & MarkRollCallEvent) | ({ + type: 'CloseRollCallEvent'; + } & CloseRollCallEvent) | ({ + type: 'InsertQueueEvent'; + } & ChairInsertQueueEvent) | ({ + type: 'MarkRollCallBulkEvent'; + } & MarkRollCallBulkEvent); +}; + +export type ClientSessionMessage = ({ + type: 'authenticate'; +} & AuthenticateMessage) | ({ + type: 'event'; +} & EventMessage); + +/** + * AgendaItem + */ +export type AgendaItem = { /** - * Input + * Index */ - input?: unknown; + index: string; /** - * Context + * Topic */ - ctx?: { + topic: string; + /** + * Already Discussed + */ + already_discussed: boolean; +}; + +/** + * DebateContext + */ +export type DebateContext = { + debate_type: DebateTypes; + return_state: States; + /** + * Total Duration Seconds + */ + total_duration_seconds?: number | null; + /** + * Total Speeches + */ + total_speeches?: number | null; + /** + * Per Speaker Seconds + */ + per_speaker_seconds?: number | null; + /** + * Expires At + */ + expires_at?: string | null; + /** + * Topic + */ + topic?: string | null; +}; + +/** + * DelegationContext + */ +export type DelegationContext = { + /** + * Id + */ + id: number; + /** + * Name + */ + name: string; + /** + * Seat + */ + seat: string; + /** + * Code + */ + code: string; +}; + +/** + * DispatchResultMessage + */ +export type DispatchResultMessage = { + /** + * Type + */ + type?: 'dispatch_result'; + state: SessionLiveState; + effect?: SessionEffect | null; +}; + +/** + * EventResultMessage + */ +export type EventResultMessage = { + /** + * Type + */ + type?: 'event_result'; + /** + * Request Id + */ + request_id: string; + /** + * Ok + */ + ok: boolean; +}; + +/** + * MotionContext + */ +export type MotionContext = { + /** + * Id + */ + id?: number | null; + /** + * Priority + */ + priority?: number; + type: Motions; + /** + * Delegate Id + */ + delegate_id?: number | null; + debate_type?: DebateTypes | null; + /** + * Total Duration Minutes + */ + total_duration_minutes?: number | null; + /** + * Per Speaker Seconds + */ + per_speaker_seconds?: number | null; + /** + * Target Topic + */ + target_topic?: string | null; + /** + * Details + */ + details?: string | null; +}; + +/** + * QuestionContext + */ +export type QuestionContext = { + /** + * Id + */ + id?: number | null; + /** + * Priority + */ + priority?: number; + type: Questions; + /** + * Delegate Id + */ + delegate_id?: number | null; + /** + * Details + */ + details?: string | null; +}; + +/** + * RollCallContext + */ +export type RollCallContext = { + /** + * Registry + */ + registry?: { + [key: string]: RollCallChoice; + }; + /** + * Current Delegation + */ + current_delegation?: number | null; +}; + +/** + * SessionEffect + */ +export type SessionEffect = { + type: SessionEffectType; + /** + * Data + */ + data?: { [key: string]: unknown; }; }; /** - * VotingChoice + * SessionEffectType */ -export const VotingChoice = { - FAVOUR: 'Favour', - AGAINST: 'Against', - ABSTAIN: 'Abstain' -} as const; +export const SessionEffectType = { VOTE_CLOSED: 'Vote Closed' } as const; /** - * VotingChoice + * SessionEffectType */ -export type VotingChoice = typeof VotingChoice[keyof typeof VotingChoice]; +export type SessionEffectType = typeof SessionEffectType[keyof typeof SessionEffectType]; + +/** + * SessionLiveState + */ +export type SessionLiveState = { + /** + * Session Id + */ + session_id: number; + /** + * Start Time + */ + start_time: string; + /** + * Delegations + */ + delegations: { + [key: string]: DelegationContext; + }; + current_state?: States; + /** + * Timer Is Running + */ + timer_is_running?: boolean; + /** + * Timer Expiration + */ + timer_expiration?: string | null; + /** + * Timer Remaining Seconds + */ + timer_remaining_seconds?: number; + /** + * Current Speaker + */ + current_speaker?: number | null; + /** + * Gsl Queue + */ + gsl_queue?: Array; + /** + * Can Set Motion + */ + can_set_motion?: boolean; + /** + * Gsl Default Time Seconds + */ + gsl_default_time_seconds?: number; + /** + * Caucus List + */ + caucus_list?: Array; + debate?: DebateContext | null; + /** + * Submitted Motions + */ + submitted_motions?: Array; + /** + * Submitted Questions + */ + submitted_questions?: Array; + /** + * Agenda Topics + */ + agenda_topics?: { + [key: string]: AgendaItem; + }; + /** + * Active Topic Index + */ + active_topic_index?: string | null; + voting?: VotingContext | null; + /** + * Voting Choice + */ + voting_choice?: { + [key: string]: RollCallChoice; + } | null; + roll_call: RollCallContext; + /** + * Has Veto Power + */ + has_veto_power?: boolean; +}; + +/** + * StateSnapshotMessage + */ +export type StateSnapshotMessage = { + /** + * Type + */ + type?: 'state_snapshot'; + state: SessionLiveState; +}; /** * VotingContext @@ -996,11 +1185,6 @@ export type VotingContext = { voting_registry?: { [key: string]: VotingChoice; }; - majority: MajorityTypes; - /** - * Veto Power - */ - veto_power: boolean; }; /** @@ -1017,28 +1201,13 @@ export const VotingType = { */ export type VotingType = typeof VotingType[keyof typeof VotingType]; -export type DummyCommitteesDummyGetData = { - body: BodyDummyCommitteesDummyGet; - path?: never; - query: { - enum1: DelegateEvents; - enum2: ChairEvents; - }; - url: '/committees/dummy'; -}; - -export type DummyCommitteesDummyGetErrors = { - /** - * Successful Response - */ - 404: unknown; - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type DummyCommitteesDummyGetError = DummyCommitteesDummyGetErrors[keyof DummyCommitteesDummyGetErrors]; +export type ServerSessionMessage = ({ + type: 'state_snapshot'; +} & StateSnapshotMessage) | ({ + type: 'event_result'; +} & EventResultMessage) | ({ + type: 'dispatch_result'; +} & DispatchResultMessage); export type HealthCommitteesHealthGetData = { body?: never; From 96c90296f8c197d40ee67aa68103b1c0df8657da Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:16:21 -0300 Subject: [PATCH 3/8] style(backend): format style --- backend/app/core/openapi.py | 4 +--- backend/app/session/manager.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/core/openapi.py b/backend/app/core/openapi.py index f381c8b..dcdb724 100644 --- a/backend/app/core/openapi.py +++ b/backend/app/core/openapi.py @@ -17,9 +17,7 @@ def add_websocket_message_schemas(openapi_schema: dict[str, Any]) -> None: validated on the WebSocket endpoint, so export them explicitly instead of maintaining a fake HTTP route solely for schema generation. """ - components = openapi_schema.setdefault("components", {}).setdefault( - "schemas", {} - ) + components = openapi_schema.setdefault("components", {}).setdefault("schemas", {}) for name, message_type in { "ChairEvents": ChairEvents, diff --git a/backend/app/session/manager.py b/backend/app/session/manager.py index c63c63a..b302d66 100644 --- a/backend/app/session/manager.py +++ b/backend/app/session/manager.py @@ -47,7 +47,9 @@ async def broadcast_message(self, session_id: int, message: ServerSessionMessage for connection in self.active_connections[session_id]: await connection.send_json(message.model_dump(mode="json")) - async def send_message(self, session_id: int, message: ServerSessionMessage, websocket: WebSocket): + async def send_message( + self, session_id: int, message: ServerSessionMessage, websocket: WebSocket + ): """Sends a ServerSessionMessage to connected socket""" if self.active_connections.get(session_id, {}).get(websocket): await websocket.send_json(message.model_dump(mode="json")) From 3a275f02f1ce77adf6f792824fbdfe35f535b86c Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:17:06 -0300 Subject: [PATCH 4/8] refactor(frontend): refactor WebSocket messages in frontend --- frontend/src/context/SessionContext.tsx | 162 +++++++++++++----------- frontend/src/schemas/types.gen.ts | 2 +- 2 files changed, 92 insertions(+), 72 deletions(-) diff --git a/frontend/src/context/SessionContext.tsx b/frontend/src/context/SessionContext.tsx index 2c10ab3..14ded7e 100644 --- a/frontend/src/context/SessionContext.tsx +++ b/frontend/src/context/SessionContext.tsx @@ -3,98 +3,118 @@ import { createContext, useEffect, useState } from 'react'; import { useAuth } from './AuthContext'; import { useParams } from 'react-router-dom'; import { UpdateStore } from '@/store/useCommitteeStore'; -import {type SessionEvent, type SessionRepresentation} from '@/schemas/types.gen'; +import { type SessionEvent, type SessionRepresentation, type AuthenticateMessage, type ServerSessionMessage, type EventMessage } from '@/schemas/types.gen'; -interface SessionContextType{ - role: string - representation_id: number | null +interface SessionContextType { + role: string + representation_id: number | null } const SessionContext = createContext({ - role: "", - representation_id: null, + role: "", + representation_id: null, }); -let socket : WebSocket|null = null +let socket: WebSocket | null = null -export function SessionProvider({ children }: { children: ReactNode }) -{ - const { token } = useAuth() - const { sessionId } = useParams<{ sessionId: string }>(); - const parsedSessionId = Number(sessionId); +export function SessionProvider({ children }: { children: ReactNode }) { + const { token } = useAuth() + const { sessionId } = useParams<{ sessionId: string }>(); + const parsedSessionId = Number(sessionId); - const [, setStatus] = useState("Connecting..."); + const [, setStatus] = useState("Connecting..."); - useEffect(() => { - if (!token || !Number.isInteger(parsedSessionId) || parsedSessionId < 1) { - return; - } + useEffect(() => { + if (!token || !Number.isInteger(parsedSessionId) || parsedSessionId < 1) { + return; + } - const ws = new WebSocket( - `${import.meta.env.VITE_WS_URL}/ws/${parsedSessionId}`, - ); + const ws = new WebSocket( + `${import.meta.env.VITE_WS_URL}/ws/${parsedSessionId}`, + ); socket = ws - ws.onopen = () => { - ws?.send(JSON.stringify({ type: 'authenticate', access_token: token })); - setStatus("Connected"); - } - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - console.log(data); - UpdateStore(data); - }; + ws.onopen = () => { + const auth_payload: AuthenticateMessage = { + 'type': 'authenticate', // explicitly pass the type here + 'access_token': token, + }; - ws.onclose = () => setStatus("Disconnected"); + ws?.send(JSON.stringify(auth_payload)); + setStatus("Connected"); + } - return () => { + ws.onmessage = (event) => { + // Route each message received + const data = JSON.parse(event.data) as ServerSessionMessage; + console.log("Received data", data); + switch (data.type) { + case 'dispatch_result': { + UpdateStore(data.state); + if (data.effect) { + console.log("Side effect / event result", data.effect); + } + break; + } + case 'event_result': { + console.log(`Event result received: [${data.request_id}] - ok=${data.ok}`); + break; + } + default: { + console.warn("Unhandled message received:", data); + break; + } + } + }; + + ws.onclose = () => setStatus("Disconnected"); + + return () => { ws.close(); if (socket === ws) socket = null; } - }, [parsedSessionId, token]); - - const [role, setRole] = useState("") - const [representation_id, setRepresentation_id] = useState(null) - - useEffect(() => { - fetch(`${import.meta.env.VITE_API_URL}/access/sessions/${parsedSessionId}/me`, - { - method:"GET", - headers:{ - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - } - ).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);}) - }, [parsedSessionId, token]) - - const value ={ - role: role, - representation_id: representation_id - } - - return {children}; + }, [parsedSessionId, token]); + + const [role, setRole] = useState("") + const [representation_id, setRepresentation_id] = useState(null) + + useEffect(() => { + fetch(`${import.meta.env.VITE_API_URL}/access/sessions/${parsedSessionId}/me`, + { + method: "GET", + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + } + ).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); }) + }, [parsedSessionId, token]) + + const value = { + role: role, + representation_id: representation_id + } + + return {children}; } export function sendMessage(event: SessionEvent) { - if (socket && socket.readyState === WebSocket.OPEN) - { - socket.send(JSON.stringify({ - type: 'event', - request_id: crypto.randomUUID(), - event, - })); - } - else - { - console.error("WebSocket is not connected."); - } + if (socket && socket.readyState === WebSocket.OPEN) { + // Send EventMessage payload + const payload: EventMessage = { + 'type': 'event', + request_id: crypto.randomUUID(), + event, + } + socket.send(JSON.stringify(payload)); + } + else { + console.error("WebSocket is not connected."); + } } export const useSession = () => useContext(SessionContext) diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index 0666f50..0787645 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); + baseUrl: 'http://localhost:8000' | (string & {}); }; /** From c0cec609cffea70d31584d3c9efd5ccec5692db1 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:05:33 -0300 Subject: [PATCH 5/8] style: refactor formatting, linting and one bug fix --- backend/app/session/engine.py | 4 +- backend/app/tests/session/test_engine.py | 20 +++- frontend/src/components/session/timer.tsx | 124 +++++++++++----------- frontend/src/components/ui/flags.tsx | 61 +++++------ frontend/src/context/SessionContext.tsx | 4 + frontend/src/schemas/types.gen.ts | 2 +- 6 files changed, 112 insertions(+), 103 deletions(-) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index 11c5a1e..79e5a6f 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -883,8 +883,8 @@ def handle_mark_roll_call( state: SessionLiveState, event: schemas.MarkRollCallEvent, actor: SessionActor ) -> DispatchOutcome: require_chair(actor) - #removed these lines so chair can freely change the roll call - #if state.current_state != States.ROLL_CALL or state.roll_call is None: + # removed these lines so chair can freely change the roll call + # if state.current_state != States.ROLL_CALL or state.roll_call is None: # raise InvalidProceduralMove("Cannot mark roll call right now") if event.payload.delegation_id not in state.delegations: diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index 98b9b40..93d6132 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -288,7 +288,9 @@ def test_delegate_can_submit_motion_in_open_gsl( submit_debate_motion_event: sch.SubmitMotionEvent, delegate_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, submit_debate_motion_event, delegate_actor).state + state = engine.dispatch( + open_gsl_state, submit_debate_motion_event, delegate_actor + ).state assert len(state.submitted_motions) == 1 assert state.submitted_motions[0].id == 1 @@ -424,7 +426,9 @@ def test_delegate_can_cast_vote( cast_vote_event: sch.CastVoteEvent, delegate_actor: md.SessionActor, ) -> None: - state = engine.dispatch(informal_voting_state, cast_vote_event, delegate_actor).state + state = engine.dispatch( + informal_voting_state, cast_vote_event, delegate_actor + ).state assert state.voting is not None assert state.voting.voting_registry == {0: enums.VotingChoice.FAVOUR} @@ -514,7 +518,9 @@ def test_quorum_roll_call_restores_closed_gsl( }, ) - state = engine.dispatch(session_state, close_procedural_voting_event, chair_actor).state + state = engine.dispatch( + session_state, close_procedural_voting_event, chair_actor + ).state assert state.current_state == enums.States.ROLL_CALL assert state.roll_call.return_state == enums.States.CLOSED_GSL @@ -557,7 +563,9 @@ def test_quorum_roll_call_restores_moderated_caucus( }, ) - state = engine.dispatch(session_state, close_procedural_voting_event, chair_actor).state + state = engine.dispatch( + session_state, close_procedural_voting_event, chair_actor + ).state state = engine.dispatch(state, close_roll_call_event, chair_actor).state assert state.current_state == enums.States.MODERATED_CAUCUS @@ -630,7 +638,9 @@ def test_chair_can_open_informal_voting( open_informal_voting_event: sch.OpenInformalVotingEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, open_informal_voting_event, chair_actor).state + state = engine.dispatch( + open_gsl_state, open_informal_voting_event, chair_actor + ).state assert state.current_state == enums.States.VOTING_EXECUTION assert state.voting is not None diff --git a/frontend/src/components/session/timer.tsx b/frontend/src/components/session/timer.tsx index e6184cf..7c86c8f 100644 --- a/frontend/src/components/session/timer.tsx +++ b/frontend/src/components/session/timer.tsx @@ -11,76 +11,76 @@ import { SessionRoles } from "@/schemas/types.gen" export default function Timer() { - const {role} = useSession() - const isChair = role===SessionRoles.CHAIR - const delegations = useCommitteeStore((state) => state.delegations); - const currentSpeaker = useCommitteeStore((state) => state.current_speaker); - let speaker = currentSpeaker !== null && currentSpeaker !== undefined ? delegations[currentSpeaker] : { - id: -1, - seat: "", - name: "Mesa", - code: "null", - }; + const { role } = useSession() + const isChair = role === SessionRoles.CHAIR + const delegations = useCommitteeStore((state) => state.delegations); + const currentSpeaker = useCommitteeStore((state) => state.current_speaker); + const speaker = currentSpeaker !== null && currentSpeaker !== undefined ? delegations[currentSpeaker] : { + id: -1, + seat: "", + name: "Mesa", + code: "null", + }; - const timerIsRunning = useCommitteeStore((state) => state.timer_is_running) ?? false; - const timerExpiration = useCommitteeStore((state) => state.timer_expiration); - const timerRemaining = useCommitteeStore((state) => state.timer_remaining_seconds); + const timerIsRunning = useCommitteeStore((state) => state.timer_is_running) ?? false; + const timerExpiration = useCommitteeStore((state) => state.timer_expiration); + const timerRemaining = useCommitteeStore((state) => state.timer_remaining_seconds); -const [Seconds, setRemainingSeconds] = useState(0); + const [Seconds, setRemainingSeconds] = useState(0); - useEffect(() => { - if (!timerIsRunning) { - return; - } + useEffect(() => { + if (!timerIsRunning) { + return; + } - const updateRemainingSeconds = () => { - const expiration = new Date(timerExpiration ?? "").getTime(); + const updateRemainingSeconds = () => { + const expiration = new Date(timerExpiration ?? "").getTime(); - setRemainingSeconds( - Number.isNaN(expiration) - ? 0 - : Math.max(0, Math.floor((expiration - Date.now()) / 1_000)), - ); - }; + setRemainingSeconds( + Number.isNaN(expiration) + ? 0 + : Math.max(0, Math.floor((expiration - Date.now()) / 1_000)), + ); + }; - const frame = window.requestAnimationFrame(updateRemainingSeconds); - const interval = window.setInterval(updateRemainingSeconds, 1_000); + const frame = window.requestAnimationFrame(updateRemainingSeconds); + const interval = window.setInterval(updateRemainingSeconds, 1_000); - return () => { - window.cancelAnimationFrame(frame); - window.clearInterval(interval); - }; - }, [timerExpiration, timerIsRunning]); + return () => { + window.cancelAnimationFrame(frame); + window.clearInterval(interval); + }; + }, [timerExpiration, timerIsRunning]); - const remainingSeconds = timerIsRunning - ? Seconds - : (timerRemaining ?? 0); + const remainingSeconds = timerIsRunning + ? Seconds + : (timerRemaining ?? 0); - return (
-
-
- -

Com a Palavra:

-
-
- -

{speaker.name}

-
-
- -
-

{Math.floor(remainingSeconds / 60).toString().padStart(2, '0')}:{(remainingSeconds % 60).toString().padStart(2, '0')}

- - {isChair && ( -
- - - -
- )} -
+ return (
+
+
+ +

Com a Palavra:

+
+
+ +

{speaker.name}

+
+
+ +
+

{Math.floor(remainingSeconds / 60).toString().padStart(2, '0')}:{(remainingSeconds % 60).toString().padStart(2, '0')}

-
) + {isChair && ( +
+ + + +
+ )} +
+ +
) } diff --git a/frontend/src/components/ui/flags.tsx b/frontend/src/components/ui/flags.tsx index c64d545..97f4cd9 100644 --- a/frontend/src/components/ui/flags.tsx +++ b/frontend/src/components/ui/flags.tsx @@ -1,41 +1,36 @@ -import { useEffect, useState } from "react" +import { useState } from "react" import { cn } from "@/lib/utils" export default function Flags({ - code, - className = "h-10", + code, + className = "h-10", }: { - code: string - className?: string + code: string + className?: string }) { - const normalized = (code || "").toLowerCase() - const baseSrc = `${import.meta.env.BASE_URL}flags/${normalized}.svg` - const publicSrc = `${import.meta.env.BASE_URL}public/flags/${normalized}.svg` - const [src, setSrc] = useState(baseSrc) - const [usedFallback, setUsedFallback] = useState(false) + const normalized = (code || "").toLowerCase() + const baseSrc = `${import.meta.env.BASE_URL}flags/${normalized}.svg` + const publicSrc = `${import.meta.env.BASE_URL}public/flags/${normalized}.svg` + const [src, setSrc] = useState(baseSrc) + const [usedFallback, setUsedFallback] = useState(false) - useEffect(() => { - setSrc(baseSrc) - setUsedFallback(false) - }, [baseSrc]) + return ( + {code} { + if (!usedFallback) { + setUsedFallback(true) + setSrc(publicSrc) + return + } - return ( - {code} { - if (!usedFallback) { - setUsedFallback(true) - setSrc(publicSrc) - return - } - - // hide only if both paths fail - ;(e.currentTarget as HTMLImageElement).style.display = "none" - }} - /> - ) -} \ No newline at end of file + // hide only if both paths fail + ; (e.currentTarget as HTMLImageElement).style.display = "none" + }} + /> + ) +} diff --git a/frontend/src/context/SessionContext.tsx b/frontend/src/context/SessionContext.tsx index 14ded7e..b63f222 100644 --- a/frontend/src/context/SessionContext.tsx +++ b/frontend/src/context/SessionContext.tsx @@ -50,6 +50,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { const data = JSON.parse(event.data) as ServerSessionMessage; console.log("Received data", data); switch (data.type) { + case 'state_snapshot': { + UpdateStore(data.state) + break; + } case 'dispatch_result': { UpdateStore(data.state); if (data.effect) { diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index de726ca..0e284c7 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); + baseUrl: 'http://localhost:8000' | (string & {}); }; /** From 91ff6fc11ccc73e0622bef136578f1ebd12aa725 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:39:13 -0300 Subject: [PATCH 6/8] ci: exclude schemas from sonar duplication analysis --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/sonar-project.properties b/sonar-project.properties index d633df7..a60de9d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -15,3 +15,4 @@ sonar.tests=backend/app/tests sonar.exclusions=backend/app/tests/**, frontend/src/components/ui/** sonar.coverage.exclusions=frontend/src/** +sonar.cpd.exclusions=frontend/src/schemas/types.gen.ts From e6e3a396ea75ce7b983a22c512a42d0f6f9efe78 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:40:32 -0300 Subject: [PATCH 7/8] refactor(frontend): refactor flag calculation for timer --- frontend/src/components/ui/flags.tsx | 40 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/ui/flags.tsx b/frontend/src/components/ui/flags.tsx index 97f4cd9..3222fb7 100644 --- a/frontend/src/components/ui/flags.tsx +++ b/frontend/src/components/ui/flags.tsx @@ -2,18 +2,21 @@ import { useState } from "react" import { cn } from "@/lib/utils" -export default function Flags({ - code, - className = "h-10", -}: { - code: string +interface FlagsProps { + code?: string | null className?: string -}) { - const normalized = (code || "").toLowerCase() +} + +function FlagImage({ code, className }: { code: string, className?: string }) { + const normalized = code.toLowerCase().trim() const baseSrc = `${import.meta.env.BASE_URL}flags/${normalized}.svg` const publicSrc = `${import.meta.env.BASE_URL}public/flags/${normalized}.svg` const [src, setSrc] = useState(baseSrc) - const [usedFallback, setUsedFallback] = useState(false) + const [hasError, setHasError] = useState(false) + + if (hasError) { + return null + } return ( {code} { - if (!usedFallback) { - setUsedFallback(true) + onError={() => { + if (src != publicSrc) { setSrc(publicSrc) - return + } else { + setHasError(true) } - - // hide only if both paths fail - ; (e.currentTarget as HTMLImageElement).style.display = "none" }} /> ) } + +export default function Flags({ code, className = "h-10" }: FlagsProps) { + const normalized = (code || "").toLowerCase().trim() + if (!normalized || normalized === "null" || normalized === "0") { + return null + } + + // Passing key={normalized} tells React to create a fresh FlagImage whenever the code changes + return +} From 7322ef3ae7d71bb3e1df2f4bf75ba6855d61b7dc Mon Sep 17 00:00:00 2001 From: Extroias <91105220+Extroias@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:50:01 -0300 Subject: [PATCH 8/8] fix: changed flag size to fix timer box --- frontend/src/components/session/timer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/session/timer.tsx b/frontend/src/components/session/timer.tsx index 7c86c8f..22da730 100644 --- a/frontend/src/components/session/timer.tsx +++ b/frontend/src/components/session/timer.tsx @@ -63,7 +63,7 @@ export default function Timer() {

Com a Palavra:

- +

{speaker.name}