from datetime import datetime from sqlalchemy import ( BigInteger, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class Section(Base): """A sub-section inside a vertical, e.g. ('real_estate', 'dubai'). The pair (vertical, slug) is unique and identifies a section in URLs and API calls. A channel belongs to exactly one section, the section knows its vertical, and the LLM prompt store can hold a per-section override that falls back to the vertical-level prompt. """ __tablename__ = "sections" __table_args__ = ( UniqueConstraint( "vertical", "department_id", "slug", name="uq_section_vertical_department_slug", ), Index("ix_sections_vertical_department", "vertical", "department_id"), ) id: Mapped[int] = mapped_column(primary_key=True) vertical: Mapped[str] = mapped_column(String(32)) department_id: Mapped[str | None] = mapped_column(String(64), nullable=True) slug: Mapped[str] = mapped_column(String(64)) title: Mapped[str] = mapped_column(String(255)) emoji: Mapped[str | None] = mapped_column(String(8), nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) channels: Mapped[list["Channel"]] = relationship(back_populates="section") class Channel(Base): __tablename__ = "channels" __table_args__ = ( UniqueConstraint("section_id", "identifier", name="uq_channels_section_identifier"), Index("ix_channels_source_channel_id", "source_channel_id"), ) id: Mapped[int] = mapped_column(primary_key=True) # Telegram numeric channel id (peer id), nullable until first resolve tg_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True) # Username or t.me/joinchat link supplied by user identifier: Mapped[str] = mapped_column(String(255)) title: Mapped[str | None] = mapped_column(String(512), nullable=True) # 'real_estate' or 'hr' — picks which LLM prompt and lead schema is used vertical: Mapped[str] = mapped_column( String(32), default="real_estate", server_default="real_estate", index=True ) section_id: Mapped[int] = mapped_column( ForeignKey("sections.id", ondelete="RESTRICT"), index=True ) source_channel_id: Mapped[int | None] = mapped_column( ForeignKey("channels.id", ondelete="SET NULL"), nullable=True ) is_active: Mapped[bool] = mapped_column(default=True, server_default="true") last_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_poll_status: Mapped[str | None] = mapped_column(String(32), nullable=True) last_poll_error_code: Mapped[str | None] = mapped_column(String(64), nullable=True) last_poll_error: Mapped[str | None] = mapped_column(Text, nullable=True) last_poll_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) section: Mapped[Section] = relationship(back_populates="channels") messages: Mapped[list["Message"]] = relationship( back_populates="channel", cascade="all, delete-orphan", passive_deletes=True, ) class Message(Base): __tablename__ = "messages" __table_args__ = ( UniqueConstraint("channel_id", "tg_message_id", name="uq_channel_message"), Index("ix_messages_channel_date", "channel_id", "date"), ) id: Mapped[int] = mapped_column(primary_key=True) channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) tg_message_id: Mapped[int] = mapped_column(BigInteger) date: Mapped[datetime] = mapped_column(DateTime(timezone=True)) text: Mapped[str | None] = mapped_column(Text, nullable=True) sender_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) sender_username: Mapped[str | None] = mapped_column(String(64), nullable=True) sender_name: Mapped[str | None] = mapped_column(String(255), nullable=True) grouped_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) has_media: Mapped[bool] = mapped_column(default=False, server_default="false") views: Mapped[int | None] = mapped_column(nullable=True) forwards: Mapped[int | None] = mapped_column(nullable=True) raw: Mapped[dict | None] = mapped_column(JSONB, nullable=True) media_files: Mapped[list | None] = mapped_column(JSONB, nullable=True) extracted: Mapped[dict | None] = mapped_column(JSONB, nullable=True) fetched_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) channel: Mapped[Channel] = relationship(back_populates="messages") class MessageClassification(Base): __tablename__ = "message_classifications" __table_args__ = ( UniqueConstraint("message_id", "section_id", name="uq_message_classification_section"), Index("ix_message_classifications_message", "message_id"), Index("ix_message_classifications_section", "section_id"), ) id: Mapped[int] = mapped_column(primary_key=True) message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE")) section_id: Mapped[int] = mapped_column(ForeignKey("sections.id", ondelete="CASCADE")) vertical: Mapped[str] = mapped_column(String(32)) verdict: Mapped[dict] = mapped_column(JSONB) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class AppSetting(Base): """Runtime-editable settings, edited from the UI without a restart.""" __tablename__ = "app_settings" key: Mapped[str] = mapped_column(String(255), primary_key=True) value: Mapped[dict | str | int | bool | None] = mapped_column(JSONB, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() )