"""События и их доставки.
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))