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
44 changes: 44 additions & 0 deletions alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""add_password_reset_tokens_table

Revision ID: 3ead82c29db6
Revises: 21fa4ae8b1cc
Create Date: 2026-07-06 15:40:14.276063

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '3ead82c29db6'
down_revision: Union[str, Sequence[str], None] = '21fa4ae8b1cc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('password_reset_tokens',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash')
)
op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_password_reset_tokens_user_id'), table_name='password_reset_tokens')
op.drop_table('password_reset_tokens')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""add_email_verification_tokens_table
Revision ID: 526b088ddfed
Revises: 3ead82c29db6
Create Date: 2026-07-06 18:13:12.061547
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '526b088ddfed'
down_revision: Union[str, Sequence[str], None] = '3ead82c29db6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('email_verification_tokens',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash')
)
op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_email_verification_tokens_user_id'), table_name='email_verification_tokens')
op.drop_table('email_verification_tokens')
# ### end Alembic commands ###
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ services:
- APP_VERSION=0.1.0
- APP_ENV=development
- DEBUG=true

- SECRET_KEY=${SECRET_KEY}
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=30

- DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD}@db:5432/trading_db

- AI_PROVIDER=openai
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- AI_MODEL=gpt-4o

- ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:8000"]

- RESEND_API_KEY=${RESEND_API_KEY}
- EMAIL_FROM=${EMAIL_FROM}
- FRONTEND_URL=${FRONTEND_URL}
depends_on:
db:
condition: service_healthy
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"httpx>=0.28.1",
"pydantic-settings>=2.14.2",
"pyjwt[crypto]>=2.13.0",
"resend[async]>=2.32.2",
"sqlalchemy>=2.0.51",
]

Expand Down
20 changes: 18 additions & 2 deletions src/ai_trading_discipline_copilot/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Settings(BaseSettings):
refresh_token_expire_days: int = 7
bcrypt_rounds: int = 12
cookie_name: str = "refresh_token"

# Defaults True so cookies always require HTTPS outside local dev.
# Override with COOKIE_SECURE=false in .env for local development only.
cookie_secure: bool = True
Expand Down Expand Up @@ -55,8 +56,16 @@ class Settings(BaseSettings):
openai_api_key: SecretStr = SecretStr("")
ai_model: str = "gpt-4o"

# Email
resend_api_key: SecretStr
email_from: str
frontend_url: str

# CORS
allowed_origins: list[str] = ["http://localhost:3000", "http://localhost:8000"]
allowed_origins: list[str] = [
"http://localhost:3000",
"http://localhost:8000",
]

@field_validator("secret_key")
@classmethod
Expand All @@ -69,24 +78,31 @@ def secret_key_min_length(cls, v: SecretStr) -> SecretStr:
@classmethod
def validate_database_url(cls, v: SecretStr) -> SecretStr:
url_str = v.get_secret_value()

if not url_str.startswith("postgresql+asyncpg://"):
raise ValueError("database_url must start with 'postgresql+asyncpg://'")

from pydantic import TypeAdapter

try:
TypeAdapter(PostgresDsn).validate_python(url_str)
except Exception as e:
raise ValueError(f"Invalid database URL format: {e}") from None

return v

@field_validator("allowed_origins")
@classmethod
def validate_allowed_origins(cls, v: list[str]) -> list[str]:
def validate_allowed_origins(
cls,
v: list[str],
) -> list[str]:
if "*" in v:
raise ValueError(
"Wildcard '*' is not allowed in allowed_origins "
"when credentials are enabled"
)

return v


Expand Down
9 changes: 8 additions & 1 deletion src/ai_trading_discipline_copilot/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# SQLAlchemy ORM models
# Contains: base, user, trade, journal, psychology
from .email_verification_token import EmailVerificationToken
from .password_reset_token import PasswordResetToken
from .refresh_token import RefreshToken
from .user import User

__all__ = ["User", "RefreshToken"]
__all__ = [
"User",
"RefreshToken",
"PasswordResetToken",
"EmailVerificationToken",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Email verification token model."""

from __future__ import annotations

import uuid
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING

from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

from ai_trading_discipline_copilot.core.database import Base

if TYPE_CHECKING:
from .user import User


class EmailVerificationToken(Base):
"""Stores one-time email verification tokens."""

__tablename__ = "email_verification_tokens"

id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)

user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)

token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
unique=True,
)

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)

expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC) + timedelta(hours=24),
nullable=False,
)

used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)

user: Mapped[User] = relationship(
back_populates="email_verification_tokens",
)
62 changes: 62 additions & 0 deletions src/ai_trading_discipline_copilot/models/password_reset_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Password reset token model."""

from __future__ import annotations

import uuid
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING

from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

from ai_trading_discipline_copilot.core.database import Base

if TYPE_CHECKING:
from .user import User


class PasswordResetToken(Base):
"""Stores one-time password reset tokens."""

__tablename__ = "password_reset_tokens"

id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)

user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)

token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
unique=True,
)

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)

expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC) + timedelta(minutes=15),
nullable=False,
)

used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)

user: Mapped[User] = relationship(
back_populates="password_reset_tokens",
)
14 changes: 11 additions & 3 deletions src/ai_trading_discipline_copilot/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
relationship,
)

from ai_trading_discipline_copilot.core.database import Base

if TYPE_CHECKING:
from .email_verification_token import EmailVerificationToken
from .password_reset_token import PasswordResetToken
from .refresh_token import RefreshToken
from ai_trading_discipline_copilot.core.database import Base


class UserRole(enum.StrEnum):
Expand Down Expand Up @@ -60,7 +61,14 @@ class User(Base):
default=UserRole.USER,
nullable=False,
)

password_reset_tokens: Mapped[list[PasswordResetToken]] = relationship(
back_populates="user",
cascade="all, delete-orphan",
)
email_verification_tokens: Mapped[list[EmailVerificationToken]] = relationship(
back_populates="user",
cascade="all, delete-orphan",
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
Expand Down
Loading