From 0d694741281053e6bb72285fbaf232d277c2b211 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 9 Apr 2026 05:19:12 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20Group=205=20=E2=80=94=20add?= =?UTF-8?q?=20account=5Fid=20to=20PSA=20and=20notification=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit psa_post_log: backfill via psa_connection, fallback to posted_by user psa_member_mappings: backfill via psa_connection notification_logs: backfill via notification_config Co-Authored-By: Claude Sonnet 4.6 --- ...372402_add_account_id_psa_notifications.py | 77 +++++++++++++++++++ backend/app/models/notification_log.py | 6 ++ backend/app/models/psa_member_mapping.py | 6 ++ backend/app/models/psa_post_log.py | 6 ++ backend/tests/test_phase1_migrations.py | 38 +++++++++ 5 files changed, 133 insertions(+) create mode 100644 backend/alembic/versions/8aac5b372402_add_account_id_psa_notifications.py diff --git a/backend/alembic/versions/8aac5b372402_add_account_id_psa_notifications.py b/backend/alembic/versions/8aac5b372402_add_account_id_psa_notifications.py new file mode 100644 index 00000000..9b39fa04 --- /dev/null +++ b/backend/alembic/versions/8aac5b372402_add_account_id_psa_notifications.py @@ -0,0 +1,77 @@ +"""add account_id to PSA and notification tables + +Revision ID: 8aac5b372402 +Revises: a1d2a84b9abb +Create Date: 2026-04-09 00:00:00.000000 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '8aac5b372402' +down_revision: Union[str, None] = 'a1d2a84b9abb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Step 1: ADD COLUMN + for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): + op.add_column(table, sa.Column('account_id', sa.UUID(), nullable=True)) + op.create_foreign_key( + f'fk_{table}_account_id', table, 'accounts', + ['account_id'], ['id'], ondelete='CASCADE', + ) + + # Step 2: BACKFILL + # psa_post_log: prefer psa_connection → fallback to posted_by user + op.execute(""" + UPDATE psa_post_log ppl + SET account_id = COALESCE(pc.account_id, u.account_id) + FROM users u + LEFT JOIN psa_connections pc ON pc.id = ppl.psa_connection_id + WHERE ppl.posted_by = u.id + AND ppl.account_id IS NULL + """) + + # psa_member_mappings: via psa_connection + op.execute(""" + UPDATE psa_member_mappings pmm + SET account_id = pc.account_id + FROM psa_connections pc + WHERE pmm.psa_connection_id = pc.id + AND pmm.account_id IS NULL + """) + + # notification_logs: via notification_config + op.execute(""" + UPDATE notification_logs nl + SET account_id = nc.account_id + FROM notification_configs nc + WHERE nl.notification_config_id = nc.id + AND nl.account_id IS NULL + """) + + # Step 3: VERIFY + for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): + result = op.get_bind().execute( + sa.text(f"SELECT COUNT(*) FROM {table} WHERE account_id IS NULL") + ) + count = result.scalar() + if count > 0: + raise RuntimeError(f"ROLLBACK: {count} NULL account_id rows in {table}.") + + # Step 4: SET NOT NULL + for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): + op.alter_column(table, 'account_id', nullable=False) + + # Step 5: CREATE INDEX + for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): + op.create_index(f'ix_{table}_account_id', table, ['account_id']) + + +def downgrade() -> None: + for table in ('psa_post_log', 'psa_member_mappings', 'notification_logs'): + op.drop_index(f'ix_{table}_account_id', table_name=table) + op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey') + op.drop_column(table, 'account_id') diff --git a/backend/app/models/notification_log.py b/backend/app/models/notification_log.py index 5ee4e932..99f8a7cb 100644 --- a/backend/app/models/notification_log.py +++ b/backend/app/models/notification_log.py @@ -31,6 +31,12 @@ class NotificationLog(Base): nullable=False, index=True, ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) event: Mapped[str] = mapped_column(String(50), nullable=False) payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) status: Mapped[str] = mapped_column(String(20), default="sent") diff --git a/backend/app/models/psa_member_mapping.py b/backend/app/models/psa_member_mapping.py index e85925d8..6ca18109 100644 --- a/backend/app/models/psa_member_mapping.py +++ b/backend/app/models/psa_member_mapping.py @@ -25,6 +25,12 @@ class PsaMemberMapping(Base): nullable=False, index=True, ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), diff --git a/backend/app/models/psa_post_log.py b/backend/app/models/psa_post_log.py index 14697507..9e4018f8 100644 --- a/backend/app/models/psa_post_log.py +++ b/backend/app/models/psa_post_log.py @@ -35,6 +35,12 @@ class PsaPostLog(Base): ForeignKey("psa_connections.id", ondelete="SET NULL"), nullable=True, ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) ticket_id: Mapped[str] = mapped_column(String(100), nullable=False) note_type: Mapped[str] = mapped_column(String(50), nullable=False) content_posted: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/tests/test_phase1_migrations.py b/backend/tests/test_phase1_migrations.py index a4f33de6..28ae3bfb 100644 --- a/backend/tests/test_phase1_migrations.py +++ b/backend/tests/test_phase1_migrations.py @@ -343,3 +343,41 @@ async def test_user_pinned_tree_account_id_matches_user(test_db: AsyncSession): result = await test_db.execute(select(UserPinnedTree).where(UserPinnedTree.id == pin.id)) row = result.scalar_one() assert row.account_id == account.id + + +# ── Group 5: PSA & notifications ───────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_psa_member_mapping_account_id_matches_connection(test_db: AsyncSession): + """psa_member_mappings.account_id must match psa_connection's account_id.""" + from app.models.psa_connection import PsaConnection + from app.models.psa_member_mapping import PsaMemberMapping + + account, user = await _make_account_and_user(test_db, "psa1") + conn = PsaConnection( + account_id=account.id, + provider="connectwise", + display_name="Test CW", + site_url="https://cw.example.com", + company_id="TEST", + credentials_encrypted="placeholder", + ) + test_db.add(conn) + await test_db.flush() + + mapping = PsaMemberMapping( + psa_connection_id=conn.id, + user_id=user.id, + account_id=account.id, + external_member_id="cw-123", + external_member_name="Test User", + matched_by="manual_admin", + ) + test_db.add(mapping) + await test_db.commit() + + result = await test_db.execute( + select(PsaMemberMapping).where(PsaMemberMapping.id == mapping.id) + ) + row = result.scalar_one() + assert row.account_id == account.id