From ac33ac1c0d21a73aa4e75210f6bd39cf9c94fab2 Mon Sep 17 00:00:00 2001 From: Roberto Date: Sat, 16 May 2026 02:36:20 +0200 Subject: [PATCH] feat(scouts): add ScoutTriageQueue table + cloud_scout_configs gmail fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks 12+13 of Phase 2 — first new infra after rename. Alembic 008 creates scout_triage_queue with unique constraint on (scout_id, source_msg_ref) and partial index on expires_at for active rows. Adds four columns to cloud_scout_configs: auto_trash_spam, gmail_history_id, gmail_watch_expires_at, device_inactivity_pause_days. SQLAlchemy model ScoutTriageQueue added; CloudScoutConfig updated to match. Imports extended with UniqueConstraint and text. --- alembic/versions/008_scout_triage_queue.py | 59 ++++++++++++++++++++++ app/models.py | 26 ++++++++++ 2 files changed, 85 insertions(+) create mode 100644 alembic/versions/008_scout_triage_queue.py diff --git a/alembic/versions/008_scout_triage_queue.py b/alembic/versions/008_scout_triage_queue.py new file mode 100644 index 0000000..a674140 --- /dev/null +++ b/alembic/versions/008_scout_triage_queue.py @@ -0,0 +1,59 @@ +"""Scout triage queue + cloud_scout_configs alterations. + +Revision ID: 008 +Revises: 007 +Create Date: 2026-05-16 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "008" +down_revision: Union[str, None] = "007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "scout_triage_queue", + sa.Column("id", sa.Uuid(as_uuid=False), primary_key=True), + sa.Column("user_id", sa.Uuid(as_uuid=False), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("scout_id", sa.Uuid(as_uuid=False), sa.ForeignKey("cloud_scout_configs.id", ondelete="CASCADE"), nullable=False), + sa.Column("source_type", sa.String(50), nullable=False), + sa.Column("source_msg_ref", sa.String(255), nullable=False), + sa.Column("triage_verdict", sa.String(20), nullable=False), + sa.Column("triage_reason", sa.Text, nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="queued"), + sa.Column("triaged_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("acked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("scout_id", "source_msg_ref", name="uq_scout_triage_queue_scout_msg"), + ) + op.create_index("ix_scout_triage_queue_user_status", "scout_triage_queue", ["user_id", "status"]) + op.create_index( + "ix_scout_triage_queue_expires_active", + "scout_triage_queue", + ["expires_at"], + postgresql_where=sa.text("status != 'acked'"), + ) + + op.add_column("cloud_scout_configs", sa.Column("auto_trash_spam", sa.Boolean(), nullable=False, server_default=sa.text("false"))) + op.add_column("cloud_scout_configs", sa.Column("gmail_history_id", sa.String(64), nullable=True)) + op.add_column("cloud_scout_configs", sa.Column("gmail_watch_expires_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("cloud_scout_configs", sa.Column("device_inactivity_pause_days", sa.Integer(), nullable=False, server_default="14")) + + +def downgrade() -> None: + op.drop_column("cloud_scout_configs", "device_inactivity_pause_days") + op.drop_column("cloud_scout_configs", "gmail_watch_expires_at") + op.drop_column("cloud_scout_configs", "gmail_history_id") + op.drop_column("cloud_scout_configs", "auto_trash_spam") + + op.drop_index("ix_scout_triage_queue_expires_active", table_name="scout_triage_queue") + op.drop_index("ix_scout_triage_queue_user_status", table_name="scout_triage_queue") + op.drop_table("scout_triage_queue") diff --git a/app/models.py b/app/models.py index 840b859..cf55ef1 100644 --- a/app/models.py +++ b/app/models.py @@ -34,8 +34,10 @@ from sqlalchemy import ( LargeBinary, String, Text, + UniqueConstraint, Uuid, func, + text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -217,6 +219,10 @@ class CloudScoutConfig(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) + auto_trash_spam: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("false")) + gmail_history_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + gmail_watch_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + device_inactivity_pause_days: Mapped[int] = mapped_column(Integer, nullable=False, default=14, server_default="14") run_logs: Mapped[list["ScoutRunLog"]] = relationship( back_populates="cloud_scout", @@ -227,6 +233,26 @@ class CloudScoutConfig(Base): ) +class ScoutTriageQueue(Base): + __tablename__ = "scout_triage_queue" + __table_args__ = ( + UniqueConstraint("scout_id", "source_msg_ref", name="uq_scout_triage_queue_scout_msg"), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid) + user_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + scout_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), ForeignKey("cloud_scout_configs.id", ondelete="CASCADE"), nullable=False) + source_type: Mapped[str] = mapped_column(String(50), nullable=False) + source_msg_ref: Mapped[str] = mapped_column(String(255), nullable=False) + triage_verdict: Mapped[str] = mapped_column(String(20), nullable=False) + triage_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", server_default="queued") + triaged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + acked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + class ScoutRunLog(Base): __tablename__ = "scout_run_logs"