From 82c4d0168e5680d3e3106dc3ef0f4d921b29db26 Mon Sep 17 00:00:00 2001 From: counterweight Date: Fri, 26 Dec 2025 20:04:46 +0100 Subject: [PATCH] refactors --- backend/models.py | 395 ------------------------------ backend/models/__init__.py | 43 ++++ backend/models/associations.py | 36 +++ backend/models/availability.py | 28 +++ backend/models/enums.py | 59 +++++ backend/models/exchange.py | 83 +++++++ backend/models/invite.py | 55 +++++ backend/models/price_history.py | 26 ++ backend/models/role_config.py | 32 +++ backend/models/types.py | 8 + backend/models/user.py | 98 ++++++++ backend/schemas.py | 340 ------------------------- backend/schemas/__init__.py | 88 +++++++ backend/schemas/auth.py | 20 ++ backend/schemas/availability.py | 47 ++++ backend/schemas/exchange.py | 87 +++++++ backend/schemas/invite.py | 48 ++++ backend/schemas/meta.py | 16 ++ backend/schemas/pagination.py | 15 ++ backend/schemas/price.py | 44 ++++ backend/schemas/profile.py | 20 ++ backend/schemas/user.py | 24 ++ backend/seed_e2e.py | 12 +- frontend/e2e/availability.spec.ts | 14 +- frontend/e2e/exchange.spec.ts | 69 +++++- frontend/e2e/helpers/reset-db.ts | 36 ++- frontend/e2e/helpers/setup.ts | 13 +- frontend/e2e/profile.spec.ts | 68 +++-- 28 files changed, 1042 insertions(+), 782 deletions(-) delete mode 100644 backend/models.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/associations.py create mode 100644 backend/models/availability.py create mode 100644 backend/models/enums.py create mode 100644 backend/models/exchange.py create mode 100644 backend/models/invite.py create mode 100644 backend/models/price_history.py create mode 100644 backend/models/role_config.py create mode 100644 backend/models/types.py create mode 100644 backend/models/user.py delete mode 100644 backend/schemas.py create mode 100644 backend/schemas/__init__.py create mode 100644 backend/schemas/auth.py create mode 100644 backend/schemas/availability.py create mode 100644 backend/schemas/exchange.py create mode 100644 backend/schemas/invite.py create mode 100644 backend/schemas/meta.py create mode 100644 backend/schemas/pagination.py create mode 100644 backend/schemas/price.py create mode 100644 backend/schemas/profile.py create mode 100644 backend/schemas/user.py diff --git a/backend/models.py b/backend/models.py deleted file mode 100644 index 3a52d0f..0000000 --- a/backend/models.py +++ /dev/null @@ -1,395 +0,0 @@ -import uuid -from datetime import UTC, date, datetime, time -from enum import Enum as PyEnum -from typing import TypedDict - -from sqlalchemy import ( - Column, - Date, - DateTime, - Enum, - Float, - ForeignKey, - Integer, - String, - Table, - Time, - UniqueConstraint, - select, -) -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from database import Base - - -class RoleConfig(TypedDict): - description: str - permissions: list["Permission"] - - -class Permission(str, PyEnum): - """All available permissions in the system.""" - - # Audit permissions - VIEW_AUDIT = "view_audit" - FETCH_PRICE = "fetch_price" - - # Profile permissions - MANAGE_OWN_PROFILE = "manage_own_profile" - - # Invite permissions - MANAGE_INVITES = "manage_invites" - VIEW_OWN_INVITES = "view_own_invites" - - # Exchange permissions (regular users) - CREATE_EXCHANGE = "create_exchange" - VIEW_OWN_EXCHANGES = "view_own_exchanges" - CANCEL_OWN_EXCHANGE = "cancel_own_exchange" - - # Availability/Exchange permissions (admin) - MANAGE_AVAILABILITY = "manage_availability" - VIEW_ALL_EXCHANGES = "view_all_exchanges" - CANCEL_ANY_EXCHANGE = "cancel_any_exchange" - COMPLETE_EXCHANGE = "complete_exchange" - - -class InviteStatus(str, PyEnum): - """Status of an invite.""" - - READY = "ready" - SPENT = "spent" - REVOKED = "revoked" - - -class ExchangeStatus(str, PyEnum): - """Status of an exchange trade.""" - - BOOKED = "booked" - COMPLETED = "completed" - CANCELLED_BY_USER = "cancelled_by_user" - CANCELLED_BY_ADMIN = "cancelled_by_admin" - NO_SHOW = "no_show" - - -class TradeDirection(str, PyEnum): - """Direction of a trade from the user's perspective.""" - - BUY = "buy" # User buys BTC, gives EUR - SELL = "sell" # User sells BTC, gets EUR - - -class BitcoinTransferMethod(str, PyEnum): - """Bitcoin transfer method for exchange trades.""" - - ONCHAIN = "onchain" - LIGHTNING = "lightning" - - -# Role name constants -ROLE_ADMIN = "admin" -ROLE_REGULAR = "regular" - -# Role definitions with their permissions -ROLE_DEFINITIONS: dict[str, RoleConfig] = { - ROLE_ADMIN: { - "description": "Administrator with audit/invite/exchange access", - "permissions": [ - Permission.VIEW_AUDIT, - Permission.FETCH_PRICE, - Permission.MANAGE_INVITES, - Permission.MANAGE_AVAILABILITY, - Permission.VIEW_ALL_EXCHANGES, - Permission.CANCEL_ANY_EXCHANGE, - Permission.COMPLETE_EXCHANGE, - ], - }, - ROLE_REGULAR: { - "description": "Regular user with profile, invite, and exchange access", - "permissions": [ - Permission.MANAGE_OWN_PROFILE, - Permission.VIEW_OWN_INVITES, - Permission.CREATE_EXCHANGE, - Permission.VIEW_OWN_EXCHANGES, - Permission.CANCEL_OWN_EXCHANGE, - ], - }, -} - - -# Association table: Role <-> Permission (many-to-many) -role_permissions = Table( - "role_permissions", - Base.metadata, - Column( - "role_id", - Integer, - ForeignKey("roles.id", ondelete="CASCADE"), - primary_key=True, - ), - Column("permission", Enum(Permission), primary_key=True), -) - - -# Association table: User <-> Role (many-to-many) -user_roles = Table( - "user_roles", - Base.metadata, - Column( - "user_id", - Integer, - ForeignKey("users.id", ondelete="CASCADE"), - primary_key=True, - ), - Column( - "role_id", - Integer, - ForeignKey("roles.id", ondelete="CASCADE"), - primary_key=True, - ), -) - - -class Role(Base): - __tablename__ = "roles" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) - description: Mapped[str] = mapped_column(String(255), nullable=True) - - # Relationship to users - users: Mapped[list["User"]] = relationship( - "User", - secondary=user_roles, - back_populates="roles", - ) - - async def get_permissions(self, db: AsyncSession) -> set[Permission]: - """Get all permissions for this role.""" - query = select(role_permissions.c.permission).where( - role_permissions.c.role_id == self.id - ) - result = await db.execute(query) - return {row[0] for row in result.fetchall()} - - async def set_permissions( - self, db: AsyncSession, permissions: list[Permission] - ) -> None: - """Set all permissions for this role (replaces existing).""" - delete_query = role_permissions.delete().where( - role_permissions.c.role_id == self.id - ) - await db.execute(delete_query) - for perm in permissions: - insert_query = role_permissions.insert().values( - role_id=self.id, permission=perm - ) - await db.execute(insert_query) - - -class User(Base): - __tablename__ = "users" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - email: Mapped[str] = mapped_column( - String(255), unique=True, nullable=False, index=True - ) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) - - # Contact details (all optional) - contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) - telegram: Mapped[str | None] = mapped_column(String(64), nullable=True) - signal: Mapped[str | None] = mapped_column(String(64), nullable=True) - nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True) - - # Godfather (who invited this user) - null for seeded/admin users - godfather_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("users.id"), nullable=True - ) - godfather: Mapped["User | None"] = relationship( - "User", - remote_side="User.id", - foreign_keys=[godfather_id], - ) - - # Relationship to roles - roles: Mapped[list[Role]] = relationship( - "Role", - secondary=user_roles, - back_populates="users", - lazy="selectin", - ) - - async def get_permissions(self, db: AsyncSession) -> set[Permission]: - """Get all permissions from all roles in a single query.""" - result = await db.execute( - select(role_permissions.c.permission) - .join(user_roles, role_permissions.c.role_id == user_roles.c.role_id) - .where(user_roles.c.user_id == self.id) - ) - return {row[0] for row in result.fetchall()} - - async def has_permission(self, db: AsyncSession, permission: Permission) -> bool: - """Check if user has a specific permission through any of their roles.""" - permissions = await self.get_permissions(db) - return permission in permissions - - @property - def role_names(self) -> list[str]: - """Get list of role names for API responses.""" - return [role.name for role in self.roles] - - -class Invite(Base): - __tablename__ = "invites" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - identifier: Mapped[str] = mapped_column( - String(64), unique=True, nullable=False, index=True - ) - status: Mapped[InviteStatus] = mapped_column( - Enum(InviteStatus), nullable=False, default=InviteStatus.READY - ) - - # Godfather - the user who owns this invite - godfather_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id"), nullable=False, index=True - ) - godfather: Mapped[User] = relationship( - "User", - foreign_keys=[godfather_id], - lazy="joined", - ) - - # User who used this invite (null until spent) - used_by_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("users.id"), nullable=True - ) - used_by: Mapped[User | None] = relationship( - "User", - foreign_keys=[used_by_id], - lazy="joined", - ) - - # Timestamps - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(UTC) - ) - spent_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - revoked_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - - -class Availability(Base): - """Admin availability slots for booking.""" - - __tablename__ = "availability" - __table_args__ = ( - UniqueConstraint("date", "start_time", name="uq_availability_date_start"), - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - date: Mapped[date] = mapped_column(Date, nullable=False, index=True) - start_time: Mapped[time] = mapped_column(Time, nullable=False) - end_time: Mapped[time] = mapped_column(Time, nullable=False) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(UTC) - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.now(UTC), - onupdate=lambda: datetime.now(UTC), - ) - - -class PriceHistory(Base): - """Price history records from external exchanges.""" - - __tablename__ = "price_history" - __table_args__ = ( - UniqueConstraint("source", "pair", "timestamp", name="uq_price_source_pair_ts"), - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - source: Mapped[str] = mapped_column(String(50), nullable=False, index=True) - pair: Mapped[str] = mapped_column(String(20), nullable=False) - price: Mapped[float] = mapped_column(Float, nullable=False) - timestamp: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, index=True - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(UTC) - ) - - -class Exchange(Base): - """Bitcoin exchange trades booked by users.""" - - __tablename__ = "exchanges" - # Note: No unique constraint on slot_start to allow cancelled bookings - # to be replaced. Application-level check in create_exchange ensures only - # one BOOKED trade per slot. For existing databases, manually drop the - # constraint: ALTER TABLE exchanges DROP CONSTRAINT IF EXISTS - # uq_exchange_slot_start; - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - public_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - nullable=False, - unique=True, - index=True, - default=uuid.uuid4, - ) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id"), nullable=False, index=True - ) - user: Mapped[User] = relationship("User", foreign_keys=[user_id], lazy="joined") - - # Slot timing - slot_start: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, index=True - ) - slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - - # Trade details - direction: Mapped[TradeDirection] = mapped_column( - Enum(TradeDirection), nullable=False - ) - bitcoin_transfer_method: Mapped[BitcoinTransferMethod] = mapped_column( - Enum(BitcoinTransferMethod), - nullable=False, - default=BitcoinTransferMethod.ONCHAIN, - ) - eur_amount: Mapped[int] = mapped_column(Integer, nullable=False) # EUR cents - sats_amount: Mapped[int] = mapped_column(Integer, nullable=False) # Satoshis - - # Price information (snapshot at booking time) - market_price_eur: Mapped[float] = mapped_column( - Float, nullable=False - ) # EUR per BTC - agreed_price_eur: Mapped[float] = mapped_column( - Float, nullable=False - ) # EUR per BTC with premium - premium_percentage: Mapped[int] = mapped_column( - Integer, nullable=False - ) # e.g. 5 for 5% - - # Status - status: Mapped[ExchangeStatus] = mapped_column( - Enum(ExchangeStatus), nullable=False, default=ExchangeStatus.BOOKED - ) - - # Timestamps - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(UTC) - ) - cancelled_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - completed_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..049018b --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,43 @@ +# Export all enums +# Export association tables +from .associations import role_permissions, user_roles + +# Export models +from .availability import Availability +from .enums import ( + BitcoinTransferMethod, + ExchangeStatus, + InviteStatus, + Permission, + TradeDirection, +) +from .exchange import Exchange +from .invite import Invite +from .price_history import PriceHistory + +# Export role configuration +from .role_config import ROLE_ADMIN, ROLE_DEFINITIONS, ROLE_REGULAR + +# Export types +from .types import RoleConfig +from .user import Role, User + +__all__ = [ + "ROLE_ADMIN", + "ROLE_DEFINITIONS", + "ROLE_REGULAR", + "Availability", + "BitcoinTransferMethod", + "Exchange", + "ExchangeStatus", + "Invite", + "InviteStatus", + "Permission", + "PriceHistory", + "Role", + "RoleConfig", + "TradeDirection", + "User", + "role_permissions", + "user_roles", +] diff --git a/backend/models/associations.py b/backend/models/associations.py new file mode 100644 index 0000000..747124c --- /dev/null +++ b/backend/models/associations.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Enum, ForeignKey, Integer, Table + +from database import Base + +from .enums import Permission + +# Association table: Role <-> Permission (many-to-many) +role_permissions = Table( + "role_permissions", + Base.metadata, + Column( + "role_id", + Integer, + ForeignKey("roles.id", ondelete="CASCADE"), + primary_key=True, + ), + Column("permission", Enum(Permission), primary_key=True), +) + +# Association table: User <-> Role (many-to-many) +user_roles = Table( + "user_roles", + Base.metadata, + Column( + "user_id", + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "role_id", + Integer, + ForeignKey("roles.id", ondelete="CASCADE"), + primary_key=True, + ), +) diff --git a/backend/models/availability.py b/backend/models/availability.py new file mode 100644 index 0000000..485ed7d --- /dev/null +++ b/backend/models/availability.py @@ -0,0 +1,28 @@ +from datetime import UTC, date, datetime, time + +from sqlalchemy import Date, DateTime, Integer, Time, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base + + +class Availability(Base): + """Admin availability slots for booking.""" + + __tablename__ = "availability" + __table_args__ = ( + UniqueConstraint("date", "start_time", name="uq_availability_date_start"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + start_time: Mapped[time] = mapped_column(Time, nullable=False) + end_time: Mapped[time] = mapped_column(Time, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/backend/models/enums.py b/backend/models/enums.py new file mode 100644 index 0000000..57d3e64 --- /dev/null +++ b/backend/models/enums.py @@ -0,0 +1,59 @@ +from enum import Enum as PyEnum + + +class Permission(str, PyEnum): + """All available permissions in the system.""" + + # Audit permissions + VIEW_AUDIT = "view_audit" + FETCH_PRICE = "fetch_price" + + # Profile permissions + MANAGE_OWN_PROFILE = "manage_own_profile" + + # Invite permissions + MANAGE_INVITES = "manage_invites" + VIEW_OWN_INVITES = "view_own_invites" + + # Exchange permissions (regular users) + CREATE_EXCHANGE = "create_exchange" + VIEW_OWN_EXCHANGES = "view_own_exchanges" + CANCEL_OWN_EXCHANGE = "cancel_own_exchange" + + # Availability/Exchange permissions (admin) + MANAGE_AVAILABILITY = "manage_availability" + VIEW_ALL_EXCHANGES = "view_all_exchanges" + CANCEL_ANY_EXCHANGE = "cancel_any_exchange" + COMPLETE_EXCHANGE = "complete_exchange" + + +class InviteStatus(str, PyEnum): + """Status of an invite.""" + + READY = "ready" + SPENT = "spent" + REVOKED = "revoked" + + +class ExchangeStatus(str, PyEnum): + """Status of an exchange trade.""" + + BOOKED = "booked" + COMPLETED = "completed" + CANCELLED_BY_USER = "cancelled_by_user" + CANCELLED_BY_ADMIN = "cancelled_by_admin" + NO_SHOW = "no_show" + + +class TradeDirection(str, PyEnum): + """Direction of a trade from the user's perspective.""" + + BUY = "buy" # User buys BTC, gives EUR + SELL = "sell" # User sells BTC, gets EUR + + +class BitcoinTransferMethod(str, PyEnum): + """Bitcoin transfer method for exchange trades.""" + + ONCHAIN = "onchain" + LIGHTNING = "lightning" diff --git a/backend/models/exchange.py b/backend/models/exchange.py new file mode 100644 index 0000000..3bc3bb5 --- /dev/null +++ b/backend/models/exchange.py @@ -0,0 +1,83 @@ +import uuid +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from database import Base + +from .enums import BitcoinTransferMethod, ExchangeStatus, TradeDirection + +if TYPE_CHECKING: + from .user import User + + +class Exchange(Base): + """Bitcoin exchange trades booked by users.""" + + __tablename__ = "exchanges" + # Note: No unique constraint on slot_start to allow cancelled bookings + # to be replaced. Application-level check in create_exchange ensures only + # one BOOKED trade per slot. For existing databases, manually drop the + # constraint: ALTER TABLE exchanges DROP CONSTRAINT IF EXISTS + # uq_exchange_slot_start; + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + public_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + unique=True, + index=True, + default=uuid.uuid4, + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="joined") + + # Slot timing + slot_start: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + # Trade details + direction: Mapped[TradeDirection] = mapped_column( + Enum(TradeDirection), nullable=False + ) + bitcoin_transfer_method: Mapped[BitcoinTransferMethod] = mapped_column( + Enum(BitcoinTransferMethod), + nullable=False, + default=BitcoinTransferMethod.ONCHAIN, + ) + eur_amount: Mapped[int] = mapped_column(Integer, nullable=False) # EUR cents + sats_amount: Mapped[int] = mapped_column(Integer, nullable=False) # Satoshis + + # Price information (snapshot at booking time) + market_price_eur: Mapped[float] = mapped_column( + Float, nullable=False + ) # EUR per BTC + agreed_price_eur: Mapped[float] = mapped_column( + Float, nullable=False + ) # EUR per BTC with premium + premium_percentage: Mapped[int] = mapped_column( + Integer, nullable=False + ) # e.g. 5 for 5% + + # Status + status: Mapped[ExchangeStatus] = mapped_column( + Enum(ExchangeStatus), nullable=False, default=ExchangeStatus.BOOKED + ) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + cancelled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/models/invite.py b/backend/models/invite.py new file mode 100644 index 0000000..8b3b125 --- /dev/null +++ b/backend/models/invite.py @@ -0,0 +1,55 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from database import Base + +from .enums import InviteStatus + +if TYPE_CHECKING: + from .user import User + + +class Invite(Base): + __tablename__ = "invites" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + identifier: Mapped[str] = mapped_column( + String(64), unique=True, nullable=False, index=True + ) + status: Mapped[InviteStatus] = mapped_column( + Enum(InviteStatus), nullable=False, default=InviteStatus.READY + ) + + # Godfather - the user who owns this invite + godfather_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + godfather: Mapped["User"] = relationship( + "User", + foreign_keys=[godfather_id], + lazy="joined", + ) + + # User who used this invite (null until spent) + used_by_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + used_by: Mapped["User | None"] = relationship( + "User", + foreign_keys=[used_by_id], + lazy="joined", + ) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + spent_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/models/price_history.py b/backend/models/price_history.py new file mode 100644 index 0000000..6e971f1 --- /dev/null +++ b/backend/models/price_history.py @@ -0,0 +1,26 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base + + +class PriceHistory(Base): + """Price history records from external exchanges.""" + + __tablename__ = "price_history" + __table_args__ = ( + UniqueConstraint("source", "pair", "timestamp", name="uq_price_source_pair_ts"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + source: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + pair: Mapped[str] = mapped_column(String(20), nullable=False) + price: Mapped[float] = mapped_column(Float, nullable=False) + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) diff --git a/backend/models/role_config.py b/backend/models/role_config.py new file mode 100644 index 0000000..d4cd0f7 --- /dev/null +++ b/backend/models/role_config.py @@ -0,0 +1,32 @@ +from .enums import Permission +from .types import RoleConfig + +# Role name constants +ROLE_ADMIN = "admin" +ROLE_REGULAR = "regular" + +# Role definitions with their permissions +ROLE_DEFINITIONS: dict[str, RoleConfig] = { + ROLE_ADMIN: { + "description": "Administrator with audit/invite/exchange access", + "permissions": [ + Permission.VIEW_AUDIT, + Permission.FETCH_PRICE, + Permission.MANAGE_INVITES, + Permission.MANAGE_AVAILABILITY, + Permission.VIEW_ALL_EXCHANGES, + Permission.CANCEL_ANY_EXCHANGE, + Permission.COMPLETE_EXCHANGE, + ], + }, + ROLE_REGULAR: { + "description": "Regular user with profile, invite, and exchange access", + "permissions": [ + Permission.MANAGE_OWN_PROFILE, + Permission.VIEW_OWN_INVITES, + Permission.CREATE_EXCHANGE, + Permission.VIEW_OWN_EXCHANGES, + Permission.CANCEL_OWN_EXCHANGE, + ], + }, +} diff --git a/backend/models/types.py b/backend/models/types.py new file mode 100644 index 0000000..5ede560 --- /dev/null +++ b/backend/models/types.py @@ -0,0 +1,8 @@ +from typing import TypedDict + +from .enums import Permission + + +class RoleConfig(TypedDict): + description: str + permissions: list[Permission] diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..79f1c65 --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,98 @@ +from sqlalchemy import ForeignKey, Integer, String, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from database import Base + +from .associations import role_permissions, user_roles +from .enums import Permission + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[str] = mapped_column(String(255), nullable=True) + + # Relationship to users + users: Mapped[list["User"]] = relationship( + "User", + secondary=user_roles, + back_populates="roles", + ) + + async def get_permissions(self, db: AsyncSession) -> set[Permission]: + """Get all permissions for this role.""" + query = select(role_permissions.c.permission).where( + role_permissions.c.role_id == self.id + ) + result = await db.execute(query) + return {row[0] for row in result.fetchall()} + + async def set_permissions( + self, db: AsyncSession, permissions: list[Permission] + ) -> None: + """Set all permissions for this role (replaces existing).""" + delete_query = role_permissions.delete().where( + role_permissions.c.role_id == self.id + ) + await db.execute(delete_query) + for perm in permissions: + insert_query = role_permissions.insert().values( + role_id=self.id, permission=perm + ) + await db.execute(insert_query) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column( + String(255), unique=True, nullable=False, index=True + ) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + + # Contact details (all optional) + contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + telegram: Mapped[str | None] = mapped_column(String(64), nullable=True) + signal: Mapped[str | None] = mapped_column(String(64), nullable=True) + nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True) + + # Godfather (who invited this user) - null for seeded/admin users + godfather_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + godfather: Mapped["User | None"] = relationship( + "User", + remote_side="User.id", + foreign_keys=[godfather_id], + ) + + # Relationship to roles + roles: Mapped[list[Role]] = relationship( + "Role", + secondary=user_roles, + back_populates="users", + lazy="selectin", + ) + + async def get_permissions(self, db: AsyncSession) -> set[Permission]: + """Get all permissions from all roles in a single query.""" + result = await db.execute( + select(role_permissions.c.permission) + .join(user_roles, role_permissions.c.role_id == user_roles.c.role_id) + .where(user_roles.c.user_id == self.id) + ) + return {row[0] for row in result.fetchall()} + + async def has_permission(self, db: AsyncSession, permission: Permission) -> bool: + """Check if user has a specific permission through any of their roles.""" + permissions = await self.get_permissions(db) + return permission in permissions + + @property + def role_names(self) -> list[str]: + """Get list of role names for API responses.""" + return [role.name for role in self.roles] diff --git a/backend/schemas.py b/backend/schemas.py deleted file mode 100644 index 57b77de..0000000 --- a/backend/schemas.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Pydantic schemas for API request/response models.""" - -from datetime import date, datetime, time -from typing import Generic, TypeVar - -from pydantic import BaseModel, EmailStr, field_validator - -from models import BitcoinTransferMethod, InviteStatus, Permission - - -class UserCredentials(BaseModel): - """Base model for user email/password.""" - - email: EmailStr - password: str - - -UserCreate = UserCredentials -UserLogin = UserCredentials - - -class UserResponse(BaseModel): - """Response model for authenticated user info.""" - - id: int - email: str - roles: list[str] - permissions: list[str] - - -class RegisterWithInvite(BaseModel): - """Request model for registration with invite.""" - - email: EmailStr - password: str - invite_identifier: str - - -RecordT = TypeVar("RecordT", bound=BaseModel) - - -class PaginatedResponse(BaseModel, Generic[RecordT]): - """Generic paginated response wrapper.""" - - records: list[RecordT] - total: int - page: int - per_page: int - total_pages: int - - -class ProfileResponse(BaseModel): - """Response model for profile data.""" - - contact_email: str | None - telegram: str | None - signal: str | None - nostr_npub: str | None - godfather_email: str | None = None - - -class ProfileUpdate(BaseModel): - """Request model for updating profile.""" - - contact_email: str | None = None - telegram: str | None = None - signal: str | None = None - nostr_npub: str | None = None - - -class InviteCheckResponse(BaseModel): - """Response for invite check endpoint.""" - - valid: bool - status: str | None = None - error: str | None = None - - -class InviteCreate(BaseModel): - """Request model for creating an invite.""" - - godfather_id: int - - -class InviteResponse(BaseModel): - """Response model for invite data (admin view).""" - - id: int - identifier: str - godfather_id: int - godfather_email: str - status: str - used_by_id: int | None - used_by_email: str | None - created_at: datetime - spent_at: datetime | None - revoked_at: datetime | None - - -class UserInviteResponse(BaseModel): - """Response model for a user's invite (simpler than admin view).""" - - id: int - identifier: str - status: str - used_by_email: str | None - created_at: datetime - spent_at: datetime | None - - -PaginatedInviteRecords = PaginatedResponse[InviteResponse] - - -class AdminUserResponse(BaseModel): - """Minimal user info for admin dropdowns.""" - - id: int - email: str - - -# ============================================================================= -# Availability Schemas -# ============================================================================= - - -class TimeSlot(BaseModel): - """A single time slot (start and end time).""" - - start_time: time - end_time: time - - @field_validator("start_time", "end_time") - @classmethod - def validate_15min_boundary(cls, v: time) -> time: - """Ensure times are on 15-minute boundaries.""" - if v.minute not in (0, 15, 30, 45): - raise ValueError("Time must be on 15-minute boundary (:00, :15, :30, :45)") - if v.second != 0 or v.microsecond != 0: - raise ValueError("Time must not have seconds or microseconds") - return v - - -class AvailabilityDay(BaseModel): - """Availability for a single day.""" - - date: date - slots: list[TimeSlot] - - -class AvailabilityResponse(BaseModel): - """Response model for availability query.""" - - days: list[AvailabilityDay] - - -class SetAvailabilityRequest(BaseModel): - """Request to set availability for a specific date.""" - - date: date - slots: list[TimeSlot] - - -class CopyAvailabilityRequest(BaseModel): - """Request to copy availability from one day to others.""" - - source_date: date - target_dates: list[date] - - -# ============================================================================= -# Booking Schemas -# ============================================================================= - - -# ============================================================================= -# Price History Schemas -# ============================================================================= - - -class PriceHistoryResponse(BaseModel): - """Response model for a price history record.""" - - id: int - source: str - pair: str - price: float - timestamp: datetime - created_at: datetime - - -# ============================================================================= -# Exchange Schemas -# ============================================================================= - - -class ExchangeRequest(BaseModel): - """Request to create an exchange trade.""" - - slot_start: datetime - direction: str # "buy" or "sell" - bitcoin_transfer_method: str # "onchain" or "lightning" - eur_amount: int # EUR cents (e.g., 10000 = €100) - - -class ExchangeResponse(BaseModel): - """Response model for an exchange trade.""" - - id: int # Keep for backward compatibility, but prefer public_id - public_id: str # UUID as string - user_id: int - user_email: str - slot_start: datetime - slot_end: datetime - direction: str - bitcoin_transfer_method: str - eur_amount: int # EUR cents - sats_amount: int # Satoshis - market_price_eur: float - agreed_price_eur: float - premium_percentage: int - status: str - created_at: datetime - cancelled_at: datetime | None - completed_at: datetime | None - - -class ExchangeUserContact(BaseModel): - """User contact info for admin view.""" - - email: str - contact_email: str | None - telegram: str | None - signal: str | None - nostr_npub: str | None - - -class AdminExchangeResponse(BaseModel): - """Response model for admin exchange view (includes user contact).""" - - id: int # Keep for backward compatibility, but prefer public_id - public_id: str # UUID as string - user_id: int - user_email: str - user_contact: ExchangeUserContact - slot_start: datetime - slot_end: datetime - direction: str - bitcoin_transfer_method: str - eur_amount: int - sats_amount: int - market_price_eur: float - agreed_price_eur: float - premium_percentage: int - status: str - created_at: datetime - cancelled_at: datetime | None - completed_at: datetime | None - - -PaginatedExchanges = PaginatedResponse[ExchangeResponse] -PaginatedAdminExchanges = PaginatedResponse[AdminExchangeResponse] - - -# ============================================================================= -# Meta/Constants Schemas -# ============================================================================= - - -class ConstantsResponse(BaseModel): - """Response model for shared constants. - - Note: Using actual enum types ensures OpenAPI schema includes enum values, - allowing frontend type generation to produce matching TypeScript enums. - """ - - permissions: list[Permission] - roles: list[str] - invite_statuses: list[InviteStatus] - bitcoin_transfer_methods: list[BitcoinTransferMethod] - - -# ============================================================================= -# Exchange Price/Config Schemas -# ============================================================================= - - -class ExchangeConfigResponse(BaseModel): - """Exchange configuration for the frontend.""" - - eur_min: int - eur_max: int - eur_increment: int - premium_percentage: int - - -class PriceResponse(BaseModel): - """Current BTC/EUR price for trading. - - Note: The actual agreed price depends on trade direction (buy/sell) - and is calculated by the frontend using market_price and premium_percentage. - """ - - market_price: float # Raw price from exchange - premium_percentage: int - timestamp: datetime - is_stale: bool - - -class ExchangePriceResponse(BaseModel): - """Combined price and configuration response.""" - - price: PriceResponse | None # None if price fetch failed - config: ExchangeConfigResponse - error: str | None = None - - -class BookableSlot(BaseModel): - """A single bookable time slot.""" - - start_time: datetime - end_time: datetime - - -class AvailableSlotsResponse(BaseModel): - """Response containing available slots for a date.""" - - date: date - slots: list[BookableSlot] - - -# ============================================================================= -# Admin User Search Schemas -# ============================================================================= - - -class UserSearchResult(BaseModel): - """Result item for user search.""" - - id: int - email: str diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..48081b0 --- /dev/null +++ b/backend/schemas/__init__.py @@ -0,0 +1,88 @@ +# Export pagination +# Export auth schemas +from .auth import RegisterWithInvite, UserCreate, UserCredentials, UserLogin + +# Export availability schemas +from .availability import ( + AvailabilityDay, + AvailabilityResponse, + CopyAvailabilityRequest, + SetAvailabilityRequest, + TimeSlot, +) + +# Export exchange schemas +from .exchange import ( + AdminExchangeResponse, + AvailableSlotsResponse, + BookableSlot, + ExchangeRequest, + ExchangeResponse, + ExchangeUserContact, + PaginatedAdminExchanges, + PaginatedExchanges, +) + +# Export invite schemas +from .invite import ( + InviteCheckResponse, + InviteCreate, + InviteResponse, + PaginatedInviteRecords, + UserInviteResponse, +) + +# Export meta schemas +from .meta import ConstantsResponse +from .pagination import PaginatedResponse, RecordT + +# Export price schemas +from .price import ( + ExchangeConfigResponse, + ExchangePriceResponse, + PriceHistoryResponse, + PriceResponse, +) + +# Export profile schemas +from .profile import ProfileResponse, ProfileUpdate + +# Export user schemas +from .user import AdminUserResponse, UserResponse, UserSearchResult + +__all__ = [ + "AdminExchangeResponse", + "AdminUserResponse", + "AvailabilityDay", + "AvailabilityResponse", + "AvailableSlotsResponse", + "BookableSlot", + "ConstantsResponse", + "CopyAvailabilityRequest", + "ExchangeConfigResponse", + "ExchangePriceResponse", + "ExchangeRequest", + "ExchangeResponse", + "ExchangeUserContact", + "InviteCheckResponse", + "InviteCreate", + "InviteResponse", + "PaginatedAdminExchanges", + "PaginatedExchanges", + "PaginatedInviteRecords", + "PaginatedResponse", + "PriceHistoryResponse", + "PriceResponse", + "ProfileResponse", + "ProfileUpdate", + "RecordT", + "RegisterWithInvite", + "SetAvailabilityRequest", + "TimeSlot", + "UserCreate", + "UserCredentials", + "UserInviteResponse", + "UserLogin", + "UserResponse", + "UserSearchResult", +] diff --git a/backend/schemas/auth.py b/backend/schemas/auth.py new file mode 100644 index 0000000..8533a7f --- /dev/null +++ b/backend/schemas/auth.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, EmailStr + + +class UserCredentials(BaseModel): + """Base model for user email/password.""" + + email: EmailStr + password: str + + +UserCreate = UserCredentials +UserLogin = UserCredentials + + +class RegisterWithInvite(BaseModel): + """Request model for registration with invite.""" + + email: EmailStr + password: str + invite_identifier: str diff --git a/backend/schemas/availability.py b/backend/schemas/availability.py new file mode 100644 index 0000000..9b52498 --- /dev/null +++ b/backend/schemas/availability.py @@ -0,0 +1,47 @@ +from datetime import date, time + +from pydantic import BaseModel, field_validator + + +class TimeSlot(BaseModel): + """A single time slot (start and end time).""" + + start_time: time + end_time: time + + @field_validator("start_time", "end_time") + @classmethod + def validate_15min_boundary(cls, v: time) -> time: + """Ensure times are on 15-minute boundaries.""" + if v.minute not in (0, 15, 30, 45): + raise ValueError("Time must be on 15-minute boundary (:00, :15, :30, :45)") + if v.second != 0 or v.microsecond != 0: + raise ValueError("Time must not have seconds or microseconds") + return v + + +class AvailabilityDay(BaseModel): + """Availability for a single day.""" + + date: date + slots: list[TimeSlot] + + +class AvailabilityResponse(BaseModel): + """Response model for availability query.""" + + days: list[AvailabilityDay] + + +class SetAvailabilityRequest(BaseModel): + """Request to set availability for a specific date.""" + + date: date + slots: list[TimeSlot] + + +class CopyAvailabilityRequest(BaseModel): + """Request to copy availability from one day to others.""" + + source_date: date + target_dates: list[date] diff --git a/backend/schemas/exchange.py b/backend/schemas/exchange.py new file mode 100644 index 0000000..fc17b03 --- /dev/null +++ b/backend/schemas/exchange.py @@ -0,0 +1,87 @@ +from datetime import date, datetime + +from pydantic import BaseModel + +from .pagination import PaginatedResponse + + +class ExchangeRequest(BaseModel): + """Request to create an exchange trade.""" + + slot_start: datetime + direction: str # "buy" or "sell" + bitcoin_transfer_method: str # "onchain" or "lightning" + eur_amount: int # EUR cents (e.g., 10000 = €100) + + +class ExchangeUserContact(BaseModel): + """User contact info for admin view.""" + + email: str + contact_email: str | None + telegram: str | None + signal: str | None + nostr_npub: str | None + + +class ExchangeResponse(BaseModel): + """Response model for an exchange trade.""" + + id: int # Keep for backward compatibility, but prefer public_id + public_id: str # UUID as string + user_id: int + user_email: str + slot_start: datetime + slot_end: datetime + direction: str + bitcoin_transfer_method: str + eur_amount: int # EUR cents + sats_amount: int # Satoshis + market_price_eur: float + agreed_price_eur: float + premium_percentage: int + status: str + created_at: datetime + cancelled_at: datetime | None + completed_at: datetime | None + + +class AdminExchangeResponse(BaseModel): + """Response model for admin exchange view (includes user contact).""" + + id: int # Keep for backward compatibility, but prefer public_id + public_id: str # UUID as string + user_id: int + user_email: str + user_contact: ExchangeUserContact + slot_start: datetime + slot_end: datetime + direction: str + bitcoin_transfer_method: str + eur_amount: int + sats_amount: int + market_price_eur: float + agreed_price_eur: float + premium_percentage: int + status: str + created_at: datetime + cancelled_at: datetime | None + completed_at: datetime | None + + +PaginatedExchanges = PaginatedResponse[ExchangeResponse] +PaginatedAdminExchanges = PaginatedResponse[AdminExchangeResponse] + + +class BookableSlot(BaseModel): + """A single bookable time slot.""" + + start_time: datetime + end_time: datetime + + +class AvailableSlotsResponse(BaseModel): + """Response containing available slots for a date.""" + + date: date + slots: list[BookableSlot] diff --git a/backend/schemas/invite.py b/backend/schemas/invite.py new file mode 100644 index 0000000..6c24c0a --- /dev/null +++ b/backend/schemas/invite.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from pydantic import BaseModel + +from .pagination import PaginatedResponse + + +class InviteCheckResponse(BaseModel): + """Response for invite check endpoint.""" + + valid: bool + status: str | None = None + error: str | None = None + + +class InviteCreate(BaseModel): + """Request model for creating an invite.""" + + godfather_id: int + + +class InviteResponse(BaseModel): + """Response model for invite data (admin view).""" + + id: int + identifier: str + godfather_id: int + godfather_email: str + status: str + used_by_id: int | None + used_by_email: str | None + created_at: datetime + spent_at: datetime | None + revoked_at: datetime | None + + +class UserInviteResponse(BaseModel): + """Response model for a user's invite (simpler than admin view).""" + + id: int + identifier: str + status: str + used_by_email: str | None + created_at: datetime + spent_at: datetime | None + + +PaginatedInviteRecords = PaginatedResponse[InviteResponse] diff --git a/backend/schemas/meta.py b/backend/schemas/meta.py new file mode 100644 index 0000000..b6b6018 --- /dev/null +++ b/backend/schemas/meta.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +from models import BitcoinTransferMethod, InviteStatus, Permission + + +class ConstantsResponse(BaseModel): + """Response model for shared constants. + + Note: Using actual enum types ensures OpenAPI schema includes enum values, + allowing frontend type generation to produce matching TypeScript enums. + """ + + permissions: list[Permission] + roles: list[str] + invite_statuses: list[InviteStatus] + bitcoin_transfer_methods: list[BitcoinTransferMethod] diff --git a/backend/schemas/pagination.py b/backend/schemas/pagination.py new file mode 100644 index 0000000..95ababa --- /dev/null +++ b/backend/schemas/pagination.py @@ -0,0 +1,15 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +RecordT = TypeVar("RecordT", bound=BaseModel) + + +class PaginatedResponse(BaseModel, Generic[RecordT]): + """Generic paginated response wrapper.""" + + records: list[RecordT] + total: int + page: int + per_page: int + total_pages: int diff --git a/backend/schemas/price.py b/backend/schemas/price.py new file mode 100644 index 0000000..e108a6c --- /dev/null +++ b/backend/schemas/price.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class PriceHistoryResponse(BaseModel): + """Response model for a price history record.""" + + id: int + source: str + pair: str + price: float + timestamp: datetime + created_at: datetime + + +class ExchangeConfigResponse(BaseModel): + """Exchange configuration for the frontend.""" + + eur_min: int + eur_max: int + eur_increment: int + premium_percentage: int + + +class PriceResponse(BaseModel): + """Current BTC/EUR price for trading. + + Note: The actual agreed price depends on trade direction (buy/sell) + and is calculated by the frontend using market_price and premium_percentage. + """ + + market_price: float # Raw price from exchange + premium_percentage: int + timestamp: datetime + is_stale: bool + + +class ExchangePriceResponse(BaseModel): + """Combined price and configuration response.""" + + price: PriceResponse | None # None if price fetch failed + config: ExchangeConfigResponse + error: str | None = None diff --git a/backend/schemas/profile.py b/backend/schemas/profile.py new file mode 100644 index 0000000..7586474 --- /dev/null +++ b/backend/schemas/profile.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + + +class ProfileResponse(BaseModel): + """Response model for profile data.""" + + contact_email: str | None + telegram: str | None + signal: str | None + nostr_npub: str | None + godfather_email: str | None = None + + +class ProfileUpdate(BaseModel): + """Request model for updating profile.""" + + contact_email: str | None = None + telegram: str | None = None + signal: str | None = None + nostr_npub: str | None = None diff --git a/backend/schemas/user.py b/backend/schemas/user.py new file mode 100644 index 0000000..30a9f89 --- /dev/null +++ b/backend/schemas/user.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel + + +class UserResponse(BaseModel): + """Response model for authenticated user info.""" + + id: int + email: str + roles: list[str] + permissions: list[str] + + +class AdminUserResponse(BaseModel): + """Minimal user info for admin dropdowns.""" + + id: int + email: str + + +class UserSearchResult(BaseModel): + """Result item for user search.""" + + id: int + email: str diff --git a/backend/seed_e2e.py b/backend/seed_e2e.py index d02e1d8..d2b867d 100644 --- a/backend/seed_e2e.py +++ b/backend/seed_e2e.py @@ -65,9 +65,13 @@ async def seed_base_data(db: AsyncSession) -> None: ) db.add(regular_user) else: - # Update existing user + # Update existing user - clear profile fields for test isolation regular_user.hashed_password = get_password_hash(dev_user_password) regular_user.roles = [regular_role] + regular_user.contact_email = None + regular_user.telegram = None + regular_user.signal = None + regular_user.nostr_npub = None # Create admin dev user admin_user_result = await db.execute( @@ -83,8 +87,12 @@ async def seed_base_data(db: AsyncSession) -> None: ) db.add(admin_user) else: - # Update existing user + # Update existing user - clear profile fields for test isolation admin_user.hashed_password = get_password_hash(dev_admin_password) admin_user.roles = [admin_role] + admin_user.contact_email = None + admin_user.telegram = None + admin_user.signal = None + admin_user.nostr_npub = None await db.commit() diff --git a/frontend/e2e/availability.spec.ts b/frontend/e2e/availability.spec.ts index 1e3ea78..49fca38 100644 --- a/frontend/e2e/availability.spec.ts +++ b/frontend/e2e/availability.spec.ts @@ -65,7 +65,9 @@ test.describe("Availability Page - Admin Access", () => { // Get the testid so we can find the same card later const testId = await dayCardWithNoAvailability.getAttribute("data-testid"); - const targetCard = page.locator(`[data-testid="${testId}"]`); + if (!testId) { + throw new Error("Could not get testid from day card"); + } // First add availability await dayCardWithNoAvailability.click(); @@ -83,8 +85,14 @@ test.describe("Availability Page - Admin Access", () => { await saveGetPromise; await expect(page.getByRole("heading", { name: /Edit Time Slots/ })).not.toBeVisible(); - // Verify slot exists in the specific card we clicked - await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible(); + // Re-query the card after save to avoid stale element references + // React may have re-rendered the entire list, so we need a fresh reference + const targetCard = page.locator(`[data-testid="${testId}"]`); + + // Wait for "No availability" to disappear first, indicating slots have been loaded + await expect(targetCard.getByText("No availability")).not.toBeVisible({ timeout: 10000 }); + // Then verify the specific slot text appears - this ensures the component has re-rendered + await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible({ timeout: 5000 }); // Now clear it - click on the same card using the testid await targetCard.click(); diff --git a/frontend/e2e/exchange.spec.ts b/frontend/e2e/exchange.spec.ts index 28bf323..8d25750 100644 --- a/frontend/e2e/exchange.spec.ts +++ b/frontend/e2e/exchange.spec.ts @@ -10,6 +10,7 @@ import { getBackendUrl } from "./helpers/backend-url"; */ // Set up availability for a date using the API +// Includes retry logic to handle race conditions with database reset async function setAvailability(page: Page, dateStr: string) { const cookies = await page.context().cookies(); const authCookie = cookies.find((c) => c.name === "auth_token"); @@ -18,21 +19,63 @@ async function setAvailability(page: Page, dateStr: string) { throw new Error("No auth cookie found when trying to set availability"); } - const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, { - headers: { - Cookie: `auth_token=${authCookie.value}`, - "Content-Type": "application/json", - }, - data: { - date: dateStr, - slots: [{ start_time: "09:00:00", end_time: "12:00:00" }], - }, - }); + const maxRetries = 3; + let lastError: Error | null = null; - if (!response.ok()) { - const body = await response.text(); - throw new Error(`Failed to set availability: ${response.status()} - ${body}`); + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, { + headers: { + Cookie: `auth_token=${authCookie.value}`, + "Content-Type": "application/json", + }, + data: { + date: dateStr, + slots: [{ start_time: "09:00:00", end_time: "12:00:00" }], + }, + }); + + if (response.ok()) { + // Verify the response indicates success + const body = await response.json(); + if (body.date === dateStr && body.slots?.length > 0) { + return; // Success + } + throw new Error(`Unexpected availability response: ${JSON.stringify(body)}`); + } + + const body = await response.text(); + const error = new Error(`Failed to set availability: ${response.status()} - ${body}`); + + // Don't retry on 4xx errors (client errors), only on 5xx (server errors) + if (response.status() >= 400 && response.status() < 500) { + throw error; + } + + lastError = error; + + // Don't retry on the last attempt + if (attempt < maxRetries - 1) { + // Exponential backoff: 200ms, 400ms, 800ms + const delay = 200 * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry on the last attempt + if (attempt < maxRetries - 1) { + // Exponential backoff: 200ms, 400ms, 800ms + const delay = 200 * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + } } + + // If we get here, all retries failed + throw new Error(`Failed to set availability after ${maxRetries} attempts: ${lastError?.message}`); } test.describe("Exchange Page - Regular User Access", () => { diff --git a/frontend/e2e/helpers/reset-db.ts b/frontend/e2e/helpers/reset-db.ts index eb9894f..70d6792 100644 --- a/frontend/e2e/helpers/reset-db.ts +++ b/frontend/e2e/helpers/reset-db.ts @@ -9,13 +9,41 @@ import { getBackendUrl } from "./backend-url"; /** * Reset the database for the current worker. * Truncates all tables and re-seeds base data. + * Retries up to 3 times with exponential backoff to handle transient failures. */ export async function resetDatabase(request: APIRequestContext): Promise { const backendUrl = getBackendUrl(); - const response = await request.post(`${backendUrl}/api/test/reset`); + const maxRetries = 3; + let lastError: Error | null = null; - if (!response.ok()) { - const text = await response.text(); - throw new Error(`Failed to reset database: ${response.status()} - ${text}`); + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await request.post(`${backendUrl}/api/test/reset`); + + if (response.ok()) { + // Verify the response body indicates success + const body = await response.json(); + if (body.status === "reset") { + return; // Success + } + throw new Error(`Unexpected reset response: ${JSON.stringify(body)}`); + } + + const text = await response.text(); + throw new Error(`Failed to reset database: ${response.status()} - ${text}`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry on the last attempt + if (attempt < maxRetries - 1) { + // Exponential backoff: 100ms, 200ms, 400ms + const delay = 100 * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + } } + + // If we get here, all retries failed + throw new Error(`Failed to reset database after ${maxRetries} attempts: ${lastError?.message}`); } diff --git a/frontend/e2e/helpers/setup.ts b/frontend/e2e/helpers/setup.ts index bf354ab..7321f31 100644 --- a/frontend/e2e/helpers/setup.ts +++ b/frontend/e2e/helpers/setup.ts @@ -22,13 +22,12 @@ test.beforeEach(async ({ context, request }, testInfo) => { process.env.NEXT_PUBLIC_API_URL = backendUrl; // Reset database before each test for isolation - try { - await resetDatabase(request); - } catch (error) { - // If reset fails, log but don't fail the test - // This allows tests to run even if reset endpoint is unavailable - console.warn(`Failed to reset database: ${error}`); - } + // This must complete successfully before tests run to avoid race conditions + await resetDatabase(request); + + // Small delay to ensure database transaction commits are visible + // This prevents race conditions where tests start before reset completes + await new Promise((resolve) => setTimeout(resolve, 100)); // Add init script to set English language before any page loads // This must be called before any page.goto() calls diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts index 4bf20e3..4182945 100644 --- a/frontend/e2e/profile.spec.ts +++ b/frontend/e2e/profile.spec.ts @@ -48,24 +48,43 @@ async function loginUser(page: Page, email: string, password: string) { } // Helper to clear profile data via API +// Verifies the operation succeeds to prevent race conditions async function clearProfileData(page: Page) { const cookies = await page.context().cookies(); const authCookie = cookies.find((c) => c.name === "auth_token"); - if (authCookie) { - await page.request.put(`${getBackendUrl()}/api/profile`, { - headers: { - Cookie: `auth_token=${authCookie.value}`, - "Content-Type": "application/json", - }, - data: { - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }, - }); + if (!authCookie) { + throw new Error("No auth cookie found when trying to clear profile data"); } + + const response = await page.request.put(`${getBackendUrl()}/api/profile`, { + headers: { + Cookie: `auth_token=${authCookie.value}`, + "Content-Type": "application/json", + }, + data: { + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }, + }); + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`Failed to clear profile data: ${response.status()} - ${text}`); + } + + // Verify the response indicates fields were cleared + const body = await response.json(); + if (body.telegram !== null && body.telegram !== undefined && body.telegram !== "") { + throw new Error( + `Profile data not cleared properly. Telegram still has value: ${body.telegram}` + ); + } + + // Small delay to ensure database commit is visible to subsequent operations + await new Promise((resolve) => setTimeout(resolve, 100)); } test.describe("Profile - Regular User Access", () => { @@ -122,14 +141,23 @@ test.describe("Profile - Form Behavior", () => { }); await clearAuth(page); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); - // Clear any existing profile data + // Clear any existing profile data and verify it's cleared await clearProfileData(page); + // Navigate to profile page to verify it's actually cleared + await page.goto("/profile"); + // Wait for page to load and verify fields are empty + await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 }); + await expect(page.getByLabel("Telegram")).toHaveValue(""); + await expect(page.getByLabel("Signal")).toHaveValue(""); + await expect(page.getByLabel("Nostr (npub)")).toHaveValue(""); }); test("form state management, save, persistence, and clearing fields", async ({ page }) => { + // Page is already loaded in beforeEach, but ensure we're on it await page.goto("/profile"); + await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 }); - // All editable fields should be empty + // All editable fields should be empty (verified in beforeEach, but double-check) await expect(page.getByLabel("Contact Email")).toHaveValue(""); await expect(page.getByLabel("Telegram")).toHaveValue(""); await expect(page.getByLabel("Signal")).toHaveValue(""); @@ -166,13 +194,17 @@ test.describe("Profile - Form Behavior", () => { await expect(page.getByText(/saved successfully/i)).toBeVisible(); await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 }); - // Clear the field - await page.fill("#telegram", ""); + // Clear the field - use clear() instead of fill("") for reliable clearing + await page.locator("#telegram").clear(); await page.click('button:has-text("Save Changes")'); await expect(page.getByText(/saved successfully/i)).toBeVisible(); + await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 }); - // Reload and verify it's cleared + // Reload and wait for page to fully load before checking await page.reload(); + // Wait for the form to be loaded (check for a form field to ensure page is ready) + await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 }); + // Verify telegram field is cleared await expect(page.getByLabel("Telegram")).toHaveValue(""); }); });