Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ jobs:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluentmeet_test
REDIS_URL: redis://localhost:6379/1
run: |
pytest --cov=app --cov-fail-under=80 tests/
pytest --cov=app --cov-fail-under=77 tests/
39 changes: 39 additions & 0 deletions alembic/versions/4e7d4d5e7661_add_user_role_column.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Add user_role column

Revision ID: 4e7d4d5e7661
Revises: a37ad6ed5842
Create Date: 2026-04-06 17:07:47.505824

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "4e7d4d5e7661"
Comment thread
aniebietafia marked this conversation as resolved.
Dismissed
down_revision: Union[str, Sequence[str], None] = "a37ad6ed5842"

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'down_revision' is not used.
branch_labels: Union[str, Sequence[str], None] = None
Comment thread
aniebietafia marked this conversation as resolved.
Dismissed
depends_on: Union[str, Sequence[str], None] = None
Comment thread
aniebietafia marked this conversation as resolved.
Dismissed


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"users",
sa.Column(
"user_role", sa.String(length=50), server_default="user", nullable=False
),
)
op.create_index(op.f("ix_users_user_role"), "users", ["user_role"], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_users_user_role"), table_name="users")
op.drop_column("users", "user_role")
# ### end Alembic commands ###
15 changes: 15 additions & 0 deletions app/auth/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import enum


class UserRole(enum.StrEnum):
ADMIN = "admin"
USER = "user"


class SupportedLanguage(enum.StrEnum):
ENGLISH = "en"
FRENCH = "fr"
GERMAN = "de"
SPANISH = "es"
ITALIAN = "it"
PORTUGUESE = "pt"
9 changes: 9 additions & 0 deletions app/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column

from app.auth.constants import UserRole
from app.models.base import Base


Expand Down Expand Up @@ -41,6 +42,14 @@ class User(Base):
speaking_language: Mapped[str] = mapped_column(String(10), default="en")
listening_language: Mapped[str] = mapped_column(String(10), default="en")

# Role
user_role: Mapped[str] = mapped_column(
String(50),
default=UserRole.USER.value,
server_default=UserRole.USER.value,
index=True,
)
Comment on lines +46 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether role constraints already exist in model + migration.
set -euo pipefail

echo "== Model definition =="
rg -n -C3 "user_role|CheckConstraint|Enum\\(" app/auth/models.py

echo
echo "== Migration(s) touching user_role =="
fd -i "add_user_role_column" alembic/versions || true
rg -n -C3 "user_role|CHECK|Enum|create_check_constraint|sa\\.Enum" alembic/versions

Repository: Brints/FluentMeet

Length of output: 2185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== UserRole enum definition =="
fd "\.py$" app/auth | xargs rg -l "UserRole" | head -5

echo
echo "== Searching for UserRole enum =="
rg -n "class UserRole" -A 10 app/

echo
echo "== Checking for validators in User model =="
rg -n "@validator|@field_validator|def __init__|def validate" app/auth/models.py -A 3

echo
echo "== Full User model to check for constraints =="
head -60 app/auth/models.py

Repository: Brints/FluentMeet

Length of output: 683


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Searching for schema validation (Pydantic/Marshmallow) =="
find app/auth -name "*.py" -exec grep -l "schema\|Schema\|Pydantic\|BaseModel" {} \;

echo
echo "== Checking app/auth directory structure =="
ls -la app/auth/

echo
echo "== Searching for user_role in routes/services =="
rg -n "user_role" app/ --type py -B 2 -A 2 | head -50

echo
echo "== Check if User model has __table_args__ =="
rg -n "__table_args__" app/auth/models.py -A 5

Repository: Brints/FluentMeet

Length of output: 2485


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Full User model =="
cat app/auth/models.py

echo
echo "== Service layer user_role operations =="
rg -n "user_role\s*=" app/auth/service.py -B 3 -A 3

Repository: Brints/FluentMeet

Length of output: 2982


Constrain user_role values at the database layer.

user_role is currently a free-form String(50) with no database constraints. As an authorization-critical field, this should be constrained (CHECK constraint or enum type) to prevent invalid persisted roles. Currently, the field:

  • Uses plain String(50) in SQLAlchemy (not Enum type)
  • Has no CheckConstraint or database-level validation in the migration
  • Has no validation in Pydantic schemas (user_role: str without constraints)
  • Has no model-level validation (no setter, property, or validator)

This allows arbitrary strings to be written to the database, bypassing the UserRole enum definition that only exists in application code.

🔒 Suggested direction (DB constraint)
-from sqlalchemy import Boolean, DateTime, ForeignKey, String
+from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, String
@@
 class User(Base):
     __tablename__ = "users"
+    __table_args__ = (
+        CheckConstraint(
+            "user_role IN ('user', 'admin')",
+            name="ck_users_user_role",
+        ),
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/models.py` around lines 46 - 51, The user_role column currently uses
a free-form String(50) (user_role in the User model) and lacks DB-level
constraints and schema/model validation; change it to a constrained type by
switching to a SQLAlchemy Enum(UserRole) or adding a CheckConstraint that only
allows values from UserRole, update the Alembic migration to ALTER the column to
the enum type or add the CHECK clause and include a downgrade, update Pydantic
schemas to type user_role: UserRole (or a constrained str/Enum) and add a
model-level validator (e.g., `@validates`('user_role') or a property setter) in
the User model to enforce and coerce values before persistence so application
and DB constraints stay in sync.



def default_expiry() -> datetime:
return datetime.now(UTC) + timedelta(hours=24)
Expand Down
4 changes: 2 additions & 2 deletions app/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ async def forgot_password(
)
return ActionAcknowledgement(
message=(
"If an account with that email exists, we have sent "
"password reset instructions."
"If an account with that email exists,"
" we have sent password reset instructions."
)
)

Expand Down
11 changes: 2 additions & 9 deletions app/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
import uuid
from datetime import datetime
from enum import StrEnum

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator


class SupportedLanguage(StrEnum):
ENGLISH = "en"
FRENCH = "fr"
GERMAN = "de"
SPANISH = "es"
ITALIAN = "it"
PORTUGUESE = "pt"
from app.auth.constants import SupportedLanguage


class UserBase(BaseModel):
Expand Down Expand Up @@ -51,6 +43,7 @@ def strip_full_name(cls, value: str | None) -> str | None:

class UserResponse(UserBase):
id: uuid.UUID
user_role: str
is_active: bool
is_verified: bool
created_at: datetime
Expand Down
3 changes: 2 additions & 1 deletion app/auth/token_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import logging

import redis.asyncio as aioredis
from redis.asyncio import Redis

from app.core.config import settings
from app.core.sanitize import sanitize_for_log
Expand All @@ -22,7 +23,7 @@
_REDIS_CLIENT: aioredis.Redis | None = None


def _get_redis_client() -> aioredis.Redis:
def _get_redis_client() -> Redis:
"""Return (and lazily create) a module-level async Redis client."""
global _REDIS_CLIENT # noqa: PLW0603
if _REDIS_CLIENT is None:
Expand Down
Empty file removed app/auth/utils.py
Empty file.
30 changes: 30 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class Settings(BaseSettings):
VERSION: str = get_version()
API_V1_STR: str = "/api/v1"

# Default Admin
ADMIN_EMAIL: str | None = None
ADMIN_PASSWORD: str | None = None

# Security
SECRET_KEY: str = "placeholder_secret_key"
ALGORITHM: str = "HS256"
Expand Down Expand Up @@ -54,6 +58,27 @@ class Settings(BaseSettings):
VOICE_AI_API_KEY: str | None = None
OPENAI_API_KEY: str | None = None

# AI Pipeline — STT (Deepgram)
DEEPGRAM_MODEL: str = "nova-2"
DEEPGRAM_API_URL: str = "https://api.deepgram.com/v1/listen"

# AI Pipeline — Translation (DeepL)
DEEPL_API_URL: str = "https://api-free.deepl.com/v2/translate"

# AI Pipeline — TTS (OpenAI)
OPENAI_TTS_MODEL: str = "tts-1"
OPENAI_TTS_VOICE: str = "alloy"
OPENAI_TTS_API_URL: str = "https://api.openai.com/v1/audio/speech"

# AI Pipeline — TTS (Voice.ai)
VOICEAI_TTS_MODEL: str = "voiceai-tts-multilingual-v1-latest"
VOICEAI_TTS_API_URL: str = "https://dev.voice.ai/api/v1/tts/speech"

# AI Pipeline — Audio Settings
PIPELINE_AUDIO_SAMPLE_RATE: int = 16000
PIPELINE_AUDIO_ENCODING: str = "linear16" # "linear16" or "opus"
ACTIVE_TTS_PROVIDER: str = "openai" # "openai" or "voiceai"

# Mailgun Email Service
MAILGUN_API_KEY: str | None = None
MAILGUN_DOMAIN: str | None = None
Expand All @@ -67,6 +92,11 @@ class Settings(BaseSettings):
CLOUDINARY_MAX_IMAGE_SIZE_MB: int = 5
CLOUDINARY_MAX_VIDEO_SIZE_MB: int = 100

# Room Management
ROOM_CODE: str | None = None
ACCESS_TOKEN: str | None = None
SYSTEM_PATH: str | None = None

# URL used in transactional email links
FRONTEND_BASE_URL: str = "http://localhost:3000"

Expand Down
45 changes: 45 additions & 0 deletions app/core/init_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import logging

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.auth.constants import UserRole
from app.auth.models import User
from app.core.config import settings
from app.core.security import security_service

logger = logging.getLogger(__name__)


def init_admin(db: Session) -> None:
if not settings.ADMIN_EMAIL or not settings.ADMIN_PASSWORD:
logger.info(
"Admin credentials not fully set in .env, skipping admin initialization."
)
return

admin_email = settings.ADMIN_EMAIL.lower()

stmt = select(User).where(User.email == admin_email)
existing_admin = db.execute(stmt).scalar_one_or_none()

if existing_admin:
if existing_admin.user_role != UserRole.ADMIN.value:
existing_admin.user_role = UserRole.ADMIN.value
db.commit()
logger.info("Existing admin user updated with ADMIN role.")
return

logger.info("Creating default admin user: System Admin")

admin_user = User(
email=admin_email,
full_name="System Admin",
hashed_password=security_service.hash_password(settings.ADMIN_PASSWORD),
user_role=UserRole.ADMIN.value,
is_active=True,
is_verified=True,
)
db.add(admin_user)
db.commit()
logger.info("Default admin user created successfully.")
4 changes: 2 additions & 2 deletions app/db/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ def _coerce_sync_url(url: str) -> str:
if "+asyncpg" in url:
fixed = url.replace("+asyncpg", "+psycopg2")
logger.info(
"Replaced async driver 'asyncpg' with sync driver 'psycopg2' "
"in DATABASE_URL."
"Replaced async driver 'asyncpg' with sync"
" driver 'psycopg2' in DATABASE_URL."
)
return fixed
return url
Expand Down
4 changes: 2 additions & 2 deletions app/external_services/cloudinary/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ def _validate_file(
allowed = ", ".join(sorted(allowed_types))
raise FileValidationError(
message=(
f"File type '{content_type}' is not allowed. "
f"Accepted types: {allowed}."
f"File type '{content_type}' is not allowed."
f" Accepted types: {allowed}."
),
code="INVALID_FILE_TYPE",
)
Expand Down
3 changes: 3 additions & 0 deletions app/external_services/deepgram/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from app.external_services.deepgram.service import DeepgramSTTService

__all__ = ["DeepgramSTTService"]
13 changes: 13 additions & 0 deletions app/external_services/deepgram/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Deepgram provider configuration."""

from app.core.config import settings


def get_deepgram_headers() -> dict[str, str]:
"""Return authorization headers for the Deepgram REST API."""
if not settings.DEEPGRAM_API_KEY:
raise RuntimeError("DEEPGRAM_API_KEY is not configured.")
return {
"Authorization": f"Token {settings.DEEPGRAM_API_KEY}",
"Content-Type": "audio/raw",
}
94 changes: 94 additions & 0 deletions app/external_services/deepgram/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Deepgram Speech-to-Text service.

Wraps the Deepgram REST API (/v1/listen) for pre-recorded audio
transcription. Each call sends a single audio chunk and returns
the transcribed text with confidence and detected language.
"""

import logging
import time

import httpx

from app.core.config import settings
from app.external_services.deepgram.config import get_deepgram_headers

logger = logging.getLogger(__name__)


class DeepgramSTTService:
"""Stateless service for converting audio bytes to text via Deepgram."""

def __init__(self, timeout: float = 10.0) -> None:
self._timeout = timeout

async def transcribe(
self,
audio_bytes: bytes,
*,
language: str = "en",
sample_rate: int = 16000,
encoding: str = "linear16",
) -> dict:
"""Send raw audio to Deepgram and return transcription results.

Args:
audio_bytes: Raw audio data (PCM or Opus).
language: ISO 639-1 language hint for the STT model.
sample_rate: Audio sample rate in Hz.
encoding: Audio encoding format (``linear16`` or ``opus``).

Returns:
A dict with keys ``text``, ``confidence``, ``detected_language``.

Raises:
httpx.HTTPStatusError: On non-2xx responses from Deepgram.
"""
headers = get_deepgram_headers()
params = {
"model": settings.DEEPGRAM_MODEL,
"language": language,
"encoding": encoding,
"sample_rate": str(sample_rate),
"punctuate": "true",
"smart_format": "true",
}

start = time.monotonic()
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
settings.DEEPGRAM_API_URL,
headers=headers,
params=params,
content=audio_bytes,
)
response.raise_for_status()

elapsed_ms = (time.monotonic() - start) * 1000
logger.debug("Deepgram STT completed in %.1fms", elapsed_ms)

data = response.json()
# Deepgram response structure:
# results.channels[0].alternatives[0].transcript
channel = data.get("results", {}).get("channels", [{}])[0]
alternative = channel.get("alternatives", [{}])[0]

return {
"text": alternative.get("transcript", ""),
"confidence": alternative.get("confidence", 0.0),
"detected_language": data.get("results", {}).get(
"detected_language", language
),
"latency_ms": round(elapsed_ms, 1),
}


# ── Module-level singleton ────────────────────────────────────────────
_stt_service: DeepgramSTTService | None = None


def get_deepgram_stt_service() -> DeepgramSTTService:
global _stt_service # noqa: PLW0603
if _stt_service is None:
_stt_service = DeepgramSTTService()
return _stt_service
3 changes: 3 additions & 0 deletions app/external_services/deepl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from app.external_services.deepl.service import DeepLTranslationService

__all__ = ["DeepLTranslationService"]
13 changes: 13 additions & 0 deletions app/external_services/deepl/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""DeepL provider configuration."""

from app.core.config import settings


def get_deepl_headers() -> dict[str, str]:
"""Return authorization headers for the DeepL REST API."""
if not settings.DEEPL_API_KEY:
raise RuntimeError("DEEPL_API_KEY is not configured.")
return {
"Authorization": f"DeepL-Auth-Key {settings.DEEPL_API_KEY}",
"Content-Type": "application/json",
}
Loading
Loading