"""Add card_payload to notification_outbox. Revision ID: 400 Revises: 399 Create Date: 2026-07-14 Frozen JSON snapshot of an envelope's Feishu rich-card fields (action buttons / stats / highlights / sections / subtitle / footer / body_size). Motivation: the retry worker rebuilds the ``NotificationEnvelope`` FROM the persisted outbox row and re-renders the card. The row stored only the plain payload (title / body / url), so a retried card degraded to the generic single "Open in Helix" button — the "一键续期" one-click renewal button vanished from cloud-grant expiry reminders whose first send failed and were resent by the worker. Persisting the rich-card payload lets a retry re-render the identical card. Nullable JSON, sparse — populated only for envelopes that carry rich-card content (NULL for the common plain notification), TiDB-safe (no FK). """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "400" down_revision = "399" branch_labels = None depends_on = None _TABLE = "notification_outbox" _COL = "card_payload" def _has_column(bind, table: str, column: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.COLUMNS " "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t " "AND COLUMN_NAME = :c" ), {"t": table, "c": column}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if not _has_column(bind, _TABLE, _COL): op.add_column(_TABLE, sa.Column(_COL, sa.JSON(), nullable=True)) def downgrade() -> None: bind = op.get_bind() if _has_column(bind, _TABLE, _COL): op.drop_column(_TABLE, _COL)