"""Phase 7S.3 email-to-engagement mapping Revision ID: 20260612_phase_7s3_email_mapping Revises: 20260611_phase_7s2_imap_incoming Create Date: 2026-06-12 SQLite-safe revision: - Adds reference id columns as plain Integer columns instead of adding ForeignKey constraints through ALTER TABLE, because SQLite cannot ALTER constraints without batch copy/move migration. - SQLAlchemy model relationships can still use these id columns for lookups. """ from __future__ import annotations from alembic import op import sqlalchemy as sa revision = "20260612_phase_7s3_email_mapping" down_revision = "20260611_phase_7s2_imap_incoming" branch_labels = None depends_on = None def _inspector(): return sa.inspect(op.get_bind()) def _has_table(table_name: str) -> bool: return table_name in _inspector().get_table_names() def _has_column(table_name: str, column_name: str) -> bool: if not _has_table(table_name): return False return column_name in {col["name"] for col in _inspector().get_columns(table_name)} def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: if not _has_table(table_name): return existing = {idx["name"] for idx in _inspector().get_indexes(table_name)} if index_name not in existing: op.create_index(index_name, table_name, columns) def _add_column_if_missing(table_name: str, column: sa.Column) -> None: if _has_table(table_name) and not _has_column(table_name, column.name): op.add_column(table_name, column) def upgrade() -> None: if not _has_table("email_incoming_messages"): return _add_column_if_missing( "email_incoming_messages", sa.Column("tracking_code", sa.String(length=80), nullable=True), ) # SQLite does not support ALTER TABLE ADD CONSTRAINT for ForeignKey. # Keep these as nullable integer reference columns. Application code and # SQLAlchemy models can still resolve the linked records safely. _add_column_if_missing( "email_incoming_messages", sa.Column("matched_engagement_id", sa.Integer(), nullable=True), ) _add_column_if_missing( "email_incoming_messages", sa.Column("matched_task_id", sa.Integer(), nullable=True), ) _add_column_if_missing( "email_incoming_messages", sa.Column("matched_invoice_id", sa.Integer(), nullable=True), ) _add_column_if_missing( "email_incoming_messages", sa.Column("mapping_status", sa.String(length=30), nullable=False, server_default="UNMAPPED"), ) _add_column_if_missing( "email_incoming_messages", sa.Column("mapping_notes", sa.Text(), nullable=True), ) _add_column_if_missing( "email_incoming_messages", sa.Column("mapped_at_utc", sa.DateTime(timezone=True), nullable=True), ) for name, cols in { "ix_email_incoming_messages_tracking_code": ["tracking_code"], "ix_email_incoming_messages_matched_engagement_id": ["matched_engagement_id"], "ix_email_incoming_messages_matched_task_id": ["matched_task_id"], "ix_email_incoming_messages_matched_invoice_id": ["matched_invoice_id"], "ix_email_incoming_messages_mapping_status": ["mapping_status"], "ix_email_incoming_messages_mapped_at_utc": ["mapped_at_utc"], }.items(): _create_index_if_missing("email_incoming_messages", name, cols) def downgrade() -> None: # Keep defensive because SQLite cannot reliably drop columns/constraints # in all local development environments without a batch table rewrite. pass