From eeee94f74be562acce1de4cf1dd8867d871de637 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:45:00 -0400 Subject: [PATCH] feat(psa): add PsaMemberMapping model and migration Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/alembic/env.py | 1 + .../versions/061_add_psa_member_mappings.py | 60 +++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/psa_member_mapping.py | 47 +++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 backend/alembic/versions/061_add_psa_member_mappings.py create mode 100644 backend/app/models/psa_member_mapping.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 3d3e8f80..64558e24 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,6 +21,7 @@ from app.models.kb_import import KBImport, KBImportNode # noqa: F401 from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401 from app.models.psa_connection import PsaConnection # noqa: F401 from app.models.psa_post_log import PsaPostLog # noqa: F401 +from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401 from app.core.config import settings # this is the Alembic Config object diff --git a/backend/alembic/versions/061_add_psa_member_mappings.py b/backend/alembic/versions/061_add_psa_member_mappings.py new file mode 100644 index 00000000..7d2be4df --- /dev/null +++ b/backend/alembic/versions/061_add_psa_member_mappings.py @@ -0,0 +1,60 @@ +"""Add psa_member_mappings table for user-to-CW-member mapping. + +Revision ID: 061 +Revises: 060 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "061" +down_revision = "060" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "psa_member_mappings", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "psa_connection_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("psa_connections.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("external_member_id", sa.String(100), nullable=False), + sa.Column("external_member_name", sa.String(200), nullable=False), + sa.Column("matched_by", sa.String(50), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.UniqueConstraint( + "psa_connection_id", "user_id", + name="uq_psa_member_mapping_connection_user", + ), + sa.UniqueConstraint( + "psa_connection_id", "external_member_id", + name="uq_psa_member_mapping_connection_member", + ), + ) + + +def downgrade() -> None: + op.drop_table("psa_member_mappings") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1ab589aa..1938cdf0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -38,6 +38,7 @@ from .kb_import import KBImport, KBImportNode from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration from .psa_connection import PsaConnection from .psa_post_log import PsaPostLog +from .psa_member_mapping import PsaMemberMapping __all__ = [ "User", @@ -90,4 +91,5 @@ __all__ = [ "ScriptGeneration", "PsaConnection", "PsaPostLog", + "PsaMemberMapping", ] diff --git a/backend/app/models/psa_member_mapping.py b/backend/app/models/psa_member_mapping.py new file mode 100644 index 00000000..e85925d8 --- /dev/null +++ b/backend/app/models/psa_member_mapping.py @@ -0,0 +1,47 @@ +"""Maps ResolutionFlow users to CW members.""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID + +from app.core.database import Base + + +class PsaMemberMapping(Base): + __tablename__ = "psa_member_mappings" + __table_args__ = ( + UniqueConstraint("psa_connection_id", "user_id", name="uq_psa_member_mapping_connection_user"), + UniqueConstraint("psa_connection_id", "external_member_id", name="uq_psa_member_mapping_connection_member"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + psa_connection_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("psa_connections.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + external_member_id: Mapped[str] = mapped_column(String(100), nullable=False) + external_member_name: Mapped[str] = mapped_column(String(200), nullable=False) + matched_by: Mapped[str] = mapped_column(String(50), nullable=False) # 'auto_email', 'manual_admin', 'manual_self' + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id]) + user = relationship("User", foreign_keys=[user_id])