From a75edfacfbb2ba6548bb6b5607ae56b9863975cd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Jun 2025 00:20:22 +0000 Subject: [PATCH 1/5] Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue. --- backend/app/api/main.py | 15 +- backend/app/api/routes/events.py | 185 +++++++++ backend/app/api/routes/speeches.py | 179 +++++++++ backend/app/crud.py | 211 +++++++++- backend/app/models.py | 176 +++++++- .../app/services/speech_analysis_service.py | 136 +++++++ frontend/src/client/sdk.gen.ts | 375 +++++++++++++++++- frontend/src/client/types.gen.ts | 146 +++++++ .../Analysis/SpeechAnalysisDisplay.tsx | 152 +++++++ .../src/components/Events/EventCreateForm.tsx | 157 ++++++++ .../src/components/Events/EventDetailPage.tsx | 137 +++++++ frontend/src/components/Events/EventList.tsx | 111 ++++++ .../src/components/Events/EventListItem.tsx | 56 +++ .../Events/EventParticipantManager.tsx | 230 +++++++++++ .../components/Speeches/SpeechCreateForm.tsx | 192 +++++++++ .../components/Speeches/SpeechDetailPage.tsx | 261 ++++++++++++ .../src/components/Speeches/SpeechList.tsx | 121 ++++++ .../components/Speeches/SpeechListItem.tsx | 69 ++++ .../Speeches/SpeechVersionHistory.tsx | 187 +++++++++ frontend/src/mocks/mockData.ts | 261 ++++++++++++ .../src/routes/_layout/events/$eventId.tsx | 24 ++ frontend/src/routes/_layout/events/create.tsx | 11 + frontend/src/routes/_layout/events/index.tsx | 10 + .../src/routes/_layout/speeches/$speechId.tsx | 18 + scripts/generate-client.sh | 0 25 files changed, 3393 insertions(+), 27 deletions(-) create mode 100644 backend/app/api/routes/events.py create mode 100644 backend/app/api/routes/speeches.py create mode 100644 backend/app/services/speech_analysis_service.py create mode 100644 frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx create mode 100644 frontend/src/components/Events/EventCreateForm.tsx create mode 100644 frontend/src/components/Events/EventDetailPage.tsx create mode 100644 frontend/src/components/Events/EventList.tsx create mode 100644 frontend/src/components/Events/EventListItem.tsx create mode 100644 frontend/src/components/Events/EventParticipantManager.tsx create mode 100644 frontend/src/components/Speeches/SpeechCreateForm.tsx create mode 100644 frontend/src/components/Speeches/SpeechDetailPage.tsx create mode 100644 frontend/src/components/Speeches/SpeechList.tsx create mode 100644 frontend/src/components/Speeches/SpeechListItem.tsx create mode 100644 frontend/src/components/Speeches/SpeechVersionHistory.tsx create mode 100644 frontend/src/mocks/mockData.ts create mode 100644 frontend/src/routes/_layout/events/$eventId.tsx create mode 100644 frontend/src/routes/_layout/events/create.tsx create mode 100644 frontend/src/routes/_layout/events/index.tsx create mode 100644 frontend/src/routes/_layout/speeches/$speechId.tsx mode change 100644 => 100755 scripts/generate-client.sh diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..566bd362a1 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,14 +1,17 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, private, users, utils, events, speeches # Added events, speeches from app.core.config import settings api_router = APIRouter() -api_router.include_router(login.router) -api_router.include_router(users.router) -api_router.include_router(utils.router) -api_router.include_router(items.router) +api_router.include_router(login.router) # No prefix, tags=['login'] typically +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) # Assuming utils has a prefix +api_router.include_router(items.router, prefix="/items", tags=["items"]) +api_router.include_router(events.router, prefix="/events", tags=["events"]) # Added +api_router.include_router(speeches.router, prefix="/speeches", tags=["speeches"]) # Added if settings.ENVIRONMENT == "local": - api_router.include_router(private.router) + # Assuming private router also has a prefix if it's for specific resources + api_router.include_router(private.router, prefix="/private", tags=["private"]) diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py new file mode 100644 index 0000000000..ad5863e1ce --- /dev/null +++ b/backend/app/api/routes/events.py @@ -0,0 +1,185 @@ +import uuid +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, Body +from sqlmodel import Session + +from app import crud, models # Removed schemas +from app.api import deps +from app.services import speech_analysis_service + +router = APIRouter() + + +@router.post("/", response_model=models.CoordinationEventPublic, status_code=201) +def create_event( + *, + db: Session = Depends(deps.get_db), + event_in: models.CoordinationEventCreate, + current_user: deps.CurrentUser, +) -> models.CoordinationEvent: + """ + Create a new coordination event. + The current user will be set as the creator and an initial participant. + """ + event = crud.create_event(session=db, event_in=event_in, creator_id=current_user.id) + return event + + +@router.get("/", response_model=List[models.CoordinationEventPublic]) +def list_user_events( + *, + db: Session = Depends(deps.get_db), + current_user: deps.CurrentUser, +) -> Any: + """ + List all events the current user is participating in. + """ + events = crud.get_user_events(session=db, user_id=current_user.id) + return events + + +@router.get("/{event_id}", response_model=models.CoordinationEventPublic) +def get_event_details( + *, + db: Session = Depends(deps.get_db), + event_id: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + Get details of a specific event. User must be a participant. + """ + event = crud.get_event(session=db, event_id=event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check if current user is a participant + is_participant = any(p.user_id == current_user.id for p in event.participants) + if not is_participant and event.creator_id != current_user.id: # Creator also has access + raise HTTPException(status_code=403, detail="Not enough permissions") + return event + + +@router.post("/{event_id}/participants", response_model=models.EventParticipantPublic) +def add_event_participant( + *, + db: Session = Depends(deps.get_db), + event_id: uuid.UUID, + user_id_to_add: uuid.UUID = Body(..., embed=True), + role: str = Body("participant", embed=True), + current_user: deps.CurrentUser, +) -> Any: + """ + Add a user to an event. Only the event creator can add participants. + """ + event = crud.get_event(session=db, event_id=event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + if event.creator_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the event creator can add participants") + + # Check if user to add exists (optional, DB will catch it if not) + user_to_add = db.get(models.User, user_id_to_add) + if not user_to_add: + raise HTTPException(status_code=404, detail="User to add not found") + + participant = crud.add_event_participant( + session=db, event_id=event_id, user_id=user_id_to_add, role=role + ) + if not participant: + raise HTTPException(status_code=400, detail="Participant already in event or other error") + return participant + + +@router.delete("/{event_id}/participants/{user_id_to_remove}", response_model=models.Message) +def remove_event_participant( + *, + db: Session = Depends(deps.get_db), + event_id: uuid.UUID, + user_id_to_remove: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + Remove a participant from an event. + Allowed if: + - Current user is the event creator. + - Current user is removing themselves. + """ + event = crud.get_event(session=db, event_id=event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + is_creator = event.creator_id == current_user.id + is_self_removal = user_id_to_remove == current_user.id + + if not (is_creator or is_self_removal): + raise HTTPException(status_code=403, detail="Not enough permissions to remove participant") + + # Prevent creator from being removed by themselves if they are the last participant (or handle elsewhere) + if is_self_removal and is_creator and len(event.participants) == 1: + raise HTTPException(status_code=400, detail="Creator cannot remove themselves if they are the last participant. Delete the event instead.") + + + removed_participant = crud.remove_event_participant( + session=db, event_id=event_id, user_id=user_id_to_remove + ) + if not removed_participant: + raise HTTPException(status_code=404, detail="Participant not found in this event") + return models.Message(message="Participant removed successfully") + + +@router.get("/{event_id}/participants", response_model=List[models.UserPublic]) +def list_event_participants( + *, + db: Session = Depends(deps.get_db), + event_id: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + List participants of an event. User must be a participant of the event. + """ + event = crud.get_event(session=db, event_id=event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + is_participant = any(p.user_id == current_user.id for p in event.participants) + if not is_participant and event.creator_id != current_user.id: + raise HTTPException(status_code=403, detail="User must be a participant to view other participants") + + participants = crud.get_event_participants(session=db, event_id=event_id) + return participants + + +@router.get("/{event_id}/speech-analysis", response_model=List[models.PersonalizedNudgePublic]) +def get_event_speech_analysis( + *, + db: Session = Depends(deps.get_db), + event_id: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + Perform analysis on speeches within an event and return personalized nudges + for the current user. User must be a participant of the event. + """ + event = crud.get_event(session=db, event_id=event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check if current user is a participant or creator + is_participant = any(p.user_id == current_user.id for p in event.participants) + if not is_participant and event.creator_id != current_user.id: + raise HTTPException(status_code=403, detail="User must be a participant or creator to access speech analysis.") + + all_event_nudges = speech_analysis_service.analyse_event_speeches(db=db, event_id=event_id) + + # Filter nudges for the current user + user_nudges = [ + models.PersonalizedNudgePublic( + nudge_type=n.nudge_type, + message=n.message, + severity=n.severity + ) + for n in all_event_nudges if n.user_id == current_user.id + ] + + return user_nudges diff --git a/backend/app/api/routes/speeches.py b/backend/app/api/routes/speeches.py new file mode 100644 index 0000000000..924282ac55 --- /dev/null +++ b/backend/app/api/routes/speeches.py @@ -0,0 +1,179 @@ +import uuid +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from app import crud, models # Removed schemas +from app.api import deps + +router = APIRouter() + +# Helper function to check if user is participant or creator of the event associated with a speech +def check_event_access_for_speech(db: Session, speech_id: uuid.UUID, user: models.User) -> models.SecretSpeech: + speech = crud.get_speech(session=db, speech_id=speech_id) + if not speech: + raise HTTPException(status_code=404, detail="Speech not found") + + event = crud.get_event(session=db, event_id=speech.event_id) + if not event: + raise HTTPException(status_code=404, detail="Associated event not found") # Should not happen if DB is consistent + + is_participant = any(p.user_id == user.id for p in event.participants) + if not is_participant and event.creator_id != user.id: + raise HTTPException(status_code=403, detail="User does not have access to the event of this speech") + return speech + +# Helper function to check if user is participant or creator of an event +def check_event_membership(db: Session, event_id: uuid.UUID, user: models.User) -> models.CoordinationEvent: + event = crud.get_event(session=db, event_id=event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + is_participant = any(p.user_id == user.id for p in event.participants) + if not is_participant and event.creator_id != user.id: + raise HTTPException(status_code=403, detail="User must be a participant or creator of the event") + return event + + +@router.post("/", response_model=models.SecretSpeechPublic, status_code=201) +def create_speech( + *, + db: Session = Depends(deps.get_db), + speech_in: models.SecretSpeechWithInitialVersionCreate, # Use the new combined schema + current_user: deps.CurrentUser, +) -> Any: + """ + Create a new secret speech. The current user will be set as the creator. + An initial version of the speech is created with the provided draft. + User must be a participant of the specified event. + """ + # Check if user has access to the event + event = check_event_membership(db=db, event_id=speech_in.event_id, user=current_user) + if not event: # Should be handled by check_event_membership raising HTTPException + raise HTTPException(status_code=404, detail="Event not found or user not participant.") + + # The SecretSpeechCreate model is currently empty, so we pass an instance. + # The actual speech metadata (event_id, creator_id) are passed directly to crud.create_speech + db_speech = crud.create_speech( + session=db, + speech_in=models.SecretSpeechCreate(), # Pass empty base model if no direct fields + event_id=speech_in.event_id, + creator_id=current_user.id, + initial_draft=speech_in.initial_speech_draft, + initial_tone=speech_in.initial_speech_tone, + initial_duration=speech_in.initial_estimated_duration_minutes, + ) + return db_speech + + +@router.get("/event/{event_id}", response_model=List[models.SecretSpeechPublic]) +def list_event_speeches( + *, + db: Session = Depends(deps.get_db), + event_id: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + Get all speeches for a given event. User must be a participant of the event. + """ + check_event_membership(db=db, event_id=event_id, user=current_user) + speeches = crud.get_event_speeches(session=db, event_id=event_id) + return speeches + + +@router.get("/{speech_id}", response_model=models.SecretSpeechPublic) # Consider a more detailed model for owner +def get_speech_details( + *, + db: Session = Depends(deps.get_db), + speech_id: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + Get a specific speech. User must have access to the event of this speech. + If the user is the creator of the speech, they might get more details + (e.g. draft of the current version - this needs handling in response shaping). + """ + speech = check_event_access_for_speech(db=db, speech_id=speech_id, user=current_user) + # Basic SecretSpeechPublic doesn't include version details. + # If we want to embed current version, we'd fetch it and combine. + # For now, returning speech metadata. API consumer can fetch versions separately. + return speech + + +@router.post("/{speech_id}/versions", response_model=models.SecretSpeechVersionPublic, status_code=201) +def create_speech_version( + *, + db: Session = Depends(deps.get_db), + speech_id: uuid.UUID, + version_in: models.SecretSpeechVersionCreate, + current_user: deps.CurrentUser, +) -> Any: + """ + Create a new version for a secret speech. + User must be the creator of the speech or a participant in the event (adjust as needed). + """ + speech = crud.get_speech(session=db, speech_id=speech_id) + if not speech: + raise HTTPException(status_code=404, detail="Speech not found") + + # Permission: only speech creator can add versions + if speech.creator_id != current_user.id: + # Or, check event participation if that's the rule: + # check_event_access_for_speech(db=db, speech_id=speech_id, user=current_user) + raise HTTPException(status_code=403, detail="Only the speech creator can add new versions.") + + new_version = crud.create_speech_version( + session=db, version_in=version_in, speech_id=speech_id, creator_id=current_user.id + ) + return new_version + + +@router.get("/{speech_id}/versions", response_model=List[models.SecretSpeechVersionPublic]) +def list_speech_versions( + *, + db: Session = Depends(deps.get_db), + speech_id: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + List all versions of a speech. + If current user is speech creator, they see full details (including draft). + Otherwise, they see the public version (no draft). + """ + speech = check_event_access_for_speech(db=db, speech_id=speech_id, user=current_user) + versions = crud.get_speech_versions(session=db, speech_id=speech_id) + + public_versions = [] + for v in versions: + if speech.creator_id == current_user.id or v.creator_id == current_user.id: # Speech creator or version creator sees draft + public_versions.append(models.SecretSpeechVersionDetailPublic.model_validate(v)) + else: + public_versions.append(models.SecretSpeechVersionPublic.model_validate(v)) + return public_versions + + +@router.put("/{speech_id}/versions/{version_id}/set-current", response_model=models.SecretSpeechPublic) +def set_current_speech_version( + *, + db: Session = Depends(deps.get_db), + speech_id: uuid.UUID, + version_id: uuid.UUID, + current_user: deps.CurrentUser, +) -> Any: + """ + Set a specific version of a speech as the current one. + User must be the creator of the speech. + """ + speech = crud.get_speech(session=db, speech_id=speech_id) + if not speech: + raise HTTPException(status_code=404, detail="Speech not found") + if speech.creator_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the speech creator can set the current version.") + + updated_speech = crud.set_current_speech_version( + session=db, speech_id=speech_id, version_id=version_id + ) + if not updated_speech: + raise HTTPException(status_code=404, detail="Version not found or does not belong to this speech.") + return updated_speech diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..62f87878b0 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,10 +1,25 @@ import uuid from typing import Any -from sqlmodel import Session, select +from sqlmodel import Session, select, func # Added func from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import ( # Updated imports + Item, + ItemCreate, + User, + UserCreate, + UserUpdate, + CoordinationEvent, + CoordinationEventCreate, + EventParticipant, + EventParticipantCreate, + SecretSpeech, + SecretSpeechCreate, + SecretSpeechVersion, + SecretSpeechVersionCreate, + UserPublic, # Added for get_event_participants +) def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -52,3 +67,195 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.commit() session.refresh(db_item) return db_item + + +# CoordinationEvent CRUD +def create_event( + *, session: Session, event_in: CoordinationEventCreate, creator_id: uuid.UUID +) -> CoordinationEvent: + db_event = CoordinationEvent.model_validate(event_in, update={"creator_id": creator_id}) + session.add(db_event) + session.commit() + session.refresh(db_event) + + # Add creator as the first participant + add_event_participant( + session=session, event_id=db_event.id, user_id=creator_id, role="creator" + ) + session.refresh(db_event) # Refresh to get updated participants list + return db_event + + +def get_event(*, session: Session, event_id: uuid.UUID) -> CoordinationEvent | None: + statement = select(CoordinationEvent).where(CoordinationEvent.id == event_id) + return session.exec(statement).first() + + +def get_user_events(*, session: Session, user_id: uuid.UUID) -> list[CoordinationEvent]: + statement = ( + select(CoordinationEvent) + .join(EventParticipant) + .where(EventParticipant.user_id == user_id) + ) + return session.exec(statement).all() + + +def add_event_participant( + *, session: Session, event_id: uuid.UUID, user_id: uuid.UUID, role: str = "participant" +) -> EventParticipant | None: + # Check if event and user exist + event = get_event(session=session, event_id=event_id) + if not event: + return None + # TODO: Check if user exists (assuming user_id is validated upstream or by DB) + + # Check if participant already exists + existing_participant_statement = select(EventParticipant).where( + EventParticipant.event_id == event_id, EventParticipant.user_id == user_id + ) + if session.exec(existing_participant_statement).first(): + return None # Or raise an exception/return existing + + participant_in = EventParticipantCreate(event_id=event_id, user_id=user_id, role=role) + db_participant = EventParticipant.model_validate(participant_in) + session.add(db_participant) + session.commit() + session.refresh(db_participant) + return db_participant + + +def remove_event_participant( + *, session: Session, event_id: uuid.UUID, user_id: uuid.UUID +) -> EventParticipant | None: + statement = select(EventParticipant).where( + EventParticipant.event_id == event_id, EventParticipant.user_id == user_id + ) + participant_to_delete = session.exec(statement).first() + if participant_to_delete: + session.delete(participant_to_delete) + session.commit() + return participant_to_delete + return None + + +def get_event_participants(*, session: Session, event_id: uuid.UUID) -> list[UserPublic]: + statement = ( + select(User) + .join(EventParticipant) + .where(EventParticipant.event_id == event_id) + ) + users = session.exec(statement).all() + return [UserPublic.model_validate(user) for user in users] + + +# SecretSpeech CRUD +def create_speech( + *, + session: Session, + speech_in: SecretSpeechCreate, + event_id: uuid.UUID, + creator_id: uuid.UUID, + initial_draft: str, + initial_tone: str = "neutral", # Default tone + initial_duration: int = 5, # Default duration in minutes +) -> SecretSpeech: + db_speech = SecretSpeech.model_validate( + speech_in, update={"event_id": event_id, "creator_id": creator_id} + ) + session.add(db_speech) + session.commit() + session.refresh(db_speech) + + # Create initial SecretSpeechVersion + version_in = SecretSpeechVersionCreate( + speech_draft=initial_draft, + speech_tone=initial_tone, + estimated_duration_minutes=initial_duration, + ) + # Note: create_speech_version handles version_number automatically + initial_version = create_speech_version( + session=session, version_in=version_in, speech_id=db_speech.id, creator_id=creator_id + ) + + # Set current_version_id + db_speech.current_version_id = initial_version.id + session.add(db_speech) + session.commit() + session.refresh(db_speech) + return db_speech + + +def get_speech(*, session: Session, speech_id: uuid.UUID) -> SecretSpeech | None: + statement = select(SecretSpeech).where(SecretSpeech.id == speech_id) + return session.exec(statement).first() + + +def get_event_speeches(*, session: Session, event_id: uuid.UUID) -> list[SecretSpeech]: + statement = select(SecretSpeech).where(SecretSpeech.event_id == event_id) + return session.exec(statement).all() + + +# SecretSpeechVersion CRUD +def create_speech_version( + *, + session: Session, + version_in: SecretSpeechVersionCreate, + speech_id: uuid.UUID, + creator_id: uuid.UUID, +) -> SecretSpeechVersion: + # Determine next version_number + current_max_version_statement = select(func.max(SecretSpeechVersion.version_number)).where( + SecretSpeechVersion.speech_id == speech_id + ) + max_version = session.exec(current_max_version_statement).one_or_none() + next_version_number = (max_version + 1) if max_version is not None else 1 + + db_version = SecretSpeechVersion.model_validate( + version_in, + update={ + "speech_id": speech_id, + "creator_id": creator_id, + "version_number": next_version_number, + }, + ) + session.add(db_version) + session.commit() + session.refresh(db_version) + return db_version + + +def get_speech_versions( + *, session: Session, speech_id: uuid.UUID +) -> list[SecretSpeechVersion]: + statement = ( + select(SecretSpeechVersion) + .where(SecretSpeechVersion.speech_id == speech_id) + .order_by(SecretSpeechVersion.version_number) # Or .order_by(SecretSpeechVersion.created_at) + ) + return session.exec(statement).all() + + +def get_speech_version( + *, session: Session, version_id: uuid.UUID +) -> SecretSpeechVersion | None: + statement = select(SecretSpeechVersion).where(SecretSpeechVersion.id == version_id) + return session.exec(statement).first() + + +def set_current_speech_version( + *, session: Session, speech_id: uuid.UUID, version_id: uuid.UUID +) -> SecretSpeech | None: + speech = get_speech(session=session, speech_id=speech_id) + if not speech: + return None + + version = get_speech_version(session=session, version_id=version_id) + if not version or version.speech_id != speech_id: + # Ensure the version belongs to this speech + return None + + speech.current_version_id = version_id + session.add(speech) + session.commit() + session.refresh(speech) + return speech diff --git a/backend/app/models.py b/backend/app/models.py index 2389b4a532..432d2a88f5 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,9 @@ import uuid +from datetime import datetime # Added +from typing import List, Optional # Added from pydantic import EmailStr -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import Field, Relationship, SQLModel # Removed sa_column_kwargs from import # Shared properties @@ -44,6 +46,10 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + coordinated_events: List["CoordinationEvent"] = Relationship(back_populates="creator") # Added + event_participations: List["EventParticipant"] = Relationship(back_populates="user") # Added + created_speeches: List["SecretSpeech"] = Relationship(back_populates="creator") # Added + created_speech_versions: List["SecretSpeechVersion"] = Relationship(back_populates="creator") # Added # Properties to return via API, id is always required @@ -111,3 +117,171 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=40) + + +# CoordinationEvent +class CoordinationEventBase(SQLModel): + event_type: str + event_name: str + event_date: datetime + + +class CoordinationEventCreate(CoordinationEventBase): + pass + + +class CoordinationEventUpdate(SQLModel): + event_type: Optional[str] = None + event_name: Optional[str] = None + event_date: Optional[datetime] = None + + +class CoordinationEvent(CoordinationEventBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + creator_id: uuid.UUID = Field(foreign_key="user.id") + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow, sa_column_kwargs={"onupdate": datetime.utcnow}) + + creator: "User" = Relationship(back_populates="coordinated_events") + participants: List["EventParticipant"] = Relationship(back_populates="event", cascade_delete=True) + secret_speeches: List["SecretSpeech"] = Relationship(back_populates="event", cascade_delete=True) + + +class CoordinationEventPublic(CoordinationEventBase): + id: uuid.UUID + creator_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +# EventParticipant +class EventParticipantBase(SQLModel): + role: str + user_id: uuid.UUID = Field(foreign_key="user.id") + event_id: uuid.UUID = Field(foreign_key="coordinationevent.id") + + +class EventParticipantCreate(EventParticipantBase): + pass + + +class EventParticipant(EventParticipantBase, table=True): + __tablename__ = "event_participant" # Explicit table name for association table + event_id: uuid.UUID = Field(foreign_key="coordinationevent.id", primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", primary_key=True) + added_at: datetime = Field(default_factory=datetime.utcnow) + + event: CoordinationEvent = Relationship(back_populates="participants") + user: "User" = Relationship(back_populates="event_participations") + + +class EventParticipantPublic(SQLModel): + user_id: uuid.UUID + event_id: uuid.UUID + role: str + added_at: datetime + + +# SecretSpeech +class SecretSpeechBase(SQLModel): + pass # Add fields if there are any common editable fields not related to versioning + + +class SecretSpeechCreate(SecretSpeechBase): + # Typically, the first version's content would be part of this + # or handled in a service layer that creates speech and its first version. + # This schema is for the DB model, API creation might use a different one (see below) + pass + + +# Schema for creating a SecretSpeech along with its first version via API +class SecretSpeechWithInitialVersionCreate(SecretSpeechBase): # Inherits any base fields from SecretSpeechBase + event_id: uuid.UUID + # Initial version fields + initial_speech_draft: str + initial_speech_tone: str = "neutral" + initial_estimated_duration_minutes: int = 5 + + +class SecretSpeechUpdate(SQLModel): + # e.g., for changing metadata if any, or current_version_id + current_version_id: Optional[uuid.UUID] = None + + +class SecretSpeech(SecretSpeechBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + event_id: uuid.UUID = Field(foreign_key="coordinationevent.id") + creator_id: uuid.UUID = Field(foreign_key="user.id") + # Using Optional[uuid.UUID] and sa_column_kwargs={"defer": True} is not directly supported by SQLModel for FKs in this way. + # Instead, ensure SecretSpeechVersion is defined or use forward reference if needed. + # For now, making it nullable. If it's an FK, it needs a target. + current_version_id: uuid.UUID | None = Field(default=None, foreign_key="secretspeechversion.id", nullable=True) # Deferring not standard in SQLModel like in pure SQLAlchemy + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow, sa_column_kwargs={"onupdate": datetime.utcnow}) + + event: CoordinationEvent = Relationship(back_populates="secret_speeches") + creator: "User" = Relationship(back_populates="created_speeches") + versions: List["SecretSpeechVersion"] = Relationship(back_populates="speech", cascade_delete=True) + # current_version: Optional["SecretSpeechVersion"] = Relationship(sa_relationship_kwargs={'foreign_keys': '[SecretSpeech.current_version_id]', 'lazy': 'joined'}) # This is more complex with SQLModel + + +class SecretSpeechPublic(SecretSpeechBase): + id: uuid.UUID + event_id: uuid.UUID + creator_id: uuid.UUID + current_version_id: uuid.UUID | None + created_at: datetime + updated_at: datetime + + +# SecretSpeechVersion +class SecretSpeechVersionBase(SQLModel): + speech_draft: str # Sensitive, not in public by default + speech_tone: str + estimated_duration_minutes: int + + +class SecretSpeechVersionCreate(SecretSpeechVersionBase): + pass + + +class SecretSpeechVersion(SecretSpeechVersionBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + speech_id: uuid.UUID = Field(foreign_key="secretspeech.id") + version_number: int + created_at: datetime = Field(default_factory=datetime.utcnow) + creator_id: uuid.UUID = Field(foreign_key="user.id") # To track who created this version + + speech: SecretSpeech = Relationship(back_populates="versions") + creator: "User" = Relationship(back_populates="created_speech_versions") + + +class SecretSpeechVersionPublic(SQLModel): + id: uuid.UUID + speech_id: uuid.UUID + version_number: int + # speech_draft is excluded for non-owner/creator + speech_tone: str + estimated_duration_minutes: int + created_at: datetime + creator_id: uuid.UUID # Consider if this should be exposed, or just creator's public info + + +# More nuanced SecretSpeechVersionPublic for owners to see draft +class SecretSpeechVersionDetailPublic(SecretSpeechVersionPublic): + speech_draft: str + + +# PersonalizedNudge Schemas +class PersonalizedNudgeBase(SQLModel): + nudge_type: str # e.g., "tone_mismatch", "keyword_overlap", "length_discrepancy" + message: str # The actual advice + severity: str # e.g., "info", "warning" + +# This is the public version of the nudge, intended for API responses. +# It does not include user_id because the endpoint will filter for the current user. +class PersonalizedNudgePublic(PersonalizedNudgeBase): + pass # Inherits all fields from PersonalizedNudgeBase + +# If we were to store nudges, we might have a PersonalizedNudgeDB model here. +# For now, PersonalizedNudge will be an internal dataclass in the service. diff --git a/backend/app/services/speech_analysis_service.py b/backend/app/services/speech_analysis_service.py new file mode 100644 index 0000000000..a58f50a3f1 --- /dev/null +++ b/backend/app/services/speech_analysis_service.py @@ -0,0 +1,136 @@ +import uuid +from typing import List, Dict, Any, Optional +from collections import Counter +from dataclasses import dataclass, field # Using dataclass for internal model + +from sqlmodel import Session +from app import crud, models + +# Internal representation of a nudge +@dataclass +class PersonalizedNudge: + user_id: uuid.UUID # For whom the nudge is intended + nudge_type: str # e.g., "tone_mismatch", "keyword_overlap", "length_discrepancy" + message: str # The actual advice + severity: str # e.g., "info", "warning", "suggestion" + related_speech_ids: List[uuid.UUID] = field(default_factory=list) # Optional: to link nudge to specific speeches + + +# Simplified speech data for analysis +@dataclass +class SpeechData: + speech_id: uuid.UUID + creator_id: uuid.UUID + draft: str + tone: str + duration: int + version_id: uuid.UUID + + +# Basic stopwords (extend as needed) +STOPWORDS = set([ + "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", + "have", "has", "had", "do", "does", "did", "will", "would", "should", + "can", "could", "may", "might", "must", "and", "but", "or", "nor", + "for", "so", "yet", "in", "on", "at", "by", "from", "to", "with", + "about", "above", "after", "again", "against", "all", "am", "as", + "at", "because", "before", "below", "between", "both", "but", "by", + "can't", "cannot", "could've", "couldn't", "didn't", "doesn't", + "don't", "down", "during", "each", "few", "further", "hadn't", + "hasn't", "haven't", "he", "he'd", "he'll", "he's", "her", "here", + "here's", "hers", "herself", "him", "himself", "his", "how", "how's", + "i", "i'd", "i'll", "i'm", "i've", "if", "into", "it", "it's", "its", + "itself", "let's", "me", "more", "most", "mustn't", "my", "myself", + "no", "not", "of", "off", "once", "only", "other", "ought", "our", + "ours", "ourselves", "out", "over", "own", "same", "shan't", "she", + "she'd", "she'll", "she's", "should've", "shouldn't", "so", "some", + "such", "than", "that", "that's", "their", "theirs", "them", + "themselves", "then", "there", "there's", "these", "they", "they'd", + "they'll", "they're", "they've", "this", "those", "through", "too", + "under", "until", "up", "very", "wasn't", "we", "we'd", "we'll", + "we're", "we've", "weren't", "what", "what's", "when", "when's", + "where", "where's", "which", "while", "who", "who's", "whom", "why", + "why's", "won't", "wouldn't", "you", "you'd", "you'll", "you're", + "you've", "your", "yours", "yourself", "yourselves", "it.", "this." +]) + +def basic_extract_keywords(text: str, num_keywords: int = 5) -> List[str]: + words = [word.lower().strip(".,!?;:'\"()") for word in text.split()] + filtered_words = [word for word in words if word and word not in STOPWORDS and len(word)>2] + if not filtered_words: + return [] + word_counts = Counter(filtered_words) + return [word for word, count in word_counts.most_common(num_keywords)] + +def analyse_event_speeches(db: Session, event_id: uuid.UUID) -> List[PersonalizedNudge]: + all_nudges: List[PersonalizedNudge] = [] + + event_speeches = crud.get_event_speeches(session=db, event_id=event_id) + if not event_speeches or len(event_speeches) < 1: # Need at least 1 speech for some analysis, 2 for comparison + return [] + + speech_data_list: List[SpeechData] = [] + for speech in event_speeches: + if speech.current_version_id: + version = crud.get_speech_version(session=db, version_id=speech.current_version_id) + if version and version.speech_draft: # Ensure there is a draft to analyze + speech_data_list.append( + SpeechData( + speech_id=speech.id, + creator_id=speech.creator_id, + draft=version.speech_draft, + tone=version.speech_tone, + duration=version.estimated_duration_minutes, + version_id=version.id + ) + ) + else: + # Nudge for speeches without a current version or draft + all_nudges.append(PersonalizedNudge( + user_id=speech.creator_id, + nudge_type="missing_draft", + message=f"Your speech '{crud.get_speech(session=db, speech_id=speech.id).event_name if hasattr(crud.get_speech(session=db, speech_id=speech.id), 'event_name') else 'Unnamed Speech'}' doesn't have a current version with a draft. Add a draft to include it in the analysis.", + severity="warning", + related_speech_ids=[speech.id] + )) + + if len(speech_data_list) < 2: # Most comparisons need at least two speeches with drafts + # Add nudges if only one speech has a draft? For now, returning early. + return all_nudges + + + # --- Perform Comparisons (Iterating through pairs) --- + for i in range(len(speech_data_list)): + for j in range(i + 1, len(speech_data_list)): + s1 = speech_data_list[i] + s2 = speech_data_list[j] + + # 1. Tone Comparison + if s1.tone.lower() != s2.tone.lower(): + msg1 = f"Your speech tone ('{s1.tone}') differs from another participant's ('{s2.tone}'). Consider if this contrast is intentional and how it contributes to the event's flow." + all_nudges.append(PersonalizedNudge(s1.creator_id, "tone_mismatch", msg1, "suggestion", [s1.speech_id, s2.speech_id])) + msg2 = f"Your speech tone ('{s2.tone}') differs from another participant's ('{s1.tone}'). Consider if this contrast is intentional and how it contributes to the event's flow." + all_nudges.append(PersonalizedNudge(s2.creator_id, "tone_mismatch", msg2, "suggestion", [s1.speech_id, s2.speech_id])) + + # 2. Length Comparison (e.g., if difference > 50% of shorter speech, or a fixed threshold) + shorter = min(s1.duration, s2.duration) + longer = max(s1.duration, s2.duration) + if longer > shorter * 1.5 and longer - shorter > 3: # Difference of at least 50% and 3 mins + msg_s1 = f"Your speech is {s1.duration} mins. Another participant's speech is {s2.duration} mins. You might want to coordinate lengths for better event balance." + all_nudges.append(PersonalizedNudge(s1.creator_id, "length_discrepancy", msg_s1, "suggestion", [s1.speech_id, s2.speech_id])) + msg_s2 = f"Your speech is {s2.duration} mins. Another participant's speech is {s1.duration} mins. You might want to coordinate lengths for better event balance." + all_nudges.append(PersonalizedNudge(s2.creator_id, "length_discrepancy", msg_s2, "suggestion", [s1.speech_id, s2.speech_id])) + + # 3. Basic Keyword Overlap + keywords1 = basic_extract_keywords(s1.draft, num_keywords=5) # Using the utility + keywords2 = basic_extract_keywords(s2.draft, num_keywords=5) # Using the utility + + common_keywords = set(keywords1) & set(keywords2) + if len(common_keywords) >= 1: # If at least 1 common important keyword + kw_str = ", ".join(list(common_keywords)[:3]) # Show up to 3 + msg1 = f"You and another participant both seem to touch on themes like '{kw_str}'. This could be a good link, or ensure you're bringing unique perspectives." + all_nudges.append(PersonalizedNudge(s1.creator_id, "keyword_overlap", msg1, "info", [s1.speech_id, s2.speech_id])) + msg2 = f"You and another participant both seem to touch on themes like '{kw_str}'. This could be a good link, or ensure you're bringing unique perspectives." + all_nudges.append(PersonalizedNudge(s2.creator_id, "keyword_overlap", msg2, "info", [s1.speech_id, s2.speech_id])) + + return all_nudges diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 156003aec9..1c7126baf5 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -4,6 +4,19 @@ import type { CancelablePromise } from "./core/CancelablePromise" import { OpenAPI } from "./core/OpenAPI" import { request as __request } from "./core/request" import type { + EventsListUserEventsResponse, + EventsCreateEventData, + EventsCreateEventResponse, + EventsGetEventDetailsData, + EventsGetEventDetailsResponse, + EventsAddEventParticipantData, + EventsAddEventParticipantResponse, + EventsListEventParticipantsData, + EventsListEventParticipantsResponse, + EventsRemoveEventParticipantData, + EventsRemoveEventParticipantResponse, + EventsGetEventSpeechAnalysisData, + EventsGetEventSpeechAnalysisResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, @@ -25,6 +38,18 @@ import type { LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, + SpeechesCreateSpeechData, + SpeechesCreateSpeechResponse, + SpeechesListEventSpeechesData, + SpeechesListEventSpeechesResponse, + SpeechesGetSpeechDetailsData, + SpeechesGetSpeechDetailsResponse, + SpeechesCreateSpeechVersionData, + SpeechesCreateSpeechVersionResponse, + SpeechesListSpeechVersionsData, + SpeechesListSpeechVersionsResponse, + SpeechesSetCurrentSpeechVersionData, + SpeechesSetCurrentSpeechVersionResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, @@ -48,6 +73,168 @@ import type { UtilsHealthCheckResponse, } from "./types.gen" +export class EventsService { + /** + * List User Events + * List all events the current user is participating in. + * @returns CoordinationEventPublic Successful Response + * @throws ApiError + */ + public static listUserEvents(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/events/", + }) + } + + /** + * Create Event + * Create a new coordination event. + * The current user will be set as the creator and an initial participant. + * @param data The data for the request. + * @param data.requestBody + * @returns CoordinationEventPublic Successful Response + * @throws ApiError + */ + public static createEvent( + data: EventsCreateEventData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/events/", + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Get Event Details + * Get details of a specific event. User must be a participant. + * @param data The data for the request. + * @param data.eventId + * @returns CoordinationEventPublic Successful Response + * @throws ApiError + */ + public static getEventDetails( + data: EventsGetEventDetailsData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/events/{event_id}", + path: { + event_id: data.eventId, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Add Event Participant + * Add a user to an event. Only the event creator can add participants. + * @param data The data for the request. + * @param data.eventId + * @param data.requestBody + * @returns EventParticipantPublic Successful Response + * @throws ApiError + */ + public static addEventParticipant( + data: EventsAddEventParticipantData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/events/{event_id}/participants", + path: { + event_id: data.eventId, + }, + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * List Event Participants + * List participants of an event. User must be a participant of the event. + * @param data The data for the request. + * @param data.eventId + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static listEventParticipants( + data: EventsListEventParticipantsData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/events/{event_id}/participants", + path: { + event_id: data.eventId, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Remove Event Participant + * Remove a participant from an event. + * Allowed if: + * - Current user is the event creator. + * - Current user is removing themselves. + * @param data The data for the request. + * @param data.eventId + * @param data.userIdToRemove + * @returns Message Successful Response + * @throws ApiError + */ + public static removeEventParticipant( + data: EventsRemoveEventParticipantData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/api/v1/events/{event_id}/participants/{user_id_to_remove}", + path: { + event_id: data.eventId, + user_id_to_remove: data.userIdToRemove, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Get Event Speech Analysis + * Perform analysis on speeches within an event and return personalized nudges + * for the current user. User must be a participant of the event. + * @param data The data for the request. + * @param data.eventId + * @returns PersonalizedNudgePublic Successful Response + * @throws ApiError + */ + public static getEventSpeechAnalysis( + data: EventsGetEventSpeechAnalysisData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/events/{event_id}/speech-analysis", + path: { + event_id: data.eventId, + }, + errors: { + 422: "Validation Error", + }, + }) + } +} + export class ItemsService { /** * Read Items @@ -63,7 +250,7 @@ export class ItemsService { ): CancelablePromise { return __request(OpenAPI, { method: "GET", - url: "/api/v1/items/", + url: "/api/v1/items/items/", query: { skip: data.skip, limit: data.limit, @@ -87,7 +274,7 @@ export class ItemsService { ): CancelablePromise { return __request(OpenAPI, { method: "POST", - url: "/api/v1/items/", + url: "/api/v1/items/items/", body: data.requestBody, mediaType: "application/json", errors: { @@ -109,7 +296,7 @@ export class ItemsService { ): CancelablePromise { return __request(OpenAPI, { method: "GET", - url: "/api/v1/items/{id}", + url: "/api/v1/items/items/{id}", path: { id: data.id, }, @@ -133,7 +320,7 @@ export class ItemsService { ): CancelablePromise { return __request(OpenAPI, { method: "PUT", - url: "/api/v1/items/{id}", + url: "/api/v1/items/items/{id}", path: { id: data.id, }, @@ -158,7 +345,7 @@ export class ItemsService { ): CancelablePromise { return __request(OpenAPI, { method: "DELETE", - url: "/api/v1/items/{id}", + url: "/api/v1/items/items/{id}", path: { id: data.id, }, @@ -288,7 +475,108 @@ export class PrivateService { ): CancelablePromise { return __request(OpenAPI, { method: "POST", - url: "/api/v1/private/users/", + url: "/api/v1/private/private/users/", + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } +} + +export class SpeechesService { + /** + * Create Speech + * Create a new secret speech. The current user will be set as the creator. + * An initial version of the speech is created with the provided draft. + * User must be a participant of the specified event. + * @param data The data for the request. + * @param data.requestBody + * @returns SecretSpeechPublic Successful Response + * @throws ApiError + */ + public static createSpeech( + data: SpeechesCreateSpeechData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/speeches/", + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * List Event Speeches + * Get all speeches for a given event. User must be a participant of the event. + * @param data The data for the request. + * @param data.eventId + * @returns SecretSpeechPublic Successful Response + * @throws ApiError + */ + public static listEventSpeeches( + data: SpeechesListEventSpeechesData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/speeches/event/{event_id}", + path: { + event_id: data.eventId, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Get Speech Details + * Get a specific speech. User must have access to the event of this speech. + * If the user is the creator of the speech, they might get more details + * (e.g. draft of the current version - this needs handling in response shaping). + * @param data The data for the request. + * @param data.speechId + * @returns SecretSpeechPublic Successful Response + * @throws ApiError + */ + public static getSpeechDetails( + data: SpeechesGetSpeechDetailsData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/speeches/{speech_id}", + path: { + speech_id: data.speechId, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Create Speech Version + * Create a new version for a secret speech. + * User must be the creator of the speech or a participant in the event (adjust as needed). + * @param data The data for the request. + * @param data.speechId + * @param data.requestBody + * @returns SecretSpeechVersionPublic Successful Response + * @throws ApiError + */ + public static createSpeechVersion( + data: SpeechesCreateSpeechVersionData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/speeches/{speech_id}/versions", + path: { + speech_id: data.speechId, + }, body: data.requestBody, mediaType: "application/json", errors: { @@ -296,6 +584,57 @@ export class PrivateService { }, }) } + + /** + * List Speech Versions + * List all versions of a speech. + * If current user is speech creator, they see full details (including draft). + * Otherwise, they see the public version (no draft). + * @param data The data for the request. + * @param data.speechId + * @returns SecretSpeechVersionPublic Successful Response + * @throws ApiError + */ + public static listSpeechVersions( + data: SpeechesListSpeechVersionsData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/speeches/{speech_id}/versions", + path: { + speech_id: data.speechId, + }, + errors: { + 422: "Validation Error", + }, + }) + } + + /** + * Set Current Speech Version + * Set a specific version of a speech as the current one. + * User must be the creator of the speech. + * @param data The data for the request. + * @param data.speechId + * @param data.versionId + * @returns SecretSpeechPublic Successful Response + * @throws ApiError + */ + public static setCurrentSpeechVersion( + data: SpeechesSetCurrentSpeechVersionData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/api/v1/speeches/{speech_id}/versions/{version_id}/set-current", + path: { + speech_id: data.speechId, + version_id: data.versionId, + }, + errors: { + 422: "Validation Error", + }, + }) + } } export class UsersService { @@ -313,7 +652,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "GET", - url: "/api/v1/users/", + url: "/api/v1/users/users/", query: { skip: data.skip, limit: data.limit, @@ -337,7 +676,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "POST", - url: "/api/v1/users/", + url: "/api/v1/users/users/", body: data.requestBody, mediaType: "application/json", errors: { @@ -355,7 +694,7 @@ export class UsersService { public static readUserMe(): CancelablePromise { return __request(OpenAPI, { method: "GET", - url: "/api/v1/users/me", + url: "/api/v1/users/users/me", }) } @@ -368,7 +707,7 @@ export class UsersService { public static deleteUserMe(): CancelablePromise { return __request(OpenAPI, { method: "DELETE", - url: "/api/v1/users/me", + url: "/api/v1/users/users/me", }) } @@ -385,7 +724,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "PATCH", - url: "/api/v1/users/me", + url: "/api/v1/users/users/me", body: data.requestBody, mediaType: "application/json", errors: { @@ -407,7 +746,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "PATCH", - url: "/api/v1/users/me/password", + url: "/api/v1/users/users/me/password", body: data.requestBody, mediaType: "application/json", errors: { @@ -429,7 +768,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "POST", - url: "/api/v1/users/signup", + url: "/api/v1/users/users/signup", body: data.requestBody, mediaType: "application/json", errors: { @@ -451,7 +790,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "GET", - url: "/api/v1/users/{user_id}", + url: "/api/v1/users/users/{user_id}", path: { user_id: data.userId, }, @@ -475,7 +814,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "PATCH", - url: "/api/v1/users/{user_id}", + url: "/api/v1/users/users/{user_id}", path: { user_id: data.userId, }, @@ -500,7 +839,7 @@ export class UsersService { ): CancelablePromise { return __request(OpenAPI, { method: "DELETE", - url: "/api/v1/users/{user_id}", + url: "/api/v1/users/users/{user_id}", path: { user_id: data.userId, }, @@ -525,7 +864,7 @@ export class UtilsService { ): CancelablePromise { return __request(OpenAPI, { method: "POST", - url: "/api/v1/utils/test-email/", + url: "/api/v1/utils/utils/test-email/", query: { email_to: data.emailTo, }, @@ -543,7 +882,7 @@ export class UtilsService { public static healthCheck(): CancelablePromise { return __request(OpenAPI, { method: "GET", - url: "/api/v1/utils/health-check/", + url: "/api/v1/utils/utils/health-check/", }) } } diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 67d4abd286..b35b442a2f 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1,5 +1,10 @@ // This file is auto-generated by @hey-api/openapi-ts +export type Body_events_add_event_participant = { + user_id_to_add: string + role?: string +} + export type Body_login_login_access_token = { grant_type?: string | null username: string @@ -9,6 +14,29 @@ export type Body_login_login_access_token = { client_secret?: string | null } +export type CoordinationEventCreate = { + event_type: string + event_name: string + event_date: string +} + +export type CoordinationEventPublic = { + event_type: string + event_name: string + event_date: string + id: string + creator_id: string + created_at: string + updated_at: string +} + +export type EventParticipantPublic = { + user_id: string + event_id: string + role: string + added_at: string +} + export type HTTPValidationError = { detail?: Array } @@ -44,6 +72,12 @@ export type NewPassword = { new_password: string } +export type PersonalizedNudgePublic = { + nudge_type: string + message: string + severity: string +} + export type PrivateUserCreate = { email: string password: string @@ -51,6 +85,38 @@ export type PrivateUserCreate = { is_verified?: boolean } +export type SecretSpeechPublic = { + id: string + event_id: string + creator_id: string + current_version_id: string | null + created_at: string + updated_at: string +} + +export type SecretSpeechVersionCreate = { + speech_draft: string + speech_tone: string + estimated_duration_minutes: number +} + +export type SecretSpeechVersionPublic = { + id: string + speech_id: string + version_number: number + speech_tone: string + estimated_duration_minutes: number + created_at: string + creator_id: string +} + +export type SecretSpeechWithInitialVersionCreate = { + event_id: string + initial_speech_draft: string + initial_speech_tone?: string + initial_estimated_duration_minutes?: number +} + export type Token = { access_token: string token_type?: string @@ -107,6 +173,47 @@ export type ValidationError = { type: string } +export type EventsListUserEventsResponse = Array + +export type EventsCreateEventData = { + requestBody: CoordinationEventCreate +} + +export type EventsCreateEventResponse = CoordinationEventPublic + +export type EventsGetEventDetailsData = { + eventId: string +} + +export type EventsGetEventDetailsResponse = CoordinationEventPublic + +export type EventsAddEventParticipantData = { + eventId: string + requestBody: Body_events_add_event_participant +} + +export type EventsAddEventParticipantResponse = EventParticipantPublic + +export type EventsListEventParticipantsData = { + eventId: string +} + +export type EventsListEventParticipantsResponse = Array + +export type EventsRemoveEventParticipantData = { + eventId: string + userIdToRemove: string +} + +export type EventsRemoveEventParticipantResponse = Message + +export type EventsGetEventSpeechAnalysisData = { + eventId: string +} + +export type EventsGetEventSpeechAnalysisResponse = + Array + export type ItemsReadItemsData = { limit?: number skip?: number @@ -171,6 +278,45 @@ export type PrivateCreateUserData = { export type PrivateCreateUserResponse = UserPublic +export type SpeechesCreateSpeechData = { + requestBody: SecretSpeechWithInitialVersionCreate +} + +export type SpeechesCreateSpeechResponse = SecretSpeechPublic + +export type SpeechesListEventSpeechesData = { + eventId: string +} + +export type SpeechesListEventSpeechesResponse = Array + +export type SpeechesGetSpeechDetailsData = { + speechId: string +} + +export type SpeechesGetSpeechDetailsResponse = SecretSpeechPublic + +export type SpeechesCreateSpeechVersionData = { + requestBody: SecretSpeechVersionCreate + speechId: string +} + +export type SpeechesCreateSpeechVersionResponse = SecretSpeechVersionPublic + +export type SpeechesListSpeechVersionsData = { + speechId: string +} + +export type SpeechesListSpeechVersionsResponse = + Array + +export type SpeechesSetCurrentSpeechVersionData = { + speechId: string + versionId: string +} + +export type SpeechesSetCurrentSpeechVersionResponse = SecretSpeechPublic + export type UsersReadUsersData = { limit?: number skip?: number diff --git a/frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx b/frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx new file mode 100644 index 0000000000..869ba4f9a9 --- /dev/null +++ b/frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx @@ -0,0 +1,152 @@ +import React, { useState, useCallback } from 'react'; +import { + Box, + Button, + VStack, + List, + ListItem, + Text, + Heading, + Spinner, + Alert, + AlertIcon, + AlertTitle, + AlertDescription, + Tag, + HStack, + Icon, +} from '@chakra-ui/react'; +import { InfoIcon, WarningIcon, CheckCircleIcon, QuestionOutlineIcon } from '@chakra-ui/icons'; // Example icons +// import { EventsService, PersonalizedNudgePublic as ApiNudge } from '../../client'; // Step 7 +import { mockNudges as globalMockNudges, PersonalizedNudgePublic } from '../../mocks/mockData'; // Import mock nudges + + +interface SpeechAnalysisDisplayProps { + eventId: string; +} + +// NudgeSeverityIcon and NudgeSeverityColorScheme can remain as they are, or be moved to a utils file if preferred. +const NudgeSeverityIcon: React.FC<{ severity: string }> = ({ severity }) => { + switch (severity.toLowerCase()) { + case 'warning': + return ; + case 'info': + return ; + case 'suggestion': + return + default: + return ; // Default or for "success" like severities + } +}; + +const NudgeSeverityColorScheme = (severity: string): string => { + switch (severity.toLowerCase()) { + case 'warning': return 'orange'; + case 'info': return 'blue'; + case 'suggestion': return 'purple'; + default: return 'gray'; + } +} + +const SpeechAnalysisDisplay: React.FC = ({ eventId }) => { + const [nudges, setNudges] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasAnalyzed, setHasAnalyzed] = useState(false); + const [error, setError] = useState(null); + // const { user } = useAuth(); // To pass currentUserId if backend needs it for filtering (though plan is backend filters) + + const handleFetchAnalysis = useCallback(async () => { + setIsLoading(true); + setError(null); + setHasAnalyzed(true); + try { + console.log(`SpeechAnalysisDisplay: Fetching analysis for event ${eventId}`); + // const fetchedNudges = await EventsService.getEventSpeechAnalysis({ eventId }); // Step 7 + await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate API call + + // Backend is expected to filter nudges for the current user. + // So, for mock, we just return all globalMockNudges if the eventId is one we have speeches for. + // A more sophisticated mock might check if currentUserId has speeches in that event. + if (eventId === 'event-001-wedding' || eventId === 'event-002-techconf') { + setNudges(globalMockNudges); + } else if (eventId === 'event-003-bookclub') { // Event with no speeches initially, or different user + setNudges([]); // No nudges for this event or user + } + else { + // For other eventIds in mock, or if we want to simulate an error for specific event + // throw new Error("Mock: Analysis data not available for this specific event."); + setNudges([]); // Default to no nudges for unknown mock events + } + + } catch (err) { + console.error(`Failed to fetch speech analysis for event ${eventId}:`, err); + const errorMessage = (err instanceof Error) ? err.message : 'An unknown error occurred.'; + setError(`Failed to load analysis (mock). ${errorMessage}`); + setNudges([]); // Clear previous nudges on error + } finally { + setIsLoading(false); + } + }, [eventId]); + + return ( + + + Personalized Speech Suggestions + + + + + {isLoading && ( + + + Generating your suggestions... + + )} + + {!isLoading && error && ( + + + Error Fetching Analysis! + {error} + + )} + + {!isLoading && !error && hasAnalyzed && nudges.length === 0 && ( + + + No Specific Nudges! + No specific suggestions for you at this moment, or all speeches align well! + + )} + + {!isLoading && !error && nudges.length > 0 && ( + + {nudges.map((nudge, index) => ( + + + } w={5} h={5} mt={1} /> + + + {nudge.nudge_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + + {nudge.message} + + + + ))} + + )} + + + ); +}; + +export default SpeechAnalysisDisplay; diff --git a/frontend/src/components/Events/EventCreateForm.tsx b/frontend/src/components/Events/EventCreateForm.tsx new file mode 100644 index 0000000000..f631d9b207 --- /dev/null +++ b/frontend/src/components/Events/EventCreateForm.tsx @@ -0,0 +1,157 @@ +import React, { useState } from 'react'; +import { + Button, + FormControl, + FormLabel, + Input, + VStack, + Heading, + useToast, + Select, // For event_type dropdown +} from '@chakra-ui/react'; +// import { EventsService, CoordinationEventCreate as ApiCoordinationEventCreate } from '../../client'; // Step 7 +// import { useAuth } from '../../hooks/useAuth'; // If needed for user context +import { addMockEvent, currentUserId, mockUserAlice } from '../../mocks/mockData'; // Import mock function and user +import { CoordinationEventPublic } from './EventListItem'; // To shape the object for mock list + +// Temporary interface until client is fully integrated (matches backend Pydantic model) +interface CoordinationEventCreatePayload { + event_name: string; + event_type: string; + event_date?: string; // ISO string format for date +} + +// Predefined event types - can be expanded +const eventTypes = [ + { value: "wedding_speech_pair", label: "Wedding Speech Pair" }, + { value: "vows_exchange", label: "Vows Exchange" }, + { value: "team_presentation", label: "Team Presentation" }, + { value: "debate_session", label: "Debate Session" }, + { value: "other", label: "Other" }, +]; + +const EventCreateForm: React.FC = () => { + const [eventName, setEventName] = useState(''); + const [eventType, setEventType] = useState(eventTypes[0].value); // Default to first type + const [eventDate, setEventDate] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const toast = useToast(); + // const { user } = useAuth(); + // const actualCreatorId = user?.id || currentUserId; // Use logged-in user or fallback to mock + const actualCreatorId = currentUserId; // Using mock currentUserId + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + if (!eventName.trim() || !eventType) { + toast({ + title: 'Missing fields', + description: 'Event Name and Type are required.', + status: 'error', + duration: 5000, + isClosable: true, + }); + setIsLoading(false); + return; + } + + const payload: CoordinationEventCreatePayload = { + event_name: eventName, + event_type: eventType, + ...(eventDate && { event_date: new Date(eventDate).toISOString() }), + }; + + try { + console.log('Submitting event data to mock:', payload); + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Create a full CoordinationEventPublic object for the mock list + const newMockEvent: CoordinationEventPublic = { + id: `event-mock-${Date.now()}`, // Simple unique ID for mock + ...payload, + creator_id: actualCreatorId, + // creator_name: mockUserAlice.full_name, // Assuming Alice is creating + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + addMockEvent(newMockEvent); // Add to the shared mock data array + + toast({ + title: 'Event Created (Mock)', + description: `${newMockEvent.event_name} has been successfully created.`, + status: 'success', + duration: 5000, + isClosable: true, + }); + setEventName(''); + setEventType(eventTypes[0].value); + setEventDate(''); + // Potentially redirect or update a list of events + } catch (error) { + console.error('Failed to create event:', error); + toast({ + title: 'Creation Failed', + description: 'There was an error creating the event. Please try again.', + status: 'error', + duration: 5000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Create New Coordination Event + + + Event Name + setEventName(e.target.value)} + placeholder="e.g., Alice & Bob's Wedding Speeches" + /> + + + + Event Type + + + + + Event Date (Optional) + setEventDate(e.target.value)} + /> + + + + + ); +}; + +export default EventCreateForm; diff --git a/frontend/src/components/Events/EventDetailPage.tsx b/frontend/src/components/Events/EventDetailPage.tsx new file mode 100644 index 0000000000..0864e429dd --- /dev/null +++ b/frontend/src/components/Events/EventDetailPage.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from '@tanstack/react-router'; // For accessing route parameters +import { + Box, + Heading, + Text, + VStack, + Spinner, + Alert, + AlertIcon, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + Divider, +} from '@chakra-ui/react'; +// import { EventsService } from '../../client'; // Step 7 +import { CoordinationEventPublic } from './EventListItem'; // Reuse existing interface +import EventParticipantManager from './EventParticipantManager'; +import SpeechList from '../Speeches/SpeechList'; // Added for integration +import SpeechAnalysisDisplay from '../Analysis/SpeechAnalysisDisplay'; // Added for integration +import { modifiableMockEvents, currentUserId } from '../../mocks/mockData'; // Import mock data + +const EventDetailPage: React.FC = () => { + const { eventId } = useParams({ from: '/_layout/events/$eventId' }); // Adjusted 'from' to match route definition + + const [event, setEvent] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + // const { user } = useAuth(); // For currentUserId + // const actualCurrentUserId = user?.id || currentUserId; + const actualCurrentUserId = currentUserId; // Using mock + + useEffect(() => { + if (!eventId) { + setError('Event ID not found in URL.'); + setIsLoading(false); + return; + } + + const fetchEventDetails = async () => { + setIsLoading(true); + setError(null); + try { + console.log(`EventDetailPage: Fetching event ${eventId}`); + await new Promise(resolve => setTimeout(resolve, 750)); + const foundEvent = modifiableMockEvents.find(e => e.id === eventId); + if (foundEvent) { + setEvent(foundEvent); + } else { + throw new Error("Event not found in mock data"); + } + } catch (err) { + console.error(`Failed to fetch event details for ${eventId}:`, err); + setError(`Failed to load event details. Please check the event ID or try again later.`); + } finally { + setIsLoading(false); + } + }; + + fetchEventDetails(); + }, [eventId]); + + const formatDate = (dateString?: string) => { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' + }); + }; + + if (isLoading) { + return ( + + + Loading event details... + + ); + } + + if (error) { + return ( + + + {error} + + ); + } + + if (!event) { + return ( + + + No event data available. + + ); + } + + return ( + + + {event.event_name} + + + Type: {event.event_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + Date: {formatDate(event.event_date)} + Created: {formatDate(event.created_at)} + Last Updated: {formatDate(event.updated_at)} + Event ID: {event.id} + Creator ID: {event.creator_id} + + + + + + + Participants + Speeches + Analysis & Nudges + + + + {/* currentUserId can be passed if needed by manager */} + + + + + + + + + + + ); +}; + +export default EventDetailPage; diff --git a/frontend/src/components/Events/EventList.tsx b/frontend/src/components/Events/EventList.tsx new file mode 100644 index 0000000000..9db63a4077 --- /dev/null +++ b/frontend/src/components/Events/EventList.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Button, + Heading, + VStack, + Text, + Spinner, + Alert, + AlertIcon, + SimpleGrid, +} from '@chakra-ui/react'; +import EventListItem, { CoordinationEventPublic } from './EventListItem'; // Import the item component and its type +// import { EventsService } from '../../client'; // Step 7 +import { Link as RouterLink } from '@tanstack/react-router'; // For "Create New" button +import { modifiableMockEvents, currentUserId, mockParticipants } from '../../mocks/mockData'; // Using shared mock data & mockParticipants + +const EventList: React.FC = () => { + // const { user } = useAuth(); // To get currentUserId for filtering if API returns all events + // For mock, we assume API would return events for the current user, or we filter them here. + // The backend `get_user_events` already filters by participation. + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchEvents = async () => { + setIsLoading(true); + setError(null); + try { + // Simulate API call + console.log("EventList: Fetching events for user:", currentUserId); + await new Promise(resolve => setTimeout(resolve, 750)); + + // Filter events to show only those where the current user is a participant or creator + const userEventIds = new Set(); + mockParticipants.forEach(p => { + if (p.user_id === currentUserId) { + userEventIds.add(p.event_id); + } + }); + + modifiableMockEvents.forEach(event => { + if (event.creator_id === currentUserId) { + userEventIds.add(event.id); + } + }); + + const userVisibleEvents = modifiableMockEvents.filter(event => userEventIds.has(event.id)); + setEvents(userVisibleEvents); + } catch (err) { + console.error('Failed to fetch events:', err); + setError('Failed to load events. Please try again later.'); + } finally { + setIsLoading(false); + } + }; + + fetchEvents(); + // Dependency array should be empty if we only fetch once on mount. + // If `modifiableMockEvents` could change from outside due to other components + // and we want this list to reflect that without a full page reload or prop drilling, + // a more complex state management (like Zustand, Redux, or React Context) would be needed. + // For now, this basic fetch-once is fine for mock data. + }, []); + + if (isLoading) { + return ( + + + Loading your events... + + ); + } + + if (error) { + return ( + + + {error} + + ); + } + + return ( + + + + Your Coordination Events + + + + + {events.length === 0 ? ( + + You are not part of any coordination events yet. Why not create one? + + ) : ( + + {events.map((event) => ( + + ))} + + )} + + ); +}; + +export default EventList; diff --git a/frontend/src/components/Events/EventListItem.tsx b/frontend/src/components/Events/EventListItem.tsx new file mode 100644 index 0000000000..e87904969f --- /dev/null +++ b/frontend/src/components/Events/EventListItem.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Box, Text, Heading, LinkBox, LinkOverlay } from '@chakra-ui/react'; +import { Link as RouterLink } from '@tanstack/react-router'; // Assuming usage for navigation + +// Interface based on CoordinationEventPublic schema (backend/app/models.py) +// Replace with actual import from client when available (Step 7) +export interface CoordinationEventPublic { + id: string; // Assuming UUID is string + event_name: string; + event_type: string; + event_date?: string; // ISO string + creator_id: string; + created_at: string; // ISO string + updated_at: string; // ISO string +} + +interface EventListItemProps { + event: CoordinationEventPublic; +} + +const EventListItem: React.FC = ({ event }) => { + const formatDate = (dateString?: string) => { + if (!dateString) return 'Date not set'; + try { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } catch (error) { + console.error("Error formatting date:", dateString, error); + return "Invalid date"; + } + }; + + return ( + + + + {event.event_name} + + + + Type: {event.event_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + + + Date: {formatDate(event.event_date)} + + + Event ID: {event.id} + + + ); +}; + +export default EventListItem; diff --git a/frontend/src/components/Events/EventParticipantManager.tsx b/frontend/src/components/Events/EventParticipantManager.tsx new file mode 100644 index 0000000000..840ef4e3ac --- /dev/null +++ b/frontend/src/components/Events/EventParticipantManager.tsx @@ -0,0 +1,230 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + Button, + FormControl, + FormLabel, + Input, + VStack, + HStack, + List, + ListItem, + Text, + IconButton, + useToast, + Spinner, + Alert, + AlertIcon, + Select, // For role selection + Heading, +} from '@chakra-ui/react'; +import { CloseIcon } from '@chakra-ui/icons'; +// import { EventsService, UserPublic as ApiUserPublic, EventParticipantPublic as ApiEventParticipantPublic, /* EventParticipantCreate */ } from '../../client'; // Step 7 +import { + mockParticipants, + addMockParticipant, + removeMockParticipant, + currentUserId, // To check against for permissions, e.g. can't remove self if creator + mockUserAlice, // For mocking newly added user details + mockUserBob, // For mocking newly added user details + mockUserCharlie // For mocking newly added user details +} from '../../mocks/mockData'; + +// Using existing placeholder interface, assuming structure is compatible +// Ensure UserPublic is defined if it's used for the 'user' field in EventParticipantPublic +export interface UserPublic { // Defined here if not imported from another component's def + id: string; + email: string; + full_name?: string; + is_active: boolean; + is_superuser: boolean; +} +export interface EventParticipantPublic { + user_id: string; + event_id: string; + role: string; + added_at: string; + user?: UserPublic; +} + +// Matches the Pydantic model for the request body of add_event_participant endpoint +interface AddParticipantPayload { + user_id_to_add: string; + role: string; +} + +interface EventParticipantManagerProps { + eventId: string; + // eventCreatorId?: string; // Optional: to implement specific creator permissions +} + +const participantRoles = ["participant", "speaker", "organizer", "scribe", "admin", "bride", "groom", "officiant"]; +// Mock user lookup - in real app, this might involve an API call to search users +const mockUserLookup: Record = { + [mockUserAlice.email]: mockUserAlice, + [mockUserAlice.id]: mockUserAlice, + [mockUserBob.email]: mockUserBob, + [mockUserBob.id]: mockUserBob, + [mockUserCharlie.email]: mockUserCharlie, + [mockUserCharlie.id]: mockUserCharlie, + "newuser@example.com": {id: "user-new-temp", email: "newuser@example.com", full_name: "New User", is_active: true, is_superuser: false} +}; + + +const EventParticipantManager: React.FC = ({ eventId }) => { + const [participants, setParticipants] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [userInput, setUserInput] = useState(''); // For user email or ID to add + const [newUserRole, setNewUserRole] = useState(participantRoles[0]); + const [isAdding, setIsAdding] = useState(false); + // const { user: loggedInUser } = useAuth(); // For permission checks + const loggedInUserId = currentUserId; // Using mock + + const toast = useToast(); + + const fetchParticipants = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + console.log(`EventParticipantManager: Fetching participants for event ${eventId}`); + await new Promise(resolve => setTimeout(resolve, 700)); + setParticipants(mockParticipants.filter(p => p.event_id === eventId)); + } catch (err) { + console.error('Failed to fetch participants:', err); + setError('Failed to load participants.'); + } finally { + setIsLoading(false); + } + }, [eventId]); + + useEffect(() => { + fetchParticipants(); + }, [fetchParticipants]); + + const handleAddParticipant = async (e: React.FormEvent) => { + e.preventDefault(); + if (!userInput.trim() || !newUserRole) { + toast({ title: "User email/ID or role missing", status: "warning", duration: 3000, isClosable: true }); + return; + } + setIsAdding(true); + + // Simulate resolving email to user_id or using ID directly + const userToAdd = mockUserLookup[userInput.toLowerCase()] || { id: userInput, email: userInput, full_name: `User ${userInput.substring(0,8)}`, is_active: true, is_superuser: false }; + + if (participants.find(p => p.user_id === userToAdd.id)) { + toast({ title: "Already Participant", description: `${userToAdd.full_name || userToAdd.email} is already in this event.`, status: "info" }); + setIsAdding(false); + return; + } + + const payload: AddParticipantPayload = { user_id_to_add: userToAdd.id, role: newUserRole }; + + try { + console.log("Adding participant (mock):", payload); + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API + + const newParticipantEntry: EventParticipantPublic = { + event_id: eventId, + user_id: userToAdd.id, + role: newUserRole, + added_at: new Date().toISOString(), + user: userToAdd + }; + addMockParticipant(newParticipantEntry); // Add to global mock store + fetchParticipants(); // Re-fetch to update local list + + toast({ title: 'Participant Added (Mock)', description: `${userToAdd.full_name || userToAdd.email} added as ${newUserRole}.`, status: 'success' }); + setUserInput(''); + setNewUserRole(participantRoles[0]); + } catch (err) { + console.error('Failed to add participant:', err); + toast({ title: 'Failed to Add (Mock)', description: 'Could not add participant.', status: 'error' }); + } finally { + setIsAdding(false); + } + }; + + const handleRemoveParticipant = async (userIdToRemove: string) => { + // Add permission check: e.g., only event creator or self-removal + // const event = modifiableMockEvents.find(e => e.id === eventId); + // if (loggedInUserId !== event?.creator_id && loggedInUserId !== userIdToRemove) { + // toast({ title: "Permission Denied", status: "error"}); return; + // } + if (!window.confirm(`Are you sure you want to remove participant ${mockUserLookup[userIdToRemove]?.full_name || userIdToRemove}?`)) return; + + try { + console.log("Removing participant (mock):", userIdToRemove, "from event", eventId); + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API + + removeMockParticipant(eventId, userIdToRemove); // Remove from global mock store + fetchParticipants(); // Re-fetch to update local list + + toast({ title: 'Participant Removed (Mock)', description: `Participant removed.`, status: 'success' }); + } catch (err) { + console.error('Failed to remove participant:', err); + toast({ title: 'Failed to Remove (Mock)', description: 'Could not remove participant.', status: 'error' }); + } + }; + + if (isLoading) return ; + if (error) return {error}; + + return ( + + Manage Participants + + + User Email or ID to Add + setUserInput(e.target.value)} + placeholder="Enter user's email or exact ID" + /> + + + Role + + + + + + Current Participants + {participants.length === 0 ? ( + No participants yet. + ) : ( + + {participants.map((p) => ( + + + {p.user?.full_name || p.user?.email || p.user_id} + Role: {p.role} + + {/* Basic permission: Don't allow removing self if creator, or some other logic */} + {/* This should be driven by current_user context eventually */} + } + colorScheme="red" + variant="ghost" + onClick={() => handleRemoveParticipant(p.user_id)} + // isDisabled={p.role === 'creator'} // Example: disable removing creator + /> + + ))} + + )} + + ); +}; + +export default EventParticipantManager; diff --git a/frontend/src/components/Speeches/SpeechCreateForm.tsx b/frontend/src/components/Speeches/SpeechCreateForm.tsx new file mode 100644 index 0000000000..07c4e63efe --- /dev/null +++ b/frontend/src/components/Speeches/SpeechCreateForm.tsx @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import { + Button, + FormControl, + FormLabel, + Input, + Textarea, + VStack, + Heading, + useToast, + Select, + NumberInput, + NumberInputField, + NumberInputStepper, + NumberIncrementStepper, + NumberDecrementStepper, + Box, +} from '@chakra-ui/react'; +// import { SpeechesService, SecretSpeechWithInitialVersionCreate as ApiSpeechCreate } from '../../client'; // Step 7 +import { + addMockSpeech, + currentUserId, + mockUserAlice, + SecretSpeechPublicDetailed, // For constructing the object to add + SecretSpeechVersionData, // For constructing the initial version +} from '../../mocks/mockData'; // Import mock function and user + +// Placeholder interface for the data this form collects (matches backend Pydantic model) +interface SpeechCreateFormPayload { + initial_speech_draft: string; + initial_speech_tone: string; + initial_estimated_duration_minutes: number; +} + +const speechTones = ["neutral", "sentimental", "humorous", "serious", "inspirational", "mixed", "other"]; + +interface SpeechCreateFormProps { + eventId: string; + onSpeechCreated?: () => void; // Optional callback to refresh list or close modal +} + +const SpeechCreateForm: React.FC = ({ eventId, onSpeechCreated }) => { + const [draft, setDraft] = useState(''); + const [tone, setTone] = useState(speechTones[0]); + const [duration, setDuration] = useState(5); // Can be string if input is empty + const [isLoading, setIsLoading] = useState(false); + const toast = useToast(); + // const { user } = useAuth(); + // const actualCreatorId = user?.id || currentUserId; + const actualCreatorId = currentUserId; // From mockData + const actualCreatorName = mockUserAlice.full_name; // Assuming Alice is current user + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + if (!draft.trim() || !tone || duration === '' || Number(duration) <= 0) { + toast({ + title: 'Missing or invalid fields', + description: 'Draft, Tone, and a valid Duration are required.', + status: 'error', + duration: 5000, + isClosable: true, + }); + setIsLoading(false); + return; + } + + // This is what would be sent to the backend API + const apiPayload = { + event_id: eventId, + initial_speech_draft: draft, + initial_speech_tone: tone, + initial_estimated_duration_minutes: Number(duration) + }; + console.log('Simulating API submission with payload:', apiPayload); + + try { + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // For mock data update, construct the full SecretSpeechPublicDetailed object + const newSpeechId = `speech-mock-${Date.now()}`; + const newVersionId = `v-mock-${newSpeechId}-1`; + + const initialVersion: SecretSpeechVersionData = { + id: newVersionId, + version_number: 1, + speech_id: newSpeechId, // Link back to speech + speech_draft: draft, + speech_tone: tone, + estimated_duration_minutes: Number(duration), + created_at: new Date().toISOString(), + creator_id: actualCreatorId, + }; + + const newSpeech: SecretSpeechPublicDetailed = { + id: newSpeechId, + event_id: eventId, + creator_id: actualCreatorId, + creator_name: actualCreatorName, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + current_version_id: newVersionId, + current_version: initialVersion, + }; + + addMockSpeech(newSpeech); // Add to the shared mock data array + + toast({ + title: 'Speech Created (Mock)', + description: 'Your new speech has been successfully created.', + status: 'success', + duration: 5000, + isClosable: true, + }); + setDraft(''); + setTone(speechTones[0]); + setDuration(5); + if (onSpeechCreated) { + onSpeechCreated(); + } + } catch (error) { + console.error('Failed to create speech:', error); + toast({ + title: 'Creation Failed', + description: 'There was an error creating the speech. Please try again.', + status: 'error', + duration: 5000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Add New Speech to Event + + + Initial Speech Draft +