diff --git a/README.md b/README.md index afe124f3fb..454ddc288a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Full Stack FastAPI Template +# Full Stack FastAPI Project Test Coverage @@ -52,165 +52,15 @@ [![API docs](img/docs.png)](https://github.com/fastapi/full-stack-fastapi-template) -## How To Use It +## Getting Started / Local Development Setup -You can **just fork or clone** this repository and use it as is. +Docker Compose is the recommended way to run the project locally. +Use the command `docker compose watch` to start the services. -✨ It just works. ✨ +For more details on Docker Compose, including how to access services (frontend, backend, docs, etc.), please refer to `development.md`. -### How to Use a Private Repository - -If you want to have a private repository, GitHub won't allow you to simply fork it as it doesn't allow changing the visibility of forks. - -But you can do the following: - -- Create a new GitHub repo, for example `my-full-stack`. -- Clone this repository manually, set the name with the name of the project you want to use, for example `my-full-stack`: - -```bash -git clone git@github.com:fastapi/full-stack-fastapi-template.git my-full-stack -``` - -- Enter into the new directory: - -```bash -cd my-full-stack -``` - -- Set the new origin to your new repository, copy it from the GitHub interface, for example: - -```bash -git remote set-url origin git@github.com:octocat/my-full-stack.git -``` - -- Add this repo as another "remote" to allow you to get updates later: - -```bash -git remote add upstream git@github.com:fastapi/full-stack-fastapi-template.git -``` - -- Push the code to your new repository: - -```bash -git push -u origin master -``` - -### Update From the Original Template - -After cloning the repository, and after doing changes, you might want to get the latest changes from this original template. - -- Make sure you added the original repository as a remote, you can check it with: - -```bash -git remote -v - -origin git@github.com:octocat/my-full-stack.git (fetch) -origin git@github.com:octocat/my-full-stack.git (push) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (fetch) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (push) -``` - -- Pull the latest changes without merging: - -```bash -git pull --no-commit upstream master -``` - -This will download the latest changes from this template without committing them, that way you can check everything is right before committing. - -- If there are conflicts, solve them in your editor. - -- Once you are done, commit the changes: - -```bash -git merge --continue -``` - -### Configure - -You can then update configs in the `.env` files to customize your configurations. - -Before deploying it, make sure you change at least the values for: - -- `SECRET_KEY` -- `FIRST_SUPERUSER_PASSWORD` -- `POSTGRES_PASSWORD` - -You can (and should) pass these as environment variables from secrets. - -Read the [deployment.md](./deployment.md) docs for more details. - -### Generate Secret Keys - -Some environment variables in the `.env` file have a default value of `changethis`. - -You have to change them with a secret key, to generate secret keys you can run the following command: - -```bash -python -c "import secrets; print(secrets.token_urlsafe(32))" -``` - -Copy the content and use that as password / secret key. And run that again to generate another secure key. - -## How To Use It - Alternative With Copier - -This repository also supports generating a new project using [Copier](https://copier.readthedocs.io). - -It will copy all the files, ask you configuration questions, and update the `.env` files with your answers. - -### Install Copier - -You can install Copier with: - -```bash -pip install copier -``` - -Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with: - -```bash -pipx install copier -``` - -**Note**: If you have `pipx`, installing copier is optional, you could run it directly. - -### Generate a Project With Copier - -Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`. - -Go to the directory that will be the parent of your project, and run the command with your project's name: - -```bash -copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -If you have `pipx` and you didn't install `copier`, you can run it directly: - -```bash -pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files. - -### Input Variables - -Copier will ask you for some data, you might want to have at hand before generating the project. - -But don't worry, you can just update any of that in the `.env` files afterwards. - -The input variables, with their default values (some auto generated) are: - -- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env). -- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env). -- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above. -- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env). -- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env). -- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env. -- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env. -- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env. -- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env. -- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above. -- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env. +For faster development iteration, frontend and backend services can be run directly on the host machine. +Instructions for this can be found in `frontend/README.md` and `backend/README.md`. ## Backend Development @@ -230,10 +80,18 @@ General development docs: [development.md](./development.md). This includes using Docker Compose, custom local domains, `.env` configurations, etc. -## Release Notes +## Useful Scripts -Check the file [release-notes.md](./release-notes.md). +Here's a list of scripts available in the project to help with common development tasks: -## License +- `scripts/build.sh`: Builds the Docker images for the project. +- `scripts/test.sh`: Runs the complete test suite in a Dockerized environment. This typically includes backend tests and can be expanded to include frontend end-to-end tests. +- `scripts/test-local.sh`: Runs backend tests directly on the host. It assumes the backend services (like the database) are already running (e.g., via `docker compose watch` or a similar local setup). +- `scripts/generate-client.sh`: Generates or updates the frontend client based on the backend's OpenAPI schema. This usually involves fetching the schema and running a code generation tool. +- `backend/scripts/format.sh`: Formats the backend Python code using Ruff to ensure consistent code style. +- `backend/scripts/lint.sh`: Lints the backend Python code using MyPy for static type checking and Ruff for identifying potential errors and style issues. +- `backend/scripts/test.sh`: Runs backend tests directly on the host (similar to `scripts/test-local.sh` but often focused only on backend unit/integration tests) and generates a test coverage report. -The Full Stack FastAPI Template is licensed under the terms of the MIT license. +## Release Notes + +Check the file [release-notes.md](./release-notes.md). diff --git a/backend/README.md b/backend/README.md index 17210a2f2c..85a8cd708d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -11,7 +11,7 @@ Start the local development environment with Docker Compose following the guide ## General Workflow -By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it. +Dependencies are managed with [uv](https://docs.astral.sh/uv/). If you haven't already, please install it. From `./backend/` you can install all the dependencies with: @@ -31,25 +31,25 @@ Modify or add SQLModel models for data and SQL tables in `./backend/app/models.p ## VS Code -There are already configurations in place to run the backend through the VS Code debugger, so that you can use breakpoints, pause and explore variables, etc. +VS Code configurations are provided to run the backend with the debugger, allowing use of breakpoints, variable exploration, etc. -The setup is also already configured so you can run the tests through the VS Code Python tests tab. +The setup also allows running tests via the VS Code Python tests tab. ## Docker Compose Override -During development, you can change Docker Compose settings that will only affect the local development environment in the file `docker-compose.override.yml`. +Docker Compose settings specific to local development can be configured in `docker-compose.override.yml`. -The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow. +These overrides only affect the local development environment, not production, allowing for temporary changes that aid development. -For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast. +For instance, the backend code directory is synchronized with the Docker container, reflecting live code changes inside the container. This allows for immediate testing of changes without rebuilding the Docker image. This live synchronization is intended for development; for production, Docker images should be built with the finalized code. This approach significantly speeds up the development iteration cycle. -There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again: +There is also a command override that runs `fastapi dev` instead of the default command. It starts a single server process (unlike multiple processes typical for production) and reloads the process whenever code changes are detected. Note that a syntax error in a saved Python file will cause the server to break and exit, stopping the container. After fixing the error, the container can be restarted by running: ```console $ docker compose watch ``` -There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes. +A commented-out `command` override is available in `docker-compose.override.yml`. If uncommented (and the default one commented out), it makes the backend container run a minimal process that keeps it alive without starting the main application. This allows you to `exec` into the running container and execute commands manually, such as starting a Python interpreter, testing installed dependencies, or running the development server with live reload. To get inside the container with a `bash` session you can start the stack with: @@ -71,16 +71,16 @@ root@7f2607af31c3:/app# that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`. -There you can use the `fastapi run --reload` command to run the debug live reloading server. +There you can use the `fastapi dev` command to run the debug live reloading server. ```console -$ fastapi run --reload app/main.py +$ fastapi dev app/main.py ``` ...it will look like: ```console -root@7f2607af31c3:/app# fastapi run --reload app/main.py +root@7f2607af31c3:/app# fastapi dev app/main.py ``` and then hit enter. That runs the live reloading server that auto reloads when it detects code changes. @@ -123,7 +123,7 @@ When the tests are run, a file `htmlcov/index.html` is generated, you can open i ## Migrations -As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository. +During local development, the application directory is mounted as a volume within the container. This allows you to run Alembic migration commands inside the container, with the generated migration code appearing directly in your application directory, ready to be committed to Git. Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors. @@ -133,7 +133,7 @@ Make sure you create a "revision" of your models and that you "upgrade" your dat $ docker compose exec backend bash ``` -* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`. +* Alembic is configured to import SQLModel models from `./backend/app/models.py`. * After changing a model (for example, adding a column), inside the container, create a revision, e.g.: @@ -149,7 +149,7 @@ $ alembic revision --autogenerate -m "Add column last_name to User model" $ alembic upgrade head ``` -If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in: +If migrations are not desired for this project, uncomment the lines in `./backend/app/core/db.py` that end with: ```python SQLModel.metadata.create_all(engine) @@ -161,7 +161,7 @@ and comment the line in the file `scripts/prestart.sh` that contains: $ alembic upgrade head ``` -If you don't want to start with the default models and want to remove them / modify them, from the beginning, without having any previous revision, you can remove the revision files (`.py` Python files) under `./backend/app/alembic/versions/`. And then create a first migration as described above. +If you need to reset or start fresh with migrations (e.g., squash existing migrations or initialize a new migration history), you can remove the existing revision files (the `.py` Python files) under `./backend/app/alembic/versions/`. After doing so, you can create a new initial migration as described above. ## Email Templates 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/backend/scripts/format.sh b/backend/scripts/format.sh index 7be2f81205..8996779691 100755 --- a/backend/scripts/format.sh +++ b/backend/scripts/format.sh @@ -1,5 +1,8 @@ #!/bin/sh -e +# Print commands and their arguments as they are executed set -x +# Run ruff to check for linting issues in 'app' and 'scripts' directories and automatically fix them. ruff check app scripts --fix +# Run ruff to format the code in 'app' and 'scripts' directories. ruff format app scripts diff --git a/backend/scripts/lint.sh b/backend/scripts/lint.sh index b3b2b4ecc7..31fb255fc9 100644 --- a/backend/scripts/lint.sh +++ b/backend/scripts/lint.sh @@ -1,8 +1,16 @@ #!/usr/bin/env bash +# Exit in case of error set -e +# Print commands and their arguments as they are executed set -x +# Run Mypy for static type checking on the 'app' directory. mypy app +# Run Ruff to check for linting errors in the 'app' directory. +# This command only reports errors, it does not fix them. ruff check app +# Run Ruff formatter in check mode on the 'app' directory. +# This command reports files that need formatting without actually modifying them. +# It's useful for CI to verify that code is correctly formatted. ruff format app --check diff --git a/backend/scripts/test.sh b/backend/scripts/test.sh index df23f702e3..fce03c63a7 100755 --- a/backend/scripts/test.sh +++ b/backend/scripts/test.sh @@ -1,8 +1,23 @@ #!/usr/bin/env bash +# Exit in case of error set -e +# Print commands and their arguments as they are executed set -x +# Run pytest with coverage measurement. +# --source=app specifies that coverage should be measured for the 'app' directory. +# -m pytest ensures that pytest is run as a module. coverage run --source=app -m pytest + +# Generate a text-based coverage report in the terminal. +# --show-missing will also list the line numbers of code that were not executed. coverage report --show-missing + +# Generate an HTML coverage report. +# The output will be in a directory named 'htmlcov' by default. +# The --title option sets the title of the HTML report. +# "${@-coverage}" attempts to use any arguments passed to this script in the title. +# For example, if the script is called with `backend/scripts/test.sh -k my_test`, +# the title might become "-k my_test-coverage". If no arguments are passed, it will be "-coverage". coverage html --title "${@-coverage}" diff --git a/development.md b/development.md index d7d41d73f1..74c24a7e87 100644 --- a/development.md +++ b/development.md @@ -16,6 +16,8 @@ Backend, JSON based web API based on OpenAPI: http://localhost:8000 Automatic interactive documentation with Swagger UI (from the OpenAPI backend): http://localhost:8000/docs +Automatic alternative documentation with ReDoc (from the OpenAPI backend): http://localhost:8000/redoc + Adminer, database web administration: http://localhost:8080 Traefik UI, to see how the routes are being handled by the proxy: http://localhost:8090 @@ -72,11 +74,11 @@ fastapi dev app/main.py When you start the Docker Compose stack, it uses `localhost` by default, with different ports for each service (backend, frontend, adminer, etc). -When you deploy it to production (or staging), it will deploy each service in a different subdomain, like `api.example.com` for the backend and `dashboard.example.com` for the frontend. +In production or staging environments, services are typically deployed to different subdomains, such as `api.example.com` for the backend and `dashboard.example.com` for the frontend. -In the guide about [deployment](deployment.md) you can read about Traefik, the configured proxy. That's the component in charge of transmitting traffic to each service based on the subdomain. +The [deployment guide](deployment.md) details how Traefik is used as a reverse proxy to route traffic to services based on subdomains. -If you want to test that it's all working locally, you can edit the local `.env` file, and change: +To test domain-based routing locally, you can edit the `.env` file and set: ```dotenv DOMAIN=localhost.tiangolo.com @@ -86,7 +88,7 @@ That will be used by the Docker Compose files to configure the base domain for t Traefik will use this to transmit traffic at `api.localhost.tiangolo.com` to the backend, and traffic at `dashboard.localhost.tiangolo.com` to the frontend. -The domain `localhost.tiangolo.com` is a special domain that is configured (with all its subdomains) to point to `127.0.0.1`. This way you can use that for your local development. +The domain `localhost.tiangolo.com` (and its subdomains) is configured to point to `127.0.0.1`, allowing for local development and testing of domain-based routing. After you update it, run again: @@ -94,7 +96,7 @@ After you update it, run again: docker compose watch ``` -When deploying, for example in production, the main Traefik is configured outside of the Docker Compose files. For local development, there's an included Traefik in `docker-compose.override.yml`, just to let you test that the domains work as expected, for example with `api.localhost.tiangolo.com` and `dashboard.localhost.tiangolo.com`. +While in production the main Traefik instance is typically configured outside the application's Docker Compose setup, for local development, Traefik is included and configured in `docker-compose.override.yml`. This allows testing of domain routing (e.g., `api.localhost.tiangolo.com` and `dashboard.localhost.tiangolo.com`) locally. ## Docker Compose files and env vars @@ -114,23 +116,21 @@ docker compose watch ## The .env file -The `.env` file is the one that contains all your configurations, generated keys and passwords, etc. - -Depending on your workflow, you could want to exclude it from Git, for example if your project is public. In that case, you would have to make sure to set up a way for your CI tools to obtain it while building or deploying your project. +The `.env` file contains all project configurations, including generated keys and passwords. -One way to do it could be to add each environment variable to your CI/CD system, and updating the `docker-compose.yml` file to read that specific env var instead of reading the `.env` file. +It is recommended to exclude the `.env` file from Git, especially for public projects. If excluded, ensure your CI/CD pipeline has a secure way to obtain these configurations during build or deployment (e.g., by setting environment variables directly in the CI/CD system and updating `docker-compose.yml` to read them). ## Pre-commits and code linting -we are using a tool called [pre-commit](https://pre-commit.com/) for code linting and formatting. +This project uses [pre-commit](https://pre-commit.com/) for code linting and formatting. -When you install it, it runs right before making a commit in git. This way it ensures that the code is consistent and formatted even before it is committed. +Installed pre-commit hooks run before each commit, ensuring code consistency and formatting. You can find a file `.pre-commit-config.yaml` with configurations at the root of the project. #### Install pre-commit to run automatically -`pre-commit` is already part of the dependencies of the project, but you could also install it globally if you prefer to, following [the official pre-commit docs](https://pre-commit.com/). +`pre-commit` is included in the project's dependencies. Alternatively, it can be installed globally by following [the official pre-commit docs](https://pre-commit.com/). After having the `pre-commit` tool installed and available, you need to "install" it in the local repository, so that it runs automatically before each commit. diff --git a/frontend/README.md b/frontend/README.md index bbb73cb447..52f681113f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -47,31 +47,10 @@ npm run dev * Then open your browser at http://localhost:5173/. -Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But building the image at every change will not be as productive as running the local development server with live reload. +This live server runs directly on your host for local development, which is the recommended workflow for faster iteration. After making changes, you can build the frontend Docker image to test it in a production-like environment. Building the image on every change is less productive than using the local development server with live reload. Check the file `package.json` to see other available options. -### Removing the frontend - -If you are developing an API-only app and want to remove the frontend, you can do it easily: - -* Remove the `./frontend` directory. - -* In the `docker-compose.yml` file, remove the whole service / section `frontend`. - -* In the `docker-compose.override.yml` file, remove the whole service / section `frontend` and `playwright`. - -Done, you have a frontend-less (api-only) app. 🤓 - ---- - -If you want, you can also remove the `FRONTEND` environment variables from: - -* `.env` -* `./scripts/*.sh` - -But it would be only to clean them up, leaving them won't really have any effect either way. - ## Generate Client ### Automatically @@ -99,7 +78,7 @@ npm run generate-client * Commit the changes. -Notice that everytime the backend changes (changing the OpenAPI schema), you should follow these steps again to update the frontend client. +Remember to regenerate the client whenever the backend OpenAPI schema changes. ## Using a Remote API @@ -125,7 +104,7 @@ The frontend code is structured as follows: ## End-to-End Testing with Playwright -The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command: +End-to-end tests are set up using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command: ```bash docker compose up -d --wait backend 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.test.tsx b/frontend/src/components/Analysis/SpeechAnalysisDisplay.test.tsx new file mode 100644 index 0000000000..8d930d10d4 --- /dev/null +++ b/frontend/src/components/Analysis/SpeechAnalysisDisplay.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ChakraProvider } from '@chakra-ui/react'; +import SpeechAnalysisDisplay, { PersonalizedNudgePublic } from './SpeechAnalysisDisplay'; // Import type if needed for mock +import { useQuery } from '@tanstack/react-query'; + +// Mock @tanstack/react-query's useQuery +vi.mock('@tanstack/react-query', async () => { + const original = await vi.importActual('@tanstack/react-query'); + return { + ...original, + useQuery: vi.fn(), + }; +}); + +// Mock Chakra UI's useToast (if used, though not directly in this component) +// const mockToast = vi.fn(); +// vi.mock('@chakra-ui/react', async () => { +// const originalChakra = await vi.importActual('@chakra-ui/react'); +// return { +// ...originalChakra, +// useToast: () => mockToast, +// }; +// }); + +const renderWithChakraProvider = (ui: React.ReactElement) => { + return render({ui}); +}; + +const mockNudgesData: PersonalizedNudgePublic[] = [ + { nudge_type: 'tone_tip', message: 'Consider a lighter tone.', severity: 'suggestion' }, + { nudge_type: 'length_warning', message: 'Your speech is too long.', severity: 'warning' }, +]; + +describe('SpeechAnalysisDisplay', () => { + const mockRefetch = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + // Default mock for useQuery + (useQuery as ReturnType).mockReturnValue({ + data: undefined, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + } as any); + }); + + it('renders initial state with "Get Personalized Suggestions" button', () => { + renderWithChakraProvider(); + expect(screen.getByRole('button', { name: /get personalized suggestions/i })).toBeInTheDocument(); + expect(screen.queryByText(/consider a lighter tone/i)).not.toBeInTheDocument(); // No nudges initially + }); + + it('calls refetch when the button is clicked', () => { + renderWithChakraProvider(); + const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i }); + fireEvent.click(analyzeButton); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('displays loading spinner when isFetching is true', () => { + (useQuery as ReturnType).mockReturnValue({ + data: undefined, + isFetching: true, + isError: false, + error: null, + refetch: mockRefetch, + } as any); + renderWithChakraProvider(); + // Button might also be disabled or show loading text depending on its own isLoading prop + expect(screen.getByText(/generating your suggestions.../i)).toBeInTheDocument(); + // Check for spinner role if available, or part of button's loading state + expect(screen.getByRole('status')).toBeInTheDocument(); // Chakra's Spinner has role="status" + }); + + it('displays error message when isError is true', async () => { + const testError = { message: 'Failed to fetch analysis' } as any; // Simulate ApiError structure if needed + (useQuery as ReturnType).mockReturnValue({ + data: undefined, + isFetching: false, + isError: true, + error: testError, + refetch: mockRefetch, + } as any); + // Simulate that analysis was attempted by clicking button, which sets hasAnalyzed + renderWithChakraProvider(); + // To ensure the error message for "isError" is shown only after an attempt, + // we need to simulate the button click that sets `hasAnalyzed` to true. + // However, the component directly uses `isError` from `useQuery` now. + // The button click sets `hasAnalyzed` to true, then calls `refetch`. + // If `refetch` leads to `isError`, the message shows. + // For this test, we can assume `hasAnalyzed` becomes true implicitly if isError is true after a fetch. + // A more robust way is to click, then update mock, then check. + // Simplified: If isError is true, and isFetching is false, error should show. + + // To properly test the state after a failed refetch: + const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i }); + fireEvent.click(analyzeButton); // This sets hasAnalyzed=true and calls refetch which returns the error state + + await waitFor(() => { + expect(screen.getByText(/error fetching analysis!/i)).toBeInTheDocument(); + expect(screen.getByText(testError.message, { exact: false })).toBeInTheDocument(); + }); + }); + + it('displays nudges when data is available', () => { + (useQuery as ReturnType).mockReturnValue({ + data: mockNudgesData, + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + } as any); + // To show nudges, `hasAnalyzed` also needs to be true. + renderWithChakraProvider(); + const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i }); + fireEvent.click(analyzeButton); // Sets hasAnalyzed = true & triggers refetch (which returns data) + + waitFor(() => { + expect(screen.getByText(/consider a lighter tone./i)).toBeInTheDocument(); + expect(screen.getByText(/your speech is too long./i)).toBeInTheDocument(); + expect(screen.getByText(/tone_tip/i, { selector: 'span.chakra-tag__label' })).toBeInTheDocument(); // Check tag text + }); + }); + + it('displays "no suggestions" message when data is an empty array after analysis', () => { + (useQuery as ReturnType).mockReturnValue({ + data: [], + isFetching: false, + isError: false, + error: null, + refetch: mockRefetch, + } as any); + renderWithChakraProvider(); + const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i }); + fireEvent.click(analyzeButton); // Sets hasAnalyzed = true + + waitFor(() => { + expect(screen.getByText(/no specific nudges!/i)).toBeInTheDocument(); + }); + }); + + it('button text changes to "Refresh Suggestions" after first analysis attempt if not fetching', () => { + renderWithChakraProvider(); + const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i }); + fireEvent.click(analyzeButton); // Sets hasAnalyzed = true, triggers refetch + // Assuming refetch completes and isFetching becomes false: + // useQuery mock will return isFetching: false by default after this click if not overridden + waitFor(() => { + expect(screen.getByRole('button', { name: /refresh suggestions/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx b/frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx new file mode 100644 index 0000000000..786ba190dd --- /dev/null +++ b/frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; // Removed useCallback +import { useQuery } from '@tanstack/react-query'; +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'; +import { EventsService, PersonalizedNudgePublic, ApiError } from '../../../client'; + +interface SpeechAnalysisDisplayProps { + eventId: string; +} + +const NudgeSeverityIcon: React.FC<{ severity: string }> = ({ severity }) => { + switch (severity.toLowerCase()) { + case 'warning': + return ; + case 'info': + return ; + case 'suggestion': + return + default: + return ; + } +}; + +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 [hasAnalyzed, setHasAnalyzed] = useState(false); + + const { + data: nudges, + isFetching, + isError, + error, + refetch + } = useQuery({ + queryKey: ['speechAnalysis', eventId], + queryFn: async () => { + if (!eventId) throw new Error("Event ID is required for analysis."); + return EventsService.getEventSpeechAnalysis({ eventId }); + }, + enabled: false, + }); + + const handleFetchAnalysisClick = () => { + setHasAnalyzed(true); + refetch(); + }; + + return ( + + + Personalized Speech Suggestions + + + + + {isFetching && ( + + + Generating your suggestions... + + )} + + {!isFetching && isError && ( + + + Error Fetching Analysis! + {error?.body?.detail || error?.message || 'An unexpected error occurred.'} + + )} + + {!isFetching && !isError && hasAnalyzed && (!nudges || nudges.length === 0) && ( + + + No Specific Nudges! + No specific suggestions for you at this moment, or all speeches align well! + + )} + + {!isFetching && !isError && nudges && nudges.length > 0 && ( + + {nudges.map((nudge, index) => ( + + + } w={6} h={6} 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.test.tsx b/frontend/src/components/Events/EventCreateForm.test.tsx new file mode 100644 index 0000000000..aff5840e2f --- /dev/null +++ b/frontend/src/components/Events/EventCreateForm.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ChakraProvider } from '@chakra-ui/react'; // For Chakra components +import EventCreateForm from './EventCreateForm'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +// Mocking @tanstack/react-query +vi.mock('@tanstack/react-query', async () => { + const original = await vi.importActual('@tanstack/react-query'); + return { + ...original, + useMutation: vi.fn(), + useQueryClient: vi.fn(() => ({ + invalidateQueries: vi.fn(), + })), + }; +}); + +// Mocking Chakra UI's useToast +const mockToast = vi.fn(); +vi.mock('@chakra-ui/react', async () => { + const originalChakra = await vi.importActual('@chakra-ui/react'); + return { + ...originalChakra, + useToast: () => mockToast, + }; +}); + +// Mock Tanstack Router navigation (if any redirects were used) +// vi.mock('@tanstack/react-router', async () => ({ +// ...await vi.importActual('@tanstack/react-router'), +// useNavigate: () => vi.fn(), +// })); + + +// Helper to render with ChakraProvider +const renderWithChakraProvider = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('EventCreateForm', () => { + let mockMutate: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); // Reset mocks before each test + mockMutate = vi.fn(); + (useMutation as ReturnType).mockImplementation( + (options: any) => ({ // Use 'any' for options if type is complex or not critical for mock + mutate: mockMutate, + isPending: false, + isError: false, + error: null, + data: null, + // Add other properties useMutation might return if needed by component + ...options // spread options to allow testing onSuccess/onError if needed + })); + }); + + it('renders the form with key fields and submit button', () => { + renderWithChakraProvider(); + expect(screen.getByLabelText(/event name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/event type/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/event date/i)).toBeInTheDocument(); // Label includes "(Optional)" + expect(screen.getByRole('button', { name: /create event/i })).toBeInTheDocument(); + }); + + it('allows typing into input fields', () => { + renderWithChakraProvider(); + const eventNameInput = screen.getByLabelText(/event name/i) as HTMLInputElement; + fireEvent.change(eventNameInput, { target: { value: 'My Test Event' } }); + expect(eventNameInput.value).toBe('My Test Event'); + + const eventTypeSelect = screen.getByLabelText(/event type/i) as HTMLSelectElement; + fireEvent.change(eventTypeSelect, { target: { value: 'wedding_speech_pair' } }); + expect(eventTypeSelect.value).toBe('wedding_speech_pair'); + + const eventDateInput = screen.getByLabelText(/event date/i) as HTMLInputElement; + fireEvent.change(eventDateInput, { target: { value: '2024-12-25' } }); + expect(eventDateInput.value).toBe('2024-12-25'); + }); + + it('calls mutation with correct data on submit and shows success toast', async () => { + const mockInvalidateQueries = vi.fn(); + (useQueryClient as ReturnType).mockReturnValue({ + invalidateQueries: mockInvalidateQueries, + // Add other methods if your component uses them + } as any); + + // Mock useMutation to simulate success + (useMutation as ReturnType).mockImplementation( + (options: any) => ({ + mutate: (data: any) => { + options.onSuccess?.({ ...data, id: 'new-event-id' }); // Simulate success with returned data + }, + isPending: false, + isError: false, + error: null, + } as any) + ); + + renderWithChakraProvider(); + + fireEvent.change(screen.getByLabelText(/event name/i), { target: { value: 'New Year Gala' } }); + fireEvent.change(screen.getByLabelText(/event type/i), { target: { value: 'other' } }); + fireEvent.change(screen.getByLabelText(/event date/i), { target: { value: '2025-01-01' } }); + + fireEvent.click(screen.getByRole('button', { name: /create event/i })); + + // Check that the mutation was called (implicitly by checking onSuccess effects) + // Direct check of mockMutate is tricky here due to re-mocking for onSuccess + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Event Created', + status: 'success', + }) + ); + }); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['events'] }); + + // Check if form resets (example for eventName) + expect((screen.getByLabelText(/event name/i) as HTMLInputElement).value).toBe(''); + }); + + it('shows error toast if required fields are missing on submit', () => { + renderWithChakraProvider(); + fireEvent.click(screen.getByRole('button', { name: /create event/i })); + expect(mockMutate).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Missing fields', + status: 'error', + }) + ); + }); + + it('disables submit button when mutation is pending', () => { + (useMutation as ReturnType).mockReturnValue({ + mutate: mockMutate, + isPending: true, // Simulate loading state + isError: false, + error: null, + data: null, + } as any); + + renderWithChakraProvider(); + const submitButton = screen.getByRole('button', { name: /create event/i }); + expect(submitButton).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/Events/EventCreateForm.tsx b/frontend/src/components/Events/EventCreateForm.tsx new file mode 100644 index 0000000000..33242a5363 --- /dev/null +++ b/frontend/src/components/Events/EventCreateForm.tsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react'; +import { + Button, + FormControl, + FormLabel, + Input, + VStack, + Heading, + useToast, + Select, // For event_type dropdown +} from '@chakra-ui/react'; +import React, { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Button, + FormControl, + FormLabel, + Input, + VStack, + Heading, + useToast, + Select, +} from '@chakra-ui/react'; +import { EventsService, CoordinationEventCreate, ApiError } from '../../client'; // Import client and types +// import { useNavigate } from '@tanstack/react-router'; // For redirect after creation + +// 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 queryClient = useQueryClient(); + // const navigate = useNavigate(); // For redirect + const toast = useToast(); + + const [eventName, setEventName] = useState(''); + const [eventType, setEventType] = useState(eventTypes[0].value); + const [eventDate, setEventDate] = useState(''); + + const mutation = useMutation< + CoordinationEventPublic, // Expected response type (adjust if needed, based on client) + ApiError, + CoordinationEventCreate // Input type to mutationFn + >({ + mutationFn: async (newEventData: CoordinationEventCreate) => { + // EventsService.createEvent expects EventsCreateEventData which has a requestBody property + return EventsService.createEvent({ requestBody: newEventData }); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + toast({ + title: 'Event Created', + description: `Event "${data.event_name}" has been successfully created.`, + status: 'success', + duration: 5000, + isClosable: true, + }); + setEventName(''); + setEventType(eventTypes[0].value); + setEventDate(''); + // navigate({ to: '/_layout/events' }); // Example redirect + }, + onError: (error) => { + toast({ + title: 'Creation Failed', + description: error.message || 'There was an error creating the event. Please try again.', + status: 'error', + duration: 5000, + isClosable: true, + }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!eventName.trim() || !eventType) { + toast({ + title: 'Missing fields', + description: 'Event Name and Type are required.', + status: 'error', + duration: 3000, + isClosable: true, + }); + return; + } + + const eventData: CoordinationEventCreate = { + event_name: eventName, + event_type: eventType, + // Ensure date is in 'YYYY-MM-DD' format if that's what backend expects for date-only. + // If it's datetime, then toISOString() is fine. + // The client type `CoordinationEventCreate` has `event_date: string`. + // Assuming backend handles ISO string for datetime or YYYY-MM-DD for date. + event_date: eventDate ? new Date(eventDate).toISOString() : new Date().toISOString(), // Defaulting to now if not set; adjust as needed + }; + // If event_date is truly optional and backend handles missing field: + // const eventData: CoordinationEventCreate = { + // event_name: eventName, + // event_type: eventType, + // ...(eventDate && { event_date: new Date(eventDate).toISOString() }), + // }; + + + mutation.mutate(eventData); + }; + + return ( + + + Create New Coordination Event + + err.loc?.includes('event_name'))}> + Event Name + setEventName(e.target.value)} + placeholder="e.g., Alice & Bob's Wedding Speeches" + isDisabled={mutation.isPending} + /> + + + err.loc?.includes('event_type'))}> + Event Type + + + + err.loc?.includes('event_date'))}> + Event Date + setEventDate(e.target.value)} + isDisabled={mutation.isPending} + /> + + + + + ); +}; + +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..9246598a9a --- /dev/null +++ b/frontend/src/components/Events/EventDetailPage.tsx @@ -0,0 +1,164 @@ +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 React from 'react'; // Removed useState, useEffect +import { useParams } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Heading, + Text, + VStack, + Spinner, + Alert, + AlertIcon, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + Divider, + AlertDescription, + AlertTitle, +} from '@chakra-ui/react'; +import { EventsService, CoordinationEventPublic, ApiError } from '../../client'; +import EventParticipantManager from './EventParticipantManager'; +import SpeechList from '../Speeches/SpeechList'; +import SpeechAnalysisDisplay from '../Analysis/SpeechAnalysisDisplay'; +// No longer need mockEvents for this component's direct data fetching +// import { currentUserId } from '../../mocks/mockData'; + +const EventDetailPage: React.FC = () => { + const { eventId } = useParams({ from: '/_layout/events/$eventId' }); + // const { user } = useAuth(); // For currentUserId if needed for permissions on this page directly + // const actualCurrentUserId = user?.id || currentUserId; // Using mock + + const { + data: event, + isLoading, + isError, + error + } = useQuery({ + queryKey: ['event', eventId], + queryFn: async () => { + if (!eventId) { + throw new Error("Event ID is not available."); + } + // EventsService.getEventDetails expects EventsGetEventDetailsData: { eventId: string } + return EventsService.getEventDetails({ eventId }); + }, + enabled: !!eventId, // Only run query if eventId is available + // Optional: staleTime, cacheTime, etc. + }); + + const formatDate = (dateString?: string) => { + if (!dateString) return 'N/A'; + // Using toLocaleString for date and time, toLocaleDateString for date only + return new Date(dateString).toLocaleString(undefined, { + year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' + }); + }; + + const formatJustDate = (dateString?: string) => { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', month: 'long', day: 'numeric' + }); + } + + if (!eventId) { // Handle case where eventId might not be ready from router + return ( + + + Missing Event ID + The event ID is missing from the URL. + + ); + } + + if (isLoading) { + return ( + + + Loading event details for ID: {eventId}... + + ); + } + + if (isError) { + return ( + + + + Error Loading Event + + + {error?.body?.detail || error?.message || 'An unexpected error occurred.'} + {error?.status === 404 && " The event was not found."} + + + ); + } + + if (!event) { // Should be covered by isLoading or isError, but as a fallback + return ( + + + No event data available, or event not found. + + ); + } + + 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..ff875cb492 --- /dev/null +++ b/frontend/src/components/Events/EventList.tsx @@ -0,0 +1,87 @@ +import React from 'react'; // Removed useState, useEffect +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Button, + Heading, + VStack, + Text, + Spinner, + Alert, + AlertIcon, + SimpleGrid, + AlertDescription, + AlertTitle, +} from '@chakra-ui/react'; +import EventListItem from './EventListItem'; +// No longer need to import CoordinationEventPublic from EventListItem, will use client's type +import { EventsService, CoordinationEventPublic, ApiError } from '../../client'; +import { Link as RouterLink } from '@tanstack/react-router'; + +const EventList: React.FC = () => { + const { + data: events, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ['events'], + queryFn: async () => { + // EventsService.listUserEvents returns CancelablePromise + // EventsListUserEventsResponse is Array + // The actual data is directly the response type. + return EventsService.listUserEvents(); + }, + }); + + if (isLoading) { + return ( + + + Loading your events... + + ); + } + + if (isError) { + return ( + + + + Error Loading Events + + + {error?.message || 'An unexpected error occurred. Please try again later.'} + + + ); + } + + return ( + + + + Your Coordination Events + + {/* Updated link to match Tanstack Router v0.0.1-beta.28+ structure */} + + + + {!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.test.tsx b/frontend/src/components/Events/EventListItem.test.tsx new file mode 100644 index 0000000000..cf3c311ead --- /dev/null +++ b/frontend/src/components/Events/EventListItem.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ChakraProvider } from '@chakra-ui/react'; +import EventListItem from './EventListItem'; +import { CoordinationEventPublic } from '../../client'; // Import the correct type + +// Mock @tanstack/react-router's Link component +vi.mock('@tanstack/react-router', () => ({ + Link: vi.fn(({ to, children, ...rest }) => {children}), +})); + +const renderWithChakraProvider = (ui: React.ReactElement) => { + return render({ui}); +}; + +const mockEvent: CoordinationEventPublic = { + id: 'event-123', + event_name: 'Community Tech Talk', + event_type: 'tech_talk', + event_date: '2024-09-15T10:00:00.000Z', // ISO string + creator_id: 'user-abc', + created_at: '2024-07-01T10:00:00.000Z', + updated_at: '2024-07-02T12:00:00.000Z', +}; + +const mockEventNoDate: CoordinationEventPublic = { + id: 'event-456', + event_name: 'Planning Meeting', + event_type: 'meeting', + // event_date is optional in some definitions, but CoordinationEventPublic from client requires it. + // Let's assume the client type requires it as a string, so we should provide a valid or empty string + // if the backend can send it as null/undefined and client maps to string. + // For the client type `CoordinationEventPublic`, `event_date: string`. + // If the backend guarantees a string (even if empty for no date), that's fine. + // If it can be truly null/undefined from backend, client type should be `string | null | undefined`. + // Assuming here backend sends a valid date string, or this test would need adjustment based on actual client type nullability. + event_date: new Date(2023, 5, 10).toISOString(), // Example past date if required + creator_id: 'user-def', + created_at: '2023-05-01T10:00:00.000Z', + updated_at: '2023-05-01T12:00:00.000Z', + }; + + +describe('EventListItem', () => { + it('renders event details correctly', () => { + renderWithChakraProvider(); + + expect(screen.getByText(mockEvent.event_name)).toBeInTheDocument(); + // Test formatted type: 'Tech Talk' + expect(screen.getByText(/type: Tech Talk/i)).toBeInTheDocument(); + // Test formatted date + // Date formatting can be tricky due to locales. Check for parts of it. + // For '2024-09-15T10:00:00.000Z', toLocaleDateString might give "September 15, 2024" in en-US + expect(screen.getByText(/Date: September 15, 2024/i)).toBeInTheDocument(); + expect(screen.getByText(/Event ID: event-123/i)).toBeInTheDocument(); + }); + + it('renders "Date not set" if event_date is missing or unparseable (if type allowed optional/null)', () => { + // To properly test this, CoordinationEventPublic event_date type should be string | null | undefined + // And the component's formatDate should handle it. + // Given current client type `event_date: string`, backend must send a string. + // If backend sent an empty string for "no date": + const eventWithEmptyDate = { ...mockEvent, event_date: '' }; + renderWithChakraProvider(); + expect(screen.getByText(/Date: Date not set/i)).toBeInTheDocument(); + }); + + it('renders "Invalid date" if event_date is unparseable', () => { + const eventWithInvalidDate = { ...mockEvent, event_date: 'invalid-date-string' }; + renderWithChakraProvider(); + expect(screen.getByText(/Date: Invalid date/i)).toBeInTheDocument(); + }); + + it('links to the correct event detail page', () => { + renderWithChakraProvider(); + const linkElement = screen.getByRole('link', { name: mockEvent.event_name }); + expect(linkElement).toHaveAttribute('href', `/_layout/events/${mockEvent.id}`); + }); +}); diff --git a/frontend/src/components/Events/EventListItem.tsx b/frontend/src/components/Events/EventListItem.tsx new file mode 100644 index 0000000000..06f52b90e3 --- /dev/null +++ b/frontend/src/components/Events/EventListItem.tsx @@ -0,0 +1,47 @@ +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 + +import { CoordinationEventPublic } from '../../client'; // Import type from generated client + +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 ( + + + {/* Updated link to match Tanstack Router v0.0.1-beta.28+ structure */} + + {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..9e6f3fa9d3 --- /dev/null +++ b/frontend/src/components/Events/EventParticipantManager.tsx @@ -0,0 +1,232 @@ +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 React, { useState, useCallback } from 'react'; // Removed useEffect +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Box, + Button, + FormControl, + FormLabel, + Input, + VStack, + HStack, + List, + ListItem, + Text, + IconButton, + useToast, + Spinner, + Alert, + AlertIcon, + Select, + Heading, + AlertDescription, + AlertTitle, +} from '@chakra-ui/react'; +import { CloseIcon } from '@chakra-ui/icons'; +import { EventsService, UserPublic, EventParticipantPublic, Body_events_add_event_participant, ApiError, Message } from '../../../client'; +// import { useAuth } from '../../../hooks/useAuth'; // For currentUserId + +interface EventParticipantManagerProps { + eventId: string; + // eventCreatorId?: string; // Optional for more granular permissions +} + +// Note: The UserPublic type from the client might not include all fields if they are from a separate endpoint. +// The listEventParticipants endpoint returns Array. +// We'll assume UserPublic from client has what we need (id, email, full_name). + +const participantRoles = ["participant", "speaker", "organizer", "scribe", "admin", "bride", "groom", "officiant"]; + +const EventParticipantManager: React.FC = ({ eventId }) => { + const queryClient = useQueryClient(); + const toast = useToast(); + // const { user: loggedInUser } = useAuth(); + // const currentUserId = loggedInUser?.id; + + const [userInput, setUserInput] = useState(''); // For user email or ID to add + const [newUserRole, setNewUserRole] = useState(participantRoles[0]); + + const { + data: participants, + isLoading: isLoadingParticipants, + isError: isParticipantsError, + error: participantsError + } = useQuery({ // API returns Array + queryKey: ['eventParticipants', eventId], + queryFn: async () => EventsService.listEventParticipants({ eventId }), + enabled: !!eventId, + }); + + const addParticipantMutation = useMutation< + EventParticipantPublic, // Response type from addEventParticipant + ApiError, + Body_events_add_event_participant // Request body type + >({ + mutationFn: async (participantData: Body_events_add_event_participant) => { + return EventsService.addEventParticipant({ eventId, requestBody: participantData }); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['eventParticipants', eventId] }); + toast({ + title: 'Participant Added', + description: `User has been added as ${data.role}.`, + status: 'success', + duration: 3000, + isClosable: true, + }); + setUserInput(''); + setNewUserRole(participantRoles[0]); + }, + onError: (error) => { + toast({ + title: 'Failed to Add Participant', + description: error.body?.detail || error.message || 'Could not add participant.', + status: 'error', + duration: 5000, + isClosable: true, + }); + }, + }); + + const removeParticipantMutation = useMutation< + Message, // Response type from removeEventParticipant + ApiError, + { userIdToRemove: string } // Variables for mutationFn + >({ + mutationFn: async ({ userIdToRemove }) => { + return EventsService.removeEventParticipant({ eventId, userIdToRemove }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['eventParticipants', eventId] }); + toast({ title: 'Participant Removed', status: 'success', duration: 3000, isClosable: true }); + }, + onError: (error) => { + toast({ + title: 'Failed to Remove Participant', + description: error.body?.detail || error.message || 'Could not remove participant.', + status: 'error', + duration: 5000, + isClosable: true, + }); + }, + }); + + const handleAddParticipantSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!userInput.trim() || !newUserRole) { + toast({ title: "User ID/Email and role are required.", status: "warning", duration: 3000, isClosable: true }); + return; + } + // Assuming userInput is the user_id (UUID string) as per backend expectation for `user_id_to_add` + // In a real app, might need a lookup from email to ID if backend doesn't handle email directly. + addParticipantMutation.mutate({ user_id_to_add: userInput, role: newUserRole }); + }; + + const handleRemoveParticipantClick = (userIdToRemove: string) => { + // Add more robust confirmation if desired + if (window.confirm(`Are you sure you want to remove this participant?`)) { + removeParticipantMutation.mutate({ userIdToRemove }); + } + }; + + if (isLoadingParticipants) return ( + Loading participants... + ); + + if (isParticipantsError) return ( + + + Error Loading Participants + {participantsError?.body?.detail || participantsError?.message || 'An unexpected error occurred.'} + + ); + + return ( + + Manage Participants + + err.loc?.includes('user_id_to_add'))}> + User ID to Add + setUserInput(e.target.value)} + placeholder="Enter exact User ID (UUID)" + isDisabled={addParticipantMutation.isPending} + /> + + err.loc?.includes('role'))}> + Role + + + + + + Current Participants + {!participants || participants.length === 0 ? ( + No participants yet. + ) : ( + + {participants.map((participant) => ( + + + {/* Assuming UserPublic has full_name and email. Adjust if structure differs. */} + {participant.full_name || participant.email || participant.id} + {/* The API for listEventParticipants returns UserPublic, which doesn't include 'role'. + This means we can't display role directly from this list. + This is a mismatch between `EventParticipantPublic` (which has role) and `UserPublic`. + The backend `GET /events/{event_id}/participants` returns `list[UserPublic]`. + To show role, backend would need to return `list[EventParticipantPublic]` or similar. + For now, role cannot be displayed here from the API call. + If we want to keep showing role as in mock, we'd need to adjust backend response or make another call. + */} + {/* Role: {participant.role} */} + + } + colorScheme="red" + variant="ghost" + onClick={() => handleRemoveParticipantClick(participant.id)} // participant.id is user_id here + isLoading={removeParticipantMutation.isPending && removeParticipantMutation.variables?.userIdToRemove === participant.id} + // Example permission: isDisabled={participant.id === currentUserId && event?.creator_id === currentUserId} + /> + + ))} + + )} + + ); +}; + +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..31b04cb5b9 --- /dev/null +++ b/frontend/src/components/Speeches/SpeechCreateForm.tsx @@ -0,0 +1,179 @@ +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 React, { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Button, + FormControl, + FormLabel, + Input, // Keep if needed, but duration uses NumberInput + Textarea, + VStack, + Heading, + useToast, + Select, + NumberInput, + NumberInputField, + NumberInputStepper, + NumberIncrementStepper, + NumberDecrementStepper, + Box, +} from '@chakra-ui/react'; +import { SpeechesService, SecretSpeechWithInitialVersionCreate, SecretSpeechPublic, ApiError } from '../../../client'; +// import { useAuth } from '../../../hooks/useAuth'; // For creator_id if not handled by backend + +const speechTones = ["neutral", "sentimental", "humorous", "serious", "inspirational", "mixed", "other"]; + +interface SpeechCreateFormProps { + eventId: string; + onSpeechCreated?: () => void; +} + +const SpeechCreateForm: React.FC = ({ eventId, onSpeechCreated }) => { + const queryClient = useQueryClient(); + const toast = useToast(); + // const { user } = useAuth(); // If creator_id needs to be sent explicitly from frontend + + const [draft, setDraft] = useState(''); + const [tone, setTone] = useState(speechTones[0]); + const [duration, setDuration] = useState(5); + + const mutation = useMutation< + SecretSpeechPublic, // Expected response type + ApiError, + SecretSpeechWithInitialVersionCreate // Input type to mutationFn + >({ + mutationFn: async (newSpeechData: SecretSpeechWithInitialVersionCreate) => { + // SpeechesService.createSpeech expects SpeechesCreateSpeechData which has requestBody + return SpeechesService.createSpeech({ requestBody: newSpeechData }); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['eventSpeeches', eventId] }); + toast({ + title: 'Speech Created', + description: `Your speech has been successfully added to the event.`, + status: 'success', + duration: 5000, + isClosable: true, + }); + setDraft(''); + setTone(speechTones[0]); + setDuration(5); + if (onSpeechCreated) { + onSpeechCreated(); // Typically closes modal and might trigger other actions in parent + } + }, + onError: (error) => { + toast({ + title: 'Creation Failed', + description: error.body?.detail?.[0]?.msg || error.message || 'There was an error creating the speech.', + status: 'error', + duration: 5000, + isClosable: true, + }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!draft.trim() || !tone || duration === '' || Number(duration) <= 0) { + toast({ + title: 'Missing or invalid fields', + description: 'Draft, Tone, and a valid Duration are required.', + status: 'warning', + duration: 3000, + isClosable: true, + }); + return; + } + + const speechPayload: SecretSpeechWithInitialVersionCreate = { + event_id: eventId, + initial_speech_draft: draft, + initial_speech_tone: tone, + initial_estimated_duration_minutes: Number(duration), + // creator_id would be set by the backend based on the authenticated user + }; + + mutation.mutate(speechPayload); + }; + + return ( + + + + Add New Speech to Event + + e.loc?.includes('initial_speech_draft'))}> + Initial Speech Draft +