diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4256625f..3d3e8f80 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -20,6 +20,7 @@ from app.models.ai_suggestion import AISuggestion # noqa: F401 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.core.config import settings # this is the Alembic Config object diff --git a/backend/alembic/versions/060_add_psa_post_log.py b/backend/alembic/versions/060_add_psa_post_log.py new file mode 100644 index 00000000..7001ffbd --- /dev/null +++ b/backend/alembic/versions/060_add_psa_post_log.py @@ -0,0 +1,57 @@ +"""Add psa_post_log table for PSA note posting audit trail. + +Revision ID: 060 +Revises: 059 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "060" +down_revision = "059" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "psa_post_log", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "session_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("sessions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column( + "psa_connection_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("psa_connections.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("ticket_id", sa.String(100), nullable=False), + sa.Column("note_type", sa.String(50), nullable=False), + sa.Column("content_posted", sa.Text(), nullable=False), + sa.Column("external_note_id", sa.String(100), nullable=True), + sa.Column("status", sa.String(20), nullable=False), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("status_changed_from", sa.String(100), nullable=True), + sa.Column("status_changed_to", sa.String(100), nullable=True), + sa.Column( + "posted_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id"), + nullable=False, + ), + sa.Column( + "posted_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("psa_post_log") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index afdaeb27..1ab589aa 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -37,6 +37,7 @@ from .survey_invite import SurveyInvite from .kb_import import KBImport, KBImportNode from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration from .psa_connection import PsaConnection +from .psa_post_log import PsaPostLog __all__ = [ "User", @@ -88,4 +89,5 @@ __all__ = [ "ScriptTemplate", "ScriptGeneration", "PsaConnection", + "PsaPostLog", ] diff --git a/backend/app/models/psa_post_log.py b/backend/app/models/psa_post_log.py new file mode 100644 index 00000000..54372fe0 --- /dev/null +++ b/backend/app/models/psa_post_log.py @@ -0,0 +1,58 @@ +"""Audit trail for notes posted to PSA systems.""" +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import String, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID + +from app.core.database import Base + + +class PsaPostLog(Base): + __tablename__ = "psa_post_log" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sessions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("psa_connections.id", ondelete="SET NULL"), + nullable=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) + external_note_id: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False + ) # 'success' or 'failed' + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status_changed_from: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True + ) + status_changed_to: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True + ) + posted_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), nullable=False + ) + posted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + # Relationships + session = relationship("Session", foreign_keys=[session_id]) + psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id]) + user = relationship("User", foreign_keys=[posted_by])