import uuid from datetime import datetime from typing import Optional from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, func, UniqueConstraint from sqlalchemy import Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, UUIDMixin, TimestampMixin class Member(Base, UUIDMixin, TimestampMixin): __tablename__ = "members" email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) hashed_password: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) first_name: Mapped[str] = mapped_column(String(100), nullable=False) last_name: Mapped[str] = mapped_column(String(100), nullable=False) phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) address: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) emergency_contact: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) is_claimed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) notifications_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) member_status: Mapped[str] = mapped_column(String(32), default="invited", nullable=False, index=True) claimed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) onboarding_completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) contract_signed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) contract_signer_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) contract_version: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) activated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) onboarding_data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) service_pricing_overrides: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) force_two_factor: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True) verification_codes: Mapped[list["MemberVerificationCode"]] = relationship( "MemberVerificationCode", back_populates="member", cascade="all, delete-orphan" ) refresh_tokens: Mapped[list["MemberRefreshToken"]] = relationship( "MemberRefreshToken", back_populates="member", cascade="all, delete-orphan" ) walks: Mapped[list["Walk"]] = relationship( "Walk", back_populates="member", cascade="all, delete-orphan" ) bookings: Mapped[list["Booking"]] = relationship( "Booking", back_populates="member", cascade="all, delete-orphan" ) messages: Mapped[list["AdminMessage"]] = relationship( "AdminMessage", back_populates="member", cascade="all, delete-orphan" ) notification_dispatches: Mapped[list["MemberNotificationDispatch"]] = relationship( "MemberNotificationDispatch", back_populates="member", cascade="all, delete-orphan" ) magic_link_tokens: Mapped[list["MagicLinkToken"]] = relationship( "MagicLinkToken", back_populates="member", cascade="all, delete-orphan" ) class MemberVerificationCode(Base, UUIDMixin): __tablename__ = "member_verification_codes" member_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True, ) code_hash: Mapped[str] = mapped_column(String(255), nullable=False) purpose: Mapped[str] = mapped_column(String(20), nullable=False) # "claim" | "login_2fa" expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) member: Mapped["Member"] = relationship("Member", back_populates="verification_codes") class MemberRefreshToken(Base, UUIDMixin): __tablename__ = "member_refresh_tokens" member_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True, ) token_hash: Mapped[str] = mapped_column(String(255), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) member: Mapped["Member"] = relationship("Member", back_populates="refresh_tokens") class Walk(Base, UUIDMixin, TimestampMixin): __tablename__ = "walks" member_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True, ) walked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) service_type: Mapped[str] = mapped_column(String(50), nullable=False) # pack_walk | 1_1_walk | puppy_visit duration_minutes: Mapped[int] = mapped_column(nullable=False, default=60) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) recorded_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) member: Mapped["Member"] = relationship("Member", back_populates="walks") class Booking(Base, UUIDMixin, TimestampMixin): __tablename__ = "bookings" member_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True, ) service_type: Mapped[str] = mapped_column(String(50), nullable=False) requested_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") # pending | confirmed | cancelled | completed notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) admin_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) member: Mapped["Member"] = relationship("Member", back_populates="bookings") class AdminMessage(Base, UUIDMixin, TimestampMixin): __tablename__ = "admin_messages" member_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True, ) subject: Mapped[str] = mapped_column(String(255), nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False) read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) sent_by: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # "inbound" = admin → member, "outbound" = member reply direction: Mapped[str] = mapped_column(String(16), nullable=False, default="inbound") reply_to_id: Mapped[Optional[uuid.UUID]] = mapped_column( Uuid(as_uuid=True), ForeignKey("admin_messages.id", ondelete="SET NULL"), nullable=True, ) member: Mapped["Member"] = relationship("Member", back_populates="messages") class MagicLinkToken(Base, UUIDMixin): __tablename__ = "member_magic_link_tokens" member_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True, ) token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) member: Mapped["Member"] = relationship("Member", back_populates="magic_link_tokens") class MemberNotificationDispatch(Base, UUIDMixin, TimestampMixin): __tablename__ = "member_notification_dispatches" __table_args__ = ( UniqueConstraint("member_id", "dispatch_key", name="uq_member_notification_dispatches_member_key"), ) member_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True, ) notification_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) dispatch_key: Mapped[str] = mapped_column(String(255), nullable=False) metadata_json: Mapped[Optional[dict]] = mapped_column("metadata", JSON, nullable=True) member: Mapped["Member"] = relationship("Member", back_populates="notification_dispatches")