refactors
This commit is contained in:
parent
4e1a339432
commit
82c4d0168e
28 changed files with 1042 additions and 782 deletions
|
|
@ -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
|
||||
)
|
||||
43
backend/models/__init__.py
Normal file
43
backend/models/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
36
backend/models/associations.py
Normal file
36
backend/models/associations.py
Normal file
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
28
backend/models/availability.py
Normal file
28
backend/models/availability.py
Normal file
|
|
@ -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),
|
||||
)
|
||||
59
backend/models/enums.py
Normal file
59
backend/models/enums.py
Normal file
|
|
@ -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"
|
||||
83
backend/models/exchange.py
Normal file
83
backend/models/exchange.py
Normal file
|
|
@ -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
|
||||
)
|
||||
55
backend/models/invite.py
Normal file
55
backend/models/invite.py
Normal file
|
|
@ -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
|
||||
)
|
||||
26
backend/models/price_history.py
Normal file
26
backend/models/price_history.py
Normal file
|
|
@ -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)
|
||||
)
|
||||
32
backend/models/role_config.py
Normal file
32
backend/models/role_config.py
Normal file
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
}
|
||||
8
backend/models/types.py
Normal file
8
backend/models/types.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from typing import TypedDict
|
||||
|
||||
from .enums import Permission
|
||||
|
||||
|
||||
class RoleConfig(TypedDict):
|
||||
description: str
|
||||
permissions: list[Permission]
|
||||
98
backend/models/user.py
Normal file
98
backend/models/user.py
Normal file
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
88
backend/schemas/__init__.py
Normal file
88
backend/schemas/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
20
backend/schemas/auth.py
Normal file
20
backend/schemas/auth.py
Normal file
|
|
@ -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
|
||||
47
backend/schemas/availability.py
Normal file
47
backend/schemas/availability.py
Normal file
|
|
@ -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]
|
||||
87
backend/schemas/exchange.py
Normal file
87
backend/schemas/exchange.py
Normal file
|
|
@ -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]
|
||||
48
backend/schemas/invite.py
Normal file
48
backend/schemas/invite.py
Normal file
|
|
@ -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]
|
||||
16
backend/schemas/meta.py
Normal file
16
backend/schemas/meta.py
Normal file
|
|
@ -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]
|
||||
15
backend/schemas/pagination.py
Normal file
15
backend/schemas/pagination.py
Normal file
|
|
@ -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
|
||||
44
backend/schemas/price.py
Normal file
44
backend/schemas/price.py
Normal file
|
|
@ -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
|
||||
20
backend/schemas/profile.py
Normal file
20
backend/schemas/profile.py
Normal file
|
|
@ -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
|
||||
24
backend/schemas/user.py
Normal file
24
backend/schemas/user.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue