diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..a1795010 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,214 @@ +name: Backend CI + +on: + push: + branches: [ main, develop ] + paths: + - 'backend/**' + - '.github/workflows/backend-ci.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'backend/**' + - '.github/workflows/backend-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.12] + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: testpassword + POSTGRES_USER: testuser + POSTGRES_DB: llsc_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install PDM + run: | + pip install pdm + + - name: Cache PDM dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pdm + key: ${{ runner.os }}-pdm-${{ hashFiles('backend/pdm.lock') }} + restore-keys: | + ${{ runner.os }}-pdm- + + - name: Install dependencies + working-directory: ./backend + run: | + pdm sync --group test --group lint --group dev + + - name: Set up environment variables + working-directory: ./backend + run: | + echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "SECRET_KEY=test-secret-key-for-ci" >> .env + echo "ENVIRONMENT=test" >> .env + + - name: Run database migrations + working-directory: ./backend + run: | + pdm run alembic upgrade head + + - name: Run linting + working-directory: ./backend + run: | + pdm run ruff check . + pdm run ruff format --check . + + # TODO: Re-enable mypy when type annotations are improved + # - name: Run type checking + # working-directory: ./backend + # run: | + # pdm run mypy app/ --ignore-missing-imports + + - name: Run unit tests + working-directory: ./backend + run: | + pdm run python -m pytest tests/unit/ -v --cov=app --cov-report=xml --cov-report=term-missing + + - name: Run integration tests + working-directory: ./backend + run: | + pdm run python -m pytest tests/functional/ -v + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./backend/coverage.xml + directory: ./backend + flags: backend + name: backend-coverage + + - name: Run security scan + working-directory: ./backend + run: | + pdm run bandit -r app/ -f json -o security-report.json || true + + - name: Upload security report + uses: actions/upload-artifact@v4 + if: always() + with: + name: security-report + path: backend/security-report.json + + e2e-tests: + runs-on: ubuntu-latest + needs: test + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: testpassword + POSTGRES_USER: testuser + POSTGRES_DB: llsc_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install PDM + run: | + pip install pdm + + - name: Install dependencies + working-directory: ./backend + run: | + pdm sync --group test --group lint --group dev + + - name: Set up environment variables + working-directory: ./backend + run: | + echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "SECRET_KEY=test-secret-key-for-ci" >> .env + echo "ENVIRONMENT=test" >> .env + echo "TEST_SCRIPT_BACKEND_URL=http://localhost:8000" >> .env + echo "TEST_SCRIPT_EMAIL=test@example.com" >> .env + echo "TEST_SCRIPT_PASSWORD=testpassword" >> .env + + - name: Run database migrations + working-directory: ./backend + run: | + pdm run alembic upgrade head + + - name: Start backend server + working-directory: ./backend + run: | + pdm run uvicorn app.server:app --host 0.0.0.0 --port 8000 & + sleep 10 # Wait for server to start + + - name: Run E2E tests + working-directory: ./ + run: | + pdm run python -m pytest e2e-tests/ -v --tb=short + + docker-build: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + working-directory: ./backend + run: | + docker build -t llsc-backend:latest . + + - name: Test Docker image + run: | + docker run --rm llsc-backend:latest python --version + + notify: + runs-on: ubuntu-latest + needs: [test, e2e-tests] + if: always() + + steps: + - name: Notify on success + if: needs.test.result == 'success' && needs.e2e-tests.result == 'success' + run: | + echo "✅ All tests passed! Backend is ready for deployment." + + - name: Notify on failure + if: needs.test.result == 'failure' || needs.e2e-tests.result == 'failure' + run: | + echo "❌ Tests failed! Please check the logs." + exit 1 diff --git a/backend/FORMS_SCHEMA_README.md b/backend/FORMS_SCHEMA_README.md new file mode 100644 index 00000000..d63f03fc --- /dev/null +++ b/backend/FORMS_SCHEMA_README.md @@ -0,0 +1,138 @@ +# Forms Schema Documentation + +This document describes the database schema for the forms system that was implemented. + +## Tables Created + +### 1. Core User Data + +#### `user_data` +Stores single-valued user fields: +- `id` - UUID primary key +- `date_of_birth` - Date field +- `email` - String field +- `phone` - String field + +### 2. Multi-valued Reference Tables + +#### `treatments` +Stores available treatment options: +- `id` - Integer primary key +- `name` - Unique treatment name + +Pre-populated with: +- Chemotherapy, Immunotherapy, Radiation Therapy, Surgery, Targeted Therapy, Hormone Therapy, Stem Cell Transplant, CAR-T Cell Therapy, Clinical Trial, Palliative Care + +#### `experiences` +Stores cancer-related experiences: +- `id` - Integer primary key +- `name` - Unique experience name + +Pre-populated with: +- PTSD, Relapse, Anxiety, Depression, Fatigue, Neuropathy, Hair Loss, Nausea, Loss of Appetite, Sleep Problems, Cognitive Changes, Financial Stress, Relationship Changes, Body Image Issues, Survivorship Concerns + +#### `qualities` +Stores ranking/matching qualities: +- `id` - Integer primary key +- `slug` - Unique identifier (e.g., 'same_age') +- `label` - Human-readable description + +Pre-populated with matching criteria like same age, diagnosis, treatment, location, etc. + +### 3. Bridge Tables (Many-to-Many) + +#### `user_treatments` +Links users to their treatments + +#### `user_experiences` +Links users to their experiences + +### 4. Ranking System + +#### `ranking_preferences` +Stores user ranking preferences: +- `user_id` - Reference to users table +- `quality_id` - Reference to qualities table +- `rank` - Integer ranking (1 = most important) + +### 5. Form System + +#### `forms` +Form definitions and versioning: +- `id` - UUID primary key +- `name` - Form name +- `version` - Version number +- `type` - Enum (intake, ranking, secondary, become_volunteer, become_participant) + +#### `form_submissions` +Raw form submission data: +- `id` - UUID primary key +- `form_id` - Reference to forms table +- `user_id` - Reference to users table +- `submitted_at` - Timestamp +- `answers` - JSONB field with raw form data + +## Usage Examples + +### Accessing Multi-valued Fields +Thanks to SQLAlchemy relationships, you can access multi-valued fields as lists: + +```python +from app.models import UserData + +# Get a user +user = session.query(UserData).first() + +# Access treatments as a list +user_treatments = user.treatments # Returns list of Treatment objects +treatment_names = [t.name for t in user.treatments] + +# Access experiences as a list +user_experiences = user.experiences # Returns list of Experience objects +``` + +### Creating Form Submissions +```python +from app.models import FormSubmission +import json + +submission = FormSubmission( + form_id=form_uuid, + user_id=user_uuid, + answers={ + "date_of_birth": "1990-01-01", + "treatments": ["Chemotherapy", "Radiation Therapy"], + "experiences": ["Anxiety", "Fatigue"], + # ... other form fields + } +) +session.add(submission) +session.commit() +``` + +### Setting Ranking Preferences +```python +from app.models import RankingPreference + +# User ranks "same_diagnosis" as most important (rank 1) +pref = RankingPreference( + user_id=user_uuid, + quality_id=2, # same_diagnosis quality + rank=1 +) +session.add(pref) +session.commit() +``` + +## Form Processing Workflow + +1. **Form Submission**: Raw data stored in `form_submissions.answers` as JSON +2. **Data Parsing**: Custom parser extracts structured data from JSON +3. **Database Population**: Parsed data populates `user_data` and relationship tables +4. **Versioning**: Multiple submissions create new form versions while preserving history + +This design allows for: +- Flexible form structures without schema changes +- Historical tracking of all submissions +- Structured querying of user data for matching algorithms +- Easy addition of new treatment/experience/quality options diff --git a/backend/app/interfaces/auth_service.py b/backend/app/interfaces/auth_service.py index e49d9387..d5e372f1 100644 --- a/backend/app/interfaces/auth_service.py +++ b/backend/app/interfaces/auth_service.py @@ -129,3 +129,10 @@ def is_authorized_by_email(self, access_token, requested_email): :rtype: bool """ pass + + @abstractmethod + def verify_email(self, email): + """ + Verify the email address of the user with the given email + """ + pass diff --git a/backend/app/middleware/auth_middleware.py b/backend/app/middleware/auth_middleware.py index 8bd924fe..9f752a08 100644 --- a/backend/app/middleware/auth_middleware.py +++ b/backend/app/middleware/auth_middleware.py @@ -17,7 +17,14 @@ def __init__(self, app: ASGIApp, public_paths: List[str] = None): self.logger = logging.getLogger(LOGGER_NAME("auth_middleware")) def is_public_path(self, path: str) -> bool: - return path in self.public_paths + for public_path in self.public_paths: + # Handle parameterized routes by checking if path starts with the pattern + if public_path.endswith("{email}") and path.startswith(public_path.replace("{email}", "")): + return True + # Exact match for non-parameterized routes + if path == public_path: + return True + return False async def dispatch(self, request: Request, call_next): if self.is_public_path(request.url.path): diff --git a/backend/app/models/Experience.py b/backend/app/models/Experience.py new file mode 100644 index 00000000..77ab3a55 --- /dev/null +++ b/backend/app/models/Experience.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship + +from .Base import Base + + +class Experience(Base): + __tablename__ = "experiences" + id = Column(Integer, primary_key=True) + name = Column(String, unique=True, nullable=False) # 'PTSD', 'Relapse', etc. + + # Back reference for many-to-many relationship + users = relationship("UserData", secondary="user_experiences", back_populates="experiences") diff --git a/backend/app/models/Form.py b/backend/app/models/Form.py new file mode 100644 index 00000000..81410dad --- /dev/null +++ b/backend/app/models/Form.py @@ -0,0 +1,18 @@ +import uuid + +from sqlalchemy import Column, Enum, Integer, String +from sqlalchemy.dialects.postgresql import UUID + +from .Base import Base + + +class Form(Base): + __tablename__ = "forms" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False) # 'Intake - Participant Caregiver' + version = Column(Integer, default=1, nullable=False) + type = Column( + Enum("intake", "ranking", "secondary", "become_volunteer", "become_participant", name="form_type"), + nullable=False, + ) diff --git a/backend/app/models/FormSubmission.py b/backend/app/models/FormSubmission.py new file mode 100644 index 00000000..b966844d --- /dev/null +++ b/backend/app/models/FormSubmission.py @@ -0,0 +1,21 @@ +import uuid + +from sqlalchemy import Column, DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import relationship + +from .Base import Base + + +class FormSubmission(Base): + __tablename__ = "form_submissions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + form_id = Column(UUID(as_uuid=True), ForeignKey("forms.id"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + submitted_at = Column(DateTime(timezone=True), default=func.now()) + answers = Column(JSONB, nullable=False) # raw payload + + # Relationships + form = relationship("Form") + user = relationship("User") diff --git a/backend/app/models/Quality.py b/backend/app/models/Quality.py new file mode 100644 index 00000000..1f063bbf --- /dev/null +++ b/backend/app/models/Quality.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String + +from .Base import Base + + +class Quality(Base): + __tablename__ = "qualities" + id = Column(Integer, primary_key=True) + slug = Column(String, unique=True, nullable=False) # 'same_age', 'same_diagnosis', etc. + label = Column(String, nullable=False) # human-readable description diff --git a/backend/app/models/RankingPreference.py b/backend/app/models/RankingPreference.py new file mode 100644 index 00000000..7fb7c595 --- /dev/null +++ b/backend/app/models/RankingPreference.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, ForeignKey, Integer +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from .Base import Base + + +class RankingPreference(Base): + __tablename__ = "ranking_preferences" + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True) + quality_id = Column(Integer, ForeignKey("qualities.id"), primary_key=True) + rank = Column(Integer, nullable=False) # 1 = most important + + # Relationships + user = relationship("User") + quality = relationship("Quality") diff --git a/backend/app/models/Treatment.py b/backend/app/models/Treatment.py new file mode 100644 index 00000000..0cb00774 --- /dev/null +++ b/backend/app/models/Treatment.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship + +from .Base import Base + + +class Treatment(Base): + __tablename__ = "treatments" + id = Column(Integer, primary_key=True) + name = Column(String, unique=True, nullable=False) # 'Chemotherapy', 'Immunotherapy', etc. + + # Back reference for many-to-many relationship + users = relationship("UserData", secondary="user_treatments", back_populates="treatments") diff --git a/backend/app/models/User.py b/backend/app/models/User.py index b8a9733c..31c62cc4 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -11,8 +11,8 @@ class User(Base): __tablename__ = "users" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - first_name = Column(String(80), nullable=False) - last_name = Column(String(80), nullable=False) + first_name = Column(String(80), nullable=True) + last_name = Column(String(80), nullable=True) email = Column(String(120), unique=True, nullable=False) role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) auth_id = Column(String, nullable=False) diff --git a/backend/app/models/UserData.py b/backend/app/models/UserData.py new file mode 100644 index 00000000..f4c26121 --- /dev/null +++ b/backend/app/models/UserData.py @@ -0,0 +1,93 @@ +import uuid + +from sqlalchemy import JSON, Column, Date, ForeignKey, Integer, String, Table, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from .Base import Base + +# Bridge tables for many-to-many relationships +user_treatments = Table( + "user_treatments", + Base.metadata, + Column("user_data_id", UUID(as_uuid=True), ForeignKey("user_data.id")), + Column("treatment_id", Integer, ForeignKey("treatments.id")), +) + +user_experiences = Table( + "user_experiences", + Base.metadata, + Column("user_data_id", UUID(as_uuid=True), ForeignKey("user_data.id")), + Column("experience_id", Integer, ForeignKey("experiences.id")), +) + +# Bridge tables for loved one many-to-many relationships +user_loved_one_treatments = Table( + "user_loved_one_treatments", + Base.metadata, + Column("user_data_id", UUID(as_uuid=True), ForeignKey("user_data.id")), + Column("treatment_id", Integer, ForeignKey("treatments.id")), +) + +user_loved_one_experiences = Table( + "user_loved_one_experiences", + Base.metadata, + Column("user_data_id", UUID(as_uuid=True), ForeignKey("user_data.id")), + Column("experience_id", Integer, ForeignKey("experiences.id")), +) + + +class UserData(Base): + __tablename__ = "user_data" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + # Personal Information + first_name = Column(String(80), nullable=True) + last_name = Column(String(80), nullable=True) + date_of_birth = Column(Date, nullable=True) + email = Column(String(120), nullable=True) + phone = Column(String(20), nullable=True) + city = Column(String(100), nullable=True) + province = Column(String(50), nullable=True) + postal_code = Column(String(10), nullable=True) + + # Demographics + gender_identity = Column(String(50), nullable=True) + pronouns = Column(JSON, nullable=True) # Array of strings + ethnic_group = Column(JSON, nullable=True) # Array of strings + marital_status = Column(String(50), nullable=True) + has_kids = Column(String(10), nullable=True) + + # Cancer Experience + diagnosis = Column(String(100), nullable=True) + date_of_diagnosis = Column(Date, nullable=True) + + # "Other" text fields for custom entries + other_treatment = Column(Text, nullable=True) + other_experience = Column(Text, nullable=True) + other_ethnic_group = Column(Text, nullable=True) + gender_identity_custom = Column(Text, nullable=True) + + # Flow control fields + has_blood_cancer = Column(String(10), nullable=True) + caring_for_someone = Column(String(10), nullable=True) + + # Loved One Demographics + loved_one_gender_identity = Column(String(50), nullable=True) + loved_one_age = Column(String(10), nullable=True) + + # Loved One Cancer Experience + loved_one_diagnosis = Column(String(100), nullable=True) + loved_one_date_of_diagnosis = Column(Date, nullable=True) + loved_one_other_treatment = Column(Text, nullable=True) + loved_one_other_experience = Column(Text, nullable=True) + + # Many-to-many relationships + treatments = relationship("Treatment", secondary=user_treatments, back_populates="users") + experiences = relationship("Experience", secondary=user_experiences, back_populates="users") + + # Loved one many-to-many relationships + loved_one_treatments = relationship("Treatment", secondary=user_loved_one_treatments) + loved_one_experiences = relationship("Experience", secondary=user_loved_one_experiences) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e32eedcf..4b9fdf94 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,12 +10,19 @@ # Make sure all models are here to reflect all current models # when autogenerating new migration from .Base import Base +from .Experience import Experience +from .Form import Form +from .FormSubmission import FormSubmission from .Match import Match from .MatchStatus import MatchStatus +from .Quality import Quality +from .RankingPreference import RankingPreference from .Role import Role from .SuggestedTime import suggested_times from .TimeBlock import TimeBlock +from .Treatment import Treatment from .User import User +from .UserData import UserData # Used to avoid import errors for the models __all__ = [ @@ -28,6 +35,13 @@ "User", "available_times", "suggested_times", + "UserData", + "Treatment", + "Experience", + "Quality", + "RankingPreference", + "Form", + "FormSubmission", ] log = logging.getLogger(LOGGER_NAME("models")) diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py index e69de29b..e737cca9 100644 --- a/backend/app/routes/__init__.py +++ b/backend/app/routes/__init__.py @@ -0,0 +1,3 @@ +from . import auth, availability, intake, match, send_email, suggested_times, test, user + +__all__ = ["auth", "availability", "intake", "match", "send_email", "suggested_times", "test", "user"] diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 923b8674..0e43f2e9 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token @@ -68,3 +68,31 @@ async def refresh(refresh_data: RefreshRequest, auth_service: AuthService = Depe return auth_service.renew_token(refresh_data.refresh_token) except Exception as e: raise HTTPException(status_code=401, detail=str(e)) + + +@router.post("/resetPassword/{email}") +async def reset_password(email: str, auth_service: AuthService = Depends(get_auth_service)): + try: + auth_service.reset_password(email) + # Return 204 No Content for successful password reset email sending + return Response(status_code=204) + except Exception: + # Don't reveal if email exists or not for security reasons + # Always return success even if email doesn't exist + return Response(status_code=204) + + +@router.post("/verify/{email}") +async def verify_email(email: str, auth_service: AuthService = Depends(get_auth_service)): + try: + auth_service.verify_email(email) + return Response(status_code=200) + except ValueError as e: + # Log the error for debugging but don't expose it to the client + print(f"Email verification failed for {email}: {str(e)}") + # Return 404 for user not found instead of 400 + return Response(status_code=404) + except Exception as e: + # Log unexpected errors + print(f"Unexpected error during email verification for {email}: {str(e)}") + return Response(status_code=500) diff --git a/backend/app/routes/intake.py b/backend/app/routes/intake.py new file mode 100644 index 00000000..987575e8 --- /dev/null +++ b/backend/app/routes/intake.py @@ -0,0 +1,346 @@ +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from app.middleware.auth import has_roles +from app.models import Form, FormSubmission, User +from app.schemas.user import UserRole +from app.services.implementations.intake_form_processor import IntakeFormProcessor +from app.utilities.db_utils import get_db + +# ===== Schemas ===== + + +class FormSubmissionCreate(BaseModel): + """Schema for creating a new form submission""" + + form_id: Optional[UUID] = Field( + None, description="Form ID (optional - will be auto-detected from formType if not provided)" + ) + answers: dict = Field(..., description="Form answers as JSON") + + +class FormSubmissionUpdate(BaseModel): + """Schema for updating a form submission""" + + answers: dict = Field(..., description="Updated form answers as JSON") + + +class FormSubmissionResponse(BaseModel): + """Response schema for form submission""" + + id: UUID + form_id: UUID + user_id: UUID + submitted_at: datetime + answers: dict + + model_config = ConfigDict(from_attributes=True) + + +class FormSubmissionListResponse(BaseModel): + """Response schema for listing form submissions""" + + submissions: List[FormSubmissionResponse] + total: int + + +# ===== Custom Auth Dependencies ===== + + +def is_owner_or_admin(user_id: UUID): + """ + Custom dependency that checks if the current user is either: + 1. The owner of the resource (matching user_id) + 2. An admin + """ + + async def validator( + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), + ) -> bool: + # Get current user info from request state (set by auth middleware) + current_user_auth_id = request.state.user_id + current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first() + + if not current_user: + raise HTTPException(status_code=401, detail="User not found") + + # Check if user is admin or the owner of the resource + if current_user.role.name == "admin" or current_user.id == user_id: + return True + + raise HTTPException(status_code=403, detail="Access denied") + + return Depends(validator) + + +# ===== Router Setup ===== + +router = APIRouter( + prefix="/intake", + tags=["intake"], +) + + +# ===== CRUD Endpoints ===== + + +@router.post("/submissions", response_model=FormSubmissionResponse) +async def create_form_submission( + submission: FormSubmissionCreate, + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """ + Create a new form submission and process it into structured data. + + Users can only create submissions for themselves. + + The form_id is optional - if not provided, it will be auto-detected + from the 'formType' field in the answers (participant/volunteer). + """ + try: + # Get current user + current_user_auth_id = request.state.user_id + current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first() + if not current_user: + raise HTTPException(status_code=401, detail="User not found") + + # Determine form_id if not provided + form_id = submission.form_id + if not form_id: + # Auto-detect form based on formType in answers + form_type = submission.answers.get("formType") + if not form_type: + raise HTTPException( + status_code=400, detail="formType must be specified in answers when form_id is not provided" + ) + + # Map formType to form name + form_name_mapping = {"participant": "Participant Intake Form", "volunteer": "Volunteer Intake Form"} + + form_name = form_name_mapping.get(form_type) + if not form_name: + raise HTTPException( + status_code=400, detail=f"Invalid formType: {form_type}. Must be 'participant' or 'volunteer'" + ) + + # Find the form + form = db.query(Form).filter(Form.type == "intake", Form.name == form_name).first() + + if not form: + raise HTTPException(status_code=500, detail=f"Intake form '{form_name}' not found in database") + form_id = form.id + + # Verify the form exists and is of type 'intake' + form = db.query(Form).filter(Form.id == form_id, Form.type == "intake").first() + if not form: + raise HTTPException(status_code=404, detail="Intake form not found") + + # Create the raw form submission record + db_submission = FormSubmission( + form_id=form_id, + user_id=current_user.id, # Always use the current user's ID + answers=submission.answers, + ) + + db.add(db_submission) + db.flush() # Get the submission ID without committing + + # Process the form data into structured tables + processor = IntakeFormProcessor(db) + processor.process_form_submission(user_id=str(current_user.id), form_data=submission.answers) + + # Commit everything together + db.commit() + db.refresh(db_submission) + + return FormSubmissionResponse.model_validate(db_submission) + + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Error processing form submission: {str(e)}") + + +@router.get("/submissions", response_model=FormSubmissionListResponse) +async def get_form_submissions( + user_id: Optional[UUID] = Query(None, description="Filter by user ID (admin only)"), + form_id: Optional[UUID] = Query(None, description="Filter by form ID"), + request: Request = None, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """ + Get form submissions. + + - Regular users can only see their own submissions + - Admins can see all submissions and filter by user_id + """ + try: + # Get current user + current_user_auth_id = request.state.user_id + current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first() + if not current_user: + raise HTTPException(status_code=401, detail="User not found") + + # Build query + query = db.query(FormSubmission).join(Form).filter(Form.type == "intake") + + # Apply filters based on user role + if current_user.role_id == 3: # Admin + # Admins can filter by any user_id + if user_id: + query = query.filter(FormSubmission.user_id == user_id) + else: + # Non-admins can only see their own submissions + query = query.filter(FormSubmission.user_id == current_user.id) + if user_id and str(user_id) != str(current_user.id): + raise HTTPException(status_code=403, detail="You can only view your own submissions") + + # Apply form_id filter if provided + if form_id: + query = query.filter(FormSubmission.form_id == form_id) + + # Execute query + submissions = query.all() + + return FormSubmissionListResponse( + submissions=[FormSubmissionResponse.model_validate(s) for s in submissions], total=len(submissions) + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/submissions/{submission_id}", response_model=FormSubmissionResponse) +async def get_form_submission( + submission_id: UUID, + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """ + Get a specific form submission by ID. + + Users can only access their own submissions unless they are admin. + """ + try: + # Get the submission + submission = db.query(FormSubmission).filter(FormSubmission.id == submission_id).first() + + if not submission: + raise HTTPException(status_code=404, detail="Form submission not found") + + # Check access permissions + await is_owner_or_admin(submission.user_id)(request, db) + + return FormSubmissionResponse.model_validate(submission) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/submissions/{submission_id}", response_model=FormSubmissionResponse) +async def update_form_submission( + submission_id: UUID, + update_data: FormSubmissionUpdate, + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """ + Update a form submission. + + Users can only update their own submissions unless they are admin. + """ + try: + # Get the submission + submission = db.query(FormSubmission).filter(FormSubmission.id == submission_id).first() + + if not submission: + raise HTTPException(status_code=404, detail="Form submission not found") + + # Check access permissions + await is_owner_or_admin(submission.user_id)(request, db) + + # Update the submission + submission.answers = update_data.answers + + db.commit() + db.refresh(submission) + + return FormSubmissionResponse.model_validate(submission) + + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/submissions/{submission_id}") +async def delete_form_submission( + submission_id: UUID, + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """ + Delete a form submission. + + Users can only delete their own submissions unless they are admin. + """ + try: + # Get the submission + submission = db.query(FormSubmission).filter(FormSubmission.id == submission_id).first() + + if not submission: + raise HTTPException(status_code=404, detail="Form submission not found") + + # Check access permissions + await is_owner_or_admin(submission.user_id)(request, db) + + # Delete the submission + db.delete(submission) + db.commit() + + return {"message": "Form submission deleted successfully"} + + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +# ===== Additional Utility Endpoints ===== + + +@router.get("/forms", response_model=List[dict]) +async def get_intake_forms( + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.ADMIN, UserRole.PARTICIPANT, UserRole.VOLUNTEER]), +): + """ + Get all available intake forms. + """ + try: + forms = db.query(Form).filter(Form.type == "intake").all() + + return [{"id": str(form.id), "name": form.name, "version": form.version, "type": form.type} for form in forms] + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3f7b7cbb..89212972 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -40,8 +40,8 @@ class UserBase(BaseModel): Base schema for user model with common attributes shared across schemas. """ - first_name: str = Field(..., min_length=1, max_length=50) - last_name: str = Field(..., min_length=1, max_length=50) + first_name: Optional[str] = Field(None, min_length=0, max_length=50) + last_name: Optional[str] = Field(None, min_length=0, max_length=50) email: EmailStr role: UserRole diff --git a/backend/app/server.py b/backend/app/server.py index b2775ac9..41c759d6 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -8,7 +8,7 @@ from . import models from .middleware.auth_middleware import AuthMiddleware -from .routes import auth, availability, match, send_email, suggested_times, test, user +from .routes import auth, availability, intake, match, send_email, suggested_times, test, user from .utilities.constants import LOGGER_NAME from .utilities.firebase_init import initialize_firebase from .utilities.ses.ses_init import ensure_ses_templates @@ -24,6 +24,8 @@ "/openapi.json", "/auth/login", "/auth/register", + "/auth/resetPassword/{email}", + "/auth/verify/{email}", "/health", "/test-middleware-public", "/email/send-test-email", @@ -47,6 +49,7 @@ async def lifespan(_: FastAPI): CORSMiddleware, allow_origins=[ "http://localhost:3000", + "http://localhost:3002", "https://uw-blueprint-starter-code.firebaseapp.com", "https://uw-blueprint-starter-code.web.app", # TODO: create a separate middleware function to dynamically @@ -64,6 +67,7 @@ async def lifespan(_: FastAPI): app.include_router(availability.router) app.include_router(suggested_times.router) app.include_router(match.router) +app.include_router(intake.router) app.include_router(send_email.router) app.include_router(test.router) diff --git a/backend/app/services/implementations/auth_service.py b/backend/app/services/implementations/auth_service.py index d4977afc..8311abed 100644 --- a/backend/app/services/implementations/auth_service.py +++ b/backend/app/services/implementations/auth_service.py @@ -1,6 +1,8 @@ import logging +import os import firebase_admin.auth +import requests from fastapi import HTTPException from app.utilities.constants import LOGGER_NAME @@ -46,10 +48,29 @@ def renew_token(self, refresh_token: str) -> Token: def reset_password(self, email: str) -> None: try: - firebase_admin.auth.generate_password_reset_link(email) + # Use Firebase REST API to send password reset email + url = f"https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key={os.getenv('FIREBASE_WEB_API_KEY')}" + data = { + "requestType": "PASSWORD_RESET", + "email": email, + "continueUrl": "http://localhost:3000/set-new-password", # Custom action URL + } + + response = requests.post(url, json=data) + response_json = response.json() + + if response.status_code != 200: + error_message = response_json.get("error", {}).get("message", "Unknown error") + self.logger.error(f"Failed to send password reset email: {error_message}") + # Don't raise exception for security reasons - don't reveal if email exists + return + + self.logger.info(f"Password reset email sent successfully to {email}") + except Exception as e: self.logger.error(f"Failed to reset password: {str(e)}") - raise + # Don't raise exception for security reasons - don't reveal if email exists + return def send_email_verification_link(self, email: str) -> None: try: @@ -87,3 +108,27 @@ def is_authorized_by_email(self, access_token: str, requested_email: str) -> boo except Exception as e: print(f"Authorization error: {str(e)}") return False + + def verify_email(self, email: str): + try: + user = self.user_service.get_user_by_email(email) + if not user: + self.logger.error(f"User not found for email: {email}") + raise ValueError("User not found") + + if not user.auth_id: + self.logger.error(f"User {user.id} has no auth_id") + raise ValueError("User has no auth_id") + + self.logger.info(f"Updating email verification for user {user.id} with auth_id {user.auth_id}") + firebase_admin.auth.update_user(user.auth_id, email_verified=True) + self.logger.info(f"Successfully verified email for user {user.id}") + + except ValueError as e: + # User not found in database - this might happen if there's a timing issue + # between Firebase user creation and database user creation + self.logger.warning(f"User not found in database for email {email}: {str(e)}") + raise + except Exception as e: + self.logger.error(f"Failed to verify email for {email}: {str(e)}") + raise diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py new file mode 100644 index 00000000..1dd12030 --- /dev/null +++ b/backend/app/services/implementations/intake_form_processor.py @@ -0,0 +1,377 @@ +import logging +import uuid as uuid_module +from datetime import datetime +from typing import Any, Dict, Tuple + +from sqlalchemy.orm import Session + +from app.models import Experience, Treatment, UserData + +logger = logging.getLogger(__name__) + + +class IntakeFormProcessor: + """ + Processes intake form JSON submissions into structured database tables. + Handles both predefined options and custom "Other" entries. + """ + + def __init__(self, db: Session): + """Initialize the processor with a database session.""" + self.db = db + + def process_form_submission(self, user_id: str, form_data: Dict[str, Any]) -> UserData: + """ + Process a form submission and create/update UserData. + + Args: + user_id: UUID string of the user + form_data: Dictionary containing form submission data + + Returns: + UserData: The created or updated UserData object + + Raises: + ValueError: For invalid UUIDs, dates, or other validation errors + KeyError: For missing required fields + """ + try: + # Validate required fields first + self._validate_required_fields(form_data) + + # Get or create UserData + user_data, is_new = self._get_or_create_user_data(user_id) + + # Add to session early to avoid relationship warnings + if is_new: + self.db.add(user_data) + self.db.flush() # Get ID assigned before processing relationships + + # Process different sections of the form + self._process_personal_info(user_data, form_data.get("personalInfo", {})) + self._process_demographics(user_data, form_data.get("demographics", {})) + self._process_cancer_experience(user_data, form_data.get("cancerExperience", {})) + self._process_flow_control(user_data, form_data) + + # Process treatments and experiences (many-to-many) + self._process_treatments(user_data, form_data.get("cancerExperience", {})) + self._process_experiences(user_data, form_data.get("cancerExperience", {})) + + # Process caregiver experience for volunteers (separate from cancer experience) + if "caregiverExperience" in form_data: + self._process_caregiver_experience(user_data, form_data["caregiverExperience"]) + + # Process loved one data if present + if "lovedOne" in form_data: + self._process_loved_one_data(user_data, form_data["lovedOne"]) + + # Commit all changes + self.db.commit() + self.db.refresh(user_data) + + logger.info(f"Successfully processed intake form for user {user_id}") + return user_data + + except Exception as e: + self.db.rollback() + logger.error(f"Error processing intake form for user {user_id}: {str(e)}") + raise + + def _get_or_create_user_data(self, user_id: str) -> Tuple[UserData, bool]: + """Get existing UserData or create new one. Returns (user_data, is_new).""" + # Convert string UUID to UUID object if needed + if isinstance(user_id, str): + try: + user_uuid = uuid_module.UUID(user_id) + except ValueError: + raise ValueError(f"Invalid UUID format: {user_id}") + else: + user_uuid = user_id + + # Look for existing UserData by user_id foreign key + user_data = self.db.query(UserData).filter(UserData.user_id == user_uuid).first() + if not user_data: + user_data = UserData(user_id=user_uuid) + return user_data, True # New object + return user_data, False # Existing object + + def _validate_required_fields(self, form_data: Dict[str, Any]): + """Validate that required fields are present.""" + if "personalInfo" not in form_data: + raise KeyError("personalInfo section is required") + + personal_info = form_data["personalInfo"] + required_fields = ["firstName", "lastName", "dateOfBirth", "phoneNumber", "city", "province", "postalCode"] + + for field in required_fields: + if field not in personal_info: + raise KeyError(f"Required field missing: personalInfo.{field}") + + def _trim_text(self, text: str) -> str: + """Trim whitespace from text fields.""" + if isinstance(text, str): + return text.strip() + return text + + def _parse_date(self, date_str: str): + """Parse date string with strict validation.""" + if not date_str: + return None + + # Try DD/MM/YYYY format first (frontend format) + try: + return datetime.strptime(date_str, "%d/%m/%Y").date() + except ValueError: + pass + + # Try ISO format as fallback + try: + return datetime.fromisoformat(date_str).date() + except ValueError: + raise ValueError(f"Invalid date format: {date_str}. Expected DD/MM/YYYY or ISO format.") + + def _process_personal_info(self, user_data: UserData, personal_info: Dict[str, Any]): + """Process personal information fields.""" + user_data.first_name = self._trim_text(personal_info.get("firstName")) + user_data.last_name = self._trim_text(personal_info.get("lastName")) + user_data.email = self._trim_text(personal_info.get("email")) + user_data.phone = self._trim_text(personal_info.get("phoneNumber")) + user_data.city = self._trim_text(personal_info.get("city")) + user_data.province = self._trim_text(personal_info.get("province")) + user_data.postal_code = self._trim_text(personal_info.get("postalCode")) + + # Parse date of birth with strict validation + if "dateOfBirth" in personal_info: + try: + user_data.date_of_birth = self._parse_date(personal_info["dateOfBirth"]) + except ValueError: + raise ValueError(f"Invalid date format for dateOfBirth: {personal_info['dateOfBirth']}") + + def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any]): + """Process demographic information.""" + user_data.gender_identity = self._trim_text(demographics.get("genderIdentity")) + user_data.pronouns = demographics.get("pronouns", []) + user_data.ethnic_group = demographics.get("ethnicGroup", []) + user_data.marital_status = self._trim_text(demographics.get("maritalStatus")) + user_data.has_kids = demographics.get("hasKids") + user_data.other_ethnic_group = self._trim_text(demographics.get("ethnicGroupCustom")) + user_data.gender_identity_custom = self._trim_text(demographics.get("genderIdentityCustom")) + + def _process_cancer_experience(self, user_data: UserData, cancer_experience: Dict[str, Any]): + """Process cancer experience information.""" + user_data.diagnosis = self._trim_text(cancer_experience.get("diagnosis")) + user_data.other_treatment = self._trim_text(cancer_experience.get("otherTreatment")) + user_data.other_experience = self._trim_text(cancer_experience.get("otherExperience")) + + # Parse diagnosis date with strict validation + if "dateOfDiagnosis" in cancer_experience: + try: + user_data.date_of_diagnosis = self._parse_date(cancer_experience["dateOfDiagnosis"]) + except ValueError: + raise ValueError(f"Invalid date format for dateOfDiagnosis: {cancer_experience['dateOfDiagnosis']}") + + def _process_flow_control(self, user_data: UserData, form_data: Dict[str, Any]): + """Process flow control fields.""" + user_data.has_blood_cancer = form_data.get("hasBloodCancer") + user_data.caring_for_someone = form_data.get("caringForSomeone") + + def _process_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]): + """ + Process treatments - map frontend names to database records. + Handles both predefined options and creates new ones for custom entries. + """ + treatment_names = cancer_exp.get("treatments", []) + if not treatment_names: + return + + # Clear existing treatments + user_data.treatments.clear() + + for treatment_name in treatment_names: + if not treatment_name: + continue + + # Find existing treatment + treatment = self.db.query(Treatment).filter(Treatment.name == treatment_name).first() + + if treatment: + user_data.treatments.append(treatment) + else: + # Create new treatment for custom entry + logger.info(f"Creating new treatment: {treatment_name}") + new_treatment = Treatment(name=treatment_name) + self.db.add(new_treatment) + self.db.flush() # Get the ID + user_data.treatments.append(new_treatment) + + def _process_experiences(self, user_data: UserData, cancer_exp: Dict[str, Any]): + """ + Process experiences - map frontend names to database records. + Handles both predefined options and creates new ones for custom entries. + """ + experience_names = cancer_exp.get("experiences", []) + if not experience_names: + return + + # Clear existing experiences + user_data.experiences.clear() + + for experience_name in experience_names: + if not experience_name: + continue + + # Find existing experience + experience = self.db.query(Experience).filter(Experience.name == experience_name).first() + + if experience: + user_data.experiences.append(experience) + else: + # Create new experience for custom entry + logger.info(f"Creating new experience: {experience_name}") + new_experience = Experience(name=experience_name) + self.db.add(new_experience) + self.db.flush() # Get the ID + user_data.experiences.append(new_experience) + + def _process_caregiver_experience(self, user_data: UserData, caregiver_exp: Dict[str, Any]): + """ + Process caregiver experience for volunteers who are caregivers without cancer. + Maps caregiver experiences to the same user_experiences table as cancer experiences. + """ + if not caregiver_exp: + return + + # Handle "Other" caregiver experience text + user_data.other_experience = caregiver_exp.get("otherExperience") + + # Process caregiver experiences - map to same experiences table + experience_names = caregiver_exp.get("experiences", []) + if not experience_names: + return + + # Note: We don't clear existing experiences here in case user has both + # cancer and caregiver experiences (though that would be in cancerExperience) + + for experience_name in experience_names: + if not experience_name: + continue + + # Find existing experience + experience = self.db.query(Experience).filter(Experience.name == experience_name).first() + + if experience: + # Only add if not already present + if experience not in user_data.experiences: + user_data.experiences.append(experience) + else: + # Create new experience for custom entry + logger.info(f"Creating new caregiver experience: {experience_name}") + new_experience = Experience(name=experience_name) + self.db.add(new_experience) + self.db.flush() # Get the ID + user_data.experiences.append(new_experience) + + def _process_loved_one_data(self, user_data: UserData, loved_one_data: Dict[str, Any]): + """Process loved one data including demographics and cancer experience.""" + if not loved_one_data: + return + + # Process loved one demographics + self._process_loved_one_demographics(user_data, loved_one_data.get("demographics", {})) + + # Process loved one cancer experience + self._process_loved_one_cancer_experience(user_data, loved_one_data.get("cancerExperience", {})) + + # Process loved one treatments and experiences + self._process_loved_one_treatments(user_data, loved_one_data.get("cancerExperience", {})) + self._process_loved_one_experiences(user_data, loved_one_data.get("cancerExperience", {})) + + def _process_loved_one_demographics(self, user_data: UserData, demographics: Dict[str, Any]): + """Process loved one demographic information.""" + if not demographics: + return + + user_data.loved_one_gender_identity = demographics.get("genderIdentity") + user_data.loved_one_age = demographics.get("age") + + def _process_loved_one_cancer_experience(self, user_data: UserData, cancer_exp: Dict[str, Any]): + """Process loved one cancer experience information.""" + if not cancer_exp: + return + + user_data.loved_one_diagnosis = self._trim_text(cancer_exp.get("diagnosis")) + + # Parse loved one diagnosis date with strict validation + if "dateOfDiagnosis" in cancer_exp: + try: + user_data.loved_one_date_of_diagnosis = self._parse_date(cancer_exp["dateOfDiagnosis"]) + except ValueError: + raise ValueError(f"Invalid date format for loved one dateOfDiagnosis: {cancer_exp['dateOfDiagnosis']}") + + # Handle "Other" treatment and experience text for loved one with trimming + user_data.loved_one_other_treatment = self._trim_text(cancer_exp.get("otherTreatment")) + user_data.loved_one_other_experience = self._trim_text(cancer_exp.get("otherExperience")) + + def _process_loved_one_treatments(self, user_data: UserData, cancer_exp: Dict[str, Any]): + """Process loved one treatments - map frontend names to database records.""" + treatment_names = cancer_exp.get("treatments", []) + if not treatment_names: + return + + # Clear existing loved one treatments + user_data.loved_one_treatments.clear() + + for treatment_name in treatment_names: + if not treatment_name: + continue + + # Find existing treatment + treatment = self.db.query(Treatment).filter(Treatment.name == treatment_name).first() + + if treatment: + user_data.loved_one_treatments.append(treatment) + else: + # Create new treatment for custom entry + logger.info(f"Creating new loved one treatment: {treatment_name}") + new_treatment = Treatment(name=treatment_name) + self.db.add(new_treatment) + self.db.flush() # Get the ID + user_data.loved_one_treatments.append(new_treatment) + + def _process_loved_one_experiences(self, user_data: UserData, cancer_exp: Dict[str, Any]): + """Process loved one experiences - map frontend names to database records.""" + experience_names = cancer_exp.get("experiences", []) + if not experience_names: + return + + # Clear existing loved one experiences + user_data.loved_one_experiences.clear() + + for experience_name in experience_names: + if not experience_name: + continue + + # Find existing experience + experience = self.db.query(Experience).filter(Experience.name == experience_name).first() + + if experience: + user_data.loved_one_experiences.append(experience) + else: + # Create new experience for custom entry + logger.info(f"Creating new loved one experience: {experience_name}") + new_experience = Experience(name=experience_name) + self.db.add(new_experience) + self.db.flush() # Get the ID + user_data.loved_one_experiences.append(new_experience) + + def process_ranking_form(self, user_id: str, ranking_data: Dict[str, Any]): + """ + Process ranking form submission for user preferences. + + Args: + user_id: The user's UUID + ranking_data: The ranking preferences data + """ + # TODO: Implement ranking preferences processing + # This would handle RankingPreference model population + pass diff --git a/backend/docs/intake_api.md b/backend/docs/intake_api.md new file mode 100644 index 00000000..6d8d007b --- /dev/null +++ b/backend/docs/intake_api.md @@ -0,0 +1,204 @@ +# Intake Form API Documentation + +## Overview + +The Intake Form API handles the processing of participant and volunteer intake forms, automatically detecting form types and processing complex nested JSON data into structured database records. + +## Endpoints + +### `POST /intake/submissions` + +Create a new form submission and process it into structured data. + +**Authentication**: Required (Participant, Volunteer, or Admin role) + +**Request Body**: +```json +{ + "form_id": "uuid (optional - auto-detected from formType)", + "answers": { + "formType": "participant|volunteer", + "hasBloodCancer": "yes|no", + "caringForSomeone": "yes|no", + "personalInfo": { + "firstName": "string (required)", + "lastName": "string (required)", + "dateOfBirth": "DD/MM/YYYY (required)", + "phoneNumber": "string (required)", + "city": "string (required)", + "province": "string (required)", + "postalCode": "string (required)", + "email": "string (optional)" + }, + "demographics": { + "genderIdentity": "string (optional)", + "pronouns": ["array of strings (optional)"], + "ethnicGroup": ["array of strings (optional)"], + "maritalStatus": "string (optional)", + "hasKids": "yes|no|prefer not to say (optional)", + "ethnicGroupCustom": "string (optional)", + "genderIdentityCustom": "string (optional)" + }, + "cancerExperience": { + "diagnosis": "string (optional)", + "dateOfDiagnosis": "DD/MM/YYYY (optional)", + "treatments": ["array of treatment names (optional)"], + "experiences": ["array of experience names (optional)"], + "otherTreatment": "string (optional)", + "otherExperience": "string (optional)" + }, + "lovedOne": { + "demographics": { + "genderIdentity": "string (optional)", + "age": "string (optional)" + }, + "cancerExperience": { + "diagnosis": "string (optional)", + "dateOfDiagnosis": "DD/MM/YYYY (optional)", + "treatments": ["array of treatment names (optional)"], + "experiences": ["array of experience names (optional)"], + "otherTreatment": "string (optional)", + "otherExperience": "string (optional)" + } + } + } +} +``` + +**Response (201 Created)**: +```json +{ + "id": "uuid", + "user_id": "uuid", + "form_id": "uuid", + "answers": "object", + "created_at": "timestamp", + "updated_at": "timestamp" +} +``` + +**Error Responses**: +- `400`: Missing required fields, invalid form type, or malformed data +- `401`: Authentication required +- `500`: Server error during processing + +## Form Type Auto-Detection + +The API automatically detects form types based on the `formType` field in the answers: + +- `"participant"` → Maps to "Participant Intake Form" +- `"volunteer"` → Maps to "Volunteer Intake Form" + +## Data Processing Flow + +1. **Validation**: Required fields are validated strictly +2. **Text Processing**: All text fields are automatically trimmed +3. **Date Parsing**: Dates are parsed in DD/MM/YYYY format with fallback to ISO format +4. **Relationship Handling**: Many-to-many relationships for treatments/experiences are processed +5. **User Data Creation**: Data is mapped to structured UserData model with proper foreign keys +6. **Form Submission Storage**: Raw JSON is stored in FormSubmission table for audit trail + +## Flow Logic + +The system handles 8 different user flow combinations: + +### Participant Flows: +1. **Cancer Patient Only**: `hasBloodCancer=yes`, `caringForSomeone=no` +2. **Caregiver Without Cancer**: `hasBloodCancer=no`, `caringForSomeone=yes` +3. **Cancer Patient + Caregiver**: `hasBloodCancer=yes`, `caringForSomeone=yes` +4. **No Cancer Experience**: `hasBloodCancer=no`, `caringForSomeone=no` + +### Volunteer Flows: +5. **Cancer Patient Only**: `hasBloodCancer=yes`, `caringForSomeone=no` +6. **Caregiver Without Cancer**: `hasBloodCancer=no`, `caringForSomeone=yes` +7. **Cancer Patient + Caregiver**: `hasBloodCancer=yes`, `caringForSomeone=yes` +8. **No Cancer Experience**: `hasBloodCancer=no`, `caringForSomeone=no` + +## Database Schema + +### UserData Table +Stores processed, structured user information with proper relationships to User table via `user_id` foreign key. + +### FormSubmission Table +Stores raw JSON submissions for audit trail and potential reprocessing. + +### Many-to-Many Relationships +- `user_treatments`: Links users to their cancer treatments +- `user_experiences`: Links users to their cancer experiences +- `user_loved_one_treatments`: Links users to their loved ones' treatments +- `user_loved_one_experiences`: Links users to their loved ones' experiences + +## Error Handling + +The system implements robust error handling with: +- **Input Validation**: Strict validation of required fields +- **Date Validation**: Proper date format validation with clear error messages +- **Database Rollback**: Automatic rollback on processing errors +- **SQL Injection Prevention**: Uses parameterized queries and ORM +- **Unicode Support**: Full support for international characters and emojis + +## Example Requests + +### Participant with Cancer +```bash +curl -X POST "/intake/submissions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "answers": { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "John", + "lastName": "Doe", + "dateOfBirth": "15/03/1980", + "phoneNumber": "555-123-4567", + "city": "Toronto", + "province": "Ontario", + "postalCode": "M5V 3A1" + }, + "cancerExperience": { + "diagnosis": "Leukemia", + "dateOfDiagnosis": "10/01/2020", + "treatments": ["Chemotherapy", "Radiation Therapy"], + "experiences": ["Fatigue", "Depression"] + } + } + }' +``` + +### Volunteer Caregiver +```bash +curl -X POST "/intake/submissions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "answers": { + "formType": "volunteer", + "hasBloodCancer": "no", + "caringForSomeone": "yes", + "personalInfo": { + "firstName": "Jane", + "lastName": "Smith", + "dateOfBirth": "22/07/1975", + "phoneNumber": "555-987-6543", + "city": "Vancouver", + "province": "British Columbia", + "postalCode": "V6B 2W9" + }, + "lovedOne": { + "demographics": { + "genderIdentity": "Male", + "age": "45-54" + }, + "cancerExperience": { + "diagnosis": "Lymphoma", + "dateOfDiagnosis": "05/06/2022", + "treatments": ["Chemotherapy"], + "experiences": ["Hair Loss", "Anxiety"] + } + } + } + }' +``` diff --git a/backend/migrations/versions/1e275ca8f74d_seed_treatments_table.py b/backend/migrations/versions/1e275ca8f74d_seed_treatments_table.py new file mode 100644 index 00000000..0ef12ad4 --- /dev/null +++ b/backend/migrations/versions/1e275ca8f74d_seed_treatments_table.py @@ -0,0 +1,44 @@ +"""seed treatments table + +Revision ID: 1e275ca8f74d +Revises: f4225af5f02c +Create Date: 2025-06-29 15:29:42.176058 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1e275ca8f74d" +down_revision: Union[str, None] = "f4225af5f02c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.bulk_insert( + sa.table( + "treatments", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + ), + [ + {"id": 1, "name": "Chemotherapy"}, + {"id": 2, "name": "Immunotherapy"}, + {"id": 3, "name": "Radiation Therapy"}, + {"id": 4, "name": "Surgery"}, + {"id": 5, "name": "Targeted Therapy"}, + {"id": 6, "name": "Hormone Therapy"}, + {"id": 7, "name": "Stem Cell Transplant"}, + {"id": 8, "name": "CAR-T Cell Therapy"}, + {"id": 9, "name": "Clinical Trial"}, + {"id": 10, "name": "Palliative Care"}, + ], + ) + + +def downgrade() -> None: + op.execute("DELETE FROM treatments WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)") diff --git a/backend/migrations/versions/2a086aa5a4ad_seed_forms_table.py b/backend/migrations/versions/2a086aa5a4ad_seed_forms_table.py new file mode 100644 index 00000000..2952dfd4 --- /dev/null +++ b/backend/migrations/versions/2a086aa5a4ad_seed_forms_table.py @@ -0,0 +1,52 @@ +"""seed_forms_table + +Revision ID: 2a086aa5a4ad +Revises: b11e40c23435 +Create Date: 2025-07-20 14:48:35.540230 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2a086aa5a4ad" +down_revision: Union[str, None] = "b11e40c23435" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Insert initial form records for participant and volunteer intake + op.bulk_insert( + sa.table( + "forms", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("type", sa.String(), nullable=False), + ), + [ + { + "id": "12345678-1234-1234-1234-123456789012", + "name": "Participant Intake Form", + "version": 1, + "type": "intake", + }, + { + "id": "12345678-1234-1234-1234-123456789013", + "name": "Volunteer Intake Form", + "version": 1, + "type": "intake", + }, + ], + ) + + +def downgrade() -> None: + # Remove the seeded forms + op.execute( + "DELETE FROM forms WHERE id IN ('12345678-1234-1234-1234-123456789012', '12345678-1234-1234-1234-123456789013')" + ) diff --git a/backend/migrations/versions/55934750df90_add_loved_one_fields_to_userdata.py b/backend/migrations/versions/55934750df90_add_loved_one_fields_to_userdata.py new file mode 100644 index 00000000..9f25f8a9 --- /dev/null +++ b/backend/migrations/versions/55934750df90_add_loved_one_fields_to_userdata.py @@ -0,0 +1,77 @@ +"""add_loved_one_fields_to_userdata + +Revision ID: 55934750df90 +Revises: 62d18260aaed +Create Date: 2025-07-13 16:22:01.195384 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "55934750df90" +down_revision: Union[str, None] = "62d18260aaed" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add loved one demographics fields to user_data + op.add_column("user_data", sa.Column("loved_one_gender_identity", sa.String(50), nullable=True)) + op.add_column("user_data", sa.Column("loved_one_age", sa.String(10), nullable=True)) + + # Add loved one cancer experience fields to user_data + op.add_column("user_data", sa.Column("loved_one_diagnosis", sa.String(100), nullable=True)) + op.add_column("user_data", sa.Column("loved_one_date_of_diagnosis", sa.Date(), nullable=True)) + op.add_column("user_data", sa.Column("loved_one_other_treatment", sa.Text(), nullable=True)) + op.add_column("user_data", sa.Column("loved_one_other_experience", sa.Text(), nullable=True)) + + # Create bridge table for user loved one treatments + op.create_table( + "user_loved_one_treatments", + sa.Column("user_data_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("treatment_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["treatment_id"], + ["treatments.id"], + ), + sa.ForeignKeyConstraint( + ["user_data_id"], + ["user_data.id"], + ), + sa.PrimaryKeyConstraint("user_data_id", "treatment_id"), + ) + + # Create bridge table for user loved one experiences + op.create_table( + "user_loved_one_experiences", + sa.Column("user_data_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("experience_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["experience_id"], + ["experiences.id"], + ), + sa.ForeignKeyConstraint( + ["user_data_id"], + ["user_data.id"], + ), + sa.PrimaryKeyConstraint("user_data_id", "experience_id"), + ) + + +def downgrade() -> None: + # Drop bridge tables + op.drop_table("user_loved_one_experiences") + op.drop_table("user_loved_one_treatments") + + # Drop loved one fields from user_data + op.drop_column("user_data", "loved_one_other_experience") + op.drop_column("user_data", "loved_one_other_treatment") + op.drop_column("user_data", "loved_one_date_of_diagnosis") + op.drop_column("user_data", "loved_one_diagnosis") + op.drop_column("user_data", "loved_one_age") + op.drop_column("user_data", "loved_one_gender_identity") diff --git a/backend/migrations/versions/62d18260aaed_merge_restored_migrations.py b/backend/migrations/versions/62d18260aaed_merge_restored_migrations.py new file mode 100644 index 00000000..a387b675 --- /dev/null +++ b/backend/migrations/versions/62d18260aaed_merge_restored_migrations.py @@ -0,0 +1,23 @@ +"""merge_restored_migrations + +Revision ID: 62d18260aaed +Revises: abcd1234active, f11846c50673, fef3717e0fc2 +Create Date: 2025-07-13 16:22:15.595766 + +""" + +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "62d18260aaed" +down_revision: Union[str, None] = ("abcd1234active", "f11846c50673", "fef3717e0fc2") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/migrations/versions/747735a17ed8_add_missing_fields_to_user_data.py b/backend/migrations/versions/747735a17ed8_add_missing_fields_to_user_data.py new file mode 100644 index 00000000..6c03b263 --- /dev/null +++ b/backend/migrations/versions/747735a17ed8_add_missing_fields_to_user_data.py @@ -0,0 +1,70 @@ +"""add missing fields to user_data + +Revision ID: 747735a17ed8 +Revises: 78073cc5fe98 +Create Date: 2025-07-13 16:24:01.195384 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "747735a17ed8" +down_revision: Union[str, None] = "78073cc5fe98" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add missing fields to user_data table + op.add_column("user_data", sa.Column("first_name", sa.String(80), nullable=True)) + op.add_column("user_data", sa.Column("last_name", sa.String(80), nullable=True)) + op.add_column("user_data", sa.Column("city", sa.String(100), nullable=True)) + op.add_column("user_data", sa.Column("province", sa.String(50), nullable=True)) + op.add_column("user_data", sa.Column("postal_code", sa.String(10), nullable=True)) + + # Demographics fields + op.add_column("user_data", sa.Column("gender_identity", sa.String(50), nullable=True)) + op.add_column("user_data", sa.Column("pronouns", sa.JSON, nullable=True)) # Array of strings + op.add_column("user_data", sa.Column("ethnic_group", sa.JSON, nullable=True)) # Array of strings + op.add_column("user_data", sa.Column("marital_status", sa.String(50), nullable=True)) + op.add_column("user_data", sa.Column("has_kids", sa.String(10), nullable=True)) + + # Cancer experience fields + op.add_column("user_data", sa.Column("diagnosis", sa.String(100), nullable=True)) + op.add_column("user_data", sa.Column("date_of_diagnosis", sa.Date, nullable=True)) + + # "Other" text fields for custom entries + op.add_column("user_data", sa.Column("other_treatment", sa.Text, nullable=True)) + op.add_column("user_data", sa.Column("other_experience", sa.Text, nullable=True)) + op.add_column("user_data", sa.Column("other_ethnic_group", sa.Text, nullable=True)) + op.add_column("user_data", sa.Column("gender_identity_custom", sa.Text, nullable=True)) + + # Flow control fields + op.add_column("user_data", sa.Column("has_blood_cancer", sa.String(10), nullable=True)) + op.add_column("user_data", sa.Column("caring_for_someone", sa.String(10), nullable=True)) + + +def downgrade() -> None: + # Remove added columns + op.drop_column("user_data", "caring_for_someone") + op.drop_column("user_data", "has_blood_cancer") + op.drop_column("user_data", "gender_identity_custom") + op.drop_column("user_data", "other_ethnic_group") + op.drop_column("user_data", "other_experience") + op.drop_column("user_data", "other_treatment") + op.drop_column("user_data", "date_of_diagnosis") + op.drop_column("user_data", "diagnosis") + op.drop_column("user_data", "has_kids") + op.drop_column("user_data", "marital_status") + op.drop_column("user_data", "ethnic_group") + op.drop_column("user_data", "pronouns") + op.drop_column("user_data", "gender_identity") + op.drop_column("user_data", "postal_code") + op.drop_column("user_data", "province") + op.drop_column("user_data", "city") + op.drop_column("user_data", "last_name") + op.drop_column("user_data", "first_name") diff --git a/backend/migrations/versions/78073cc5fe98_update_treatments_to_match_frontend.py b/backend/migrations/versions/78073cc5fe98_update_treatments_to_match_frontend.py new file mode 100644 index 00000000..8b53a3ea --- /dev/null +++ b/backend/migrations/versions/78073cc5fe98_update_treatments_to_match_frontend.py @@ -0,0 +1,83 @@ +"""update_treatments_to_match_frontend + +Revision ID: 78073cc5fe98 +Revises: 55934750df90 +Create Date: 2025-07-13 16:23:01.195384 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "78073cc5fe98" +down_revision: Union[str, None] = "55934750df90" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Update treatments to match frontend expectations + connection = op.get_bind() + + # Clear existing treatments and insert new ones + connection.execute(sa.text("DELETE FROM user_treatments")) + connection.execute(sa.text("DELETE FROM user_loved_one_treatments")) + connection.execute(sa.text("DELETE FROM treatments")) + + # Insert updated treatments that match frontend + treatments = [ + (1, "Chemotherapy"), + (2, "Immunotherapy"), + (3, "Radiation Therapy"), + (4, "Surgery"), + (5, "Targeted Therapy"), + (6, "Hormone Therapy"), + (7, "Stem Cell Transplant"), + (8, "CAR-T Cell Therapy"), + (9, "Clinical Trial"), + (10, "Palliative Care"), + (11, "Supportive Care"), + (12, "Watchful Waiting"), + (13, "Maintenance Therapy"), + (14, "Combination Therapy"), + (15, "Experimental Treatment"), + ] + + for treatment_id, treatment_name in treatments: + connection.execute( + sa.text("INSERT INTO treatments (id, name) VALUES (:id, :name)"), + {"id": treatment_id, "name": treatment_name}, + ) + + +def downgrade() -> None: + # Restore original treatments + connection = op.get_bind() + + # Clear current treatments + connection.execute(sa.text("DELETE FROM user_treatments")) + connection.execute(sa.text("DELETE FROM user_loved_one_treatments")) + connection.execute(sa.text("DELETE FROM treatments")) + + # Insert original treatments + original_treatments = [ + (1, "Chemotherapy"), + (2, "Immunotherapy"), + (3, "Radiation Therapy"), + (4, "Surgery"), + (5, "Targeted Therapy"), + (6, "Hormone Therapy"), + (7, "Stem Cell Transplant"), + (8, "CAR-T Cell Therapy"), + (9, "Clinical Trial"), + (10, "Palliative Care"), + ] + + for treatment_id, treatment_name in original_treatments: + connection.execute( + sa.text("INSERT INTO treatments (id, name) VALUES (:id, :name)"), + {"id": treatment_id, "name": treatment_name}, + ) diff --git a/backend/migrations/versions/7b797eccb3aa_add_user_id_foreign_key_to_user_data.py b/backend/migrations/versions/7b797eccb3aa_add_user_id_foreign_key_to_user_data.py new file mode 100644 index 00000000..9e67b7c7 --- /dev/null +++ b/backend/migrations/versions/7b797eccb3aa_add_user_id_foreign_key_to_user_data.py @@ -0,0 +1,43 @@ +"""add_user_id_foreign_key_to_user_data + +Revision ID: 7b797eccb3aa +Revises: 2a086aa5a4ad +Create Date: 2025-07-20 15:20:07.646983 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "7b797eccb3aa" +down_revision: Union[str, None] = "2a086aa5a4ad" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Step 1: Add user_id column as nullable initially + op.add_column("user_data", sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True)) + + # Step 2: Delete existing relationships and user_data records (assuming test data) + op.execute("DELETE FROM user_experiences") + op.execute("DELETE FROM user_treatments") + op.execute("DELETE FROM user_loved_one_experiences") + op.execute("DELETE FROM user_loved_one_treatments") + op.execute("DELETE FROM user_data") + + # Step 3: Make the column NOT NULL now that table is empty + op.alter_column("user_data", "user_id", nullable=False) + + # Step 4: Add foreign key constraint + op.create_foreign_key("fk_user_data_user_id", "user_data", "users", ["user_id"], ["id"]) + + +def downgrade() -> None: + # Remove foreign key constraint and user_id column + op.drop_constraint("fk_user_data_user_id", "user_data", type_="foreignkey") + op.drop_column("user_data", "user_id") diff --git a/backend/migrations/versions/88c4cf2a6bd2_merge_heads.py b/backend/migrations/versions/88c4cf2a6bd2_merge_heads.py new file mode 100644 index 00000000..f8f1989e --- /dev/null +++ b/backend/migrations/versions/88c4cf2a6bd2_merge_heads.py @@ -0,0 +1,23 @@ +"""merge heads + +Revision ID: 88c4cf2a6bd2 +Revises: abcd1234active, d6d4e2e5af85, fef3717e0fc2 +Create Date: 2025-07-20 16:06:01.056373 + +""" + +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "88c4cf2a6bd2" +down_revision: Union[str, None] = ("abcd1234active", "d6d4e2e5af85", "fef3717e0fc2") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/migrations/versions/b11e40c23435_increase_varchar_field_lengths.py b/backend/migrations/versions/b11e40c23435_increase_varchar_field_lengths.py new file mode 100644 index 00000000..7da47d23 --- /dev/null +++ b/backend/migrations/versions/b11e40c23435_increase_varchar_field_lengths.py @@ -0,0 +1,44 @@ +"""increase_varchar_field_lengths + +Revision ID: b11e40c23435 +Revises: 747735a17ed8 +Create Date: 2025-07-13 16:42:47.456742 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b11e40c23435" +down_revision: Union[str, None] = "747735a17ed8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Increase VARCHAR(10) fields to VARCHAR(256) to accommodate longer values + op.alter_column("user_data", "has_kids", existing_type=sa.VARCHAR(10), type_=sa.VARCHAR(256), nullable=True) + + op.alter_column("user_data", "has_blood_cancer", existing_type=sa.VARCHAR(10), type_=sa.VARCHAR(256), nullable=True) + + op.alter_column( + "user_data", "caring_for_someone", existing_type=sa.VARCHAR(10), type_=sa.VARCHAR(256), nullable=True + ) + + op.alter_column("user_data", "loved_one_age", existing_type=sa.VARCHAR(10), type_=sa.VARCHAR(256), nullable=True) + + +def downgrade() -> None: + # Revert back to VARCHAR(10) + op.alter_column("user_data", "loved_one_age", existing_type=sa.VARCHAR(256), type_=sa.VARCHAR(10), nullable=True) + + op.alter_column( + "user_data", "caring_for_someone", existing_type=sa.VARCHAR(256), type_=sa.VARCHAR(10), nullable=True + ) + + op.alter_column("user_data", "has_blood_cancer", existing_type=sa.VARCHAR(256), type_=sa.VARCHAR(10), nullable=True) + + op.alter_column("user_data", "has_kids", existing_type=sa.VARCHAR(256), type_=sa.VARCHAR(10), nullable=True) diff --git a/backend/migrations/versions/d6d4e2e5af85_set_first_last_name_to_nullable.py b/backend/migrations/versions/d6d4e2e5af85_set_first_last_name_to_nullable.py new file mode 100644 index 00000000..8b411ff5 --- /dev/null +++ b/backend/migrations/versions/d6d4e2e5af85_set_first_last_name_to_nullable.py @@ -0,0 +1,32 @@ +"""set-first-last-name-to-nullable + +Revision ID: d6d4e2e5af85 +Revises: c9bc2b4d1036 +Create Date: 2025-06-11 22:22:13.206761 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d6d4e2e5af85" +down_revision: Union[str, None] = "c9bc2b4d1036" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("users", "first_name", existing_type=sa.VARCHAR(length=80), nullable=True) + op.alter_column("users", "last_name", existing_type=sa.VARCHAR(length=80), nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("users", "last_name", existing_type=sa.VARCHAR(length=80), nullable=False) + op.alter_column("users", "first_name", existing_type=sa.VARCHAR(length=80), nullable=False) + # ### end Alembic commands ### diff --git a/backend/migrations/versions/e6cf430117b4_seed_experiences_table.py b/backend/migrations/versions/e6cf430117b4_seed_experiences_table.py new file mode 100644 index 00000000..2cb5818e --- /dev/null +++ b/backend/migrations/versions/e6cf430117b4_seed_experiences_table.py @@ -0,0 +1,49 @@ +"""seed experiences table + +Revision ID: e6cf430117b4 +Revises: 1e275ca8f74d +Create Date: 2025-06-29 15:29:47.004086 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e6cf430117b4" +down_revision: Union[str, None] = "1e275ca8f74d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.bulk_insert( + sa.table( + "experiences", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + ), + [ + {"id": 1, "name": "PTSD"}, + {"id": 2, "name": "Relapse"}, + {"id": 3, "name": "Anxiety"}, + {"id": 4, "name": "Depression"}, + {"id": 5, "name": "Fatigue"}, + {"id": 6, "name": "Neuropathy"}, + {"id": 7, "name": "Hair Loss"}, + {"id": 8, "name": "Nausea"}, + {"id": 9, "name": "Loss of Appetite"}, + {"id": 10, "name": "Sleep Problems"}, + {"id": 11, "name": "Cognitive Changes"}, + {"id": 12, "name": "Financial Stress"}, + {"id": 13, "name": "Relationship Changes"}, + {"id": 14, "name": "Body Image Issues"}, + {"id": 15, "name": "Survivorship Concerns"}, + ], + ) + + +def downgrade() -> None: + op.execute("DELETE FROM experiences WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)") diff --git a/backend/migrations/versions/f11846c50673_seed_qualities_table.py b/backend/migrations/versions/f11846c50673_seed_qualities_table.py new file mode 100644 index 00000000..7e3268c5 --- /dev/null +++ b/backend/migrations/versions/f11846c50673_seed_qualities_table.py @@ -0,0 +1,47 @@ +"""seed qualities table + +Revision ID: f11846c50673 +Revises: e6cf430117b4 +Create Date: 2025-06-29 15:29:51.672149 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f11846c50673" +down_revision: Union[str, None] = "e6cf430117b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.bulk_insert( + sa.table( + "qualities", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("label", sa.String(), nullable=False), + ), + [ + {"id": 1, "slug": "same_age", "label": "Similar Age"}, + {"id": 2, "slug": "same_diagnosis", "label": "Same Diagnosis"}, + {"id": 3, "slug": "same_stage", "label": "Same Cancer Stage"}, + {"id": 4, "slug": "same_treatment", "label": "Similar Treatment"}, + {"id": 5, "slug": "same_location", "label": "Geographic Proximity"}, + {"id": 6, "slug": "same_gender", "label": "Same Gender"}, + {"id": 7, "slug": "family_status", "label": "Similar Family Status"}, + {"id": 8, "slug": "career_stage", "label": "Similar Career Stage"}, + {"id": 9, "slug": "shared_interests", "label": "Shared Interests/Hobbies"}, + {"id": 10, "slug": "communication_style", "label": "Communication Style"}, + {"id": 11, "slug": "emotional_support", "label": "Emotional Support Preference"}, + {"id": 12, "slug": "practical_support", "label": "Practical Support Preference"}, + ], + ) + + +def downgrade() -> None: + op.execute("DELETE FROM qualities WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)") diff --git a/backend/migrations/versions/f4225af5f02c_create_forms_schema_tables.py b/backend/migrations/versions/f4225af5f02c_create_forms_schema_tables.py new file mode 100644 index 00000000..63ace26b --- /dev/null +++ b/backend/migrations/versions/f4225af5f02c_create_forms_schema_tables.py @@ -0,0 +1,138 @@ +"""create forms schema tables + +Revision ID: f4225af5f02c +Revises: c9bc2b4d1036 +Create Date: 2025-06-29 15:28:37.734725 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "f4225af5f02c" +down_revision: Union[str, None] = "c9bc2b4d1036" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "experiences", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "forms", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column( + "type", + sa.Enum("intake", "ranking", "secondary", "become_volunteer", "become_participant", name="form_type"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "qualities", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("label", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_table( + "treatments", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "user_data", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("date_of_birth", sa.Date(), nullable=True), + sa.Column("email", sa.String(length=120), nullable=True), + sa.Column("phone", sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "user_experiences", + sa.Column("user_data_id", sa.UUID(), nullable=True), + sa.Column("experience_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["experience_id"], + ["experiences.id"], + ), + sa.ForeignKeyConstraint( + ["user_data_id"], + ["user_data.id"], + ), + ) + op.create_table( + "user_treatments", + sa.Column("user_data_id", sa.UUID(), nullable=True), + sa.Column("treatment_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["treatment_id"], + ["treatments.id"], + ), + sa.ForeignKeyConstraint( + ["user_data_id"], + ["user_data.id"], + ), + ) + op.create_table( + "form_submissions", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("form_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("answers", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.ForeignKeyConstraint( + ["form_id"], + ["forms.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ranking_preferences", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("quality_id", sa.Integer(), nullable=False), + sa.Column("rank", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["quality_id"], + ["qualities.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("user_id", "quality_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("ranking_preferences") + op.drop_table("form_submissions") + op.drop_table("user_treatments") + op.drop_table("user_experiences") + op.drop_table("user_data") + op.drop_table("treatments") + op.drop_table("qualities") + op.drop_table("forms") + op.drop_table("experiences") + # ### end Alembic commands ### diff --git a/backend/pdm.lock b/backend/pdm.lock index 5cdc2910..2e403191 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -2,30 +2,29 @@ # It is not intended for manual editing. [metadata] -groups = ["default"] +groups = ["default", "dev", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:9e47b00aee014713a4fb48d43fd8d74aaabcaceca75495b77c55006ac79ecd86" +content_hash = "sha256:847e479aa02d5a9569f0b7dc0f6458685d7627180b11b79efc24e4df29fd3f9b" [[metadata.targets]] requires_python = "==3.12.*" [[package]] name = "alembic" -version = "1.13.3" -requires_python = ">=3.8" +version = "1.16.4" +requires_python = ">=3.9" summary = "A database migration tool for SQLAlchemy." groups = ["default"] dependencies = [ "Mako", - "SQLAlchemy>=1.3.0", - "importlib-metadata; python_version < \"3.9\"", - "importlib-resources; python_version < \"3.9\"", - "typing-extensions>=4", + "SQLAlchemy>=1.4.0", + "tomli; python_version < \"3.11\"", + "typing-extensions>=4.12", ] files = [ - {file = "alembic-1.13.3-py3-none-any.whl", hash = "sha256:908e905976d15235fae59c9ac42c4c5b75cfcefe3d27c0fbf7ae15a37715d80e"}, - {file = "alembic-1.13.3.tar.gz", hash = "sha256:203503117415561e203aa14541740643a611f641517f0209fcae63e9fa09f1a2"}, + {file = "alembic-1.16.4-py3-none-any.whl", hash = "sha256:b05e51e8e82efc1abd14ba2af6392897e145930c3e0a2faf2b0da2f7f7fd660d"}, + {file = "alembic-1.16.4.tar.gz", hash = "sha256:efab6ada0dd0fae2c92060800e0bf5c1dc26af15a10e02fb4babff164b4725e2"}, ] [[package]] @@ -44,41 +43,58 @@ files = [ [[package]] name = "anyio" -version = "4.6.0" +version = "4.9.0" requires_python = ">=3.9" summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["default"] +groups = ["default", "test"] dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", "sniffio>=1.1", - "typing-extensions>=4.1; python_version < \"3.11\"", + "typing-extensions>=4.5; python_version < \"3.13\"", +] +files = [ + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, +] + +[[package]] +name = "bandit" +version = "1.8.6" +requires_python = ">=3.9" +summary = "Security oriented static analyser for python code." +groups = ["lint"] +dependencies = [ + "PyYAML>=5.3.1", + "colorama>=0.3.9; platform_system == \"Windows\"", + "rich", + "stevedore>=1.20.0", ] files = [ - {file = "anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a"}, - {file = "anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb"}, + {file = "bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0"}, + {file = "bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b"}, ] [[package]] name = "boto3" -version = "1.35.71" -requires_python = ">=3.8" +version = "1.39.9" +requires_python = ">=3.9" summary = "The AWS SDK for Python" groups = ["default"] dependencies = [ - "botocore<1.36.0,>=1.35.71", + "botocore<1.40.0,>=1.39.9", "jmespath<2.0.0,>=0.7.1", - "s3transfer<0.11.0,>=0.10.0", + "s3transfer<0.14.0,>=0.13.0", ] files = [ - {file = "boto3-1.35.71-py3-none-any.whl", hash = "sha256:e2969a246bb3208122b3c349c49cc6604c6fc3fc2b2f65d99d3e8ccd745b0c16"}, - {file = "boto3-1.35.71.tar.gz", hash = "sha256:3ed7172b3d4fceb6218bb0ec3668c4d40c03690939c2fca4f22bb875d741a07f"}, + {file = "boto3-1.39.9-py3-none-any.whl", hash = "sha256:5bc85e9fdec4e21ef5ca2c22b4d51a3e32b53f3da36ce51f5a3ea4dbde07b132"}, + {file = "boto3-1.39.9.tar.gz", hash = "sha256:e3d3a6b617e1575e7ec854c820a882ab2e189a0421e74dc0dca2c9e13d4370a5"}, ] [[package]] name = "botocore" -version = "1.35.71" -requires_python = ">=3.8" +version = "1.39.9" +requires_python = ">=3.9" summary = "Low-level, data-driven core of boto 3." groups = ["default"] dependencies = [ @@ -88,14 +104,14 @@ dependencies = [ "urllib3<1.27,>=1.25.4; python_version < \"3.10\"", ] files = [ - {file = "botocore-1.35.71-py3-none-any.whl", hash = "sha256:fc46e7ab1df3cef66dfba1633f4da77c75e07365b36f03bd64a3793634be8fc1"}, - {file = "botocore-1.35.71.tar.gz", hash = "sha256:f9fa058e0393660c3fe53c1e044751beb64b586def0bd2212448a7c328b0cbba"}, + {file = "botocore-1.39.9-py3-none-any.whl", hash = "sha256:a9691cbe03a3bc8b2720b3c36e5c5a2eecace6acd72bfb1107f00e75edaec4f3"}, + {file = "botocore-1.39.9.tar.gz", hash = "sha256:02f141c2849e4589a79feea245ce4ecc478d48b7865572445af8aae3b041772d"}, ] [[package]] name = "cachecontrol" -version = "0.14.0" -requires_python = ">=3.7" +version = "0.14.3" +requires_python = ">=3.9" summary = "httplib2 caching for requests" groups = ["default"] dependencies = [ @@ -103,30 +119,30 @@ dependencies = [ "requests>=2.16.0", ] files = [ - {file = "cachecontrol-0.14.0-py3-none-any.whl", hash = "sha256:f5bf3f0620c38db2e5122c0726bdebb0d16869de966ea6a2befe92470b740ea0"}, - {file = "cachecontrol-0.14.0.tar.gz", hash = "sha256:7db1195b41c81f8274a7bbd97c956f44e8348265a1bc7641c37dfebc39f0c938"}, + {file = "cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae"}, + {file = "cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11"}, ] [[package]] name = "cachetools" -version = "5.5.0" +version = "5.5.2" requires_python = ">=3.7" summary = "Extensible memoizing collections and decorators" groups = ["default"] files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, + {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, + {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, ] [[package]] name = "certifi" -version = "2024.8.30" -requires_python = ">=3.6" +version = "2025.7.14" +requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["default"] +groups = ["default", "test"] files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, ] [[package]] @@ -167,43 +183,40 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.2" -requires_python = ">=3.7.0" +version = "3.4.2" +requires_python = ">=3.7" summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." groups = ["default"] files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, ] [[package]] name = "click" -version = "8.1.7" -requires_python = ">=3.7" +version = "8.2.1" +requires_python = ">=3.10" summary = "Composable command line interface toolkit" groups = ["default"] dependencies = [ "colorama; platform_system == \"Windows\"", - "importlib-metadata; python_version < \"3.8\"", ] files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] [[package]] @@ -211,63 +224,118 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["default"] +groups = ["default", "lint", "test"] marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.9.2" +requires_python = ">=3.9" +summary = "Code coverage measurement for Python" +groups = ["test"] +files = [ + {file = "coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0"}, + {file = "coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d"}, + {file = "coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355"}, + {file = "coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0"}, + {file = "coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b"}, + {file = "coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4"}, + {file = "coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b"}, +] + +[[package]] +name = "coverage" +version = "7.9.2" +extras = ["toml"] +requires_python = ">=3.9" +summary = "Code coverage measurement for Python" +groups = ["test"] +dependencies = [ + "coverage==7.9.2", + "tomli; python_full_version <= \"3.11.0a6\"", +] +files = [ + {file = "coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0"}, + {file = "coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d"}, + {file = "coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355"}, + {file = "coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0"}, + {file = "coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b"}, + {file = "coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4"}, + {file = "coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b"}, +] + [[package]] name = "cryptography" -version = "43.0.1" -requires_python = ">=3.7" +version = "45.0.5" +requires_python = "!=3.9.0,!=3.9.1,>=3.7" summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." groups = ["default"] dependencies = [ - "cffi>=1.12; platform_python_implementation != \"PyPy\"", -] -files = [ - {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, - {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, - {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, - {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, - {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, - {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, - {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, + "cffi>=1.14; platform_python_implementation != \"PyPy\"", +] +files = [ + {file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174"}, + {file = "cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9"}, + {file = "cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63"}, + {file = "cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a"}, + {file = "cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f"}, + {file = "cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97"}, + {file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"}, ] [[package]] name = "distlib" -version = "0.3.8" +version = "0.4.0" summary = "Distribution utilities" groups = ["default"] files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] [[package]] name = "dnspython" -version = "2.6.1" -requires_python = ">=3.8" +version = "2.7.0" +requires_python = ">=3.9" summary = "DNS toolkit" groups = ["default"] files = [ - {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, - {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, ] [[package]] @@ -287,161 +355,166 @@ files = [ [[package]] name = "fastapi" -version = "0.115.0" +version = "0.116.1" requires_python = ">=3.8" summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" groups = ["default"] dependencies = [ "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", - "starlette<0.39.0,>=0.37.2", + "starlette<0.48.0,>=0.40.0", "typing-extensions>=4.8.0", ] files = [ - {file = "fastapi-0.115.0-py3-none-any.whl", hash = "sha256:17ea427674467486e997206a5ab25760f6b09e069f099b96f5b55a32fb6f1631"}, - {file = "fastapi-0.115.0.tar.gz", hash = "sha256:f93b4ca3529a8ebc6fc3fcf710e5efa8de3df9b41570958abf1d97d843138004"}, + {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, + {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, ] [[package]] name = "fastapi-cli" -version = "0.0.5" +version = "0.0.8" requires_python = ">=3.8" summary = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" groups = ["default"] dependencies = [ - "typer>=0.12.3", + "rich-toolkit>=0.14.8", + "typer>=0.15.1", "uvicorn[standard]>=0.15.0", ] files = [ - {file = "fastapi_cli-0.0.5-py3-none-any.whl", hash = "sha256:e94d847524648c748a5350673546bbf9bcaeb086b33c24f2e82e021436866a46"}, - {file = "fastapi_cli-0.0.5.tar.gz", hash = "sha256:d30e1239c6f46fcb95e606f02cdda59a1e2fa778a54b64686b3ff27f6211ff9f"}, + {file = "fastapi_cli-0.0.8-py3-none-any.whl", hash = "sha256:0ea95d882c85b9219a75a65ab27e8da17dac02873e456850fa0a726e96e985eb"}, + {file = "fastapi_cli-0.0.8.tar.gz", hash = "sha256:2360f2989b1ab4a3d7fc8b3a0b20e8288680d8af2e31de7c38309934d7f8a0ee"}, ] [[package]] name = "fastapi-cli" -version = "0.0.5" +version = "0.0.8" extras = ["standard"] requires_python = ">=3.8" summary = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" groups = ["default"] dependencies = [ - "fastapi-cli==0.0.5", + "fastapi-cli==0.0.8", + "fastapi-cloud-cli>=0.1.1", + "uvicorn[standard]>=0.15.0", +] +files = [ + {file = "fastapi_cli-0.0.8-py3-none-any.whl", hash = "sha256:0ea95d882c85b9219a75a65ab27e8da17dac02873e456850fa0a726e96e985eb"}, + {file = "fastapi_cli-0.0.8.tar.gz", hash = "sha256:2360f2989b1ab4a3d7fc8b3a0b20e8288680d8af2e31de7c38309934d7f8a0ee"}, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.1.4" +requires_python = ">=3.8" +summary = "Deploy and manage FastAPI Cloud apps from the command line 🚀" +groups = ["default"] +dependencies = [ + "httpx>=0.27.0", + "pydantic[email]>=1.6.1", + "rich-toolkit>=0.14.5", + "rignore>=0.5.1", + "sentry-sdk>=2.20.0", + "typer>=0.12.3", "uvicorn[standard]>=0.15.0", ] files = [ - {file = "fastapi_cli-0.0.5-py3-none-any.whl", hash = "sha256:e94d847524648c748a5350673546bbf9bcaeb086b33c24f2e82e021436866a46"}, - {file = "fastapi_cli-0.0.5.tar.gz", hash = "sha256:d30e1239c6f46fcb95e606f02cdda59a1e2fa778a54b64686b3ff27f6211ff9f"}, + {file = "fastapi_cloud_cli-0.1.4-py3-none-any.whl", hash = "sha256:1db1ba757aa46a16a5e5dacf7cddc137ca0a3c42f65dba2b1cc6a8f24c41be42"}, + {file = "fastapi_cloud_cli-0.1.4.tar.gz", hash = "sha256:a0ab7633d71d864b4041896b3fe2f462de61546db7c52eb13e963f4d40af0eba"}, ] [[package]] name = "fastapi" -version = "0.115.0" +version = "0.116.1" extras = ["standard"] requires_python = ">=3.8" summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" groups = ["default"] dependencies = [ "email-validator>=2.0.0", - "fastapi-cli[standard]>=0.0.5", - "fastapi==0.115.0", + "fastapi-cli[standard]>=0.0.8", + "fastapi==0.116.1", "httpx>=0.23.0", - "jinja2>=2.11.2", - "python-multipart>=0.0.7", + "jinja2>=3.1.5", + "python-multipart>=0.0.18", "uvicorn[standard]>=0.12.0", ] files = [ - {file = "fastapi-0.115.0-py3-none-any.whl", hash = "sha256:17ea427674467486e997206a5ab25760f6b09e069f099b96f5b55a32fb6f1631"}, - {file = "fastapi-0.115.0.tar.gz", hash = "sha256:f93b4ca3529a8ebc6fc3fcf710e5efa8de3df9b41570958abf1d97d843138004"}, + {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, + {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, ] [[package]] name = "filelock" -version = "3.16.1" -requires_python = ">=3.8" +version = "3.18.0" +requires_python = ">=3.9" summary = "A platform independent file lock." groups = ["default"] files = [ - {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, - {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, + {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, + {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, ] [[package]] name = "firebase-admin" -version = "6.8.0" -requires_python = ">=3.7" +version = "7.0.0" +requires_python = ">=3.9" summary = "Firebase Admin Python SDK" groups = ["default"] dependencies = [ - "cachecontrol>=0.12.14", - "google-api-core[grpc]<3.0.0dev,>=1.22.1; platform_python_implementation != \"PyPy\"", - "google-api-python-client>=1.7.8", - "google-cloud-firestore>=2.19.0; platform_python_implementation != \"PyPy\"", - "google-cloud-storage>=1.37.1", - "pyjwt[crypto]>=2.5.0", + "cachecontrol>=0.14.3", + "google-api-core[grpc]<3.0.0dev,>=2.25.1; platform_python_implementation != \"PyPy\"", + "google-cloud-firestore>=2.21.0; platform_python_implementation != \"PyPy\"", + "google-cloud-storage>=3.1.1", + "httpx[http2]==0.28.1", + "pyjwt[crypto]>=2.10.1", ] files = [ - {file = "firebase_admin-6.8.0-py3-none-any.whl", hash = "sha256:84d5fd82859c4d27b63338c3fe9724667dfe400aa2fd9fef0efffbf6e23bca82"}, - {file = "firebase_admin-6.8.0.tar.gz", hash = "sha256:24a9870219cfd6578586357858e00758aea26a39df74c53be5d803f5654d883e"}, + {file = "firebase_admin-7.0.0-py3-none-any.whl", hash = "sha256:1769d0a68e7cd17b2d5e36adeae1400b83cd540104c02c39411666abf7eec800"}, + {file = "firebase_admin-7.0.0.tar.gz", hash = "sha256:b98b184d4250103a54b3b40ee38562596e6a01aa4054d51c4ba2df7884304897"}, ] [[package]] name = "google-api-core" -version = "2.20.0" +version = "2.25.1" requires_python = ">=3.7" summary = "Google API client core library" groups = ["default"] dependencies = [ - "google-auth<3.0.dev0,>=2.14.1", - "googleapis-common-protos<2.0.dev0,>=1.56.2", - "proto-plus<2.0.0dev,>=1.22.3", - "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<6.0.0.dev0,>=3.19.5", - "requests<3.0.0.dev0,>=2.18.0", + "google-auth<3.0.0,>=2.14.1", + "googleapis-common-protos<2.0.0,>=1.56.2", + "proto-plus<2.0.0,>=1.22.3", + "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", + "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.19.5", + "requests<3.0.0,>=2.18.0", ] files = [ - {file = "google_api_core-2.20.0-py3-none-any.whl", hash = "sha256:ef0591ef03c30bb83f79b3d0575c3f31219001fc9c5cf37024d08310aeffed8a"}, - {file = "google_api_core-2.20.0.tar.gz", hash = "sha256:f74dff1889ba291a4b76c5079df0711810e2d9da81abfdc99957bc961c1eb28f"}, + {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, + {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, ] [[package]] name = "google-api-core" -version = "2.20.0" +version = "2.25.1" extras = ["grpc"] requires_python = ">=3.7" summary = "Google API client core library" groups = ["default"] marker = "platform_python_implementation != \"PyPy\"" dependencies = [ - "google-api-core==2.20.0", - "grpcio-status<2.0.dev0,>=1.33.2", - "grpcio-status<2.0.dev0,>=1.49.1; python_version >= \"3.11\"", - "grpcio<2.0dev,>=1.33.2", - "grpcio<2.0dev,>=1.49.1; python_version >= \"3.11\"", + "google-api-core==2.25.1", + "grpcio-status<2.0.0,>=1.33.2", + "grpcio-status<2.0.0,>=1.49.1; python_version >= \"3.11\"", + "grpcio<2.0.0,>=1.33.2", + "grpcio<2.0.0,>=1.49.1; python_version >= \"3.11\"", ] files = [ - {file = "google_api_core-2.20.0-py3-none-any.whl", hash = "sha256:ef0591ef03c30bb83f79b3d0575c3f31219001fc9c5cf37024d08310aeffed8a"}, - {file = "google_api_core-2.20.0.tar.gz", hash = "sha256:f74dff1889ba291a4b76c5079df0711810e2d9da81abfdc99957bc961c1eb28f"}, -] - -[[package]] -name = "google-api-python-client" -version = "2.146.0" -requires_python = ">=3.7" -summary = "Google API Client Library for Python" -groups = ["default"] -dependencies = [ - "google-api-core!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0.dev0,>=1.31.5", - "google-auth!=2.24.0,!=2.25.0,<3.0.0.dev0,>=1.32.0", - "google-auth-httplib2<1.0.0,>=0.2.0", - "httplib2<1.dev0,>=0.19.0", - "uritemplate<5,>=3.0.1", -] -files = [ - {file = "google_api_python_client-2.146.0-py2.py3-none-any.whl", hash = "sha256:b1e62c9889c5ef6022f11d30d7ef23dc55100300f0e8aaf8aa09e8e92540acad"}, - {file = "google_api_python_client-2.146.0.tar.gz", hash = "sha256:41f671be10fa077ee5143ee9f0903c14006d39dc644564f4e044ae96b380bf68"}, + {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, + {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, ] [[package]] name = "google-auth" -version = "2.35.0" +version = "2.40.3" requires_python = ">=3.7" summary = "Google Authentication Library" groups = ["default"] @@ -451,27 +524,13 @@ dependencies = [ "rsa<5,>=3.1.4", ] files = [ - {file = "google_auth-2.35.0-py2.py3-none-any.whl", hash = "sha256:25df55f327ef021de8be50bad0dfd4a916ad0de96da86cd05661c9297723ad3f"}, - {file = "google_auth-2.35.0.tar.gz", hash = "sha256:f4c64ed4e01e8e8b646ef34c018f8bf3338df0c8e37d8b3bba40e7f574a3278a"}, -] - -[[package]] -name = "google-auth-httplib2" -version = "0.2.0" -summary = "Google Authentication Library: httplib2 transport" -groups = ["default"] -dependencies = [ - "google-auth", - "httplib2>=0.19.0", -] -files = [ - {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, - {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, + {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, + {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, ] [[package]] name = "google-cloud-core" -version = "2.4.1" +version = "2.4.3" requires_python = ">=3.7" summary = "Google Cloud API client core library" groups = ["default"] @@ -481,13 +540,13 @@ dependencies = [ "importlib-metadata>1.0.0; python_version < \"3.8\"", ] files = [ - {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, - {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, + {file = "google_cloud_core-2.4.3-py2.py3-none-any.whl", hash = "sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e"}, + {file = "google_cloud_core-2.4.3.tar.gz", hash = "sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53"}, ] [[package]] name = "google-cloud-firestore" -version = "2.20.2" +version = "2.21.0" requires_python = ">=3.7" summary = "Google Cloud Firestore API client library" groups = ["default"] @@ -502,32 +561,32 @@ dependencies = [ "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0dev,>=3.20.2", ] files = [ - {file = "google_cloud_firestore-2.20.2-py3-none-any.whl", hash = "sha256:0ff7b4c66e3ad2fe00f7d5d8c15127bf4ff8b316c6e4eb635ac51d9a9bcd828b"}, - {file = "google_cloud_firestore-2.20.2.tar.gz", hash = "sha256:0ad2e33fa7da0ba8fb7ccc324f91d3f57866b770e24840bd62f6a272f747c5f9"}, + {file = "google_cloud_firestore-2.21.0-py3-none-any.whl", hash = "sha256:bf33ccc38a27afc60748d1f9bb7c46b078d0d39d288636bdfd967611d7b3f17f"}, + {file = "google_cloud_firestore-2.21.0.tar.gz", hash = "sha256:0c37faa8506297f827eefc38feb155247a6dcb9a541289631015d125f1b003f8"}, ] [[package]] name = "google-cloud-storage" -version = "2.18.2" +version = "3.2.0" requires_python = ">=3.7" summary = "Google Cloud Storage API client library" groups = ["default"] dependencies = [ - "google-api-core<3.0.0dev,>=2.15.0", - "google-auth<3.0dev,>=2.26.1", - "google-cloud-core<3.0dev,>=2.3.0", - "google-crc32c<2.0dev,>=1.0", - "google-resumable-media>=2.7.2", - "requests<3.0.0dev,>=2.18.0", + "google-api-core<3.0.0,>=2.15.0", + "google-auth<3.0.0,>=2.26.1", + "google-cloud-core<3.0.0,>=2.4.2", + "google-crc32c<2.0.0,>=1.1.3", + "google-resumable-media<3.0.0,>=2.7.2", + "requests<3.0.0,>=2.22.0", ] files = [ - {file = "google_cloud_storage-2.18.2-py2.py3-none-any.whl", hash = "sha256:97a4d45c368b7d401ed48c4fdfe86e1e1cb96401c9e199e419d289e2c0370166"}, - {file = "google_cloud_storage-2.18.2.tar.gz", hash = "sha256:aaf7acd70cdad9f274d29332673fcab98708d0e1f4dceb5a5356aaef06af4d99"}, + {file = "google_cloud_storage-3.2.0-py3-none-any.whl", hash = "sha256:ff7a9a49666954a7c3d1598291220c72d3b9e49d9dfcf9dfaecb301fc4fb0b24"}, + {file = "google_cloud_storage-3.2.0.tar.gz", hash = "sha256:decca843076036f45633198c125d1861ffbf47ebf5c0e3b98dcb9b2db155896c"}, ] [[package]] name = "google-crc32c" -version = "1.6.0" +version = "1.7.1" requires_python = ">=3.9" summary = "A python wrapper of the C library 'Google CRC32C'" groups = ["default"] @@ -535,12 +594,12 @@ dependencies = [ "importlib-resources>=1.3; python_version < \"3.9\" and os_name == \"nt\"", ] files = [ - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d"}, - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3"}, - {file = "google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760"}, - {file = "google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc"}, + {file = "google_crc32c-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194"}, + {file = "google_crc32c-1.7.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e"}, + {file = "google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337"}, + {file = "google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65"}, + {file = "google_crc32c-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6"}, + {file = "google_crc32c-1.7.1.tar.gz", hash = "sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472"}, ] [[package]] @@ -559,163 +618,198 @@ files = [ [[package]] name = "googleapis-common-protos" -version = "1.65.0" +version = "1.70.0" requires_python = ">=3.7" summary = "Common protobufs used in Google APIs" groups = ["default"] dependencies = [ - "protobuf!=3.20.0,!=3.20.1,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<6.0.0.dev0,>=3.20.2", + "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", ] files = [ - {file = "googleapis_common_protos-1.65.0-py2.py3-none-any.whl", hash = "sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63"}, - {file = "googleapis_common_protos-1.65.0.tar.gz", hash = "sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0"}, + {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, + {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, ] [[package]] name = "greenlet" -version = "3.1.1" -requires_python = ">=3.7" +version = "3.2.3" +requires_python = ">=3.9" summary = "Lightweight in-process concurrent programming" groups = ["default"] -marker = "(platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") and python_version < \"3.13\"" +marker = "(platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") and python_version < \"3.14\"" files = [ - {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, - {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, - {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, + {file = "greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163"}, + {file = "greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849"}, + {file = "greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365"}, ] [[package]] name = "grpcio" -version = "1.66.1" -requires_python = ">=3.8" +version = "1.73.1" +requires_python = ">=3.9" summary = "HTTP/2-based RPC framework" groups = ["default"] marker = "platform_python_implementation != \"PyPy\"" files = [ - {file = "grpcio-1.66.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:a92c4f58c01c77205df6ff999faa008540475c39b835277fb8883b11cada127a"}, - {file = "grpcio-1.66.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fdb14bad0835914f325349ed34a51940bc2ad965142eb3090081593c6e347be9"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f03a5884c56256e08fd9e262e11b5cfacf1af96e2ce78dc095d2c41ccae2c80d"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ca2559692d8e7e245d456877a85ee41525f3ed425aa97eb7a70fc9a79df91a0"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca1be089fb4446490dd1135828bd42a7c7f8421e74fa581611f7afdf7ab761"}, - {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d639c939ad7c440c7b2819a28d559179a4508783f7e5b991166f8d7a34b52815"}, - {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b9feb4e5ec8dc2d15709f4d5fc367794d69277f5d680baf1910fc9915c633524"}, - {file = "grpcio-1.66.1-cp312-cp312-win32.whl", hash = "sha256:7101db1bd4cd9b880294dec41a93fcdce465bdbb602cd8dc5bd2d6362b618759"}, - {file = "grpcio-1.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:b0aa03d240b5539648d996cc60438f128c7f46050989e35b25f5c18286c86734"}, - {file = "grpcio-1.66.1.tar.gz", hash = "sha256:35334f9c9745add3e357e3372756fd32d925bd52c41da97f4dfdafbde0bf0ee2"}, + {file = "grpcio-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:921b25618b084e75d424a9f8e6403bfeb7abef074bb6c3174701e0f2542debcf"}, + {file = "grpcio-1.73.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:277b426a0ed341e8447fbf6c1d6b68c952adddf585ea4685aa563de0f03df887"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:96c112333309493c10e118d92f04594f9055774757f5d101b39f8150f8c25582"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f48e862aed925ae987eb7084409a80985de75243389dc9d9c271dd711e589918"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83a6c2cce218e28f5040429835fa34a29319071079e3169f9543c3fbeff166d2"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:65b0458a10b100d815a8426b1442bd17001fdb77ea13665b2f7dc9e8587fdc6b"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0a9f3ea8dce9eae9d7cb36827200133a72b37a63896e0e61a9d5ec7d61a59ab1"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de18769aea47f18e782bf6819a37c1c528914bfd5683b8782b9da356506190c8"}, + {file = "grpcio-1.73.1-cp312-cp312-win32.whl", hash = "sha256:24e06a5319e33041e322d32c62b1e728f18ab8c9dbc91729a3d9f9e3ed336642"}, + {file = "grpcio-1.73.1-cp312-cp312-win_amd64.whl", hash = "sha256:303c8135d8ab176f8038c14cc10d698ae1db9c480f2b2823f7a987aa2a4c5646"}, + {file = "grpcio-1.73.1.tar.gz", hash = "sha256:7fce2cd1c0c1116cf3850564ebfc3264fba75d3c74a7414373f1238ea365ef87"}, ] [[package]] name = "grpcio-status" -version = "1.66.1" -requires_python = ">=3.8" +version = "1.73.1" +requires_python = ">=3.9" summary = "Status proto mapping for gRPC" groups = ["default"] marker = "platform_python_implementation != \"PyPy\"" dependencies = [ "googleapis-common-protos>=1.5.5", - "grpcio>=1.66.1", - "protobuf<6.0dev,>=5.26.1", + "grpcio>=1.73.1", + "protobuf<7.0.0,>=6.30.0", ] files = [ - {file = "grpcio_status-1.66.1-py3-none-any.whl", hash = "sha256:cf9ed0b4a83adbe9297211c95cb5488b0cd065707e812145b842c85c4782ff02"}, - {file = "grpcio_status-1.66.1.tar.gz", hash = "sha256:b3f7d34ccc46d83fea5261eea3786174459f763c31f6e34f1d24eba6d515d024"}, + {file = "grpcio_status-1.73.1-py3-none-any.whl", hash = "sha256:538595c32a6c819c32b46a621a51e9ae4ffcd7e7e1bce35f728ef3447e9809b6"}, + {file = "grpcio_status-1.73.1.tar.gz", hash = "sha256:928f49ccf9688db5f20cd9e45c4578a1d01ccca29aeaabf066f2ac76aa886668"}, ] [[package]] name = "h11" -version = "0.14.0" -requires_python = ">=3.7" +version = "0.16.0" +requires_python = ">=3.8" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["default"] -dependencies = [ - "typing-extensions; python_version < \"3.8\"", -] +groups = ["default", "test"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] -name = "httpcore" -version = "1.0.5" -requires_python = ">=3.8" -summary = "A minimal low-level HTTP client." +name = "h2" +version = "4.2.0" +requires_python = ">=3.9" +summary = "Pure-Python HTTP/2 protocol implementation" groups = ["default"] dependencies = [ - "certifi", - "h11<0.15,>=0.13", + "hpack<5,>=4.1", + "hyperframe<7,>=6.1", ] files = [ - {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, - {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, + {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, + {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, ] [[package]] -name = "httplib2" -version = "0.22.0" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -summary = "A comprehensive HTTP client library." +name = "hpack" +version = "4.1.0" +requires_python = ">=3.9" +summary = "Pure-Python HPACK header encoding" groups = ["default"] +files = [ + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +requires_python = ">=3.8" +summary = "A minimal low-level HTTP client." +groups = ["default", "test"] dependencies = [ - "pyparsing!=3.0.0,!=3.0.1,!=3.0.2,!=3.0.3,<4,>=2.4.2; python_version > \"3.0\"", - "pyparsing<3,>=2.4.2; python_version < \"3.0\"", + "certifi", + "h11>=0.16", ] files = [ - {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, - {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [[package]] name = "httptools" -version = "0.6.1" +version = "0.6.4" requires_python = ">=3.8.0" summary = "A collection of framework independent HTTP protocol utils." groups = ["default"] files = [ - {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, - {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, - {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, - {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, - {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, - {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, - {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, - {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, + {file = "httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2"}, + {file = "httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44"}, + {file = "httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1"}, + {file = "httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2"}, + {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81"}, + {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f"}, + {file = "httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970"}, + {file = "httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c"}, ] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["default"] +groups = ["default", "test"] dependencies = [ "anyio", "certifi", "httpcore==1.*", "idna", - "sniffio", ] files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [[package]] -name = "identify" -version = "2.6.1" +name = "httpx" +version = "0.28.1" +extras = ["http2"] requires_python = ">=3.8" +summary = "The next generation HTTP client." +groups = ["default"] +dependencies = [ + "h2<5,>=3", + "httpx==0.28.1", +] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +requires_python = ">=3.9" +summary = "Pure-Python HTTP/2 framing" +groups = ["default"] +files = [ + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, +] + +[[package]] +name = "identify" +version = "2.6.12" +requires_python = ">=3.9" summary = "File identification library for Python" groups = ["default"] files = [ - {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, - {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, + {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, + {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, ] [[package]] @@ -723,7 +817,7 @@ name = "idna" version = "3.10" requires_python = ">=3.6" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default"] +groups = ["default", "test"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -742,18 +836,18 @@ files = [ [[package]] name = "iniconfig" -version = "2.0.0" -requires_python = ">=3.7" +version = "2.1.0" +requires_python = ">=3.8" summary = "brain-dead simple config-ini parsing" -groups = ["default"] +groups = ["default", "test"] files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.6" requires_python = ">=3.7" summary = "A very fast and expressive template engine." groups = ["default"] @@ -761,8 +855,8 @@ dependencies = [ "MarkupSafe>=2.0", ] files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [[package]] @@ -778,7 +872,7 @@ files = [ [[package]] name = "mako" -version = "1.3.5" +version = "1.3.10" requires_python = ">=3.8" summary = "A super-fast templating language that borrows the best ideas from the existing templating languages." groups = ["default"] @@ -786,8 +880,8 @@ dependencies = [ "MarkupSafe>=0.9.2", ] files = [ - {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"}, - {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"}, + {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, + {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, ] [[package]] @@ -795,7 +889,7 @@ name = "markdown-it-py" version = "3.0.0" requires_python = ">=3.8" summary = "Python port of markdown-it. Markdown parsing, done right!" -groups = ["default"] +groups = ["default", "lint"] dependencies = [ "mdurl~=0.1", ] @@ -806,22 +900,22 @@ files = [ [[package]] name = "markupsafe" -version = "2.1.5" -requires_python = ">=3.7" +version = "3.0.2" +requires_python = ">=3.9" summary = "Safely add untrusted strings to HTML/XML markup." groups = ["default"] files = [ - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] [[package]] @@ -829,7 +923,7 @@ name = "mdurl" version = "0.1.2" requires_python = ">=3.7" summary = "Markdown URL utilities" -groups = ["default"] +groups = ["default", "lint"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -837,23 +931,56 @@ files = [ [[package]] name = "msgpack" -version = "1.1.0" +version = "1.1.1" requires_python = ">=3.8" summary = "MessagePack serializer" groups = ["default"] files = [ - {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d"}, - {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2"}, - {file = "msgpack-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420"}, - {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2"}, - {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39"}, - {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f"}, - {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247"}, - {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c"}, - {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b"}, - {file = "msgpack-1.1.0-cp312-cp312-win32.whl", hash = "sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b"}, - {file = "msgpack-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f"}, - {file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"}, + {file = "msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238"}, + {file = "msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a"}, + {file = "msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c"}, + {file = "msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4"}, + {file = "msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd"}, +] + +[[package]] +name = "mypy" +version = "1.17.0" +requires_python = ">=3.9" +summary = "Optional static typing for Python" +groups = ["lint"] +dependencies = [ + "mypy-extensions>=1.0.0", + "pathspec>=0.9.0", + "tomli>=1.1.0; python_version < \"3.11\"", + "typing-extensions>=4.6.0", +] +files = [ + {file = "mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb"}, + {file = "mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d"}, + {file = "mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8"}, + {file = "mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e"}, + {file = "mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8"}, + {file = "mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d"}, + {file = "mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496"}, + {file = "mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +requires_python = ">=3.8" +summary = "Type system extensions for programs checked with the mypy type checker." +groups = ["lint"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] [[package]] @@ -869,40 +996,65 @@ files = [ [[package]] name = "packaging" -version = "24.1" +version = "25.0" requires_python = ">=3.8" summary = "Core utilities for Python packages" -groups = ["default"] +groups = ["default", "test"] files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] -name = "platformdirs" -version = "4.3.6" +name = "pathspec" +version = "0.12.1" requires_python = ">=3.8" +summary = "Utility library for gitignore style pattern matching of file paths." +groups = ["lint"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pbr" +version = "6.1.1" +requires_python = ">=2.6" +summary = "Python Build Reasonableness" +groups = ["lint"] +dependencies = [ + "setuptools", +] +files = [ + {file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"}, + {file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +requires_python = ">=3.9" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." groups = ["default"] files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, ] [[package]] name = "pluggy" -version = "1.5.0" -requires_python = ">=3.8" +version = "1.6.0" +requires_python = ">=3.9" summary = "plugin and hook calling mechanisms for python" -groups = ["default"] +groups = ["default", "test"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [[package]] name = "pre-commit" -version = "4.0.0" +version = "4.2.0" requires_python = ">=3.9" summary = "A framework for managing and maintaining multi-language pre-commit hooks." groups = ["default"] @@ -914,50 +1066,50 @@ dependencies = [ "virtualenv>=20.10.0", ] files = [ - {file = "pre_commit-4.0.0-py2.py3-none-any.whl", hash = "sha256:0ca2341cf94ac1865350970951e54b1a50521e57b7b500403307aed4315a1234"}, - {file = "pre_commit-4.0.0.tar.gz", hash = "sha256:5d9807162cc5537940f94f266cbe2d716a75cfad0d78a317a92cac16287cfed6"}, + {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, + {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, ] [[package]] name = "proto-plus" -version = "1.24.0" +version = "1.26.1" requires_python = ">=3.7" -summary = "Beautiful, Pythonic protocol buffers." +summary = "Beautiful, Pythonic protocol buffers" groups = ["default"] dependencies = [ - "protobuf<6.0.0dev,>=3.19.0", + "protobuf<7.0.0,>=3.19.0", ] files = [ - {file = "proto-plus-1.24.0.tar.gz", hash = "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445"}, - {file = "proto_plus-1.24.0-py3-none-any.whl", hash = "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12"}, + {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, + {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, ] [[package]] name = "protobuf" -version = "5.28.2" -requires_python = ">=3.8" +version = "6.31.1" +requires_python = ">=3.9" summary = "" groups = ["default"] files = [ - {file = "protobuf-5.28.2-cp310-abi3-win32.whl", hash = "sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d"}, - {file = "protobuf-5.28.2-cp310-abi3-win_amd64.whl", hash = "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132"}, - {file = "protobuf-5.28.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7"}, - {file = "protobuf-5.28.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f"}, - {file = "protobuf-5.28.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f"}, - {file = "protobuf-5.28.2-py3-none-any.whl", hash = "sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece"}, - {file = "protobuf-5.28.2.tar.gz", hash = "sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0"}, + {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, + {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, + {file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, + {file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, + {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, ] [[package]] name = "psycopg2" -version = "2.9.9" -requires_python = ">=3.7" +version = "2.9.10" +requires_python = ">=3.8" summary = "psycopg2 - Python-PostgreSQL Database Adapter" groups = ["default"] files = [ - {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, - {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, - {file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"}, + {file = "psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067"}, + {file = "psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e"}, + {file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"}, ] [[package]] @@ -973,16 +1125,16 @@ files = [ [[package]] name = "pyasn1-modules" -version = "0.4.1" +version = "0.4.2" requires_python = ">=3.8" summary = "A collection of ASN.1-based protocols modules" groups = ["default"] dependencies = [ - "pyasn1<0.7.0,>=0.4.6", + "pyasn1<0.7.0,>=0.6.1", ] files = [ - {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, - {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, ] [[package]] @@ -999,142 +1151,167 @@ files = [ [[package]] name = "pydantic" -version = "2.9.2" -requires_python = ">=3.8" +version = "2.11.7" +requires_python = ">=3.9" summary = "Data validation using Python type hints" groups = ["default"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.23.4", - "typing-extensions>=4.12.2; python_version >= \"3.13\"", - "typing-extensions>=4.6.1; python_version < \"3.13\"", + "pydantic-core==2.33.2", + "typing-extensions>=4.12.2", + "typing-inspection>=0.4.0", ] files = [ - {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, - {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, ] [[package]] name = "pydantic-core" -version = "2.23.4" -requires_python = ">=3.8" +version = "2.33.2" +requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" groups = ["default"] dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] files = [ - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, - {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, - {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, - {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +extras = ["email"] +requires_python = ">=3.9" +summary = "Data validation using Python type hints" +groups = ["default"] +dependencies = [ + "email-validator>=2.0.0", + "pydantic==2.11.7", +] +files = [ + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, ] [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.2" requires_python = ">=3.8" summary = "Pygments is a syntax highlighting package written in Python." -groups = ["default"] +groups = ["default", "lint", "test"] files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [[package]] name = "pyjwt" -version = "2.9.0" -requires_python = ">=3.8" +version = "2.10.1" +requires_python = ">=3.9" summary = "JSON Web Token implementation in Python" groups = ["default"] files = [ - {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, - {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, ] [[package]] name = "pyjwt" -version = "2.9.0" +version = "2.10.1" extras = ["crypto"] -requires_python = ">=3.8" +requires_python = ">=3.9" summary = "JSON Web Token implementation in Python" groups = ["default"] dependencies = [ "cryptography>=3.4.0", - "pyjwt==2.9.0", -] -files = [ - {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, - {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, + "pyjwt==2.10.1", ] - -[[package]] -name = "pyparsing" -version = "3.1.4" -requires_python = ">=3.6.8" -summary = "pyparsing module - Classes and methods to define and execute parsing grammars" -groups = ["default"] -marker = "python_version > \"3.0\"" files = [ - {file = "pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c"}, - {file = "pyparsing-3.1.4.tar.gz", hash = "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032"}, + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, ] [[package]] name = "pyright" -version = "1.1.381" +version = "1.1.403" requires_python = ">=3.7" summary = "Command line wrapper for pyright" groups = ["default"] dependencies = [ "nodeenv>=1.6.0", - "typing-extensions>=3.7; python_version < \"3.8\"", + "typing-extensions>=4.1", ] files = [ - {file = "pyright-1.1.381-py3-none-any.whl", hash = "sha256:5dc0aa80a265675d36abab59c674ae01dbe476714f91845b61b841d34aa99081"}, - {file = "pyright-1.1.381.tar.gz", hash = "sha256:314cf0c1351c189524fb10c7ac20688ecd470e8cc505c394d642c9c80bf7c3a5"}, + {file = "pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3"}, + {file = "pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104"}, ] [[package]] name = "pytest" -version = "8.3.3" -requires_python = ">=3.8" +version = "8.4.1" +requires_python = ">=3.9" summary = "pytest: simple powerful testing with Python" -groups = ["default"] +groups = ["default", "test"] dependencies = [ - "colorama; sys_platform == \"win32\"", - "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", - "iniconfig", - "packaging", + "colorama>=0.4; sys_platform == \"win32\"", + "exceptiongroup>=1; python_version < \"3.11\"", + "iniconfig>=1", + "packaging>=20", "pluggy<2,>=1.5", + "pygments>=2.7.2", "tomli>=1; python_version < \"3.11\"", ] files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, ] [[package]] name = "pytest-asyncio" -version = "0.25.3" +version = "1.1.0" requires_python = ">=3.9" summary = "Pytest support for asyncio" -groups = ["default"] +groups = ["default", "test"] dependencies = [ + "backports-asyncio-runner<2,>=1.1; python_version < \"3.11\"", "pytest<9,>=8.2", + "typing-extensions>=4.12; python_version < \"3.10\"", ] files = [ - {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, - {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +requires_python = ">=3.9" +summary = "Pytest plugin for measuring coverage." +groups = ["test"] +dependencies = [ + "coverage[toml]>=7.5", + "pluggy>=1.2", + "pytest>=6.2.5", +] +files = [ + {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, + {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, ] [[package]] @@ -1153,24 +1330,24 @@ files = [ [[package]] name = "python-dotenv" -version = "1.0.1" -requires_python = ">=3.8" +version = "1.1.1" +requires_python = ">=3.9" summary = "Read key-value pairs from a .env file and set them as environment variables" -groups = ["default"] +groups = ["default", "dev"] files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, ] [[package]] name = "python-multipart" -version = "0.0.10" +version = "0.0.20" requires_python = ">=3.8" summary = "A streaming multipart parser for Python" groups = ["default"] files = [ - {file = "python_multipart-0.0.10-py3-none-any.whl", hash = "sha256:2b06ad9e8d50c7a8db80e3b56dab590137b323410605af2be20d62a5f1ba1dc8"}, - {file = "python_multipart-0.0.10.tar.gz", hash = "sha256:46eb3c6ce6fdda5fb1a03c7e11d490e407c6930a2703fe7aef4da71c374688fa"}, + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, ] [[package]] @@ -1178,7 +1355,7 @@ name = "pyyaml" version = "6.0.2" requires_python = ">=3.8" summary = "YAML parser and emitter for Python" -groups = ["default"] +groups = ["default", "lint"] files = [ {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, @@ -1194,7 +1371,7 @@ files = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" requires_python = ">=3.8" summary = "Python HTTP for Humans." groups = ["default"] @@ -1205,79 +1382,145 @@ dependencies = [ "urllib3<3,>=1.21.1", ] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [[package]] name = "rich" -version = "13.8.1" -requires_python = ">=3.7.0" +version = "14.0.0" +requires_python = ">=3.8.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -groups = ["default"] +groups = ["default", "lint"] dependencies = [ "markdown-it-py>=2.2.0", "pygments<3.0.0,>=2.13.0", - "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"", + "typing-extensions<5.0,>=4.0.0; python_version < \"3.11\"", ] files = [ - {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, - {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, + {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, + {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, +] + +[[package]] +name = "rich-toolkit" +version = "0.14.8" +requires_python = ">=3.8" +summary = "Rich toolkit for building command-line applications" +groups = ["default"] +dependencies = [ + "click>=8.1.7", + "rich>=13.7.1", + "typing-extensions>=4.12.2", +] +files = [ + {file = "rich_toolkit-0.14.8-py3-none-any.whl", hash = "sha256:c54bda82b93145a79bbae04c3e15352e6711787c470728ff41fdfa0c2f0c11ae"}, + {file = "rich_toolkit-0.14.8.tar.gz", hash = "sha256:1f77b32e6c25d9e3644c1efbce00d8d90daf2457b3abdb4699e263c03b9ca6cf"}, +] + +[[package]] +name = "rignore" +version = "0.6.4" +requires_python = ">=3.8" +summary = "Python Bindings for the ignore crate" +groups = ["default"] +files = [ + {file = "rignore-0.6.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74720d074b79f32449d5d212ce732e0144a294a184246d1f1e7bcc1fc5c83b69"}, + {file = "rignore-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a8184fcf567bd6b6d7b85a0c138d98dd40f63054141c96b175844414c5530d7"}, + {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcb0d7d7ecc3fbccf6477bb187c04a091579ea139f15f139abe0b3b48bdfef69"}, + {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feac73377a156fb77b3df626c76f7e5893d9b4e9e886ac8c0f9d44f1206a2a91"}, + {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:465179bc30beb1f7a3439e428739a2b5777ed26660712b8c4e351b15a7c04483"}, + {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a4877b4dca9cf31a4d09845b300c677c86267657540d0b4d3e6d0ce3110e6e9"}, + {file = "rignore-0.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456456802b1e77d1e2d149320ee32505b8183e309e228129950b807d204ddd17"}, + {file = "rignore-0.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c1ff2fc223f1d9473d36923160af37bf765548578eb9d47a2f52e90da8ae408"}, + {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e445fbc214ae18e0e644a78086ea5d0f579e210229a4fbe86367d11a4cd03c11"}, + {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e07d9c5270fc869bc431aadcfb6ed0447f89b8aafaa666914c077435dc76a123"}, + {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a6ccc0ea83d2c0c6df6b166f2acacedcc220a516436490f41e99a5ae73b6019"}, + {file = "rignore-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:536392c5ec91755db48389546c833c4ab1426fe03e5a8522992b54ef8a244e7e"}, + {file = "rignore-0.6.4-cp312-cp312-win32.whl", hash = "sha256:f5f9dca46fc41c0a1e236767f68be9d63bdd2726db13a0ae3a30f68414472969"}, + {file = "rignore-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:e02eecb9e1b9f9bf7c9030ae73308a777bed3b2486204cc74dfcfbe699ab1497"}, + {file = "rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab"}, ] [[package]] name = "rsa" -version = "4.9" -requires_python = ">=3.6,<4" +version = "4.9.1" +requires_python = "<4,>=3.6" summary = "Pure-Python RSA implementation" groups = ["default"] dependencies = [ "pyasn1>=0.1.3", ] files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, ] [[package]] name = "ruff" -version = "0.6.7" +version = "0.12.4" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." -groups = ["default"] -files = [ - {file = "ruff-0.6.7-py3-none-linux_armv6l.whl", hash = "sha256:08277b217534bfdcc2e1377f7f933e1c7957453e8a79764d004e44c40db923f2"}, - {file = "ruff-0.6.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c6707a32e03b791f4448dc0dce24b636cbcdee4dd5607adc24e5ee73fd86c00a"}, - {file = "ruff-0.6.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:533d66b7774ef224e7cf91506a7dafcc9e8ec7c059263ec46629e54e7b1f90ab"}, - {file = "ruff-0.6.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17a86aac6f915932d259f7bec79173e356165518859f94649d8c50b81ff087e9"}, - {file = "ruff-0.6.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3f8822defd260ae2460ea3832b24d37d203c3577f48b055590a426a722d50ef"}, - {file = "ruff-0.6.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba4efe5c6dbbb58be58dd83feedb83b5e95c00091bf09987b4baf510fee5c99"}, - {file = "ruff-0.6.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:525201b77f94d2b54868f0cbe5edc018e64c22563da6c5c2e5c107a4e85c1c0d"}, - {file = "ruff-0.6.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8854450839f339e1049fdbe15d875384242b8e85d5c6947bb2faad33c651020b"}, - {file = "ruff-0.6.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f0b62056246234d59cbf2ea66e84812dc9ec4540518e37553513392c171cb18"}, - {file = "ruff-0.6.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b1462fa56c832dc0cea5b4041cfc9c97813505d11cce74ebc6d1aae068de36b"}, - {file = "ruff-0.6.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:02b083770e4cdb1495ed313f5694c62808e71764ec6ee5db84eedd82fd32d8f5"}, - {file = "ruff-0.6.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c05fd37013de36dfa883a3854fae57b3113aaa8abf5dea79202675991d48624"}, - {file = "ruff-0.6.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f49c9caa28d9bbfac4a637ae10327b3db00f47d038f3fbb2195c4d682e925b14"}, - {file = "ruff-0.6.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0e1655868164e114ba43a908fd2d64a271a23660195017c17691fb6355d59bb"}, - {file = "ruff-0.6.7-py3-none-win32.whl", hash = "sha256:a939ca435b49f6966a7dd64b765c9df16f1faed0ca3b6f16acdf7731969deb35"}, - {file = "ruff-0.6.7-py3-none-win_amd64.whl", hash = "sha256:590445eec5653f36248584579c06252ad2e110a5d1f32db5420de35fb0e1c977"}, - {file = "ruff-0.6.7-py3-none-win_arm64.whl", hash = "sha256:b28f0d5e2f771c1fe3c7a45d3f53916fc74a480698c4b5731f0bea61e52137c8"}, - {file = "ruff-0.6.7.tar.gz", hash = "sha256:44e52129d82266fa59b587e2cd74def5637b730a69c4542525dfdecfaae38bd5"}, +groups = ["default", "lint"] +files = [ + {file = "ruff-0.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cb0d261dac457ab939aeb247e804125a5d521b21adf27e721895b0d3f83a0d0a"}, + {file = "ruff-0.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:55c0f4ca9769408d9b9bac530c30d3e66490bd2beb2d3dae3e4128a1f05c7442"}, + {file = "ruff-0.12.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8224cc3722c9ad9044da7f89c4c1ec452aef2cfe3904365025dd2f51daeae0e"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9949d01d64fa3672449a51ddb5d7548b33e130240ad418884ee6efa7a229586"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:be0593c69df9ad1465e8a2d10e3defd111fdb62dcd5be23ae2c06da77e8fcffb"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7dea966bcb55d4ecc4cc3270bccb6f87a337326c9dcd3c07d5b97000dbff41c"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afcfa3ab5ab5dd0e1c39bf286d829e042a15e966b3726eea79528e2e24d8371a"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c057ce464b1413c926cdb203a0f858cd52f3e73dcb3270a3318d1630f6395bb3"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64b90d1122dc2713330350626b10d60818930819623abbb56535c6466cce045"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abc48f3d9667fdc74022380b5c745873499ff827393a636f7a59da1515e7c57"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b2449dc0c138d877d629bea151bee8c0ae3b8e9c43f5fcaafcd0c0d0726b184"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:56e45bb11f625db55f9b70477062e6a1a04d53628eda7784dce6e0f55fd549eb"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:478fccdb82ca148a98a9ff43658944f7ab5ec41c3c49d77cd99d44da019371a1"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0fc426bec2e4e5f4c4f182b9d2ce6a75c85ba9bcdbe5c6f2a74fcb8df437df4b"}, + {file = "ruff-0.12.4-py3-none-win32.whl", hash = "sha256:4de27977827893cdfb1211d42d84bc180fceb7b72471104671c59be37041cf93"}, + {file = "ruff-0.12.4-py3-none-win_amd64.whl", hash = "sha256:fe0b9e9eb23736b453143d72d2ceca5db323963330d5b7859d60d101147d461a"}, + {file = "ruff-0.12.4-py3-none-win_arm64.whl", hash = "sha256:0618ec4442a83ab545e5b71202a5c0ed7791e8471435b94e655b570a5031a98e"}, + {file = "ruff-0.12.4.tar.gz", hash = "sha256:13efa16df6c6eeb7d0f091abae50f58e9522f3843edb40d56ad52a5a4a4b6873"}, ] [[package]] name = "s3transfer" -version = "0.10.4" -requires_python = ">=3.8" +version = "0.13.1" +requires_python = ">=3.9" summary = "An Amazon S3 Transfer Manager" groups = ["default"] dependencies = [ - "botocore<2.0a.0,>=1.33.2", + "botocore<2.0a.0,>=1.37.4", ] files = [ - {file = "s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e"}, - {file = "s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7"}, + {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"}, + {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"}, +] + +[[package]] +name = "sentry-sdk" +version = "2.33.0" +requires_python = ">=3.6" +summary = "Python client for Sentry (https://sentry.io)" +groups = ["default"] +dependencies = [ + "certifi", + "urllib3>=1.26.11", +] +files = [ + {file = "sentry_sdk-2.33.0-py2.py3-none-any.whl", hash = "sha256:a762d3f19a1c240e16c98796f2a5023f6e58872997d5ae2147ac3ed378b23ec2"}, + {file = "sentry_sdk-2.33.0.tar.gz", hash = "sha256:cdceed05e186846fdf80ceea261fe0a11ebc93aab2f228ed73d076a07804152e"}, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +requires_python = ">=3.9" +summary = "Easily download, build, install, upgrade, and uninstall Python packages" +groups = ["lint"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] [[package]] @@ -1293,13 +1536,13 @@ files = [ [[package]] name = "six" -version = "1.16.0" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.17.0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Python 2 and 3 compatibility utilities" groups = ["default"] files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] @@ -1307,7 +1550,7 @@ name = "sniffio" version = "1.3.1" requires_python = ">=3.7" summary = "Sniff out which async library your code is running under" -groups = ["default"] +groups = ["default", "test"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1315,46 +1558,60 @@ files = [ [[package]] name = "sqlalchemy" -version = "2.0.35" +version = "2.0.41" requires_python = ">=3.7" summary = "Database Abstraction Library" groups = ["default"] dependencies = [ - "greenlet!=0.4.17; (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") and python_version < \"3.13\"", + "greenlet>=1; (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") and python_version < \"3.14\"", "importlib-metadata; python_version < \"3.8\"", "typing-extensions>=4.6.0", ] files = [ - {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:eb60b026d8ad0c97917cb81d3662d0b39b8ff1335e3fabb24984c6acd0c900a2"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6921ee01caf375363be5e9ae70d08ce7ca9d7e0e8983183080211a062d299468"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdf1a0dbe5ced887a9b127da4ffd7354e9c1a3b9bb330dce84df6b70ccb3a8d"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93a71c8601e823236ac0e5d087e4f397874a421017b3318fd92c0b14acf2b6db"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e04b622bb8a88f10e439084486f2f6349bf4d50605ac3e445869c7ea5cf0fa8c"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1b56961e2d31389aaadf4906d453859f35302b4eb818d34a26fab72596076bb8"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-win32.whl", hash = "sha256:0f9f3f9a3763b9c4deb8c5d09c4cc52ffe49f9876af41cc1b2ad0138878453cf"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-win_amd64.whl", hash = "sha256:25b0f63e7fcc2a6290cb5f7f5b4fc4047843504983a28856ce9b35d8f7de03cc"}, - {file = "SQLAlchemy-2.0.35-py3-none-any.whl", hash = "sha256:2ab3f0336c0387662ce6221ad30ab3a5e6499aab01b9790879b6578fd9b8faa1"}, - {file = "sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0"}, + {file = "sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576"}, + {file = "sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9"}, ] [[package]] name = "starlette" -version = "0.38.6" -requires_python = ">=3.8" +version = "0.47.2" +requires_python = ">=3.9" summary = "The little ASGI library that shines." groups = ["default"] dependencies = [ - "anyio<5,>=3.4.0", - "typing-extensions>=3.10.0; python_version < \"3.10\"", + "anyio<5,>=3.6.2", + "typing-extensions>=4.10.0; python_version < \"3.13\"", ] files = [ - {file = "starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05"}, - {file = "starlette-0.38.6.tar.gz", hash = "sha256:863a1588f5574e70a821dadefb41e4881ea451a47a3cd1b4df359d4ffefe5ead"}, + {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, + {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, +] + +[[package]] +name = "stevedore" +version = "5.4.1" +requires_python = ">=3.9" +summary = "Manage dynamic plugins for Python applications" +groups = ["lint"] +dependencies = [ + "pbr>=2.0.0", +] +files = [ + {file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"}, + {file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"}, ] [[package]] name = "typer" -version = "0.12.5" +version = "0.16.0" requires_python = ">=3.7" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." groups = ["default"] @@ -1365,47 +1622,64 @@ dependencies = [ "typing-extensions>=3.7.4.3", ] files = [ - {file = "typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b"}, - {file = "typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722"}, + {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, + {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250611" +requires_python = ">=3.9" +summary = "Typing stubs for requests" +groups = ["lint"] +dependencies = [ + "urllib3>=2", +] +files = [ + {file = "types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072"}, + {file = "types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826"}, ] [[package]] name = "typing-extensions" -version = "4.12.2" -requires_python = ">=3.8" -summary = "Backported and Experimental Type Hints for Python 3.8+" -groups = ["default"] +version = "4.14.1" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" +groups = ["default", "lint", "test"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] [[package]] -name = "uritemplate" -version = "4.1.1" -requires_python = ">=3.6" -summary = "Implementation of RFC 6570 URI Templates" +name = "typing-inspection" +version = "0.4.1" +requires_python = ">=3.9" +summary = "Runtime typing introspection tools" groups = ["default"] +dependencies = [ + "typing-extensions>=4.12.0", +] files = [ - {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, - {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, ] [[package]] name = "urllib3" -version = "2.2.3" -requires_python = ">=3.8" +version = "2.5.0" +requires_python = ">=3.9" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["default"] +groups = ["default", "lint"] files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [[package]] name = "uvicorn" -version = "0.30.6" -requires_python = ">=3.8" +version = "0.35.0" +requires_python = ">=3.9" summary = "The lightning-fast ASGI server." groups = ["default"] dependencies = [ @@ -1414,53 +1688,53 @@ dependencies = [ "typing-extensions>=4.0; python_version < \"3.11\"", ] files = [ - {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, - {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, ] [[package]] name = "uvicorn" -version = "0.30.6" +version = "0.35.0" extras = ["standard"] -requires_python = ">=3.8" +requires_python = ">=3.9" summary = "The lightning-fast ASGI server." groups = ["default"] dependencies = [ "colorama>=0.4; sys_platform == \"win32\"", - "httptools>=0.5.0", + "httptools>=0.6.3", "python-dotenv>=0.13", "pyyaml>=5.1", - "uvicorn==0.30.6", - "uvloop!=0.15.0,!=0.15.1,>=0.14.0; (sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"", + "uvicorn==0.35.0", + "uvloop>=0.15.1; (sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"", "watchfiles>=0.13", "websockets>=10.4", ] files = [ - {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, - {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, ] [[package]] name = "uvloop" -version = "0.20.0" +version = "0.21.0" requires_python = ">=3.8.0" summary = "Fast implementation of asyncio event loop on top of libuv" groups = ["default"] marker = "(sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"" files = [ - {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d"}, - {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e"}, - {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9"}, - {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab"}, - {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5"}, - {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00"}, - {file = "uvloop-0.20.0.tar.gz", hash = "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"}, + {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, ] [[package]] name = "virtualenv" -version = "20.26.6" -requires_python = ">=3.7" +version = "20.31.2" +requires_python = ">=3.8" summary = "Virtual Python Environment builder" groups = ["default"] dependencies = [ @@ -1470,54 +1744,54 @@ dependencies = [ "platformdirs<5,>=3.9.1", ] files = [ - {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, - {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, + {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"}, + {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"}, ] [[package]] name = "watchfiles" -version = "0.24.0" -requires_python = ">=3.8" +version = "1.1.0" +requires_python = ">=3.9" summary = "Simple, modern and high performance file watching and code reload in python." groups = ["default"] dependencies = [ "anyio>=3.0.0", ] files = [ - {file = "watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a"}, - {file = "watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234"}, - {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef"}, - {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968"}, - {file = "watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444"}, - {file = "watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896"}, - {file = "watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418"}, - {file = "watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"}, + {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"}, + {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"}, ] [[package]] name = "websockets" -version = "13.1" -requires_python = ">=3.8" +version = "15.0.1" +requires_python = ">=3.9" summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" groups = ["default"] files = [ - {file = "websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc"}, - {file = "websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49"}, - {file = "websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf"}, - {file = "websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c"}, - {file = "websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3"}, - {file = "websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f"}, - {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, + {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, + {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, + {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, + {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 47dd13c1..5fa5a945 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -44,21 +44,66 @@ tests = "pytest -v" asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" pythonpath = ["."] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" + +[tool.coverage.run] +source = ["app"] +omit = ["*/tests/*", "*/__pycache__/*", "*/venv/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", +] + +[tool.bandit] +exclude_dirs = ["tests", "migrations"] +skips = ["B101"] # Skip assert_used test [tool.pdm.dev-dependencies] test = [ "pytest>=7.0.0", - "pytest-asyncio>=0.24.0", - "pytest-mock>=3.10.0", + "pytest-asyncio>=0.20.0", + "httpx>=0.24.0", + "pytest-cov>=4.0.0", +] +lint = [ + "ruff>=0.1.0", + "mypy>=1.0.0", + "bandit>=1.7.0", + "types-requests>=2.31.0", +] +dev = [ + "python-dotenv>=1.0.0", ] [tool.ruff] +line-length = 120 target-version = "py312" -# Read more here https://docs.astral.sh/ruff/rules/ -# By default, Ruff enables Flake8's E and F rules -# Pyflakes - F, pycodestyle - E, W -# flake8-builtins - A -# Pylint - PLC, PLE, PLW -# isort - I -lint.select = ['E', 'F', 'W', 'A', 'PLC', 'PLE', 'PLW', 'I'] -line-length = 120 \ No newline at end of file + +[tool.ruff.lint] +select = ["E", "F", "W", "A", "PLC", "PLE", "PLW", "I"] +ignore = ["E501"] # Line too long (handled by formatter) + +[tool.mypy] +python_version = "3.12" +warn_return_any = false # Disabled for now - too many existing issues +warn_unused_configs = true +disallow_untyped_defs = false # Keep false until ready for strict typing +ignore_missing_imports = true +allow_untyped_calls = true +allow_untyped_defs = true +allow_incomplete_defs = true +check_untyped_defs = false +disallow_any_generics = false +disallow_subclassing_any = false +warn_redundant_casts = false +warn_unused_ignores = false +warn_no_return = false +warn_unreachable = false +strict_optional = false diff --git a/backend/test_intake.db b/backend/test_intake.db new file mode 100644 index 00000000..ae147b15 Binary files /dev/null and b/backend/test_intake.db differ diff --git a/backend/tests/unit/test_intake_api.py b/backend/tests/unit/test_intake_api.py new file mode 100644 index 00000000..baf6c1be --- /dev/null +++ b/backend/tests/unit/test_intake_api.py @@ -0,0 +1,54 @@ +import pytest +from fastapi.testclient import TestClient + +from app.server import app + +# TODO: ADD MORE TESTS (testing for this is super mimimal at the moment) + + +@pytest.fixture +def client(): + """Create test client for FastAPI app""" + return TestClient(app) + + +class TestIntakeAPI: + """Basic API endpoint tests""" + + def test_unauthenticated_request_returns_401(self, client): + """Test that unauthenticated requests return 401""" + form_data = { + "answers": { + "formType": "participant", + "hasBloodCancer": "yes", + "personalInfo": { + "firstName": "Test", + "lastName": "User", + "dateOfBirth": "01/01/1990", + "phoneNumber": "555-1234", + "city": "Test City", + "province": "Test Province", + "postalCode": "T1T 1T1", + }, + } + } + + response = client.post("/intake/submissions", json=form_data) + assert response.status_code == 401 + + def test_malformed_json_returns_401_due_to_auth_first(self, client): + """Test that malformed JSON returns 401 because auth happens before JSON parsing""" + response = client.post( + "/intake/submissions", content="{ invalid json", headers={"Content-Type": "application/json"} + ) + + # Auth happens before JSON parsing, so we get 401, not 422 + assert response.status_code == 401 + + def test_intake_endpoint_exists(self, client): + """Test that the intake endpoint exists and responds""" + # Empty request should still hit the endpoint (not 404) + response = client.post("/intake/submissions") + + # Should not be 404 (endpoint exists) + assert response.status_code != 404 diff --git a/backend/tests/unit/test_intake_form_processor.py b/backend/tests/unit/test_intake_form_processor.py new file mode 100644 index 00000000..a9ce4362 --- /dev/null +++ b/backend/tests/unit/test_intake_form_processor.py @@ -0,0 +1,1344 @@ +import uuid +from datetime import date + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.models.Experience import Experience +from app.models.Role import Role +from app.models.Treatment import Treatment +from app.models.User import User +from app.models.UserData import UserData +from app.schemas.user import UserRole +from app.services.implementations.intake_form_processor import IntakeFormProcessor + +# Test DB Configuration - Use SQLite with UUID handling fixes +SQLALCHEMY_DATABASE_URL = "sqlite:///./test_intake.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """Provide a clean database session for each test""" + # Create tables excluding FormSubmission to avoid JSONB issues + tables_to_create = [ + Role.__table__, + User.__table__, + UserData.__table__, + Treatment.__table__, + Experience.__table__, + # Bridge tables from UserData model + UserData.__table__.metadata.tables.get("user_treatments"), + UserData.__table__.metadata.tables.get("user_experiences"), + UserData.__table__.metadata.tables.get("user_loved_one_treatments"), + UserData.__table__.metadata.tables.get("user_loved_one_experiences"), + ] + + for table in tables_to_create: + if table is not None: + table.create(bind=engine, checkfirst=True) + + session = TestingSessionLocal() + + try: + # Clean up any existing data first + session.query(UserData).delete() + session.query(User).delete() + session.query(Treatment).delete() + session.query(Experience).delete() + session.query(Role).delete() + session.commit() + + # Create test roles + roles = [ + Role(id=1, name=UserRole.PARTICIPANT), + Role(id=2, name=UserRole.VOLUNTEER), + Role(id=3, name=UserRole.ADMIN), + ] + for role in roles: + session.add(role) + + # Create test treatments (predefined) + treatments = [ + Treatment(id=1, name="Chemotherapy"), + Treatment(id=2, name="Surgery"), + Treatment(id=3, name="Radiation Therapy"), + ] + for treatment in treatments: + session.add(treatment) + + # Create test experiences (predefined) + experiences = [ + Experience(id=1, name="Anxiety"), + Experience(id=2, name="Fatigue"), + Experience(id=3, name="Depression"), + ] + for experience in experiences: + session.add(experience) + + session.commit() + yield session + + finally: + session.rollback() + session.close() + # Clean up - drop the tables we created + tables_to_drop = [ + UserData.__table__.metadata.tables.get("user_loved_one_experiences"), + UserData.__table__.metadata.tables.get("user_loved_one_treatments"), + UserData.__table__.metadata.tables.get("user_experiences"), + UserData.__table__.metadata.tables.get("user_treatments"), + UserData.__table__, + User.__table__, + Treatment.__table__, + Experience.__table__, + Role.__table__, + ] + + for table in tables_to_drop: + if table is not None: + table.drop(bind=engine, checkfirst=True) + + +@pytest.fixture +def test_user(db_session): + """Create a test user for intake processing""" + test_uuid = uuid.uuid4() + user = User( + id=test_uuid, first_name="Test", last_name="User", email="test@example.com", role_id=1, auth_id="test_auth_id" + ) + db_session.add(user) + db_session.commit() + return user + + +def test_participant_with_cancer_only(db_session, test_user): + """Test processing a complete participant intake form with cancer experience""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "John", + "lastName": "Doe", + "dateOfBirth": "15/03/1985", + "phoneNumber": "555-123-4567", + "city": "Toronto", + "province": "Ontario", + "postalCode": "M1A 1A1", + }, + "demographics": { + "genderIdentity": "Male", + "pronouns": ["he", "him"], + "ethnicGroup": ["White"], + "maritalStatus": "Married", + "hasKids": "yes", + }, + "cancerExperience": { + "diagnosis": "Leukemia", + "dateOfDiagnosis": "01/01/2023", + "treatments": ["Chemotherapy", "Surgery"], + "experiences": ["Anxiety", "Fatigue"], + "otherTreatment": "Some custom treatment details", + "otherExperience": "Custom experience notes", + }, + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Personal Info + assert user_data.first_name == "John" + assert user_data.last_name == "Doe" + assert user_data.date_of_birth == date(1985, 3, 15) + assert user_data.phone == "555-123-4567" + assert user_data.city == "Toronto" + assert user_data.province == "Ontario" + assert user_data.postal_code == "M1A 1A1" + + # Assert - Demographics + assert user_data.gender_identity == "Male" + assert user_data.pronouns == ["he", "him"] + assert user_data.ethnic_group == ["White"] + assert user_data.marital_status == "Married" + assert user_data.has_kids == "yes" + + # Assert - Cancer Experience + assert user_data.diagnosis == "Leukemia" + assert user_data.date_of_diagnosis == date(2023, 1, 1) + assert user_data.other_treatment == "Some custom treatment details" + assert user_data.other_experience == "Custom experience notes" + + # Assert - Flow Control + assert user_data.has_blood_cancer == "yes" + assert user_data.caring_for_someone == "no" + + # Assert - Treatments (many-to-many) + treatment_names = [t.name for t in user_data.treatments] + assert "Chemotherapy" in treatment_names + assert "Surgery" in treatment_names + assert len(user_data.treatments) == 2 + + # Assert - Experiences (many-to-many) + experience_names = [e.name for e in user_data.experiences] + assert "Anxiety" in experience_names + assert "Fatigue" in experience_names + assert len(user_data.experiences) == 2 + + # Assert - No loved one data + assert user_data.loved_one_gender_identity is None + assert user_data.loved_one_diagnosis is None + assert len(user_data.loved_one_treatments) == 0 + assert len(user_data.loved_one_experiences) == 0 + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_custom_treatments_and_experiences(db_session, test_user): + """Test that custom treatments and experiences are created in the database""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Jane", + "lastName": "Smith", + "dateOfBirth": "20/12/1990", + "phoneNumber": "555-987-6543", + "city": "Vancouver", + "province": "British Columbia", + "postalCode": "V6B 1A1", + }, + "demographics": { + "genderIdentity": "Female", + "pronouns": ["she", "her"], + "ethnicGroup": ["Asian"], + "maritalStatus": "Single", + "hasKids": "no", + }, + "cancerExperience": { + "diagnosis": "Lymphoma", + "dateOfDiagnosis": "15/06/2022", + "treatments": ["Custom Treatment X", "Experimental Therapy Y"], # New treatments + "experiences": ["Custom Symptom A", "Unique Experience B"], # New experiences + "otherTreatment": "Details about experimental treatment", + "otherExperience": "Unique side effects experienced", + }, + } + + # Verify custom treatments/experiences don't exist yet + assert db_session.query(Treatment).filter(Treatment.name == "Custom Treatment X").first() is None + assert db_session.query(Experience).filter(Experience.name == "Custom Symptom A").first() is None + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - New treatments were created + custom_treatment_x = db_session.query(Treatment).filter(Treatment.name == "Custom Treatment X").first() + custom_treatment_y = db_session.query(Treatment).filter(Treatment.name == "Experimental Therapy Y").first() + assert custom_treatment_x is not None + assert custom_treatment_y is not None + + # Assert - New experiences were created + custom_symptom_a = db_session.query(Experience).filter(Experience.name == "Custom Symptom A").first() + unique_experience_b = db_session.query(Experience).filter(Experience.name == "Unique Experience B").first() + assert custom_symptom_a is not None + assert unique_experience_b is not None + + # Assert - User is linked to custom treatments and experiences + user_treatment_names = [t.name for t in user_data.treatments] + user_experience_names = [e.name for e in user_data.experiences] + + assert "Custom Treatment X" in user_treatment_names + assert "Experimental Therapy Y" in user_treatment_names + assert "Custom Symptom A" in user_experience_names + assert "Unique Experience B" in user_experience_names + + # Assert - Custom text fields are stored + assert user_data.other_treatment == "Details about experimental treatment" + assert user_data.other_experience == "Unique side effects experienced" + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_volunteer_caregiver_experience_processing(db_session, test_user): + """Test processing volunteer caregiver experience (separate from cancer experience)""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "volunteer", + "hasBloodCancer": "no", + "caringForSomeone": "yes", + "personalInfo": { + "firstName": "Alice", + "lastName": "Volunteer", + "dateOfBirth": "25/08/1975", + "phoneNumber": "555-111-2222", + "city": "Calgary", + "province": "Alberta", + "postalCode": "T2A 1A1", + }, + "demographics": { + "genderIdentity": "Female", + "pronouns": ["she", "her"], + "ethnicGroup": ["Indigenous"], + "maritalStatus": "Divorced", + "hasKids": "yes", + }, + "caregiverExperience": { # Note: caregiverExperience, not cancerExperience + "experiences": ["Financial Stress", "Relationship Changes"], + "otherExperience": "Dealing with healthcare system complexity", + }, + "lovedOne": { + "demographics": {"genderIdentity": "Male", "age": "45-54"}, + "cancerExperience": { + "diagnosis": "Brain Cancer", + "dateOfDiagnosis": "10/05/2020", + "treatments": ["Surgery", "Radiation Therapy"], + "experiences": ["Depression", "Cognitive Changes"], + "otherTreatment": "Specialized brain surgery", + "otherExperience": "Memory issues post-surgery", + }, + }, + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Flow Control + assert user_data.has_blood_cancer == "no" + assert user_data.caring_for_someone == "yes" + + # Assert - Personal Info + assert user_data.first_name == "Alice" + assert user_data.last_name == "Volunteer" + assert user_data.city == "Calgary" + + # Assert - Demographics + assert user_data.gender_identity == "Female" + assert user_data.ethnic_group == ["Indigenous"] + assert user_data.marital_status == "Divorced" + + # Assert - Caregiver Experience (mapped to user experiences) + experience_names = [e.name for e in user_data.experiences] + assert "Financial Stress" in experience_names + assert "Relationship Changes" in experience_names + assert user_data.other_experience == "Dealing with healthcare system complexity" + + # Assert - No personal cancer experience + assert user_data.diagnosis is None + assert user_data.date_of_diagnosis is None + assert len(user_data.treatments) == 0 + + # Assert - Loved One Data + assert user_data.loved_one_gender_identity == "Male" + assert user_data.loved_one_age == "45-54" + assert user_data.loved_one_diagnosis == "Brain Cancer" + assert user_data.loved_one_date_of_diagnosis == date(2020, 5, 10) + + # Assert - Loved One Treatments and Experiences + loved_one_treatment_names = [t.name for t in user_data.loved_one_treatments] + loved_one_experience_names = [e.name for e in user_data.loved_one_experiences] + + assert "Surgery" in loved_one_treatment_names + assert "Radiation Therapy" in loved_one_treatment_names + assert "Depression" in loved_one_experience_names + assert "Cognitive Changes" in loved_one_experience_names + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_form_submission_json_structure(db_session, test_user): + """Test that the processor handles complex JSON form data correctly including nested structures""" + try: + # Arrange - Complex form data with nested structures + processor = IntakeFormProcessor(db_session) + complex_form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "yes", + "personalInfo": { + "firstName": "Maria", + "lastName": "Complex", + "dateOfBirth": "12/11/1988", + "phoneNumber": "555-999-8888", + "city": "Edmonton", + "province": "Alberta", + "postalCode": "T5A 2B2", + }, + "demographics": { + "genderIdentity": "Self-describe", + "genderIdentityCustom": "Non-binary", + "pronouns": ["they", "them"], + "ethnicGroup": ["Other", "Asian"], + "ethnicGroupCustom": "Mixed heritage - Filipino and Indigenous", + "maritalStatus": "Common-law", + "hasKids": "yes", + }, + "cancerExperience": { + "diagnosis": "Ovarian Cancer", + "dateOfDiagnosis": "03/07/2022", + "treatments": ["Chemotherapy", "Custom Treatment Protocol"], + "experiences": ["Anxiety", "Custom Side Effect"], + "otherTreatment": "Experimental immunotherapy trial", + "otherExperience": "Severe neuropathy affecting daily activities", + }, + "lovedOne": { + "demographics": {"genderIdentity": "Female", "age": "65+"}, + "cancerExperience": { + "diagnosis": "Lung Cancer", + "dateOfDiagnosis": "15/01/2021", + "treatments": ["Radiation Therapy", "Palliative Care"], + "experiences": ["Sleep Problems", "Loss of Appetite"], + "otherTreatment": "Comfort care measures", + "otherExperience": "End-of-life care planning", + }, + }, + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), complex_form_data) + + # Assert - Complex Demographics Processing + assert user_data.gender_identity == "Self-describe" + assert user_data.gender_identity_custom == "Non-binary" + assert user_data.pronouns == ["they", "them"] + assert "Other" in user_data.ethnic_group and "Asian" in user_data.ethnic_group + assert user_data.other_ethnic_group == "Mixed heritage - Filipino and Indigenous" + + # Assert - Custom Treatments Created + custom_treatment = db_session.query(Treatment).filter(Treatment.name == "Custom Treatment Protocol").first() + assert custom_treatment is not None + assert custom_treatment in user_data.treatments + + # Assert - Custom Experiences Created + custom_experience = db_session.query(Experience).filter(Experience.name == "Custom Side Effect").first() + assert custom_experience is not None + assert custom_experience in user_data.experiences + + # Assert - "Other" Text Fields + assert user_data.other_treatment == "Experimental immunotherapy trial" + assert user_data.other_experience == "Severe neuropathy affecting daily activities" + + # Assert - Loved One Complex Data + assert user_data.loved_one_gender_identity == "Female" + assert user_data.loved_one_age == "65+" + assert user_data.loved_one_diagnosis == "Lung Cancer" + assert user_data.loved_one_other_treatment == "Comfort care measures" + assert user_data.loved_one_other_experience == "End-of-life care planning" + + # Assert - Both User and Loved One Have Relationships + assert len(user_data.treatments) >= 2 # Chemo + Custom + assert len(user_data.experiences) >= 2 # Anxiety + Custom + assert len(user_data.loved_one_treatments) >= 2 # Radiation + Palliative + assert len(user_data.loved_one_experiences) >= 2 # Sleep + Appetite + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_empty_and_minimal_data_handling(db_session, test_user): + """Test processor handles minimal/empty data gracefully""" + try: + # Arrange - Minimal form data + processor = IntakeFormProcessor(db_session) + minimal_form_data = { + "formType": "volunteer", + "hasBloodCancer": "no", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Min", + "lastName": "Imal", + "dateOfBirth": "01/01/2000", + "phoneNumber": "", # Empty string + "city": "Toronto", + "province": "Ontario", + "postalCode": "M1A 1A1", + }, + "demographics": { + "genderIdentity": "Prefer not to say", + "pronouns": [], # Empty array + "ethnicGroup": [], # Empty array + "maritalStatus": "", # Empty string + "hasKids": "", + }, + # No cancerExperience, caregiverExperience, or lovedOne sections + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), minimal_form_data) + + # Assert - Required fields populated + assert user_data.first_name == "Min" + assert user_data.last_name == "Imal" + assert user_data.date_of_birth == date(2000, 1, 1) + assert user_data.city == "Toronto" + + # Assert - Empty fields handled gracefully + assert user_data.phone == "" + assert user_data.pronouns == [] + assert user_data.ethnic_group == [] + assert user_data.marital_status == "" + + # Assert - Optional sections remain None/empty + assert user_data.diagnosis is None + assert user_data.other_treatment is None + assert len(user_data.treatments) == 0 + assert len(user_data.experiences) == 0 + assert user_data.loved_one_gender_identity is None + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_participant_caregiver_without_cancer(db_session, test_user): + """Test Flow 2: Participant caregiver without cancer (basic demographics + loved one data)""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "no", + "caringForSomeone": "yes", + "personalInfo": { + "firstName": "Sarah", + "lastName": "Caregiver", + "dateOfBirth": "10/09/1975", + "phoneNumber": "555-222-3333", + "city": "Ottawa", + "province": "Ontario", + "postalCode": "K1A 0A6", + }, + "demographics": { + "genderIdentity": "Female", + "pronouns": ["she", "her"], + "ethnicGroup": ["Black"], + "maritalStatus": "Married", + "hasKids": "yes", + }, + "lovedOne": { + "demographics": {"genderIdentity": "Male", "age": "55-64"}, + "cancerExperience": { + "diagnosis": "Prostate Cancer", + "dateOfDiagnosis": "20/03/2021", + "treatments": ["Surgery", "Hormone Therapy"], + "experiences": ["Anxiety", "Relationship Changes"], + "otherTreatment": "Robotic surgery", + "otherExperience": "Intimacy concerns", + }, + }, + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Flow Control + assert user_data.has_blood_cancer == "no" + assert user_data.caring_for_someone == "yes" + + # Assert - Personal Info + assert user_data.first_name == "Sarah" + assert user_data.last_name == "Caregiver" + + # Assert - Demographics + assert user_data.gender_identity == "Female" + assert user_data.ethnic_group == ["Black"] + + # Assert - No personal cancer experience + assert user_data.diagnosis is None + assert user_data.date_of_diagnosis is None + assert len(user_data.treatments) == 0 + assert len(user_data.experiences) == 0 + + # Assert - Loved One Data + assert user_data.loved_one_gender_identity == "Male" + assert user_data.loved_one_age == "55-64" + assert user_data.loved_one_diagnosis == "Prostate Cancer" + assert user_data.loved_one_date_of_diagnosis == date(2021, 3, 20) + assert user_data.loved_one_other_treatment == "Robotic surgery" + assert user_data.loved_one_other_experience == "Intimacy concerns" + + # Assert - Loved One Relationships + loved_one_treatment_names = [t.name for t in user_data.loved_one_treatments] + loved_one_experience_names = [e.name for e in user_data.loved_one_experiences] + assert "Surgery" in loved_one_treatment_names + assert "Hormone Therapy" in loved_one_treatment_names + assert "Anxiety" in loved_one_experience_names + assert "Relationship Changes" in loved_one_experience_names + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_participant_cancer_patient_and_caregiver(db_session, test_user): + """Test Flow 5: Participant with cancer AND caregiver (own cancer + loved one data)""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "yes", + "personalInfo": { + "firstName": "David", + "lastName": "BothRoles", + "dateOfBirth": "05/11/1980", + "phoneNumber": "555-444-5555", + "city": "Halifax", + "province": "Nova Scotia", + "postalCode": "B3H 3C3", + }, + "demographics": { + "genderIdentity": "Male", + "pronouns": ["he", "him"], + "ethnicGroup": ["White", "Other"], + "ethnicGroupCustom": "Mixed European heritage", + "maritalStatus": "Married", + "hasKids": "yes", + }, + "cancerExperience": { + "diagnosis": "Lymphoma", + "dateOfDiagnosis": "15/08/2022", + "treatments": ["Chemotherapy", "Radiation Therapy"], + "experiences": ["Fatigue", "Depression"], + "otherTreatment": "Targeted therapy", + "otherExperience": "Cognitive fog", + }, + "lovedOne": { + "demographics": {"genderIdentity": "Female", "age": "35-44"}, + "cancerExperience": { + "diagnosis": "Breast Cancer", + "dateOfDiagnosis": "10/01/2023", + "treatments": ["Surgery", "Chemotherapy"], + "experiences": ["Hair Loss", "Body Image Issues"], + "otherTreatment": "Reconstruction surgery", + "otherExperience": "Fertility concerns", + }, + }, + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Flow Control + assert user_data.has_blood_cancer == "yes" + assert user_data.caring_for_someone == "yes" + + # Assert - Own Cancer Experience + assert user_data.diagnosis == "Lymphoma" + assert user_data.date_of_diagnosis == date(2022, 8, 15) + assert user_data.other_treatment == "Targeted therapy" + assert user_data.other_experience == "Cognitive fog" + + # Assert - Own Treatments/Experiences + treatment_names = [t.name for t in user_data.treatments] + experience_names = [e.name for e in user_data.experiences] + assert "Chemotherapy" in treatment_names + assert "Radiation Therapy" in treatment_names + assert "Fatigue" in experience_names + assert "Depression" in experience_names + + # Assert - Loved One Data + assert user_data.loved_one_diagnosis == "Breast Cancer" + assert user_data.loved_one_date_of_diagnosis == date(2023, 1, 10) + assert user_data.loved_one_other_treatment == "Reconstruction surgery" + assert user_data.loved_one_other_experience == "Fertility concerns" + + # Assert - Loved One Relationships + loved_one_treatment_names = [t.name for t in user_data.loved_one_treatments] + loved_one_experience_names = [e.name for e in user_data.loved_one_experiences] + assert "Surgery" in loved_one_treatment_names + assert "Hair Loss" in loved_one_experience_names + + # Assert - Custom demographics + assert "Other" in user_data.ethnic_group + assert user_data.other_ethnic_group == "Mixed European heritage" + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_participant_no_cancer_experience(db_session, test_user): + """Test Flow 7: Participant with no cancer experience (basic demographics only)""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "no", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Emma", + "lastName": "NoCancer", + "dateOfBirth": "22/04/1995", + "phoneNumber": "555-777-8888", + "city": "Winnipeg", + "province": "Manitoba", + "postalCode": "R3C 3P4", + }, + "demographics": { + "genderIdentity": "Female", + "pronouns": ["she", "her"], + "ethnicGroup": ["Asian", "Indigenous"], + "maritalStatus": "Single", + "hasKids": "no", + }, + # No cancerExperience, caregiverExperience, or lovedOne sections + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Flow Control + assert user_data.has_blood_cancer == "no" + assert user_data.caring_for_someone == "no" + + # Assert - Personal Info + assert user_data.first_name == "Emma" + assert user_data.last_name == "NoCancer" + assert user_data.date_of_birth == date(1995, 4, 22) + + # Assert - Demographics + assert user_data.gender_identity == "Female" + assert user_data.pronouns == ["she", "her"] + assert "Asian" in user_data.ethnic_group + assert "Indigenous" in user_data.ethnic_group + assert user_data.marital_status == "Single" + assert user_data.has_kids == "no" + + # Assert - No cancer-related data + assert user_data.diagnosis is None + assert user_data.date_of_diagnosis is None + assert user_data.other_treatment is None + assert user_data.other_experience is None + assert len(user_data.treatments) == 0 + assert len(user_data.experiences) == 0 + + # Assert - No loved one data + assert user_data.loved_one_gender_identity is None + assert user_data.loved_one_diagnosis is None + assert len(user_data.loved_one_treatments) == 0 + assert len(user_data.loved_one_experiences) == 0 + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_volunteer_cancer_patient_only(db_session, test_user): + """Test Flow 6: Volunteer with cancer only (cancer experience, no caregiving)""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "volunteer", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Michael", + "lastName": "VolunteerSurvivor", + "dateOfBirth": "18/07/1970", + "phoneNumber": "555-101-2020", + "city": "Regina", + "province": "Saskatchewan", + "postalCode": "S4P 3Y2", + }, + "demographics": { + "genderIdentity": "Male", + "pronouns": ["he", "him"], + "ethnicGroup": ["Indigenous"], + "maritalStatus": "Widowed", + "hasKids": "yes", + }, + "cancerExperience": { + "diagnosis": "Myeloma", + "dateOfDiagnosis": "12/05/2019", + "treatments": ["Chemotherapy", "Stem Cell Transplant"], + "experiences": ["Depression", "Survivorship Concerns"], + "otherTreatment": "Maintenance therapy", + "otherExperience": "Long-term survivor guilt", + }, + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Flow Control + assert user_data.has_blood_cancer == "yes" + assert user_data.caring_for_someone == "no" + + # Assert - Personal Info + assert user_data.first_name == "Michael" + assert user_data.last_name == "VolunteerSurvivor" + + # Assert - Cancer Experience + assert user_data.diagnosis == "Myeloma" + assert user_data.date_of_diagnosis == date(2019, 5, 12) + assert user_data.other_treatment == "Maintenance therapy" + assert user_data.other_experience == "Long-term survivor guilt" + + # Assert - Treatments/Experiences + treatment_names = [t.name for t in user_data.treatments] + experience_names = [e.name for e in user_data.experiences] + assert "Chemotherapy" in treatment_names + assert "Stem Cell Transplant" in treatment_names + assert "Depression" in experience_names + assert "Survivorship Concerns" in experience_names + + # Assert - No loved one data (not a caregiver) + assert user_data.loved_one_gender_identity is None + assert user_data.loved_one_diagnosis is None + assert len(user_data.loved_one_treatments) == 0 + assert len(user_data.loved_one_experiences) == 0 + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_volunteer_cancer_patient_and_caregiver(db_session, test_user): + """Test Flow 3: Volunteer with cancer AND caregiver (own cancer + loved one data)""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "volunteer", + "hasBloodCancer": "yes", + "caringForSomeone": "yes", + "personalInfo": { + "firstName": "Lisa", + "lastName": "VolunteerBoth", + "dateOfBirth": "03/12/1965", + "phoneNumber": "555-303-4040", + "city": "Victoria", + "province": "British Columbia", + "postalCode": "V8W 1P6", + }, + "demographics": { + "genderIdentity": "Female", + "pronouns": ["she", "her"], + "ethnicGroup": ["White"], + "maritalStatus": "Married", + "hasKids": "yes", + }, + "cancerExperience": { + "diagnosis": "Breast Cancer", + "dateOfDiagnosis": "08/11/2015", + "treatments": ["Surgery", "Chemotherapy", "Radiation Therapy"], + "experiences": ["Hair Loss", "Survivorship Concerns"], + "otherTreatment": "Hormone blocking therapy", + "otherExperience": "10-year survivor perspective", + }, + "lovedOne": { + "demographics": {"genderIdentity": "Male", "age": "65+"}, + "cancerExperience": { + "diagnosis": "Pancreatic Cancer", + "dateOfDiagnosis": "25/09/2023", + "treatments": ["Surgery", "Palliative Care"], + "experiences": ["Loss of Appetite", "Fatigue"], + "otherTreatment": "Whipple procedure", + "otherExperience": "End-of-life discussions", + }, + }, + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Flow Control + assert user_data.has_blood_cancer == "yes" + assert user_data.caring_for_someone == "yes" + + # Assert - Own Cancer Experience (10-year survivor) + assert user_data.diagnosis == "Breast Cancer" + assert user_data.date_of_diagnosis == date(2015, 11, 8) + assert user_data.other_treatment == "Hormone blocking therapy" + assert user_data.other_experience == "10-year survivor perspective" + + # Assert - Own Treatments (comprehensive) + treatment_names = [t.name for t in user_data.treatments] + assert "Surgery" in treatment_names + assert "Chemotherapy" in treatment_names + assert "Radiation Therapy" in treatment_names + assert len(user_data.treatments) == 3 + + # Assert - Loved One Data (current patient) + assert user_data.loved_one_diagnosis == "Pancreatic Cancer" + assert user_data.loved_one_date_of_diagnosis == date(2023, 9, 25) + assert user_data.loved_one_other_treatment == "Whipple procedure" + assert user_data.loved_one_other_experience == "End-of-life discussions" + + # Assert - Both user and loved one have data + assert len(user_data.treatments) >= 3 + assert len(user_data.experiences) >= 2 + assert len(user_data.loved_one_treatments) >= 2 + assert len(user_data.loved_one_experiences) >= 2 + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_volunteer_no_cancer_experience(db_session, test_user): + """Test Flow 8: Volunteer with no cancer experience (basic demographics only)""" + try: + # Arrange + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "volunteer", + "hasBloodCancer": "no", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Robert", + "lastName": "VolunteerHelper", + "dateOfBirth": "14/06/1985", + "phoneNumber": "555-505-6060", + "city": "Fredericton", + "province": "New Brunswick", + "postalCode": "E3B 5A3", + }, + "demographics": { + "genderIdentity": "Male", + "pronouns": ["he", "him"], + "ethnicGroup": ["White"], + "maritalStatus": "Single", + "hasKids": "no", + }, + # No cancerExperience, caregiverExperience, or lovedOne sections + } + + # Act + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Assert - Flow Control + assert user_data.has_blood_cancer == "no" + assert user_data.caring_for_someone == "no" + + # Assert - Personal Info + assert user_data.first_name == "Robert" + assert user_data.last_name == "VolunteerHelper" + assert user_data.date_of_birth == date(1985, 6, 14) + + # Assert - Demographics + assert user_data.gender_identity == "Male" + assert user_data.pronouns == ["he", "him"] + assert user_data.ethnic_group == ["White"] + assert user_data.marital_status == "Single" + assert user_data.has_kids == "no" + + # Assert - No cancer-related data + assert user_data.diagnosis is None + assert user_data.date_of_diagnosis is None + assert user_data.other_treatment is None + assert user_data.other_experience is None + assert len(user_data.treatments) == 0 + assert len(user_data.experiences) == 0 + + # Assert - No loved one data + assert user_data.loved_one_gender_identity is None + assert user_data.loved_one_diagnosis is None + assert len(user_data.loved_one_treatments) == 0 + assert len(user_data.loved_one_experiences) == 0 + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +# Error Handling Tests +def test_invalid_user_id_format(db_session): + """Test error handling with invalid UUID format""" + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Test", + "lastName": "User", + "dateOfBirth": "01/01/1990", + "phoneNumber": "555-1234", + "city": "Test City", + "province": "Test Province", + "postalCode": "T1T 1T1", + }, + } + + with pytest.raises(ValueError, match="Invalid UUID format"): + processor.process_form_submission("invalid-uuid-format", form_data) + + +def test_missing_personal_info_section(db_session, test_user): + """Test error handling with missing personalInfo section""" + processor = IntakeFormProcessor(db_session) + + # Missing personalInfo entirely should raise KeyError + with pytest.raises(KeyError, match="personalInfo section is required"): + processor.process_form_submission( + str(test_user.id), + { + "formType": "participant", + "hasBloodCancer": "yes", + # No personalInfo section + }, + ) + + +def test_missing_required_personal_info_fields(db_session, test_user): + """Test error handling with missing required personalInfo fields""" + processor = IntakeFormProcessor(db_session) + + # Missing required personalInfo fields + with pytest.raises(KeyError, match="Required field missing: personalInfo.lastName"): + processor.process_form_submission( + str(test_user.id), + { + "formType": "participant", + "hasBloodCancer": "yes", + "personalInfo": { + "firstName": "Test" + # Missing other required fields + }, + }, + ) + + +def test_malformed_date_formats(db_session, test_user): + """Test error handling with various malformed date formats""" + processor = IntakeFormProcessor(db_session) + + # Invalid date format + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Test", + "lastName": "User", + "dateOfBirth": "invalid-date", + "phoneNumber": "555-1234", + "city": "Test City", + "province": "Test Province", + "postalCode": "T1T 1T1", + }, + } + + with pytest.raises(ValueError, match="Invalid date format for dateOfBirth"): + processor.process_form_submission(str(test_user.id), form_data) + + +def test_database_rollback_on_error(db_session, test_user): + """Test that database transaction rolls back properly on errors""" + processor = IntakeFormProcessor(db_session) + + # Count initial UserData records + initial_count = db_session.query(UserData).count() + + # Attempt to process invalid data that should trigger rollback + try: + processor.process_form_submission( + str(test_user.id), + { + "formType": "participant", + "hasBloodCancer": "yes", + "personalInfo": { + "firstName": "Test", + "lastName": "User", + "dateOfBirth": "invalid-date", # This will cause an error + "phoneNumber": "555-1234", + "city": "Test City", + "province": "Test Province", + "postalCode": "T1T 1T1", + }, + }, + ) + except ValueError: + pass # Expected error + + # Verify no new records were created (rollback worked) + final_count = db_session.query(UserData).count() + assert final_count == initial_count + + +# Data Integrity Tests +def test_duplicate_form_submission_handling(db_session, test_user): + """Test handling of duplicate form submissions for the same user""" + try: + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Original", + "lastName": "User", + "dateOfBirth": "01/01/1990", + "phoneNumber": "555-1111", + "city": "Original City", + "province": "Original Province", + "postalCode": "O1O 1O1", + }, + "cancerExperience": { + "diagnosis": "Original Cancer", + "dateOfDiagnosis": "01/01/2020", + "treatments": ["Surgery"], + "experiences": ["Fatigue"], + }, + } + + # First submission + processor.process_form_submission(str(test_user.id), form_data) + db_session.commit() + + # Second submission with different data (should update existing record) + form_data["personalInfo"]["firstName"] = "Updated" + form_data["personalInfo"]["city"] = "Updated City" + form_data["cancerExperience"]["diagnosis"] = "Updated Cancer" + + processor.process_form_submission(str(test_user.id), form_data) + db_session.commit() + + # Verify only one UserData record exists for this user (using correct field name) + user_data_count = db_session.query(UserData).filter(UserData.user_id == test_user.id).count() + assert user_data_count == 1 + + # Verify data was updated, not duplicated + final_user_data = db_session.query(UserData).filter(UserData.user_id == test_user.id).first() + assert final_user_data.first_name == "Updated" + assert final_user_data.city == "Updated City" + assert final_user_data.diagnosis == "Updated Cancer" + + except Exception: + db_session.rollback() + raise + + +def test_text_trimming_and_normalization(db_session, test_user): + """Test that text fields are properly trimmed and normalized""" + try: + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": " John ", # Extra spaces + "lastName": "\tDoe\n", # Tabs and newlines + "dateOfBirth": "01/01/1990", + "phoneNumber": " 555-1234 ", + "city": " Toronto ", + "province": " Ontario ", + "postalCode": " M5V 3A1 ", + }, + "demographics": {"genderIdentity": " Male ", "maritalStatus": " Single "}, + "cancerExperience": { + "diagnosis": " Leukemia ", + "dateOfDiagnosis": "01/01/2020", + "treatments": [" Surgery ", " Chemotherapy "], + "experiences": [" Fatigue "], + "otherTreatment": " Custom treatment ", + "otherExperience": " Custom experience ", + }, + } + + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Verify text fields are trimmed + assert user_data.first_name == "John" + assert user_data.last_name == "Doe" + assert user_data.phone == "555-1234" + assert user_data.city == "Toronto" + assert user_data.province == "Ontario" + assert user_data.postal_code == "M5V 3A1" + assert user_data.gender_identity == "Male" + assert user_data.marital_status == "Single" + assert user_data.diagnosis == "Leukemia" + assert user_data.other_treatment == "Custom treatment" + assert user_data.other_experience == "Custom experience" + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_sql_injection_prevention(db_session, test_user): + """Test that the system prevents SQL injection attempts""" + try: + processor = IntakeFormProcessor(db_session) + + # Attempt SQL injection in various fields + malicious_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "'; DROP TABLE users; --", + "lastName": "Robert'; DELETE FROM user_data; --", + "dateOfBirth": "01/01/1990", + "phoneNumber": "555-1234", + "city": "Toronto'; SELECT * FROM users; --", + "province": "Ontario", + "postalCode": "M5V 3A1", + }, + "cancerExperience": { + "diagnosis": "'; UNION SELECT password FROM users; --", + "dateOfDiagnosis": "01/01/2020", + "treatments": ["Surgery"], + "experiences": ["Fatigue"], + "otherTreatment": "'; INSERT INTO admin_users VALUES (1); --", + }, + } + + # This should process safely without executing malicious SQL + user_data = processor.process_form_submission(str(test_user.id), malicious_data) + + # Verify the malicious strings are stored as literal text, not executed + assert user_data.first_name == "'; DROP TABLE users; --" + assert "DELETE FROM user_data" in user_data.last_name + assert "INSERT INTO admin_users VALUES" in user_data.other_treatment + + # Verify no actual SQL injection occurred by checking database integrity + user_count = db_session.query(User).count() + assert user_count > 0 # Users table still exists and has data + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_unicode_and_special_characters(db_session, test_user): + """Test handling of Unicode characters and special symbols""" + try: + processor = IntakeFormProcessor(db_session) + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "José", # Accented characters + "lastName": "François-Müller", # Multiple special chars + "dateOfBirth": "01/01/1990", + "phoneNumber": "555-1234", + "city": "Montréal", # French accent + "province": "Québec", # French accent + "postalCode": "H3A 1A1", + }, + "demographics": { + "genderIdentity": "Non-binary", + "pronouns": ["they", "them"], + "ethnicGroup": ["Other"], + "ethnicGroupCustom": "中国人 (Chinese) & हिन्दी (Hindi) 🌍", # Unicode mix + }, + "cancerExperience": { + "diagnosis": "Leucémie (Leukemia)", + "dateOfDiagnosis": "01/01/2020", + "treatments": ["Chimiothérapie"], + "experiences": ["Fatigue"], + "otherTreatment": "Traitement spécialisé avec émojis 💊🏥", + "otherExperience": "Expérience émotionnelle complexe 😔➡️😊", + }, + } + + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Verify Unicode characters are preserved + assert user_data.first_name == "José" + assert user_data.last_name == "François-Müller" + assert user_data.city == "Montréal" + assert user_data.province == "Québec" + assert "中国人" in user_data.other_ethnic_group + assert "हिन्दी" in user_data.other_ethnic_group + assert "🌍" in user_data.other_ethnic_group + assert "💊🏥" in user_data.other_treatment + assert "😔➡️😊" in user_data.other_experience + + db_session.commit() + + except Exception: + db_session.rollback() + raise + + +def test_boundary_date_values(db_session, test_user): + """Test handling of boundary date values""" + try: + processor = IntakeFormProcessor(db_session) + + # Test with very old and very recent dates + form_data = { + "formType": "participant", + "hasBloodCancer": "yes", + "caringForSomeone": "no", + "personalInfo": { + "firstName": "Old", + "lastName": "Person", + "dateOfBirth": "01/01/1920", # Very old date + "phoneNumber": "555-1234", + "city": "Toronto", + "province": "Ontario", + "postalCode": "M5V 3A1", + }, + "cancerExperience": { + "diagnosis": "Leukemia", + "dateOfDiagnosis": "31/12/2023", # Very recent date + "treatments": ["Surgery"], + "experiences": ["Fatigue"], + }, + } + + user_data = processor.process_form_submission(str(test_user.id), form_data) + + # Verify boundary dates are handled correctly + assert user_data.date_of_birth == date(1920, 1, 1) + assert user_data.date_of_diagnosis == date(2023, 12, 31) + + db_session.commit() + + except Exception: + db_session.rollback() + raise diff --git a/frontend/APIClients/authAPIClient.js b/frontend/APIClients/authAPIClient.js deleted file mode 100644 index 37cb633d..00000000 --- a/frontend/APIClients/authAPIClient.js +++ /dev/null @@ -1,50 +0,0 @@ -// frontend/services/authService.js - -import axios from 'axios'; - -// To Do: Add proper URL below -const API_URL = ''; - -export const registerUser = async (userData) => { - try { - const response = await axios.post(`${API_URL}/register`, userData); - return response.data; - } catch (error) { - throw error.response ? error.response.data : new Error('Network error'); - } -}; - -export const loginUser = async (credentials) => { - try { - const response = await axios.post(`${API_URL}/login`, credentials); - return response.data; - } catch (error) { - throw error.response ? error.response.data : new Error('Network error'); - } -}; - -export const logoutUser = async (token) => { - try { - const response = await axios.post( - `${API_URL}/logout`, - {}, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - return response.data; - } catch (error) { - throw error.response ? error.response.data : new Error('Network error'); - } -}; - -export const refreshUser = async (refreshToken) => { - try { - const response = await axios.post(`${API_URL}/refresh`, { refresh_token: refreshToken }); - return response.data; - } catch (error) { - throw error.response ? error.response.data : new Error('Network error'); - } -}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6be8c73..4b318ad9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "dependencies": { "@chakra-ui/react": "^3.13.0", + "firebase": "^11.10.0", + "humps": "^2.0.1", + "jwt-decode": "^4.0.0", "next": "^14.2.24", "next-themes": "^0.4.6", "react": "^18", @@ -466,6 +469,570 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@firebase/ai": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", + "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", + "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", + "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", + "dependencies": { + "@firebase/analytics": "0.10.17", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==" + }, + "node_modules/@firebase/app": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz", + "integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.1.tgz", + "integrity": "sha512-MgNdlms9Qb0oSny87pwpjKush9qUwCJhfmTJHDfrcKo4neLGiSeVE4qJkzP7EQTIUFKp84pbTxobSAXkiuQVYQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.26.tgz", + "integrity": "sha512-PkX+XJMLDea6nmnopzFKlr+s2LMQGqdyT2DHdbx1v1dPSqOol2YzgpgymmhC67vitXVpNvS3m/AiWQWWhhRRPQ==", + "dependencies": { + "@firebase/app-check": "0.10.1", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==" + }, + "node_modules/@firebase/app-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz", + "integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==", + "dependencies": { + "@firebase/app": "0.13.2", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.28", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.28.tgz", + "integrity": "sha512-HpMSo/cc6Y8IX7bkRIaPPqT//Jt83iWy5rmDWeThXQCAImstkdNo3giFLORJwrZw2ptiGkOij64EH1ztNJzc7Q==", + "dependencies": { + "@firebase/auth": "1.10.8", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-compat/node_modules/@firebase/auth": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.8.tgz", + "integrity": "sha512-GpuTz5ap8zumr/ocnPY57ZanX02COsXloY6Y/2LYPAuXYiaJRf6BAGDEdRq1BMjP93kqQnKNuKZUTMZbQ8MNYA==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.18.tgz", + "integrity": "sha512-n28kPCkE2dL2U28fSxZJjzPPVpKsQminJ6NrzcKXAI0E/lYC8YhfwpyllScqVEvAI3J2QgJZWYgrX+1qGI+SQQ==", + "dependencies": { + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.10.tgz", + "integrity": "sha512-VMVk7zxIkgwlVQIWHOKFahmleIjiVFwFOjmakXPd/LDgaB/5vzwsB5DWIYo+3KhGxWpidQlR8geCIn39YflJIQ==", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.20.tgz", + "integrity": "sha512-H9Rpj1pQ1yc9+4HQOotFGLxqAXwOzCHsRSRjcQFNOr8lhUt6LeYjf0NSRL04sc4X0dWe8DsCvYKxMYvFG/iOJw==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.11.tgz", + "integrity": "sha512-itEsHARSsYS95+udF/TtIzNeQ0Uhx4uIna0sk4E0wQJBUnLc/G1X6D7oRljoOuwwCezRLGvWBRyNrugv/esOEw==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/database": "1.0.20", + "@firebase/database-types": "1.0.15", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.15.tgz", + "integrity": "sha512-XWHJ0VUJ0k2E9HDMlKxlgy/ZuTa9EvHCGLjaKSUvrQnwhgZuRU5N3yX6SZ+ftf2hTzZmfRkv+b3QRvGg40bKNw==", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.12.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.8.0.tgz", + "integrity": "sha512-QSRk+Q1/CaabKyqn3C32KSFiOdZpSqI9rpLK5BHPcooElumOBooPFa6YkDdiT+/KhJtel36LdAacha9BptMj2A==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "@firebase/webchannel-wrapper": "1.0.3", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.53", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.53.tgz", + "integrity": "sha512-qI3yZL8ljwAYWrTousWYbemay2YZa+udLWugjdjju2KODWtLG94DfO4NALJgPLv8CVGcDHNFXoyQexdRA0Cz8Q==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.12.9", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.9.tgz", + "integrity": "sha512-FG95w6vjbUXN84Ehezc2SDjGmGq225UYbHrb/ptkRT7OTuCiQRErOQuyt1jI1tvcDekdNog+anIObihNFz79Lg==", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.26.tgz", + "integrity": "sha512-A798/6ff5LcG2LTWqaGazbFYnjBW8zc65YfID/en83ALmkhu2b0G8ykvQnLtakbV9ajrMYPn7Yc/XcYsZIUsjA==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/functions": "0.12.9", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==" + }, + "node_modules/@firebase/installations": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.18.tgz", + "integrity": "sha512-NQ86uGAcvO8nBRwVltRL9QQ4Reidc/3whdAasgeWCPIcrhOKDuNpAALa6eCVryLnK14ua2DqekCOX5uC9XbU/A==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.18.tgz", + "integrity": "sha512-aLFohRpJO5kKBL/XYL4tN+GdwEB/Q6Vo9eZOM/6Kic7asSUgmSfGPpGUZO1OAaSRGwF4Lqnvi1f/f9VZnKzChw==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", + "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.22.tgz", + "integrity": "sha512-GJcrPLc+Hu7nk+XQ70Okt3M1u1eRr2ZvpMbzbc54oTPJZySHcX9ccZGVFcsZbSZ6o1uqumm8Oc7OFkD3Rn1/og==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.22.tgz", + "integrity": "sha512-5ZHtRnj6YO6f/QPa/KU6gryjmX4Kg33Kn4gRpNU6M1K47Gm8kcQwPkX7erRUYEH1mIWptfvjvXMHWoZaWjkU7A==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/messaging": "0.12.22", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==" + }, + "node_modules/@firebase/performance": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.7.tgz", + "integrity": "sha512-JTlTQNZKAd4+Q5sodpw6CN+6NmwbY72av3Lb6wUKTsL7rb3cuBIhQSrslWbVz0SwK3x0ZNcqX24qtRbwKiv+6w==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.20.tgz", + "integrity": "sha512-XkFK5NmOKCBuqOKWeRgBUFZZGz9SzdTZp4OqeUg+5nyjapTiZ4XoiiUL8z7mB2q+63rPmBl7msv682J3rcDXIQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/performance": "0.7.7", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==" + }, + "node_modules/@firebase/remote-config": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.5.tgz", + "integrity": "sha512-fU0c8HY0vrVHwC+zQ/fpXSqHyDMuuuglV94VF6Yonhz8Fg2J+KOowPGANM0SZkLvVOYpTeWp3ZmM+F6NjwWLnw==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.18.tgz", + "integrity": "sha512-YiETpldhDy7zUrnS8e+3l7cNs0sL7+tVAxvVYU0lu7O+qLHbmdtAxmgY+wJqWdW2c9nDvBFec7QiF58pEUu0qQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-types": "0.4.0", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", + "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==" + }, + "node_modules/@firebase/storage": { + "version": "0.13.14", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.14.tgz", + "integrity": "sha512-xTq5ixxORzx+bfqCpsh+o3fxOsGoDjC1nO0Mq2+KsOcny3l7beyBhP/y1u5T6mgsFQwI1j6oAkbT5cWdDBx87g==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.24.tgz", + "integrity": "sha512-XHn2tLniiP7BFKJaPZ0P8YQXKiVJX+bMyE2j2YWjYfaddqiJnROJYqSomwW6L3Y+gZAga35ONXUJQju6MB6SOQ==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.1.tgz", + "integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==", + "hasInstallScript": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", + "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==" + }, "node_modules/@floating-ui/core": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", @@ -491,6 +1058,35 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -856,6 +1452,60 @@ "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -897,7 +1547,6 @@ "version": "20.16.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.6.tgz", "integrity": "sha512-T7PpxM/6yeDE+AdlVysT62BX6/bECZOmQAgiFg5NoBd5MQheZ3tzal7f1wvzfiEcmrcJNRi2zRr2nY2zF+0uqw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -1980,7 +2629,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1990,7 +2638,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2439,11 +3086,57 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2456,7 +3149,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -2949,6 +3641,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3476,6 +4176,17 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3526,6 +4237,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.10.0.tgz", + "integrity": "sha512-nKBXoDzF0DrXTBQJlZa+sbC5By99ysYU1D6PkMRYknm0nCW7rJly47q492Ht7Ndz5MeYSBuboKuhS1e6mFC03w==", + "dependencies": { + "@firebase/ai": "1.4.1", + "@firebase/analytics": "0.10.17", + "@firebase/analytics-compat": "0.2.23", + "@firebase/app": "0.13.2", + "@firebase/app-check": "0.10.1", + "@firebase/app-check-compat": "0.3.26", + "@firebase/app-compat": "0.4.2", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.10.8", + "@firebase/auth-compat": "0.5.28", + "@firebase/data-connect": "0.3.10", + "@firebase/database": "1.0.20", + "@firebase/database-compat": "2.0.11", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-compat": "0.3.53", + "@firebase/functions": "0.12.9", + "@firebase/functions-compat": "0.3.26", + "@firebase/installations": "0.6.18", + "@firebase/installations-compat": "0.2.18", + "@firebase/messaging": "0.12.22", + "@firebase/messaging-compat": "0.2.22", + "@firebase/performance": "0.7.7", + "@firebase/performance-compat": "0.2.20", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-compat": "0.2.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-compat": "0.3.24", + "@firebase/util": "1.12.1" + } + }, + "node_modules/firebase/node_modules/@firebase/auth": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.8.tgz", + "integrity": "sha512-GpuTz5ap8zumr/ocnPY57ZanX02COsXloY6Y/2LYPAuXYiaJRf6BAGDEdRq1BMjP93kqQnKNuKZUTMZbQ8MNYA==", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -3635,6 +4404,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -3904,6 +4681,21 @@ "react-is": "^16.7.0" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" + }, + "node_modules/humps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", + "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4171,7 +4963,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4551,6 +5342,14 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4627,6 +5426,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4634,6 +5438,11 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5389,6 +6198,29 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-compare": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", @@ -5562,6 +6394,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -5691,6 +6531,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safe-regex-test": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", @@ -6035,7 +6894,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6426,7 +7284,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/uqr": { @@ -6452,6 +7309,32 @@ "dev": true, "license": "MIT" }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6669,6 +7552,14 @@ "dev": true, "license": "ISC" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", @@ -6682,6 +7573,49 @@ "node": ">= 14" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2c3eaaa4..d507f94a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ }, "dependencies": { "@chakra-ui/react": "^3.13.0", + "firebase": "^11.10.0", + "humps": "^2.0.1", + "jwt-decode": "^4.0.0", "next": "^14.2.24", "next-themes": "^0.4.6", "react": "^18", diff --git a/frontend/public/admin.png b/frontend/public/admin.png new file mode 100644 index 00000000..5b74ac1a Binary files /dev/null and b/frontend/public/admin.png differ diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts new file mode 100644 index 00000000..f2d73f31 --- /dev/null +++ b/frontend/src/APIClients/authAPIClient.ts @@ -0,0 +1,324 @@ +import { + AuthenticatedUser, + UserCreateResponse, + LoginRequest, + AuthResponse, + RefreshRequest, + Token, + UserRole, + SignUpMethod, +} from '../types/authTypes'; + +import AUTHENTICATED_USER_KEY from '../constants/AuthConstants'; +import baseAPIClient from './baseAPIClient'; +import { getLocalStorageObjProperty, setLocalStorageObjProperty } from '../utils/LocalStorageUtils'; +import { signInWithEmailAndPassword, applyActionCode } from 'firebase/auth'; +import { auth } from '@/config/firebase'; +import { sendEmailVerificationToUser } from '@/services/firebaseAuthService'; + +// Validation helper +const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +// Authentication result type +export interface AuthResult { + success: boolean; + user?: AuthenticatedUser; + error?: string; + errorCode?: string; +} + +const login = async (email: string, password: string): Promise => { + try { + // Validate inputs + if (!validateEmail(email)) { + return { success: false, error: 'Please enter a valid email address' }; + } + + if (!email || !password) { + return { success: false, error: 'Please enter both email and password' }; + } + + // Attempt Firebase authentication + const userCredential = await signInWithEmailAndPassword(auth, email, password); + const user = userCredential.user; + + // Check if email is verified + if (!user.emailVerified) { + return { + success: false, + error: + 'Please verify your email address before signing in. Check your inbox for a verification link.', + errorCode: 'auth/email-not-verified', + }; + } + + // Attempt backend login + try { + const loginRequest: LoginRequest = { email, password }; + const { data } = await baseAPIClient.post('/auth/login', loginRequest, { + withCredentials: true, + }); + localStorage.setItem(AUTHENTICATED_USER_KEY, JSON.stringify(data)); + return { success: true, user: { ...data.user, ...data } }; + } catch { + // Backend login failure is not critical since Firebase auth succeeded + return { + success: true, + user: { email: user.email, uid: user.uid } as unknown as AuthenticatedUser, + }; + } + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + const errorCode = (error as { code?: string }).code; + + switch (errorCode) { + case 'auth/user-not-found': + return { + success: false, + error: 'No account found with this email address. Please sign up first.', + errorCode, + }; + case 'auth/wrong-password': + return { success: false, error: 'Incorrect password. Please try again.', errorCode }; + case 'auth/invalid-credential': + return { + success: false, + error: + 'Invalid email or password. Please check your credentials and try again. If you recently signed up, make sure to verify your email first.', + errorCode, + }; + case 'auth/invalid-email': + return { success: false, error: 'Invalid email address format.', errorCode }; + case 'auth/too-many-requests': + return { + success: false, + error: 'Too many failed attempts. Please try again later.', + errorCode, + }; + case 'auth/user-disabled': + return { + success: false, + error: 'This account has been disabled. Please contact support.', + errorCode, + }; + default: + return { + success: false, + error: 'Authentication failed. Please check your credentials and try again.', + errorCode, + }; + } + } + + return { success: false, error: 'An unexpected error occurred. Please try again.' }; + } +}; + +const logout = async (): Promise => { + const bearerToken = `Bearer ${getLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'accessToken')}`; + + try { + await baseAPIClient.post('/auth/logout', {}, { headers: { Authorization: bearerToken } }); + localStorage.removeItem(AUTHENTICATED_USER_KEY); + return true; + } catch (error) { + console.error('Logout error:', error); + return false; + } +}; + +// Get current authenticated user from localStorage +const getCurrentUser = (): AuthenticatedUser | null => { + try { + const userDataString = localStorage.getItem(AUTHENTICATED_USER_KEY); + if (!userDataString) return null; + + const userData = JSON.parse(userDataString); + return userData; + } catch (error) { + console.error('Error retrieving current user:', error); + return null; + } +}; + +export const register = async ({ + first_name, + last_name, + email, + password, + role = UserRole.PARTICIPANT, + signupMethod = SignUpMethod.PASSWORD, +}: { + first_name?: string | null; + last_name?: string | null; + email: string; + password: string; + role?: UserRole; + signupMethod?: SignUpMethod; +}): Promise => { + try { + const registerRequest = { + first_name, + last_name, + email, + password, + role, + signupMethod, + }; + + // Register user in backend (this creates the Firebase user) + await baseAPIClient.post('/auth/register', registerRequest, { + withCredentials: true, + }); + + console.log('[REGISTER] User registered successfully in backend'); + + // Sign in to Firebase to ensure user is authenticated + try { + const userCredential = await signInWithEmailAndPassword(auth, email, password); + const user = userCredential.user; + console.log('[REGISTER] Firebase sign-in successful, user:', user.email); + + // Wait a moment to ensure Firebase auth state is fully updated + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Now send the verification email + const emailSent = await sendEmailVerificationToUser(); + if (emailSent) { + console.log('[REGISTER] Email verification sent successfully after registration'); + } else { + console.warn('[REGISTER] Failed to send email verification after registration'); + } + } catch (firebaseError) { + console.error('[REGISTER] Firebase sign-in failed:', firebaseError); + // Continue with registration even if Firebase sign-in fails + // The user can still verify their email later + } + + // Try backend login but don't fail if it doesn't work + try { + const loginResult = await login(email, password); + return loginResult; + } catch (loginError) { + console.warn('[REGISTER] Backend login failed, but registration was successful:', loginError); + // Return success even if backend login fails, since Firebase user was created + return { + success: true, + user: { email, uid: auth.currentUser?.uid || 'unknown' } as unknown as AuthenticatedUser, + }; + } + } catch (error) { + console.error('[REGISTER] Registration failed:', error); + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { status?: number; data?: { detail?: string } } }) + .response; + if (response?.status === 409) { + return { + success: false, + error: 'An account with this email already exists. Please try logging in instead.', + }; + } else if (response?.status === 400) { + const detail = response?.data?.detail || 'Invalid registration data'; + return { success: false, error: detail }; + } + } + + return { success: false, error: 'Registration failed. Please try again.' }; + } +}; + +const resetPassword = async (email: string): Promise<{ success: boolean; error?: string }> => { + try { + if (!validateEmail(email)) { + return { success: false, error: 'Please enter a valid email address' }; + } + + await baseAPIClient.post(`/auth/resetPassword/${email}`, {}, { withCredentials: true }); + return { success: true }; + } catch { + return { success: false, error: 'Failed to send reset email. Please try again.' }; + } +}; + +const verifyEmail = async (email: string): Promise => { + try { + await baseAPIClient.post(`/auth/verify/${email}`, {}, { withCredentials: true }); + return true; + } catch (error: unknown) { + if ( + error && + typeof error === 'object' && + 'response' in error && + error.response && + typeof error.response === 'object' && + 'status' in error.response && + error.response.status === 404 + ) { + return false; + } + return false; + } +}; + +const verifyEmailWithCode = async ( + oobCode: string, +): Promise<{ success: boolean; error?: string }> => { + try { + // Verify with Firebase + await applyActionCode(auth, oobCode); + + // Get the current user to get their email + const currentUser = auth.currentUser; + const email = currentUser?.email; + + if (email) { + // Try to verify with backend (optional) + try { + await verifyEmail(email); + } catch { + // Backend verification failure is not critical since Firebase verification succeeded + } + } + + return { success: true }; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + const errorCode = (error as { code?: string }).code; + if (errorCode === 'auth/invalid-action-code') { + return { + success: false, + error: 'Invalid or expired verification link. Please request a new one.', + }; + } else if (errorCode === 'auth/expired-action-code') { + return { + success: false, + error: 'Verification link has expired. Please request a new one.', + }; + } + } + return { success: false, error: 'Verification failed. Please try again.' }; + } +}; + +const refresh = async (): Promise => { + try { + const refreshToken = getLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'refreshToken'); + + const refreshRequest: RefreshRequest = { refreshToken: refreshToken as string }; + const { data } = await baseAPIClient.post('/auth/refresh', refreshRequest, { + withCredentials: true, + }); + + setLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'accessToken', data.accessToken); + setLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'refreshToken', data.refreshToken); + return true; + } catch (error) { + console.error('Refresh token error:', error); + return false; + } +}; + +export { login, logout, getCurrentUser, resetPassword, verifyEmail, verifyEmailWithCode, refresh }; diff --git a/frontend/src/APIClients/baseAPIClient.ts b/frontend/src/APIClients/baseAPIClient.ts new file mode 100644 index 00000000..39c63c07 --- /dev/null +++ b/frontend/src/APIClients/baseAPIClient.ts @@ -0,0 +1,69 @@ +import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +// Fix this import +import { jwtDecode } from 'jwt-decode'; +import { camelizeKeys, decamelizeKeys } from 'humps'; + +import AUTHENTICATED_USER_KEY from '../constants/AuthConstants'; +import { setLocalStorageObjProperty } from '../utils/LocalStorageUtils'; + +import { DecodedJWT } from '../types/authTypes'; + +const baseAPIClient = axios.create({ + // TODO: Fix this + baseURL: process.env.REACT_APP_BACKEND_URL || 'http://localhost:8000', +}); + +// Python API uses snake_case, frontend uses camelCase +// convert request and response data to/from snake_case and camelCase through axios interceptors +// python { +baseAPIClient.interceptors.response.use((response: AxiosResponse) => { + if (response.data && response.headers['content-type'] === 'application/json') { + response.data = camelizeKeys(response.data); + } + return response; +}); +// } python + +baseAPIClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { + const newConfig = { ...config }; + + // if access token in header has expired, do a refresh + const authHeaderParts = config.headers.Authorization?.toString().split(' '); + if ( + authHeaderParts && + authHeaderParts.length >= 2 && + authHeaderParts[0].toLowerCase() === 'bearer' + ) { + const decodedToken = jwtDecode(authHeaderParts[1]) as DecodedJWT; + + if ( + decodedToken && + (typeof decodedToken === 'string' || + decodedToken.exp <= Math.round(new Date().getTime() / 1000)) + ) { + const { data } = await axios.post( + `${process.env.REACT_APP_BACKEND_URL}/auth/refresh`, + {}, + { withCredentials: true }, + ); + + const accessToken = data.accessToken || data.access_token; + setLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'accessToken', accessToken); + + newConfig.headers.Authorization = `Bearer ${accessToken}`; + } + } + + // python { + if (config.params) { + newConfig.params = decamelizeKeys(config.params); + } + if (config.data && !(config.data instanceof FormData)) { + newConfig.data = decamelizeKeys(config.data); + } + // } python + + return newConfig; +}); + +export default baseAPIClient; diff --git a/frontend/src/config/firebase.tsx b/frontend/src/config/firebase.tsx new file mode 100644 index 00000000..a3a38d8f --- /dev/null +++ b/frontend/src/config/firebase.tsx @@ -0,0 +1,22 @@ +import { initializeApp } from 'firebase/app'; +import { getAuth } from 'firebase/auth'; + +// Your Firebase configuration using environment variables +const firebaseConfig = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_WEB_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, +}; + +// Debug logging to check API key +// console.log('Firebase API Key:', process.env.NEXT_PUBLIC_FIREBASE_WEB_API_KEY); + +// Initialize Firebase +const app = initializeApp(firebaseConfig); + +// Initialize Firebase Authentication and get a reference to the service +export const auth = getAuth(app); + +// Export the app instance if needed elsewhere +export default app; diff --git a/frontend/src/constants/AuthConstants.ts b/frontend/src/constants/AuthConstants.ts new file mode 100644 index 00000000..f326e2ab --- /dev/null +++ b/frontend/src/constants/AuthConstants.ts @@ -0,0 +1,3 @@ +const AUTHENTICATED_USER_KEY = 'AUTHENTICATED_USER'; + +export default AUTHENTICATED_USER_KEY; diff --git a/frontend/src/hooks/useEmailVerification.ts b/frontend/src/hooks/useEmailVerification.ts new file mode 100644 index 00000000..6321819d --- /dev/null +++ b/frontend/src/hooks/useEmailVerification.ts @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { + sendEmailVerificationToUser, + sendSignInLinkToUserEmail, +} from '@/services/firebaseAuthService'; + +export const useEmailVerification = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const sendVerificationEmail = async () => { + setIsLoading(true); + setError(null); + setSuccess(false); + + try { + const result = await sendEmailVerificationToUser(); + if (result) { + setSuccess(true); + } else { + setError('Failed to send verification email'); + } + } catch (err) { + setError('An error occurred while sending verification email'); + console.error('Email verification error:', err); + } finally { + setIsLoading(false); + } + }; + + const sendSignInLink = async (email: string) => { + setIsLoading(true); + setError(null); + setSuccess(false); + + try { + const result = await sendSignInLinkToUserEmail(email); + if (result) { + setSuccess(true); + } else { + setError('Failed to send sign-in link'); + } + } catch (err) { + setError('An error occurred while sending sign-in link'); + console.error('Sign-in link error:', err); + } finally { + setIsLoading(false); + } + }; + + const reset = () => { + setError(null); + setSuccess(false); + }; + + return { + sendVerificationEmail, + sendSignInLink, + isLoading, + error, + success, + reset, + }; +}; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 9b879c48..d3dc46be 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -1,21 +1,11 @@ import '@/styles/globals.css'; import type { AppProps } from 'next/app'; import { Provider } from '@/components/ui/provider'; -import Head from 'next/head'; export default function App({ Component, pageProps }: AppProps) { return ( - <> - - - - - - - - + + + ); } diff --git a/frontend/src/pages/_document.tsx b/frontend/src/pages/_document.tsx index f124b972..d05438e8 100644 --- a/frontend/src/pages/_document.tsx +++ b/frontend/src/pages/_document.tsx @@ -3,7 +3,12 @@ import { Html, Head, Main, NextScript } from 'next/document'; export default function Document() { return ( - + + +
diff --git a/frontend/src/pages/action.tsx b/frontend/src/pages/action.tsx new file mode 100644 index 00000000..a0591508 --- /dev/null +++ b/frontend/src/pages/action.tsx @@ -0,0 +1,124 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { verifyEmailWithCode } from '@/APIClients/authAPIClient'; + +export default function ActionPage() { + const router = useRouter(); + const { mode, oobCode } = router.query; + const [isProcessing, setIsProcessing] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const handleAction = async () => { + if (!mode || !oobCode) { + setError('Invalid verification link'); + setIsProcessing(false); + return; + } + + if (mode === 'verifyEmail') { + try { + const result = await verifyEmailWithCode(oobCode as string); + + if (result.success) { + router.replace(`/?verified=true&mode=verifyEmail`); + } else { + setError(result.error || 'Verification failed'); + setIsProcessing(false); + } + } catch { + setError('An error occurred during verification'); + setIsProcessing(false); + } + } else if (mode === 'resetPassword') { + const targetUrl = `/set-new-password?oobCode=${oobCode}`; + if (router.asPath !== targetUrl) { + router.replace(targetUrl); + } + } else { + setError('Invalid action mode'); + setIsProcessing(false); + } + }; + + if (mode && oobCode) { + handleAction(); + } + }, [mode, oobCode, router]); + + if (error) { + return ( +
+
+

Verification Failed

+

{error}

+ +
+
+ ); + } + + return ( +
+
+

Processing...

+

Please wait while we process your request.

+ {isProcessing && ( +
+
+
+ )} + +
+
+ ); +} diff --git a/frontend/src/pages/admin-login.tsx b/frontend/src/pages/admin-login.tsx new file mode 100644 index 00000000..704e3952 --- /dev/null +++ b/frontend/src/pages/admin-login.tsx @@ -0,0 +1,232 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button, Input } from '@chakra-ui/react'; +import { Field } from '@/components/ui/field'; +import { InputGroup } from '@/components/ui/input-group'; +import { useRouter } from 'next/router'; +import { login } from '@/APIClients/authAPIClient'; + +const veniceBlue = '#1d3448'; +const fieldGray = '#414651'; +const teal = '#056067'; + +export default function AdminLogin() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + try { + const result = await login(email, password); + if (result) { + console.log('Admin login success:', result); + router.push('/admin/dashboard'); + } else { + setError('Invalid email or password'); + } + } catch (err: unknown) { + console.error('Admin login error:', err); + setError('Login failed. Please try again.'); + } + }; + + return ( + + {/* Left: Admin Login Form */} + + + + Admin Portal - First Connection Peer +
+ Support Program +
+ + Welcome Back! + + + Sign in with your email and password. + +
+ + Email + + } + mb={4} + > + + setEmail(e.target.value)} + /> + + + + Password + + } + mb={2} + > + + setPassword(e.target.value)} + /> + + + + router.push('/reset-password')} + > + Forgot Password? + + + {error && ( + + {error} + + )} + +
+ + Don't have an account?{' '} + + Click here to sign up. + + +
+
+ {/* Right: Image */} + + Admin Portal Visual + +
+ ); +} diff --git a/frontend/src/pages/admin.tsx b/frontend/src/pages/admin.tsx new file mode 100644 index 00000000..5bc397b8 --- /dev/null +++ b/frontend/src/pages/admin.tsx @@ -0,0 +1,276 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button, Input } from '@chakra-ui/react'; +import { Field } from '@/components/ui/field'; +import { InputGroup } from '@/components/ui/input-group'; +import { useRouter } from 'next/router'; +import { register } from '@/APIClients/authAPIClient'; +import { UserRole, SignUpMethod } from '@/types/authTypes'; + +const veniceBlue = '#1d3448'; +const fieldGray = '#414651'; +const teal = '#056067'; + +export default function AdminLoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + try { + const userData = { + first_name: '', + last_name: '', + email, + password, + role: UserRole.ADMIN, + signupMethod: SignUpMethod.PASSWORD, + }; + const result = await register(userData); + console.log('Admin registration success:', result); + router.push(`/verify?email=${encodeURIComponent(email)}&role=admin`); + } catch (err: unknown) { + console.error('Admin registration error:', err); + if ( + err && + typeof err === 'object' && + 'response' in err && + err.response && + typeof err.response === 'object' && + 'data' in err.response && + err.response.data && + typeof err.response.data === 'object' && + 'detail' in err.response.data + ) { + setError((err.response.data as { detail: string }).detail || 'Registration failed'); + } else { + setError('Registration failed'); + } + } + }; + + return ( + + {/* Left: Admin Login Form */} + + + + Admin Portal - First Connection Peer +
+ Support Program +
+ + Welcome! + + + Let's start by setting up your admin account. + +
+ + Email + + } + mb={4} + > + + setEmail(e.target.value)} + /> + + + + Password + + } + mb={2} + > + + setPassword(e.target.value)} + /> + + + + Confirm Password + + } + mb={6} + > + + setConfirmPassword(e.target.value)} + /> + + + {error && ( + + {typeof error === 'string' ? error : JSON.stringify(error)} + + )} + +
+ + Already have an account?{' '} + + Sign in. + + +
+
+ {/* Right: Image */} + + Admin Portal Visual + +
+ ); +} diff --git a/frontend/src/pages/admin/dashboard.tsx b/frontend/src/pages/admin/dashboard.tsx new file mode 100644 index 00000000..8b78b6e0 --- /dev/null +++ b/frontend/src/pages/admin/dashboard.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import Image from 'next/image'; +import { Box, Flex, Heading, Text, Link } from '@chakra-ui/react'; + +const veniceBlue = '#1d3448'; + +export default function AdminDashboard() { + return ( + + {/* Left: Dashboard Content */} + + + + Admin Portal - First Connection Peer Support Program + + + Welcome! + + + We sent a confirmation link to john.doe@gmail.com + + + Didn't get a link?{' '} + + Click here to resend. + + + + + {/* Right: Image */} + + Admin Portal Visual + + + ); +} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 9cd696a2..784fe1af 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,82 +1,247 @@ -import React from 'react'; -import { Box, Flex, Heading, Text, Button, VStack } from '@chakra-ui/react'; +import React, { useState, useEffect } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button, Input } from '@chakra-ui/react'; +import { Field } from '@/components/ui/field'; +import { InputGroup } from '@/components/ui/input-group'; import { useRouter } from 'next/router'; -import { COLORS } from '@/constants/form'; +import { login } from '@/APIClients/authAPIClient'; -export default function HomePage() { +const veniceBlue = '#1d3448'; +const fieldGray = '#414651'; +const teal = '#056067'; + +export default function LoginPage() { const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isFromEmailVerification, setIsFromEmailVerification] = useState(false); + + // Check if user is coming from email verification + useEffect(() => { + const { verified, mode } = router.query; + if (verified === 'true' && mode === 'verifyEmail') { + setIsFromEmailVerification(true); + } + }, [router.query]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const result = await login(email, password); + + if (result.success) { + router.push('/welcome'); + } else { + setError(result.error || 'Login failed. Please try again.'); + } + } catch { + setError('An unexpected error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; return ( - - + {/* Left: Login Form */} + - + - First Connection + First Connection Peer +
+ Support Program +
+ + {isFromEmailVerification ? 'Thank you for confirming!' : 'Welcome Back!'} - - Choose your intake form type to get started + {isFromEmailVerification + ? 'Your email has been successfully verified. Please sign in again to continue.' + : 'Sign in with your email and password.'} - - - + + setEmail(e.target.value)} + /> + + + + Password + + } + mb={2} + > + + setPassword(e.target.value)} + /> + + + + router.push('/reset-password')} + > + Forgot Password? + + + {error && ( + + {error} + + )} - - + - The form will adapt based on your selections about blood cancer experience and - caregiving status. + Don't have an account?{' '} + + Complete our First Connection Participant Form. + -
+
+
+ {/* Right: Image */} + + First Connection Peer Support ); diff --git a/frontend/src/pages/participant-form.tsx b/frontend/src/pages/participant-form.tsx new file mode 100644 index 00000000..7243a6f7 --- /dev/null +++ b/frontend/src/pages/participant-form.tsx @@ -0,0 +1,372 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button, Input } from '@chakra-ui/react'; +import { Field } from '@/components/ui/field'; +import { InputGroup } from '@/components/ui/input-group'; +import { register } from '@/APIClients/authAPIClient'; +import { useRouter } from 'next/router'; +import { UserRole, SignUpMethod } from '@/types/authTypes'; + +const veniceBlue = '#1d3448'; +const fieldGray = '#414651'; +const teal = '#056067'; + +export function ParticipantFormPage() { + const [signupType, setSignupType] = useState('volunteer'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + try { + const userData = { + first_name: '', + last_name: '', + email, + password, + role: signupType === 'volunteer' ? UserRole.VOLUNTEER : UserRole.PARTICIPANT, + signupMethod: SignUpMethod.PASSWORD, + }; + const result = await register(userData); + console.log('Registration success:', result); + router.push(`/verify?email=${encodeURIComponent(email)}&role=${signupType}`); + } catch (err: unknown) { + console.error('Registration error:', err); + if ( + err && + typeof err === 'object' && + 'response' in err && + err.response && + typeof err.response === 'object' && + 'data' in err.response && + err.response.data && + typeof err.response.data === 'object' && + 'detail' in err.response.data + ) { + setError((err.response.data as { detail: string }).detail || 'Registration failed'); + } else { + setError('Registration failed'); + } + } + }; + + return ( + + {/* Left: Participant Form */} + + + + First Connection Peer +
+ Support Program +
+ + Welcome to our application portal! + + + Let's start by creating an account. + +
+ + Email + + } + mb={4} + > + + setEmail(e.target.value)} + /> + + + + Password + + } + mb={4} + > + + setPassword(e.target.value)} + /> + + + + Confirm Password + + } + mb={4} + > + + setConfirmPassword(e.target.value)} + /> + + + + I am signing up: + +
+
setSignupType('volunteer')} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + cursor: 'pointer', + fontFamily: "'Open Sans', sans-serif", + fontSize: 14, + color: '#414651', + fontWeight: 600, + }} + > +
+ As a Peer Support Volunteer +
+
setSignupType('request')} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + cursor: 'pointer', + fontFamily: "'Open Sans', sans-serif", + fontSize: 14, + color: '#414651', + fontWeight: 600, + }} + > +
+ To Request Peer Support +
+
+ {error && ( + + {typeof error === 'string' ? error : JSON.stringify(error)} + + )} + +
+ + Already have an account?{' '} + + Sign in + + +
+
+ {/* Right: Image */} + + First Connection Peer Support + +
+ ); +} + +export default function ParticipantFormPageWrapper() { + return ( + <> + + + + ); +} diff --git a/frontend/src/pages/password-changed.tsx b/frontend/src/pages/password-changed.tsx new file mode 100644 index 00000000..becc1ebd --- /dev/null +++ b/frontend/src/pages/password-changed.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Box, Flex, Heading, Text, Button } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; + +const teal = '#056067'; + +export default function PasswordChangedPage() { + const router = useRouter(); + return ( + + + + + + + + Password changed! + + + You can now sign in with your new password. + + + + + ); +} diff --git a/frontend/src/pages/reset-password.tsx b/frontend/src/pages/reset-password.tsx new file mode 100644 index 00000000..40c66d40 --- /dev/null +++ b/frontend/src/pages/reset-password.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button, Input } from '@chakra-ui/react'; +import { Field } from '@/components/ui/field'; +import { InputGroup } from '@/components/ui/input-group'; +import { useRouter } from 'next/router'; +import { resetPassword } from '@/APIClients/authAPIClient'; + +const veniceBlue = '#1d3448'; +const teal = '#056067'; + +export default function ResetPasswordPage() { + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + setMessage(''); + + try { + const result = await resetPassword(email); + + if (result.success) { + setMessage( + 'If the email exists, a password reset link has been sent to your email address.', + ); + } else { + setError(result.error || 'Failed to send reset email. Please try again.'); + } + } catch { + setError('An error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( + + {/* Left: Reset Password Form */} + + + + First Connection Peer +
+ Support Program +
+ + Reset Your Password + + + Enter the email address associated with your account to receive password reset options. + +
+ + Email + + } + mb={4} + > + + setEmail(e.target.value)} + /> + + + +
+ {message && ( + + {message} + + )} + {error && ( + + {error} + + )} + + Return to{' '} + + login + + . + +
+
+ {/* Right: Image */} + + First Connection Peer Support + +
+ ); +} diff --git a/frontend/src/pages/set-new-password.tsx b/frontend/src/pages/set-new-password.tsx new file mode 100644 index 00000000..cf953a34 --- /dev/null +++ b/frontend/src/pages/set-new-password.tsx @@ -0,0 +1,300 @@ +import React, { useState, useEffect, useRef } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button, Input } from '@chakra-ui/react'; +import { Field } from '@/components/ui/field'; +import { InputGroup } from '@/components/ui/input-group'; +import { useRouter } from 'next/router'; +import firebaseApp from '@/config/firebase'; + +const veniceBlue = '#1d3448'; +const teal = '#056067'; + +export default function SetNewPasswordPage() { + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [passwordError, setPasswordError] = useState(''); // For password mismatch only + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + // Add refs for the input fields + const passwordRef = useRef(null); + const confirmPasswordRef = useRef(null); + + // Get API key from config + const apiKey = + (firebaseApp.options.apiKey as string) || process.env.NEXT_PUBLIC_FIREBASE_WEB_API_KEY; + + // Only require oobCode + useEffect(() => { + const { oobCode } = router.query; + if (!oobCode) { + setError('Invalid or expired password reset link. Please request a new one.'); + } else { + setError(''); + } + }, [router.query]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Unfocus both input fields + passwordRef.current?.blur(); + confirmPasswordRef.current?.blur(); + + setError(''); + setPasswordError(''); // Clear previous password errors + setIsLoading(true); + + if (password !== confirmPassword) { + setPasswordError('Passwords do not match. Please try again.'); + setIsLoading(false); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters long.'); + setIsLoading(false); + return; + } + + try { + const { oobCode } = router.query; + if (!oobCode) { + setError('Invalid password reset link. Please request a new one.'); + setIsLoading(false); + return; + } + const response = await fetch( + `https://identitytoolkit.googleapis.com/v1/accounts:resetPassword?key=${apiKey}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + oobCode: oobCode, + newPassword: password, + }), + }, + ); + const data = await response.json(); + if (response.ok && !data.error) { + setError(''); + setTimeout(() => { + router.push('/'); + }, 2000); + } else { + const errorMessage = data.error?.message || 'Failed to reset password. Please try again.'; + setError(errorMessage); + } + } catch { + setError('An error occurred while resetting your password. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const { oobCode } = router.query; + const showForm = Boolean(oobCode) && !error; + + return ( + + {/* Left: Set New Password Form */} + + + + First Connection Peer +
+ Support Program +
+ + Reset Your Password + + + Set a new password to restore access to your account. + + {error && ( + + {error} + + )} + {showForm && ( +
+ + New Password + + } + mb={4} + > + + setPassword(e.target.value)} + disabled={isLoading} + /> + + + + Confirm New Password + + } + mb={4} + > + + setConfirmPassword(e.target.value)} + disabled={isLoading} + /> + + + {/* Show password mismatch error only after submit attempt */} + {passwordError && ( + + {passwordError} + + )} + +
+ )} + + Return to{' '} + + login + + . + +
+
+ {/* Right: Image */} + + First Connection Peer Support + +
+ ); +} diff --git a/frontend/src/pages/verify.tsx b/frontend/src/pages/verify.tsx new file mode 100644 index 00000000..9daf06bc --- /dev/null +++ b/frontend/src/pages/verify.tsx @@ -0,0 +1,173 @@ +import { useRouter } from 'next/router'; +import { Box, Flex, Heading, Text, Button } from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; +import { useEmailVerification } from '@/hooks/useEmailVerification'; +import { + getEmailForSignIn, + clearEmailForSignIn, + setEmailForSignIn, +} from '@/services/firebaseAuthService'; +import { auth } from '@/config/firebase'; + +export default function VerifyPage() { + const router = useRouter(); + const { email, role } = router.query; + const [displayEmail, setDisplayEmail] = useState(''); + const [message, setMessage] = useState<{ type: 'success' | 'error' | null; text: string }>({ + type: null, + text: '', + }); + + const { sendVerificationEmail, sendSignInLink, isLoading, error, success } = + useEmailVerification(); + + useEffect(() => { + // Get email from query params or localStorage + const emailFromQuery = email as string; + const emailFromStorage = getEmailForSignIn(); + const finalEmail = emailFromQuery || emailFromStorage || 'john.doe@gmail.com'; + setDisplayEmail(finalEmail); + + // Store the email from query params if available + if (emailFromQuery) { + setEmailForSignIn(emailFromQuery); + } + }, [email]); + + useEffect(() => { + if (success) { + setMessage({ type: 'success', text: 'Email sent successfully! Please check your inbox.' }); + } + }, [success]); + + useEffect(() => { + if (error) { + setMessage({ type: 'error', text: error }); + } + }, [error]); + + const handleResendEmail = async () => { + setMessage({ type: null, text: '' }); + + // Debug: Log current auth state + console.log('Current auth user:', auth.currentUser); + console.log('User email verified:', auth.currentUser?.emailVerified); + + if (role === 'admin') { + // For admin users, send sign-in link + await sendSignInLink(displayEmail); + } else { + // For regular users, send verification email + await sendVerificationEmail(); + } + }; + + const handleBackToLogin = () => { + clearEmailForSignIn(); + router.push('/'); + }; + + return ( + + + + + First Connection Peer +
+ Support Program +
+ + Welcome to our application portal! + + + We sent a confirmation link to {displayEmail} + + + {message.text && ( + + {message.text} + + )} + + + Didn't get a link?{' '} + + + +
+
+ + First Connection Peer Support + +
+ ); +} diff --git a/frontend/src/pages/welcome.tsx b/frontend/src/pages/welcome.tsx new file mode 100644 index 00000000..6a36d7ec --- /dev/null +++ b/frontend/src/pages/welcome.tsx @@ -0,0 +1,188 @@ +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { getCurrentUser } from '@/APIClients/authAPIClient'; +import { AuthenticatedUser } from '@/types/authTypes'; + +export default function WelcomePage() { + const router = useRouter(); + const [currentUser, setCurrentUser] = useState(null); + + useEffect(() => { + // Check if user is logged in when component mounts + const user = getCurrentUser(); + setCurrentUser(user); + }, []); + + const handleContinueInEnglish = () => { + // Cast to any to handle the nested user structure + const userData = currentUser as any; + + // Check if user exists and has a roleId + if (userData && userData.user && userData.user.roleId) { + // Check user role based on roleId - assuming 1=participant, 2=volunteer, 3=admin + if (userData.user.roleId === 1) { + router.push('/participant/intake'); + } else if (userData.user.roleId === 2) { + router.push('/volunteer/intake'); + } else { + router.push('/participant-form'); + } + } else { + console.log('No user logged in, routing to sign-in form'); + // If no user is logged in, redirect to signup/login + router.push('/'); + } + }; + + return ( + + {/* Left: Content */} + + + + First Connection Peer +
+ Support Program +
+ + Welcome to our application portal! + + + You can learn more about the program{' '} + + here + + . + + + We're going to ask you a few questions to get started. + + + This form takes ~10 minutes to complete. Your responses will not be saved if you close + the tab, or exit this web page. + + + + + Already have an account?{' '} + + Sign In + + +
+
+ {/* Right: Image */} + + First Connection Peer Support + +
+ ); +} diff --git a/frontend/src/services/firebaseAuthService.ts b/frontend/src/services/firebaseAuthService.ts new file mode 100644 index 00000000..a8e551e7 --- /dev/null +++ b/frontend/src/services/firebaseAuthService.ts @@ -0,0 +1,143 @@ +import { + sendEmailVerification as firebaseSendEmailVerification, + sendSignInLinkToEmail, + ActionCodeSettings, +} from 'firebase/auth'; +import { auth } from '@/config/firebase'; + +// Storage keys for email management +const EMAIL_FOR_SIGN_IN_KEY = 'emailForSignIn'; + +/** + * Sends email verification to the currently authenticated user + * @returns Promise - true if successful, false otherwise + */ +export const sendEmailVerificationToUser = async (): Promise => { + try { + const user = auth.currentUser; + console.log('[EMAIL_VERIFICATION] Current auth state:', { + user: user ? { email: user.email, uid: user.uid, emailVerified: user.emailVerified } : null, + authState: auth.currentUser ? 'authenticated' : 'not authenticated', + }); + + if (!user) { + console.error( + 'No authenticated user found. User must be signed in to send email verification.', + ); + return false; + } + + // Check if email is already verified + if (user.emailVerified) { + console.log('Email is already verified'); + return true; + } + + console.log('[EMAIL_VERIFICATION] Sending verification email to:', user.email); + await firebaseSendEmailVerification(user); + console.log('Email verification sent successfully'); + return true; + } catch (error) { + console.error('Error sending email verification:', error); + + // Log specific error details for debugging + if (error && typeof error === 'object' && 'code' in error) { + const errorCode = (error as any).code; + console.error('Firebase error code:', errorCode); + + // Handle specific Firebase error codes + if (errorCode === 'auth/too-many-requests') { + console.error('Too many requests. Please wait before trying again.'); + } else if (errorCode === 'auth/invalid-user') { + console.error('Invalid user state. User may not be properly authenticated.'); + } else if (errorCode === 'auth/user-not-found') { + console.error('User not found. Authentication state may be invalid.'); + } + } + + return false; + } +}; + +/** + * Sends a sign-in link to the specified email address + * @param email - The email address to send the sign-in link to + * @returns Promise - true if successful, false otherwise + */ +export const sendSignInLinkToUserEmail = async (email: string): Promise => { + try { + if (!email || !email.trim()) { + console.error('Email is required to send sign-in link'); + return false; + } + + const actionCodeSettings: ActionCodeSettings = { + url: `${window.location.origin}/action`, + handleCodeInApp: true, + }; + + await sendSignInLinkToEmail(auth, email, actionCodeSettings); + console.log('Sign-in link sent successfully'); + return true; + } catch (error) { + console.error('Error sending sign-in link:', error); + + // Log specific error details for debugging + if (error && typeof error === 'object' && 'code' in error) { + const errorCode = (error as any).code; + console.error('Firebase error code:', errorCode); + + // Handle specific Firebase error codes + if (errorCode === 'auth/invalid-email') { + console.error('Invalid email address provided'); + } else if (errorCode === 'auth/too-many-requests') { + console.error('Too many requests. Please wait before trying again.'); + } + } + + return false; + } +}; + +/** + * Gets the email stored for sign-in from localStorage + * @returns string | null - The stored email or null if not found + */ +export const getEmailForSignIn = (): string | null => { + try { + if (typeof window !== 'undefined') { + return localStorage.getItem(EMAIL_FOR_SIGN_IN_KEY); + } + return null; + } catch (error) { + console.error('Error getting email for sign-in:', error); + return null; + } +}; + +/** + * Stores the email for sign-in in localStorage + * @param email - The email address to store + */ +export const setEmailForSignIn = (email: string): void => { + try { + if (typeof window !== 'undefined' && email) { + localStorage.setItem(EMAIL_FOR_SIGN_IN_KEY, email); + } + } catch (error) { + console.error('Error storing email for sign-in:', error); + } +}; + +/** + * Clears the email stored for sign-in from localStorage + */ +export const clearEmailForSignIn = (): void => { + try { + if (typeof window !== 'undefined') { + localStorage.removeItem(EMAIL_FOR_SIGN_IN_KEY); + } + } catch (error) { + console.error('Error clearing email for sign-in:', error); + } +}; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 13d40b89..3768dd4b 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -17,7 +17,7 @@ body { color: var(--foreground); background: var(--background); - font-family: Arial, Helvetica, sans-serif; + font-family: 'Open Sans', Arial, Helvetica, sans-serif; } @layer utilities { diff --git a/frontend/src/types/authTypes.ts b/frontend/src/types/authTypes.ts new file mode 100644 index 00000000..fec7ea5a --- /dev/null +++ b/frontend/src/types/authTypes.ts @@ -0,0 +1,55 @@ +export type DecodedJWT = string | null | { [key: string]: unknown; exp: number }; + +export enum SignUpMethod { + PASSWORD = 'PASSWORD', + GOOGLE = 'GOOGLE', +} + +export enum UserRole { + PARTICIPANT = 'participant', + VOLUNTEER = 'volunteer', + ADMIN = 'admin', +} + +export interface UserBase { + firstName: string; + lastName: string; + email: string; + role: UserRole; +} + +export interface UserCreateRequest extends UserBase { + password?: string; + authId?: string; + signupMethod: SignUpMethod; +} + +export interface UserCreateResponse { + id: string; + firstName: string; + lastName: string; + email: string; + roleId: number; + authId: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface Token { + accessToken: string; + refreshToken: string; +} + +export interface RefreshRequest { + refreshToken: string; +} + +export interface AuthResponse extends Token { + user: UserCreateResponse; +} + +// Type for an authenticated user in the system +export type AuthenticatedUser = (UserCreateResponse & Token) | null; diff --git a/frontend/src/utils/LocalStorageUtils.ts b/frontend/src/utils/LocalStorageUtils.ts new file mode 100644 index 00000000..d7620dcd --- /dev/null +++ b/frontend/src/utils/LocalStorageUtils.ts @@ -0,0 +1,40 @@ +// Get a string value from localStorage as an object +export const getLocalStorageObj = (localStorageKey: string): O | null => { + const stringifiedObj = localStorage.getItem(localStorageKey); + let object = null; + + if (stringifiedObj) { + try { + object = JSON.parse(stringifiedObj); + } catch (error) { + object = null; + } + } + + return object; +}; + +// Get a property of an object value from localStorage +export const getLocalStorageObjProperty = , P>( + localStorageKey: string, + property: string, +): P | null => { + const object = getLocalStorageObj(localStorageKey); + if (!object) return null; + + return object[property]; +}; + +// Set a property of an object value in localStorage +export const setLocalStorageObjProperty = >( + localStorageKey: string, + property: string, + value: string, +): void => { + const object: Record | null = getLocalStorageObj(localStorageKey); + + if (!object) return; + + object[property] = value; + localStorage.setItem(localStorageKey, JSON.stringify(object)); +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..a1d25f81 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,283 @@ +{ + "name": "llsc", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "axios": "^1.9.0", + "humps": "^2.0.1", + "jwt-decode": "^4.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", + "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==" + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..ee75d756 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "axios": "^1.9.0", + "humps": "^2.0.1", + "jwt-decode": "^4.0.0" + } +}