Newer
Older
gn-synapse / app / models / events.py
"""События и их доставки.

Event — конверт из docs/05-ingestion-api.md; Delivery — попытка/результат
доставки в конкретную цель конкретного канала по подошедшему правилу.
"""

import uuid
from datetime import datetime

from sqlalchemy import (
    BigInteger,
    CheckConstraint,
    DateTime,
    ForeignKey,
    Index,
    String,
    Text,
    func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base

# Статусы доставки:
#   pending    — в работе (квота у отправщика, ждёт next_retry_at)
#   delivered  — доставлено
#   failed     — все попытки исчерпаны
#   skipped    — срезано шумодавом правила (не доставлено намеренно, аудит)
# Одна запись на доставку: счётчик attempts и next_retry_at, не новые строки.
DELIVERY_STATUSES = ("pending", "delivered", "failed", "skipped")


class Event(Base):
    __tablename__ = "events"
    __table_args__ = (
        CheckConstraint("priority IN ('low','normal','high','critical')", name="ck_priority"),
        CheckConstraint("status IN ('queued','processing','done','failed')", name="ck_event_status"),
        Index("ix_events_dedup", "dedup_key", "created_at"),
    )

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    source_id: Mapped[int] = mapped_column(
        ForeignKey("sources.id", ondelete="RESTRICT"), index=True
    )
    subject: Mapped[str] = mapped_column(String(64))
    action: Mapped[str] = mapped_column(String(64))
    priority: Mapped[str] = mapped_column(String(16), default="normal")
    payload: Mapped[dict] = mapped_column(JSONB, default=dict)

    dedup_key: Mapped[str | None] = mapped_column(String(255))
    # created_at + ttl_seconds; воркер не берёт события с expires_at < now.
    expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    # Резерв контракта: в MVP scheduling не реализован.
    scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

    status: Mapped[str] = mapped_column(String(16), default="queued")
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    updated_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), onupdate=func.now()
    )


class Delivery(Base):
    __tablename__ = "deliveries"
    __table_args__ = (
        CheckConstraint("channel IN ('telegram','email','s2s','internal_log')", name="ck_channel"),
        CheckConstraint(
            "status IN ('pending','delivered','failed','skipped')", name="ck_delivery_status"
        ),
        Index("ix_deliveries_retry", "status", "next_retry_at"),
    )

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    event_id: Mapped[uuid.UUID] = mapped_column(
        ForeignKey("events.id", ondelete="CASCADE"), index=True
    )
    # Какое правило породило доставку (nullable: событие, не попавшее ни в одно
    # правило, тоже пишем — история «что случилось»).
    rule_id: Mapped[int | None] = mapped_column(
        ForeignKey("routing_rules.id", ondelete="SET NULL")
    )
    channel: Mapped[str] = mapped_column(String(32))
    channel_target_id: Mapped[int | None] = mapped_column(
        ForeignKey("channel_targets.id", ondelete="SET NULL")
    )

    status: Mapped[str] = mapped_column(String(16), default="pending")
    attempts: Mapped[int] = mapped_column(default=0)
    last_error: Mapped[str | None] = mapped_column(Text)
    next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    # Текст, реально отправленный в канал (рендер шаблона) — аудит.
    rendered_message: Mapped[str | None] = mapped_column(Text)

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))