Skip to content

Database layer

SQLModel-based database layer for BeCoMe API.

Tables

Table Description
users User accounts with authentication data
projects Decision-making projects with scale configuration
project_members Many-to-many: users ↔ projects with roles
invitations Per-user invitations to join a project (unique per project+invitee)
expert_opinions Fuzzy triangular numbers from experts (unique per user+project)
calculation_results Cached BeCoMe calculation results. Holds the numbers only: the Likert agreement verdict is derived from the project scale on read (api/services/likert_verdict.py), never stored, so it cannot drift from the project
password_reset_tokens Tokens for password reset via email
email_verification_tokens Activation tokens for the email verification flow, stored as SHA-256 hashes

Entity relationships

users (1:N) ──► projects (admin ownership)
      │
      ├─(N:M)─► project_members ◄─(N:M)─ projects
      │
      ├─(1:N)─► expert_opinions ◄─(N:1)─ projects
      │
      ├─(1:N)─► password_reset_tokens
      │
      ├─(1:N)─► email_verification_tokens
      │
      └─(1:N)─► invitations (invitee_id, inviter_id) ◄─(N:1)─ projects

projects (1:1) ──► calculation_results

Usage

Creating tables

from api.db.engine import create_db_and_tables

create_db_and_tables()

On SQLite (local development and the test suite), the FastAPI lifespan hook calls create_db_and_tables() at startup. Alembic owns the deployed PostgreSQL schemas instead: the migrations live in migrations/ and run before each Railway deploy, and create_db_and_tables() returns without doing anything there. For how schema management works, see Environments.

Session dependency

from fastapi import Depends
from sqlmodel import Session

from api.db.session import get_session


@app.get("/users/{user_id}")
def get_user(user_id: UUID, session: Session = Depends(get_session)):
    user = session.get(User, user_id)
    return user

Model examples

from api.db.models import User, Project, ExpertOpinion, MemberRole

# Create user
user = User(
    email="[email protected]",
    hashed_password="$2b$12$...",
    first_name="John",
    last_name="Doe",
)

# Create project
project = Project(
    name="Budget Allocation 2024",
    description="Annual budget distribution",
    admin_id=user.id,
    scale_min=0.0,
    scale_max=100.0,
    scale_unit="%",
)

# Add expert opinion (fuzzy triangular number)
opinion = ExpertOpinion(
    project_id=project.id,
    user_id=user.id,
    position="Financial Analyst",
    lower_bound=30.0,  # pessimistic
    peak=45.0,  # most likely
    upper_bound=60.0,  # optimistic
)

Configuration

Set the database URL with the DATABASE_URL environment variable:

# Local development: the PostgreSQL from docker/docker-compose.yml
#   docker compose -f docker/docker-compose.yml up -d db
DATABASE_URL=postgresql://become:become@localhost:5432/become

# Deployed environments: the least-privilege become_app role on the managed instance
DATABASE_URL=postgresql://user:pass@host:5432/become

# Fallback when Docker is not available. Boots, but uses create_all rather than
# Alembic, so it never exercises a migration.
DATABASE_URL=sqlite:///./become.db

File structure

api/db/
├── __init__.py     # Package marker
├── engine.py       # Database engine and table creation
├── models.py       # SQLModel table definitions
├── session.py      # Database session management (get_session)
├── utils.py        # Utilities (utc_now, ensure_utc, EMAIL_REGEX)
└── README.md       # This file