diff --git a/alembic/versions/0017_email_digest.py b/alembic/versions/0017_email_digest.py new file mode 100644 index 0000000..a3e09d0 --- /dev/null +++ b/alembic/versions/0017_email_digest.py @@ -0,0 +1,59 @@ +"""email digests: User.email_digest_opt_in, User.digest_tone, email_sends table. + +Revision ID: 0017 +Revises: 0016 +Create Date: 2026-05-25 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0017" +down_revision: Union[str, None] = "0016" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "email_digest_opt_in", sa.Boolean(), nullable=False, + server_default=sa.text("1"), + ), + ) + op.add_column( + "users", + sa.Column("digest_tone", sa.String(length=16), nullable=True), + ) + + op.create_table( + "email_sends", + sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("error", sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], ondelete="CASCADE", + ), + ) + op.create_index( + "ix_email_sends_user_kind_sent", + "email_sends", + ["user_id", "kind", "sent_at"], + ) + op.create_index( + op.f("ix_email_sends_user_id"), "email_sends", ["user_id"], + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_email_sends_user_id"), table_name="email_sends") + op.drop_index("ix_email_sends_user_kind_sent", table_name="email_sends") + op.drop_table("email_sends") + op.drop_column("users", "digest_tone") + op.drop_column("users", "email_digest_opt_in") diff --git a/app/models.py b/app/models.py index 2d16d01..64ea415 100644 --- a/app/models.py +++ b/app/models.py @@ -22,6 +22,7 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -174,6 +175,12 @@ class User(Base): credit_until: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) + email_digest_opt_in: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default=text("1"), + ) + # NULL = use INTERMEDIATE at render time. Server-side mirror of the + # dashboard tone, decoupled because the dashboard pref is localStorage. + digest_tone: Mapped[str | None] = mapped_column(String(16)) __table_args__ = ( UniqueConstraint("email", name="uq_users_email"), @@ -311,3 +318,25 @@ class JobRun(Base): items_written: Mapped[int | None] = mapped_column(Integer) __table_args__ = (Index("ix_jobruns_name_started", "name", "started_at"),) + + +class EmailSend(Base): + """Audit row per digest email send. Used for idempotency (don't send + twice on the same UTC day) and for surfacing 'last delivery' on the + Settings page.""" + __tablename__ = "email_sends" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, + ) + kind: Mapped[str] = mapped_column(String(16), nullable=False) # "daily" | "weekly" + sent_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False, + ) + status: Mapped[str] = mapped_column(String(16), nullable=False) # "sent" | "skipped" | "error" + error: Mapped[str | None] = mapped_column(String(255)) + + __table_args__ = ( + Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"), + )