From 99af0164e60c5b211bbdb33a2bddfe7fa426af36 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 16 Dec 2025 12:27:18 -0600 Subject: [PATCH] Add PostgreSQL database support with Alembic migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add PostgreSQL service to docker-compose with health checks - Add SQLAlchemy async models for all entities (Agency, User, Campaign, Proof, ProofVersion, FlaggedItem, ResolvedItem, ErrorItem) - Add Alembic migration framework with initial schema migration - Add repository layer for CRUD operations - Add REST API endpoints for campaigns, proofs, and audit items - Add file storage service for proof uploads - Update WebSocket handler to optionally persist analysis results Frontend: - Add apiService.ts for REST API communication - Update geminiService.ts to support database persistence options Deployment: - Update deploy.sh to handle database migrations (6-step process) - Update Dockerfile to include alembic configuration - Add PostgreSQL environment variables to .env templates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .env.deploy.example | 9 + backend/.env.example | 8 + backend/Dockerfile | 4 + backend/alembic.ini | 42 ++ backend/alembic/env.py | 74 +++ backend/alembic/script.py.mako | 26 + .../alembic/versions/001_initial_schema.py | 160 ++++++ backend/app/api/__init__.py | 3 + backend/app/api/routes.py | 488 ++++++++++++++++++ backend/app/api/schemas.py | 165 ++++++ backend/app/config.py | 10 + backend/app/main.py | 17 +- backend/app/models/database.py | 50 ++ backend/app/models/models.py | 175 +++++++ backend/app/repositories/__init__.py | 11 + backend/app/repositories/audit_repository.py | 163 ++++++ .../app/repositories/campaign_repository.py | 165 ++++++ backend/app/repositories/proof_repository.py | 219 ++++++++ backend/app/repositories/user_repository.py | 95 ++++ backend/app/services/storage_service.py | 115 +++++ backend/app/websocket/handlers.py | 122 +++-- backend/requirements.txt | 3 + deploy.sh | 57 +- docker-compose.yml | 25 + frontend/services/apiService.ts | 317 ++++++++++++ frontend/services/geminiService.ts | 55 +- 26 files changed, 2534 insertions(+), 44 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial_schema.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/routes.py create mode 100644 backend/app/api/schemas.py create mode 100644 backend/app/models/database.py create mode 100644 backend/app/models/models.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/audit_repository.py create mode 100644 backend/app/repositories/campaign_repository.py create mode 100644 backend/app/repositories/proof_repository.py create mode 100644 backend/app/repositories/user_repository.py create mode 100644 backend/app/services/storage_service.py create mode 100644 frontend/services/apiService.ts diff --git a/.env.deploy.example b/.env.deploy.example index 408da9c..69e7c9c 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -14,6 +14,15 @@ COMPOSE_PROJECT_NAME=modcomms-prod # Examples: 8000 for prod, 8001 for dev BACKEND_PORT=8000 +# PostgreSQL port (must be unique per instance) +# Examples: 5432 for prod, 5433 for dev +POSTGRES_PORT=5432 + +# PostgreSQL credentials (change in production!) +POSTGRES_USER=modcomms +POSTGRES_PASSWORD=change_this_in_production +POSTGRES_DB=modcomms + # Frontend deployment directory (Apache document root) # Examples: /var/www/modcomms-prod, /var/www/modcomms-dev FRONTEND_DEPLOY_DIR=/var/www/html/modcomms diff --git a/backend/.env.example b/backend/.env.example index 4ae8591..b4ce94d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,3 +21,11 @@ AZURE_CLIENT_ID=your_azure_client_id_here # Development only - set to "true" to disable authentication (NOT for production) DISABLE_AUTH=false + +# Database Configuration (PostgreSQL) +# Format: postgresql+asyncpg://user:password@host:port/database +DATABASE_URL=postgresql+asyncpg://modcomms:modcomms_dev@localhost:5432/modcomms + +# File Storage Path (for uploaded proofs) +# Defaults to ../storage relative to backend/ +# FILE_STORAGE_PATH=/path/to/storage diff --git a/backend/Dockerfile b/backend/Dockerfile index 0d129e0..b2b0e42 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -18,6 +18,10 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY backend/app/ ./app/ +# Copy alembic configuration for migrations +COPY backend/alembic.ini . +COPY backend/alembic/ ./alembic/ + # Copy reference docs into the image COPY reference_docs/ ./reference_docs/ diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cd64a16 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..185d0e1 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,74 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.config import settings +from app.models.database import Base +from app.models.models import ( + Agency, User, Campaign, Proof, ProofVersion, + FlaggedItem, ResolvedItem, ErrorItem, DropdownOption +) + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url(): + return settings.DATABASE_URL + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async engine.""" + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = get_url() + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..685a3ca --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,160 @@ +"""Initial schema + +Revision ID: 001_initial +Revises: +Create Date: 2024-12-16 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '001_initial' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create agencies table + op.create_table( + 'agencies', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('name', sa.String(255), nullable=False, unique=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Create users table + op.create_table( + 'users', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('azure_ad_oid', sa.String(255), nullable=False, unique=True), + sa.Column('email', sa.String(255), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('role', sa.String(50), nullable=False, server_default='basic_user'), + sa.Column('agency_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agencies.id'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Create campaigns table + op.create_table( + 'campaigns', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('workfront_id', sa.String(100), nullable=True), + sa.Column('client_lead', sa.String(255), nullable=True), + sa.Column('agency_lead', sa.String(255), nullable=True), + sa.Column('brand_guidelines', sa.String(50), nullable=True), + sa.Column('status', sa.String(50), server_default='In Progress'), + sa.Column('agency_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agencies.id'), nullable=True), + sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Create proofs table + op.create_table( + 'proofs', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('campaign_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('campaigns.id', ondelete='CASCADE'), nullable=False), + sa.Column('proof_name', sa.String(255), nullable=False), + sa.Column('channel', sa.String(100), nullable=True), + sa.Column('sub_channel', sa.String(100), nullable=True), + sa.Column('proof_type', sa.String(100), nullable=True), + sa.Column('workfront_id', sa.String(100), nullable=True), + sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint('campaign_id', 'proof_name', name='uq_campaign_proof_name'), + ) + + # Create proof_versions table + op.create_table( + 'proof_versions', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('proof_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proofs.id', ondelete='CASCADE'), nullable=False), + sa.Column('version', sa.Integer, nullable=False), + sa.Column('file_storage_key', sa.String(500), nullable=True), + sa.Column('thumbnail_url', sa.Text, nullable=True), + sa.Column('agent_review', postgresql.JSONB, nullable=True), + sa.Column('overall_status', sa.String(50), nullable=True), + sa.Column('workfront_id', sa.String(100), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint('proof_id', 'version', name='uq_proof_version'), + ) + + # Create flagged_items table + op.create_table( + 'flagged_items', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('proof_version_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proof_versions.id'), nullable=False), + sa.Column('agent_flagged', sa.String(100), nullable=False), + sa.Column('comments', sa.Text, nullable=True), + sa.Column('submitter_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Create resolved_items table + op.create_table( + 'resolved_items', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('proof_version_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proof_versions.id'), nullable=False), + sa.Column('agent', sa.String(100), nullable=False), + sa.Column('issue', sa.Text, nullable=True), + sa.Column('resolution', sa.Text, nullable=True), + sa.Column('submitter_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Create error_items table + op.create_table( + 'error_items', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('proof_version_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proof_versions.id'), nullable=False), + sa.Column('error_summary', sa.Text, nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Create dropdown_options table for configurable options + op.create_table( + 'dropdown_options', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), + sa.Column('option_type', sa.String(50), nullable=False), + sa.Column('parent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('dropdown_options.id'), nullable=True), + sa.Column('value', sa.String(255), nullable=False), + sa.Column('display_order', sa.Integer, server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Create indexes for common queries + op.create_index('idx_campaigns_agency', 'campaigns', ['agency_id']) + op.create_index('idx_proofs_campaign', 'proofs', ['campaign_id']) + op.create_index('idx_proof_versions_proof', 'proof_versions', ['proof_id']) + op.create_index('idx_proof_versions_status', 'proof_versions', ['overall_status']) + op.create_index('idx_users_agency', 'users', ['agency_id']) + op.create_index('idx_flagged_items_version', 'flagged_items', ['proof_version_id']) + op.create_index('idx_resolved_items_version', 'resolved_items', ['proof_version_id']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('idx_resolved_items_version') + op.drop_index('idx_flagged_items_version') + op.drop_index('idx_users_agency') + op.drop_index('idx_proof_versions_status') + op.drop_index('idx_proof_versions_proof') + op.drop_index('idx_proofs_campaign') + op.drop_index('idx_campaigns_agency') + + # Drop tables in reverse order + op.drop_table('dropdown_options') + op.drop_table('error_items') + op.drop_table('resolved_items') + op.drop_table('flagged_items') + op.drop_table('proof_versions') + op.drop_table('proofs') + op.drop_table('campaigns') + op.drop_table('users') + op.drop_table('agencies') diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..2bb843d --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,3 @@ +from app.api.routes import router + +__all__ = ["router"] diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..eb69750 --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,488 @@ +"""REST API routes for campaigns, proofs, and audit items.""" +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.schemas import ( + CampaignCreate, + CampaignUpdate, + CampaignResponse, + ProofCreate, + ProofResponse, + ProofVersionResponse, + FlaggedItemCreate, + FlaggedItemResponse, + ResolvedItemCreate, + ResolvedItemResponse, + ErrorItemResponse, + AnalyticsResponse, + DropdownOptionsResponse, + UserResponse, +) +from app.dependencies.auth import get_current_user +from app.models.database import get_db +from app.repositories import ( + CampaignRepository, + ProofRepository, + UserRepository, + AuditRepository, +) + +router = APIRouter() + + +# Helper to get user from DB based on Azure claims +async def get_db_user( + session: AsyncSession, + user_claims: dict, +) -> Optional[uuid.UUID]: + """Get or create user from Azure AD claims and return user ID.""" + user_repo = UserRepository(session) + azure_oid = user_claims.get("oid") or user_claims.get("sub") + if not azure_oid: + return None + + user = await user_repo.get_or_create_from_azure( + azure_ad_oid=azure_oid, + email=user_claims.get("email", user_claims.get("preferred_username", "")), + name=user_claims.get("name", "Unknown"), + ) + return user.id + + +# Campaign endpoints +@router.get("/campaigns", response_model=list[CampaignResponse]) +async def list_campaigns( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List all campaigns.""" + repo = CampaignRepository(db) + campaigns_with_counts = await repo.get_with_proof_counts() + + return [ + CampaignResponse( + id=item["campaign"].id, + name=item["campaign"].name, + workfront_id=item["campaign"].workfront_id, + client_lead=item["campaign"].client_lead, + agency_lead=item["campaign"].agency_lead, + brand_guidelines=item["campaign"].brand_guidelines, + status=item["campaign"].status, + agency=item["campaign"].agency.name if item["campaign"].agency else None, + created_at=item["campaign"].created_at, + updated_at=item["campaign"].updated_at, + proofs=item["proof_count"], + ) + for item in campaigns_with_counts + ] + + +@router.post("/campaigns", response_model=CampaignResponse, status_code=201) +async def create_campaign( + data: CampaignCreate, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Create a new campaign.""" + user_id = await get_db_user(db, user) + repo = CampaignRepository(db) + + # Check if campaign name already exists + existing = await repo.get_by_name(data.name) + if existing: + raise HTTPException(status_code=400, detail="Campaign with this name already exists") + + campaign = await repo.create( + name=data.name, + workfront_id=data.workfront_id, + client_lead=data.client_lead, + agency_lead=data.agency_lead, + brand_guidelines=data.brand_guidelines, + created_by=user_id, + ) + + return CampaignResponse( + id=campaign.id, + name=campaign.name, + workfront_id=campaign.workfront_id, + client_lead=campaign.client_lead, + agency_lead=campaign.agency_lead, + brand_guidelines=campaign.brand_guidelines, + status=campaign.status, + agency=None, + created_at=campaign.created_at, + updated_at=campaign.updated_at, + proofs=0, + ) + + +@router.get("/campaigns/{campaign_id}", response_model=CampaignResponse) +async def get_campaign( + campaign_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Get a campaign by ID.""" + repo = CampaignRepository(db) + campaign = await repo.get_by_id(campaign_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + return CampaignResponse( + id=campaign.id, + name=campaign.name, + workfront_id=campaign.workfront_id, + client_lead=campaign.client_lead, + agency_lead=campaign.agency_lead, + brand_guidelines=campaign.brand_guidelines, + status=campaign.status, + agency=campaign.agency.name if campaign.agency else None, + created_at=campaign.created_at, + updated_at=campaign.updated_at, + proofs=len(campaign.proofs), + ) + + +@router.put("/campaigns/{campaign_id}", response_model=CampaignResponse) +async def update_campaign( + campaign_id: uuid.UUID, + data: CampaignUpdate, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Update a campaign.""" + repo = CampaignRepository(db) + campaign = await repo.update( + campaign_id, + name=data.name, + workfront_id=data.workfront_id, + client_lead=data.client_lead, + agency_lead=data.agency_lead, + brand_guidelines=data.brand_guidelines, + status=data.status, + ) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + return CampaignResponse( + id=campaign.id, + name=campaign.name, + workfront_id=campaign.workfront_id, + client_lead=campaign.client_lead, + agency_lead=campaign.agency_lead, + brand_guidelines=campaign.brand_guidelines, + status=campaign.status, + agency=campaign.agency.name if campaign.agency else None, + created_at=campaign.created_at, + updated_at=campaign.updated_at, + proofs=len(campaign.proofs) if campaign.proofs else 0, + ) + + +@router.delete("/campaigns/{campaign_id}", status_code=204) +async def delete_campaign( + campaign_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Delete a campaign.""" + repo = CampaignRepository(db) + success = await repo.delete(campaign_id) + if not success: + raise HTTPException(status_code=404, detail="Campaign not found") + + +# Proof endpoints +@router.get("/campaigns/{campaign_id}/proofs", response_model=list[ProofResponse]) +async def list_proofs( + campaign_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """List all proofs for a campaign.""" + repo = ProofRepository(db) + proofs = await repo.list_by_campaign(campaign_id) + + return [ + ProofResponse( + id=proof.id, + proof_name=proof.proof_name, + channel=proof.channel, + sub_channel=proof.sub_channel, + proof_type=proof.proof_type, + workfront_id=proof.workfront_id, + created_at=proof.created_at, + versions=[ + ProofVersionResponse( + id=v.id, + version=v.version, + file_storage_key=v.file_storage_key, + thumbnail_url=v.thumbnail_url, + agent_review=v.agent_review, + overall_status=v.overall_status, + workfront_id=v.workfront_id, + created_at=v.created_at, + ) + for v in proof.versions + ], + ) + for proof in proofs + ] + + +@router.get("/proofs/{proof_id}", response_model=ProofResponse) +async def get_proof( + proof_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Get a proof by ID.""" + repo = ProofRepository(db) + proof = await repo.get_by_id(proof_id) + if not proof: + raise HTTPException(status_code=404, detail="Proof not found") + + return ProofResponse( + id=proof.id, + proof_name=proof.proof_name, + channel=proof.channel, + sub_channel=proof.sub_channel, + proof_type=proof.proof_type, + workfront_id=proof.workfront_id, + created_at=proof.created_at, + versions=[ + ProofVersionResponse( + id=v.id, + version=v.version, + file_storage_key=v.file_storage_key, + thumbnail_url=v.thumbnail_url, + agent_review=v.agent_review, + overall_status=v.overall_status, + workfront_id=v.workfront_id, + created_at=v.created_at, + ) + for v in proof.versions + ], + ) + + +@router.delete("/proofs/{proof_id}", status_code=204) +async def delete_proof( + proof_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Delete a proof.""" + repo = ProofRepository(db) + success = await repo.delete(proof_id) + if not success: + raise HTTPException(status_code=404, detail="Proof not found") + + +# Audit endpoints +@router.post("/proofs/{proof_id}/versions/{version}/flag", response_model=FlaggedItemResponse, status_code=201) +async def flag_proof_version( + proof_id: uuid.UUID, + version: int, + data: FlaggedItemCreate, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Flag an issue on a proof version.""" + user_id = await get_db_user(db, user) + proof_repo = ProofRepository(db) + audit_repo = AuditRepository(db) + + # Get the proof version + proof_version = await proof_repo.get_version(proof_id, version) + if not proof_version: + raise HTTPException(status_code=404, detail="Proof version not found") + + flagged = await audit_repo.create_flagged_item( + proof_version_id=proof_version.id, + agent_flagged=data.agent_flagged, + comments=data.comments, + submitter_id=user_id, + ) + + # Get related data for response + proof = await proof_repo.get_by_id(proof_id) + + return FlaggedItemResponse( + id=flagged.id, + proof_version_id=flagged.proof_version_id, + agent_flagged=flagged.agent_flagged, + comments=flagged.comments, + submitter_name=user.get("name"), + submitter_agency=None, + campaign_name=proof.campaign.name if proof and proof.campaign else None, + proof_name=proof.proof_name if proof else None, + version=version, + created_at=flagged.created_at, + ) + + +@router.post("/proofs/{proof_id}/versions/{version}/resolve", response_model=ResolvedItemResponse, status_code=201) +async def resolve_proof_version( + proof_id: uuid.UUID, + version: int, + data: ResolvedItemCreate, + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Resolve an issue on a proof version.""" + user_id = await get_db_user(db, user) + proof_repo = ProofRepository(db) + audit_repo = AuditRepository(db) + + # Get the proof version + proof_version = await proof_repo.get_version(proof_id, version) + if not proof_version: + raise HTTPException(status_code=404, detail="Proof version not found") + + resolved = await audit_repo.create_resolved_item( + proof_version_id=proof_version.id, + agent=data.agent, + issue=data.issue, + resolution=data.resolution, + submitter_id=user_id, + ) + + # Get related data for response + proof = await proof_repo.get_by_id(proof_id) + + return ResolvedItemResponse( + id=resolved.id, + proof_version_id=resolved.proof_version_id, + agent=resolved.agent, + issue=resolved.issue, + resolution=resolved.resolution, + submitter_name=user.get("name"), + submitter_agency=None, + campaign_name=proof.campaign.name if proof and proof.campaign else None, + proof_name=proof.proof_name if proof else None, + version=version, + created_at=resolved.created_at, + ) + + +@router.get("/audit/flagged", response_model=list[FlaggedItemResponse]) +async def list_flagged_items( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List all flagged items.""" + audit_repo = AuditRepository(db) + flagged_items = await audit_repo.get_flagged_items(limit=limit, offset=offset) + + return [ + FlaggedItemResponse( + id=item.id, + proof_version_id=item.proof_version_id, + agent_flagged=item.agent_flagged, + comments=item.comments, + submitter_name=item.submitter.name if item.submitter else None, + submitter_agency=item.submitter.agency.name if item.submitter and item.submitter.agency else None, + campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None, + proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None, + version=item.proof_version.version if item.proof_version else None, + created_at=item.created_at, + ) + for item in flagged_items + ] + + +@router.get("/audit/resolved", response_model=list[ResolvedItemResponse]) +async def list_resolved_items( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List all resolved items.""" + audit_repo = AuditRepository(db) + resolved_items = await audit_repo.get_resolved_items(limit=limit, offset=offset) + + return [ + ResolvedItemResponse( + id=item.id, + proof_version_id=item.proof_version_id, + agent=item.agent, + issue=item.issue, + resolution=item.resolution, + submitter_name=item.submitter.name if item.submitter else None, + submitter_agency=item.submitter.agency.name if item.submitter and item.submitter.agency else None, + campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None, + proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None, + version=item.proof_version.version if item.proof_version else None, + created_at=item.created_at, + ) + for item in resolved_items + ] + + +@router.get("/audit/errors", response_model=list[ErrorItemResponse]) +async def list_error_items( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List all error items.""" + audit_repo = AuditRepository(db) + error_items = await audit_repo.get_error_items(limit=limit, offset=offset) + + return [ + ErrorItemResponse( + id=item.id, + proof_version_id=item.proof_version_id, + error_summary=item.error_summary, + campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None, + proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None, + version=item.proof_version.version if item.proof_version else None, + created_at=item.created_at, + ) + for item in error_items + ] + + +# Analytics endpoint +@router.get("/analytics", response_model=AnalyticsResponse) +async def get_analytics( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """Get analytics data.""" + repo = CampaignRepository(db) + analytics = await repo.get_analytics() + return AnalyticsResponse(**analytics) + + +# Users endpoint (admin only) +@router.get("/users", response_model=list[UserResponse]) +async def list_users( + db: AsyncSession = Depends(get_db), + user: dict = Depends(get_current_user), +): + """List all users (admin only).""" + user_repo = UserRepository(db) + users = await user_repo.list_all() + + return [ + UserResponse( + id=u.id, + email=u.email, + name=u.name, + role=u.role, + agency=u.agency.name if u.agency else None, + created_at=u.created_at, + ) + for u in users + ] diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..25474ca --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,165 @@ +"""Pydantic schemas for API request/response validation.""" +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +# Campaign schemas +class CampaignCreate(BaseModel): + name: str + workfront_id: Optional[str] = None + client_lead: Optional[str] = None + agency_lead: Optional[str] = None + brand_guidelines: Optional[str] = None + + +class CampaignUpdate(BaseModel): + name: Optional[str] = None + workfront_id: Optional[str] = None + client_lead: Optional[str] = None + agency_lead: Optional[str] = None + brand_guidelines: Optional[str] = None + status: Optional[str] = None + + +class CampaignResponse(BaseModel): + id: uuid.UUID + name: str + workfront_id: Optional[str] + client_lead: Optional[str] + agency_lead: Optional[str] + brand_guidelines: Optional[str] + status: str + agency: Optional[str] + created_at: datetime + updated_at: datetime + proofs: int = 0 + + class Config: + from_attributes = True + + +# Proof schemas +class ProofCreate(BaseModel): + proof_name: str + channel: Optional[str] = None + sub_channel: Optional[str] = None + proof_type: Optional[str] = None + + +class ProofVersionResponse(BaseModel): + id: uuid.UUID + version: int + file_storage_key: Optional[str] + thumbnail_url: Optional[str] + agent_review: Optional[dict] + overall_status: Optional[str] + workfront_id: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class ProofResponse(BaseModel): + id: uuid.UUID + proof_name: str + channel: Optional[str] + sub_channel: Optional[str] + proof_type: Optional[str] + workfront_id: Optional[str] + created_at: datetime + versions: list[ProofVersionResponse] = [] + + class Config: + from_attributes = True + + +# Audit schemas +class FlaggedItemCreate(BaseModel): + proof_version_id: uuid.UUID + agent_flagged: str + comments: Optional[str] = None + + +class FlaggedItemResponse(BaseModel): + id: uuid.UUID + proof_version_id: uuid.UUID + agent_flagged: str + comments: Optional[str] + submitter_name: Optional[str] + submitter_agency: Optional[str] + campaign_name: Optional[str] + proof_name: Optional[str] + version: Optional[int] + created_at: datetime + + class Config: + from_attributes = True + + +class ResolvedItemCreate(BaseModel): + proof_version_id: uuid.UUID + agent: str + issue: Optional[str] = None + resolution: Optional[str] = None + + +class ResolvedItemResponse(BaseModel): + id: uuid.UUID + proof_version_id: uuid.UUID + agent: str + issue: Optional[str] + resolution: Optional[str] + submitter_name: Optional[str] + submitter_agency: Optional[str] + campaign_name: Optional[str] + proof_name: Optional[str] + version: Optional[int] + created_at: datetime + + class Config: + from_attributes = True + + +class ErrorItemResponse(BaseModel): + id: uuid.UUID + proof_version_id: uuid.UUID + error_summary: Optional[str] + campaign_name: Optional[str] + proof_name: Optional[str] + version: Optional[int] + created_at: datetime + + class Config: + from_attributes = True + + +# Analytics schemas +class AnalyticsResponse(BaseModel): + total_reviews: int + passed: int + failed: int + errors: int + legal_review: int + + +# Dropdown options schemas +class DropdownOptionsResponse(BaseModel): + campaigns: list[str] + channels: dict[str, dict[str, list[str]]] + + +# User schemas +class UserResponse(BaseModel): + id: uuid.UUID + email: str + name: str + role: str + agency: Optional[str] + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/config.py b/backend/app/config.py index 43ced75..f3788ba 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,6 +25,16 @@ class Settings: # Auth bypass for development (set to "true" to skip auth) DISABLE_AUTH: bool = os.getenv("DISABLE_AUTH", "false").lower() == "true" + # Database configuration + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://modcomms:modcomms_dev@localhost:5432/modcomms" + ) + + # File storage path for uploaded proofs + _default_storage = Path(__file__).parent.parent.parent / "storage" + FILE_STORAGE_PATH: str = os.getenv("FILE_STORAGE_PATH", str(_default_storage)) + def validate(self) -> None: """Validate required settings are present.""" if not self.GEMINI_API_KEY: diff --git a/backend/app/main.py b/backend/app/main.py index d70ba76..8f291ea 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,8 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.services.auth_service import verify_access_token from app.dependencies.auth import get_current_user +from app.models.database import init_db, close_db +from app.api import router as api_router # Configure logging logging.basicConfig( @@ -40,6 +42,15 @@ async def lifespan(app: FastAPI): # Validate settings settings.validate() + # Initialize database + print("Initializing database connection...") + try: + await init_db() + print("Database initialized successfully") + except Exception as e: + logger.warning(f"Database initialization failed (may not be available): {e}") + print(f"Warning: Database not available - running in stateless mode") + # Initialize services print("Loading reference documents...") reference_docs = ReferenceDocsService(settings.REFERENCE_DOCS_PATH) @@ -59,8 +70,9 @@ async def lifespan(app: FastAPI): yield - # Cleanup on shutdown (if needed) + # Cleanup on shutdown print("Shutting down...") + await close_db() # Create FastAPI app @@ -80,6 +92,9 @@ app.add_middleware( allow_headers=["*"], ) +# Include API routes +app.include_router(api_router, prefix="/api") + @app.get("/health") async def health_check(): diff --git a/backend/app/models/database.py b/backend/app/models/database.py new file mode 100644 index 0000000..520e849 --- /dev/null +++ b/backend/app/models/database.py @@ -0,0 +1,50 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + + +class Base(DeclarativeBase): + """Base class for all SQLAlchemy models.""" + pass + + +# Create async engine +engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_pre_ping=True, + pool_size=5, + max_overflow=10, +) + +# Create async session factory +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db() -> AsyncSession: + """FastAPI dependency to get database session.""" + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db() -> None: + """Initialize database tables.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def close_db() -> None: + """Close database connections.""" + await engine.dispose() diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..45af22d --- /dev/null +++ b/backend/app/models/models.py @@ -0,0 +1,175 @@ +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.database import Base + + +class Agency(Base): + """Agency/organization that users belong to.""" + __tablename__ = "agencies" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + users: Mapped[list["User"]] = relationship("User", back_populates="agency") + campaigns: Mapped[list["Campaign"]] = relationship("Campaign", back_populates="agency") + + +class User(Base): + """User account linked to Azure AD.""" + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + azure_ad_oid: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(50), nullable=False, default="basic_user") + agency_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("agencies.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + agency: Mapped[Optional["Agency"]] = relationship("Agency", back_populates="users") + campaigns: Mapped[list["Campaign"]] = relationship("Campaign", back_populates="created_by_user") + proofs: Mapped[list["Proof"]] = relationship("Proof", back_populates="created_by_user") + flagged_items: Mapped[list["FlaggedItem"]] = relationship("FlaggedItem", back_populates="submitter") + resolved_items: Mapped[list["ResolvedItem"]] = relationship("ResolvedItem", back_populates="submitter") + + +class Campaign(Base): + """Marketing campaign containing proofs.""" + __tablename__ = "campaigns" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False) + workfront_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + client_lead: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + agency_lead: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + brand_guidelines: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + status: Mapped[str] = mapped_column(String(50), default="In Progress") + agency_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("agencies.id"), nullable=True) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + agency: Mapped[Optional["Agency"]] = relationship("Agency", back_populates="campaigns") + created_by_user: Mapped[Optional["User"]] = relationship("User", back_populates="campaigns") + proofs: Mapped[list["Proof"]] = relationship("Proof", back_populates="campaign", cascade="all, delete-orphan") + + +class Proof(Base): + """Marketing proof/asset to be reviewed.""" + __tablename__ = "proofs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + campaign_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False) + proof_name: Mapped[str] = mapped_column(String(255), nullable=False) + channel: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + sub_channel: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + proof_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + workfront_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="proofs") + created_by_user: Mapped[Optional["User"]] = relationship("User", back_populates="proofs") + versions: Mapped[list["ProofVersion"]] = relationship("ProofVersion", back_populates="proof", cascade="all, delete-orphan", order_by="desc(ProofVersion.version)") + + __table_args__ = ( + UniqueConstraint("campaign_id", "proof_name", name="uq_campaign_proof_name"), + ) + + +class ProofVersion(Base): + """Version of a proof with analysis results.""" + __tablename__ = "proof_versions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + proof_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proofs.id", ondelete="CASCADE"), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False) + file_storage_key: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + thumbnail_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + agent_review: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + overall_status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + workfront_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + proof: Mapped["Proof"] = relationship("Proof", back_populates="versions") + flagged_items: Mapped[list["FlaggedItem"]] = relationship("FlaggedItem", back_populates="proof_version") + resolved_items: Mapped[list["ResolvedItem"]] = relationship("ResolvedItem", back_populates="proof_version") + error_items: Mapped[list["ErrorItem"]] = relationship("ErrorItem", back_populates="proof_version") + + __table_args__ = ( + UniqueConstraint("proof_id", "version", name="uq_proof_version"), + ) + + +class FlaggedItem(Base): + """Record of a flagged issue on a proof version.""" + __tablename__ = "flagged_items" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + proof_version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proof_versions.id"), nullable=False) + agent_flagged: Mapped[str] = mapped_column(String(100), nullable=False) + comments: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + submitter_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + proof_version: Mapped["ProofVersion"] = relationship("ProofVersion", back_populates="flagged_items") + submitter: Mapped[Optional["User"]] = relationship("User", back_populates="flagged_items") + + +class ResolvedItem(Base): + """Record of a resolved issue on a proof version.""" + __tablename__ = "resolved_items" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + proof_version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proof_versions.id"), nullable=False) + agent: Mapped[str] = mapped_column(String(100), nullable=False) + issue: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + resolution: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + submitter_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + proof_version: Mapped["ProofVersion"] = relationship("ProofVersion", back_populates="resolved_items") + submitter: Mapped[Optional["User"]] = relationship("User", back_populates="resolved_items") + + +class ErrorItem(Base): + """Record of an analysis error on a proof version.""" + __tablename__ = "error_items" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + proof_version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proof_versions.id"), nullable=False) + error_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + proof_version: Mapped["ProofVersion"] = relationship("ProofVersion", back_populates="error_items") + + +class DropdownOption(Base): + """Configurable dropdown options for channels/sub-channels/proof types.""" + __tablename__ = "dropdown_options" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + option_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'channel', 'sub_channel', 'proof_type' + parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("dropdown_options.id"), nullable=True) + value: Mapped[str] = mapped_column(String(255), nullable=False) + display_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Self-referential relationship for hierarchy + parent: Mapped[Optional["DropdownOption"]] = relationship("DropdownOption", remote_side=[id], back_populates="children") + children: Mapped[list["DropdownOption"]] = relationship("DropdownOption", back_populates="parent") diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..f4ba9f1 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1,11 @@ +from app.repositories.campaign_repository import CampaignRepository +from app.repositories.proof_repository import ProofRepository +from app.repositories.user_repository import UserRepository +from app.repositories.audit_repository import AuditRepository + +__all__ = [ + "CampaignRepository", + "ProofRepository", + "UserRepository", + "AuditRepository", +] diff --git a/backend/app/repositories/audit_repository.py b/backend/app/repositories/audit_repository.py new file mode 100644 index 0000000..2e8a48e --- /dev/null +++ b/backend/app/repositories/audit_repository.py @@ -0,0 +1,163 @@ +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.models import FlaggedItem, ResolvedItem, ErrorItem, ProofVersion, Proof, Campaign + + +class AuditRepository: + """Repository for audit-related database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + # Flagged Items + async def create_flagged_item( + self, + proof_version_id: uuid.UUID, + agent_flagged: str, + comments: Optional[str] = None, + submitter_id: Optional[uuid.UUID] = None, + ) -> FlaggedItem: + """Create a new flagged item.""" + flagged = FlaggedItem( + proof_version_id=proof_version_id, + agent_flagged=agent_flagged, + comments=comments, + submitter_id=submitter_id, + ) + self.session.add(flagged) + await self.session.flush() + return flagged + + async def get_flagged_items( + self, + agency_id: Optional[uuid.UUID] = None, + limit: int = 100, + offset: int = 0, + ) -> list[FlaggedItem]: + """Get all flagged items, optionally filtered by agency.""" + query = ( + select(FlaggedItem) + .options( + selectinload(FlaggedItem.proof_version) + .selectinload(ProofVersion.proof) + .selectinload(Proof.campaign), + selectinload(FlaggedItem.submitter), + ) + .join(ProofVersion) + .join(Proof) + .join(Campaign) + ) + if agency_id: + query = query.where(Campaign.agency_id == agency_id) + query = query.order_by(FlaggedItem.created_at.desc()).limit(limit).offset(offset) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + # Resolved Items + async def create_resolved_item( + self, + proof_version_id: uuid.UUID, + agent: str, + issue: Optional[str] = None, + resolution: Optional[str] = None, + submitter_id: Optional[uuid.UUID] = None, + ) -> ResolvedItem: + """Create a new resolved item.""" + resolved = ResolvedItem( + proof_version_id=proof_version_id, + agent=agent, + issue=issue, + resolution=resolution, + submitter_id=submitter_id, + ) + self.session.add(resolved) + await self.session.flush() + return resolved + + async def get_resolved_items( + self, + agency_id: Optional[uuid.UUID] = None, + limit: int = 100, + offset: int = 0, + ) -> list[ResolvedItem]: + """Get all resolved items, optionally filtered by agency.""" + query = ( + select(ResolvedItem) + .options( + selectinload(ResolvedItem.proof_version) + .selectinload(ProofVersion.proof) + .selectinload(Proof.campaign), + selectinload(ResolvedItem.submitter), + ) + .join(ProofVersion) + .join(Proof) + .join(Campaign) + ) + if agency_id: + query = query.where(Campaign.agency_id == agency_id) + query = query.order_by(ResolvedItem.created_at.desc()).limit(limit).offset(offset) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + # Error Items + async def create_error_item( + self, + proof_version_id: uuid.UUID, + error_summary: Optional[str] = None, + ) -> ErrorItem: + """Create a new error item.""" + error = ErrorItem( + proof_version_id=proof_version_id, + error_summary=error_summary, + ) + self.session.add(error) + await self.session.flush() + return error + + async def get_error_items( + self, + agency_id: Optional[uuid.UUID] = None, + limit: int = 100, + offset: int = 0, + ) -> list[ErrorItem]: + """Get all error items, optionally filtered by agency.""" + query = ( + select(ErrorItem) + .options( + selectinload(ErrorItem.proof_version) + .selectinload(ProofVersion.proof) + .selectinload(Proof.campaign), + ) + .join(ProofVersion) + .join(Proof) + .join(Campaign) + ) + if agency_id: + query = query.where(Campaign.agency_id == agency_id) + query = query.order_by(ErrorItem.created_at.desc()).limit(limit).offset(offset) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def get_audit_log( + self, + agency_id: Optional[uuid.UUID] = None, + limit: int = 100, + ) -> dict: + """Get combined audit log with flagged, resolved, and error items.""" + flagged = await self.get_flagged_items(agency_id=agency_id, limit=limit) + resolved = await self.get_resolved_items(agency_id=agency_id, limit=limit) + errors = await self.get_error_items(agency_id=agency_id, limit=limit) + + return { + "flagged_items": flagged, + "resolved_items": resolved, + "error_items": errors, + } diff --git a/backend/app/repositories/campaign_repository.py b/backend/app/repositories/campaign_repository.py new file mode 100644 index 0000000..6c2aa54 --- /dev/null +++ b/backend/app/repositories/campaign_repository.py @@ -0,0 +1,165 @@ +import uuid +from typing import Optional + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.models import Campaign, Proof, ProofVersion + + +class CampaignRepository: + """Repository for campaign-related database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create( + self, + name: str, + workfront_id: Optional[str] = None, + client_lead: Optional[str] = None, + agency_lead: Optional[str] = None, + brand_guidelines: Optional[str] = None, + agency_id: Optional[uuid.UUID] = None, + created_by: Optional[uuid.UUID] = None, + ) -> Campaign: + """Create a new campaign.""" + campaign = Campaign( + name=name, + workfront_id=workfront_id, + client_lead=client_lead, + agency_lead=agency_lead, + brand_guidelines=brand_guidelines, + agency_id=agency_id, + created_by=created_by, + ) + self.session.add(campaign) + await self.session.flush() + return campaign + + async def get_by_id(self, campaign_id: uuid.UUID) -> Optional[Campaign]: + """Get campaign by ID with related data.""" + result = await self.session.execute( + select(Campaign) + .options( + selectinload(Campaign.agency), + selectinload(Campaign.created_by_user), + selectinload(Campaign.proofs).selectinload(Proof.versions), + ) + .where(Campaign.id == campaign_id) + ) + return result.scalar_one_or_none() + + async def get_by_name(self, name: str) -> Optional[Campaign]: + """Get campaign by name.""" + result = await self.session.execute( + select(Campaign).where(Campaign.name == name) + ) + return result.scalar_one_or_none() + + async def list_all( + self, + agency_id: Optional[uuid.UUID] = None, + limit: int = 100, + offset: int = 0, + ) -> list[Campaign]: + """List campaigns, optionally filtered by agency.""" + query = select(Campaign).options( + selectinload(Campaign.agency), + selectinload(Campaign.proofs), + ) + if agency_id: + query = query.where(Campaign.agency_id == agency_id) + query = query.order_by(Campaign.updated_at.desc()).limit(limit).offset(offset) + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def update( + self, + campaign_id: uuid.UUID, + **kwargs, + ) -> Optional[Campaign]: + """Update campaign fields.""" + campaign = await self.get_by_id(campaign_id) + if not campaign: + return None + + for key, value in kwargs.items(): + if hasattr(campaign, key) and value is not None: + setattr(campaign, key, value) + + await self.session.flush() + return campaign + + async def delete(self, campaign_id: uuid.UUID) -> bool: + """Delete a campaign and all related proofs.""" + campaign = await self.get_by_id(campaign_id) + if not campaign: + return False + + await self.session.delete(campaign) + await self.session.flush() + return True + + async def get_with_proof_counts( + self, + agency_id: Optional[uuid.UUID] = None, + ) -> list[dict]: + """Get campaigns with proof counts.""" + query = ( + select( + Campaign, + func.count(Proof.id).label("proof_count"), + ) + .outerjoin(Proof) + .group_by(Campaign.id) + ) + if agency_id: + query = query.where(Campaign.agency_id == agency_id) + query = query.order_by(Campaign.updated_at.desc()) + + result = await self.session.execute(query) + rows = result.all() + + campaigns_with_counts = [] + for row in rows: + campaign = row[0] + proof_count = row[1] + campaigns_with_counts.append({ + "campaign": campaign, + "proof_count": proof_count, + }) + return campaigns_with_counts + + async def get_analytics( + self, + agency_id: Optional[uuid.UUID] = None, + ) -> dict: + """Get analytics data for campaigns.""" + # Base query for proof versions with status counts + query = ( + select( + func.count(ProofVersion.id).label("total"), + func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Passed").label("passed"), + func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Failed").label("failed"), + func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Analysis Error").label("errors"), + func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Requires Manual Legal Review").label("legal_review"), + ) + .select_from(ProofVersion) + .join(Proof) + .join(Campaign) + ) + if agency_id: + query = query.where(Campaign.agency_id == agency_id) + + result = await self.session.execute(query) + row = result.one() + + return { + "total_reviews": row.total, + "passed": row.passed, + "failed": row.failed, + "errors": row.errors, + "legal_review": row.legal_review, + } diff --git a/backend/app/repositories/proof_repository.py b/backend/app/repositories/proof_repository.py new file mode 100644 index 0000000..be121bb --- /dev/null +++ b/backend/app/repositories/proof_repository.py @@ -0,0 +1,219 @@ +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.models import Proof, ProofVersion + + +class ProofRepository: + """Repository for proof-related database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create( + self, + campaign_id: uuid.UUID, + proof_name: str, + channel: Optional[str] = None, + sub_channel: Optional[str] = None, + proof_type: Optional[str] = None, + workfront_id: Optional[str] = None, + created_by: Optional[uuid.UUID] = None, + ) -> Proof: + """Create a new proof.""" + proof = Proof( + campaign_id=campaign_id, + proof_name=proof_name, + channel=channel, + sub_channel=sub_channel, + proof_type=proof_type, + workfront_id=workfront_id, + created_by=created_by, + ) + self.session.add(proof) + await self.session.flush() + return proof + + async def get_by_id(self, proof_id: uuid.UUID) -> Optional[Proof]: + """Get proof by ID with versions.""" + result = await self.session.execute( + select(Proof) + .options( + selectinload(Proof.versions), + selectinload(Proof.campaign), + ) + .where(Proof.id == proof_id) + ) + return result.scalar_one_or_none() + + async def get_by_campaign_and_name( + self, + campaign_id: uuid.UUID, + proof_name: str, + ) -> Optional[Proof]: + """Get proof by campaign ID and proof name.""" + result = await self.session.execute( + select(Proof) + .options(selectinload(Proof.versions)) + .where(Proof.campaign_id == campaign_id, Proof.proof_name == proof_name) + ) + return result.scalar_one_or_none() + + async def list_by_campaign( + self, + campaign_id: uuid.UUID, + ) -> list[Proof]: + """List all proofs for a campaign.""" + result = await self.session.execute( + select(Proof) + .options(selectinload(Proof.versions)) + .where(Proof.campaign_id == campaign_id) + .order_by(Proof.created_at.desc()) + ) + return list(result.scalars().all()) + + async def delete(self, proof_id: uuid.UUID) -> bool: + """Delete a proof and all its versions.""" + proof = await self.get_by_id(proof_id) + if not proof: + return False + + await self.session.delete(proof) + await self.session.flush() + return True + + async def create_version( + self, + proof_id: uuid.UUID, + version: int, + file_storage_key: Optional[str] = None, + thumbnail_url: Optional[str] = None, + agent_review: Optional[dict] = None, + overall_status: Optional[str] = None, + workfront_id: Optional[str] = None, + ) -> ProofVersion: + """Create a new version of a proof.""" + proof_version = ProofVersion( + proof_id=proof_id, + version=version, + file_storage_key=file_storage_key, + thumbnail_url=thumbnail_url, + agent_review=agent_review, + overall_status=overall_status, + workfront_id=workfront_id, + ) + self.session.add(proof_version) + await self.session.flush() + return proof_version + + async def get_version( + self, + proof_id: uuid.UUID, + version: int, + ) -> Optional[ProofVersion]: + """Get a specific version of a proof.""" + result = await self.session.execute( + select(ProofVersion) + .where( + ProofVersion.proof_id == proof_id, + ProofVersion.version == version, + ) + ) + return result.scalar_one_or_none() + + async def get_version_by_id( + self, + version_id: uuid.UUID, + ) -> Optional[ProofVersion]: + """Get proof version by ID.""" + result = await self.session.execute( + select(ProofVersion) + .options(selectinload(ProofVersion.proof).selectinload(Proof.campaign)) + .where(ProofVersion.id == version_id) + ) + return result.scalar_one_or_none() + + async def get_latest_version_number(self, proof_id: uuid.UUID) -> int: + """Get the latest version number for a proof.""" + result = await self.session.execute( + select(ProofVersion.version) + .where(ProofVersion.proof_id == proof_id) + .order_by(ProofVersion.version.desc()) + .limit(1) + ) + version = result.scalar_one_or_none() + return version if version else 0 + + async def get_or_create_proof( + self, + campaign_id: uuid.UUID, + proof_name: str, + channel: Optional[str] = None, + sub_channel: Optional[str] = None, + proof_type: Optional[str] = None, + created_by: Optional[uuid.UUID] = None, + ) -> tuple[Proof, bool]: + """Get existing proof or create new one. Returns (proof, is_new).""" + proof = await self.get_by_campaign_and_name(campaign_id, proof_name) + if proof: + return proof, False + + proof = await self.create( + campaign_id=campaign_id, + proof_name=proof_name, + channel=channel, + sub_channel=sub_channel, + proof_type=proof_type, + created_by=created_by, + ) + return proof, True + + async def add_version_with_review( + self, + campaign_id: uuid.UUID, + proof_name: str, + channel: Optional[str], + sub_channel: Optional[str], + proof_type: Optional[str], + file_storage_key: Optional[str], + thumbnail_url: Optional[str], + agent_review: dict, + overall_status: str, + created_by: Optional[uuid.UUID] = None, + ) -> tuple[Proof, ProofVersion]: + """Create or get proof and add a new version with review results.""" + proof, is_new = await self.get_or_create_proof( + campaign_id=campaign_id, + proof_name=proof_name, + channel=channel, + sub_channel=sub_channel, + proof_type=proof_type, + created_by=created_by, + ) + + latest_version = await self.get_latest_version_number(proof.id) + new_version_number = latest_version + 1 + + # Generate workfront ID + import random + base_id = proof.workfront_id or f"#WF_{random.randint(10000, 99999)}" + version_workfront_id = f"{base_id.split('-V')[0]}-V{new_version_number}" + + if not proof.workfront_id: + proof.workfront_id = base_id + + version = await self.create_version( + proof_id=proof.id, + version=new_version_number, + file_storage_key=file_storage_key, + thumbnail_url=thumbnail_url, + agent_review=agent_review, + overall_status=overall_status, + workfront_id=version_workfront_id, + ) + + return proof, version diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py new file mode 100644 index 0000000..a2dd8f7 --- /dev/null +++ b/backend/app/repositories/user_repository.py @@ -0,0 +1,95 @@ +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.models import User, Agency + + +class UserRepository: + """Repository for user-related database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_by_id(self, user_id: uuid.UUID) -> Optional[User]: + """Get user by ID.""" + result = await self.session.execute( + select(User) + .options(selectinload(User.agency)) + .where(User.id == user_id) + ) + return result.scalar_one_or_none() + + async def get_by_azure_oid(self, azure_ad_oid: str) -> Optional[User]: + """Get user by Azure AD Object ID.""" + result = await self.session.execute( + select(User) + .options(selectinload(User.agency)) + .where(User.azure_ad_oid == azure_ad_oid) + ) + return result.scalar_one_or_none() + + async def get_or_create_from_azure( + self, + azure_ad_oid: str, + email: str, + name: str, + agency_name: Optional[str] = None + ) -> User: + """Get existing user or create new one from Azure AD claims.""" + user = await self.get_by_azure_oid(azure_ad_oid) + if user: + return user + + # Create new user + agency = None + if agency_name: + agency = await self.get_or_create_agency(agency_name) + + user = User( + azure_ad_oid=azure_ad_oid, + email=email, + name=name, + role="basic_user", + agency_id=agency.id if agency else None, + ) + self.session.add(user) + await self.session.flush() + return user + + async def get_or_create_agency(self, name: str) -> Agency: + """Get existing agency or create new one.""" + result = await self.session.execute( + select(Agency).where(Agency.name == name) + ) + agency = result.scalar_one_or_none() + if agency: + return agency + + agency = Agency(name=name) + self.session.add(agency) + await self.session.flush() + return agency + + async def list_all(self) -> list[User]: + """List all users.""" + result = await self.session.execute( + select(User).options(selectinload(User.agency)) + ) + return list(result.scalars().all()) + + async def update_role(self, user_id: uuid.UUID, role: str) -> Optional[User]: + """Update user role.""" + user = await self.get_by_id(user_id) + if user: + user.role = role + await self.session.flush() + return user + + async def list_agencies(self) -> list[Agency]: + """List all agencies.""" + result = await self.session.execute(select(Agency)) + return list(result.scalars().all()) diff --git a/backend/app/services/storage_service.py b/backend/app/services/storage_service.py new file mode 100644 index 0000000..8691270 --- /dev/null +++ b/backend/app/services/storage_service.py @@ -0,0 +1,115 @@ +import hashlib +import os +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +import aiofiles + +from app.config import settings + + +class StorageService: + """Service for storing and retrieving proof files.""" + + def __init__(self): + self.storage_path = Path(settings.FILE_STORAGE_PATH) + self._ensure_storage_exists() + + def _ensure_storage_exists(self) -> None: + """Ensure the storage directory exists.""" + self.storage_path.mkdir(parents=True, exist_ok=True) + + def _generate_storage_key( + self, + campaign_id: uuid.UUID, + proof_name: str, + version: int, + file_extension: str, + ) -> str: + """Generate a unique storage key for a file.""" + date_prefix = datetime.utcnow().strftime("%Y/%m") + safe_proof_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in proof_name) + filename = f"{safe_proof_name}_v{version}{file_extension}" + return f"{date_prefix}/{campaign_id}/{filename}" + + def _get_file_path(self, storage_key: str) -> Path: + """Get the full file path for a storage key.""" + return self.storage_path / storage_key + + async def store_file( + self, + file_data: bytes, + campaign_id: uuid.UUID, + proof_name: str, + version: int, + file_type: str, + ) -> str: + """Store a file and return the storage key.""" + # Determine file extension from MIME type + extension_map = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "application/pdf": ".pdf", + } + extension = extension_map.get(file_type, ".bin") + + storage_key = self._generate_storage_key( + campaign_id=campaign_id, + proof_name=proof_name, + version=version, + file_extension=extension, + ) + + file_path = self._get_file_path(storage_key) + file_path.parent.mkdir(parents=True, exist_ok=True) + + async with aiofiles.open(file_path, "wb") as f: + await f.write(file_data) + + return storage_key + + async def get_file(self, storage_key: str) -> Optional[bytes]: + """Retrieve a file by its storage key.""" + file_path = self._get_file_path(storage_key) + if not file_path.exists(): + return None + + async with aiofiles.open(file_path, "rb") as f: + return await f.read() + + async def delete_file(self, storage_key: str) -> bool: + """Delete a file by its storage key.""" + file_path = self._get_file_path(storage_key) + if not file_path.exists(): + return False + + os.remove(file_path) + return True + + def get_file_url(self, storage_key: str) -> str: + """Get a URL to access the file (for local storage, returns a relative path).""" + return f"/files/{storage_key}" + + async def generate_thumbnail_data_url( + self, + file_data: bytes, + file_type: str, + ) -> str: + """Generate a data URL for the file (for small previews).""" + import base64 + b64_data = base64.b64encode(file_data).decode("utf-8") + return f"data:{file_type};base64,{b64_data}" + + def get_checksum(self, file_data: bytes) -> str: + """Calculate MD5 checksum of file data.""" + return hashlib.md5(file_data).hexdigest() + + +# Singleton instance +storage_service = StorageService() diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 3b1425f..0e04a10 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -1,10 +1,16 @@ import base64 import logging +import uuid +from typing import Optional + from fastapi import WebSocket from app.websocket.manager import ConnectionManager from app.services.analysis_service import AnalysisService from app.models.schemas import SubReview +from app.models.database import async_session_factory +from app.repositories import ProofRepository, CampaignRepository, UserRepository +from app.services.storage_service import storage_service logger = logging.getLogger(__name__) @@ -87,39 +93,97 @@ async def handle_analyze_message( is_wip=is_wip, ) + # Build the result dict + result_dict = { + "legalAgentReview": { + "ragStatus": result.legalAgentReview.ragStatus, + "feedback": result.legalAgentReview.feedback, + "issues": result.legalAgentReview.issues, + "isFinancialPromotion": result.legalAgentReview.isFinancialPromotion, + "financialPromotionReason": result.legalAgentReview.financialPromotionReason, + }, + "brandAgentReview": { + "ragStatus": result.brandAgentReview.ragStatus, + "feedback": result.brandAgentReview.feedback, + "issues": result.brandAgentReview.issues, + }, + "toneAgentReview": { + "ragStatus": result.toneAgentReview.ragStatus, + "feedback": result.toneAgentReview.feedback, + "issues": result.toneAgentReview.issues, + }, + "channelAgentReview": { + "ragStatus": result.channelAgentReview.ragStatus, + "feedback": result.channelAgentReview.feedback, + "issues": result.channelAgentReview.issues, + }, + "leadAgentSummary": result.leadAgentSummary, + "overallStatus": result.overallStatus, + "financialPromotionReason": result.financialPromotionReason, + } + + # Persist to database if campaign info provided + proof_id: Optional[str] = None + version_id: Optional[str] = None + campaign_id = data.get("campaign_id") + proof_name = data.get("proof_name") + + if campaign_id and proof_name: + try: + logger.info(f"[WEBSOCKET] Persisting result for campaign {campaign_id}, proof {proof_name}") + async with async_session_factory() as session: + proof_repo = ProofRepository(session) + + # Store the file + file_storage_key = await storage_service.store_file( + file_data=file_data, + campaign_id=uuid.UUID(campaign_id), + proof_name=proof_name, + version=1, # Will be updated by add_version_with_review + file_type=file_type, + ) + + # Generate thumbnail for small files + thumbnail_url = None + if len(file_data) < 500000: # < 500KB + thumbnail_url = await storage_service.generate_thumbnail_data_url(file_data, file_type) + + # Save proof and version + proof, version = await proof_repo.add_version_with_review( + campaign_id=uuid.UUID(campaign_id), + proof_name=proof_name, + channel=data.get("channel"), + sub_channel=data.get("sub_channel"), + proof_type=data.get("proof_type"), + file_storage_key=file_storage_key, + thumbnail_url=thumbnail_url, + agent_review=result_dict, + overall_status=result.overallStatus, + ) + + await session.commit() + proof_id = str(proof.id) + version_id = str(version.id) + logger.info(f"[WEBSOCKET] Persisted proof {proof_id} version {version.version}") + + except Exception as e: + logger.error(f"[WEBSOCKET] Failed to persist result: {str(e)}") + # Continue - still send result to client even if persistence fails + # Send the complete result logger.info(f"[WEBSOCKET] Analysis complete, sending result - overallStatus: {result.overallStatus}") if manager.is_connected(client_id): - await manager.send_message(client_id, { + response = { "type": "complete", - "result": { - "legalAgentReview": { - "ragStatus": result.legalAgentReview.ragStatus, - "feedback": result.legalAgentReview.feedback, - "issues": result.legalAgentReview.issues, - "isFinancialPromotion": result.legalAgentReview.isFinancialPromotion, - "financialPromotionReason": result.legalAgentReview.financialPromotionReason, - }, - "brandAgentReview": { - "ragStatus": result.brandAgentReview.ragStatus, - "feedback": result.brandAgentReview.feedback, - "issues": result.brandAgentReview.issues, - }, - "toneAgentReview": { - "ragStatus": result.toneAgentReview.ragStatus, - "feedback": result.toneAgentReview.feedback, - "issues": result.toneAgentReview.issues, - }, - "channelAgentReview": { - "ragStatus": result.channelAgentReview.ragStatus, - "feedback": result.channelAgentReview.feedback, - "issues": result.channelAgentReview.issues, - }, - "leadAgentSummary": result.leadAgentSummary, - "overallStatus": result.overallStatus, - "financialPromotionReason": result.financialPromotionReason, - } - }) + "result": result_dict, + } + # Include proof/version IDs if persisted + if proof_id: + response["proof_id"] = proof_id + if version_id: + response["version_id"] = version_id + + await manager.send_message(client_id, response) logger.info(f"[WEBSOCKET] Result sent to client: {client_id}") except Exception as e: diff --git a/backend/requirements.txt b/backend/requirements.txt index d15d752..76d4e5b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,6 @@ aiofiles>=23.2.1 websockets>=12.0 python-jose[cryptography]>=3.3.0 httpx>=0.26.0 +sqlalchemy[asyncio]>=2.0.0 +asyncpg>=0.29.0 +alembic>=1.13.0 diff --git a/deploy.sh b/deploy.sh index e0373ad..6022646 100755 --- a/deploy.sh +++ b/deploy.sh @@ -46,7 +46,11 @@ fi # Set defaults for docker compose variables COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-modcomms}" BACKEND_PORT="${BACKEND_PORT:-8000}" -echo " Environment: ${COMPOSE_PROJECT_NAME} (port ${BACKEND_PORT})" +POSTGRES_PORT="${POSTGRES_PORT:-5432}" +POSTGRES_USER="${POSTGRES_USER:-modcomms}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-modcomms_dev}" +POSTGRES_DB="${POSTGRES_DB:-modcomms}" +echo " Environment: ${COMPOSE_PROJECT_NAME} (backend:${BACKEND_PORT}, postgres:${POSTGRES_PORT})" # Create .env file for docker compose (so manual docker compose commands work) cat > .env << EOF @@ -54,6 +58,10 @@ cat > .env << EOF # Edit .env.deploy instead and re-run deploy.sh COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME} BACKEND_PORT=${BACKEND_PORT} +POSTGRES_PORT=${POSTGRES_PORT} +POSTGRES_USER=${POSTGRES_USER} +POSTGRES_PASSWORD=${POSTGRES_PASSWORD} +POSTGRES_DB=${POSTGRES_DB} EOF echo " ✓ .env created for docker compose" @@ -75,7 +83,7 @@ echo " ✓ backend/.env exists" # --- 1. Pull latest code (skip if not a git repo or no remote) --- echo "" -echo "[1/5] Updating code..." +echo "[1/6] Updating code..." if [ -d .git ]; then if git remote -v | grep -q origin; then git pull || echo "Warning: git pull failed, continuing with local code" @@ -88,7 +96,7 @@ fi # --- 2. Build frontend --- echo "" -echo "[2/5] Building frontend..." +echo "[2/6] Building frontend..." cd frontend # Install dependencies (npm install is idempotent) @@ -106,7 +114,7 @@ cd "$SCRIPT_DIR" # --- 3. Deploy frontend to Apache --- echo "" -echo "[3/5] Deploying frontend to ${FRONTEND_DEPLOY_DIR}..." +echo "[3/6] Deploying frontend to ${FRONTEND_DEPLOY_DIR}..." # Create directory if it doesn't exist sudo mkdir -p "$FRONTEND_DEPLOY_DIR" @@ -123,7 +131,7 @@ echo " ✓ Frontend deployed" # --- 4. Update backend configuration --- echo "" -echo "[4/5] Updating backend configuration..." +echo "[4/6] Updating backend configuration..." # Update CORS_ORIGINS if specified if [ -n "$CORS_ORIGINS" ]; then @@ -135,15 +143,46 @@ if [ -n "$CORS_ORIGINS" ]; then echo " ✓ CORS_ORIGINS updated" fi -# --- 5. Build and start/restart backend containers --- +# --- 5. Build containers and run database migrations --- echo "" -echo "[5/5] Building and starting backend..." +echo "[5/6] Building containers and running database migrations..." # Build image (always rebuild to pick up code changes) docker compose build -# Start or restart containers (docker compose up -d is idempotent) -docker compose up -d +# Start PostgreSQL first and wait for it to be healthy +echo " Starting PostgreSQL..." +docker compose up -d postgres + +echo " Waiting for PostgreSQL to be ready..." +for i in {1..30}; do + if docker compose exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + echo " ✓ PostgreSQL is ready" + break + fi + if [ $i -eq 30 ]; then + echo " Error: PostgreSQL failed to start" + docker compose logs postgres + exit 1 + fi + sleep 1 +done + +# Run database migrations +echo " Running database migrations..." +if docker compose run --rm backend alembic upgrade head; then + echo " ✓ Database migrations complete" +else + echo " Error: Database migrations failed" + exit 1 +fi + +# --- 6. Start backend service --- +echo "" +echo "[6/6] Starting backend service..." + +# Start backend container +docker compose up -d backend # Wait for health check echo " Waiting for backend to be healthy..." diff --git a/docker-compose.yml b/docker-compose.yml index 7321ecd..5963351 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,22 @@ services: + postgres: + image: postgres:16-alpine + ports: + - "${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-modcomms} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-modcomms_dev} + POSTGRES_DB: ${POSTGRES_DB:-modcomms} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-modcomms} -d ${POSTGRES_DB:-modcomms}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + backend: build: context: . @@ -10,6 +28,10 @@ services: environment: - HOST=0.0.0.0 - PORT=8000 + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-modcomms}:${POSTGRES_PASSWORD:-modcomms_dev}@postgres:5432/${POSTGRES_DB:-modcomms} + depends_on: + postgres: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s @@ -17,3 +39,6 @@ services: retries: 3 start_period: 10s restart: unless-stopped + +volumes: + postgres_data: diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts new file mode 100644 index 0000000..499b526 --- /dev/null +++ b/frontend/services/apiService.ts @@ -0,0 +1,317 @@ +import { IPublicClientApplication } from '@azure/msal-browser'; +import { getAccessToken } from './authService'; +import type { AgentReview, FlaggedItem, ResolvedItem, ErrorItem } from '../types'; + +const API_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; + +// Types for API responses +export interface CampaignResponse { + id: string; + name: string; + workfront_id: string | null; + client_lead: string | null; + agency_lead: string | null; + brand_guidelines: string | null; + status: string; + agency: string | null; + created_at: string; + updated_at: string; + proofs: number; +} + +export interface ProofVersionResponse { + id: string; + version: number; + file_storage_key: string | null; + thumbnail_url: string | null; + agent_review: AgentReview | null; + overall_status: string | null; + workfront_id: string | null; + created_at: string; +} + +export interface ProofResponse { + id: string; + proof_name: string; + channel: string | null; + sub_channel: string | null; + proof_type: string | null; + workfront_id: string | null; + created_at: string; + versions: ProofVersionResponse[]; +} + +export interface AnalyticsResponse { + total_reviews: number; + passed: number; + failed: number; + errors: number; + legal_review: number; +} + +export interface FlaggedItemResponse { + id: string; + proof_version_id: string; + agent_flagged: string; + comments: string | null; + submitter_name: string | null; + submitter_agency: string | null; + campaign_name: string | null; + proof_name: string | null; + version: number | null; + created_at: string; +} + +export interface ResolvedItemResponse { + id: string; + proof_version_id: string; + agent: string; + issue: string | null; + resolution: string | null; + submitter_name: string | null; + submitter_agency: string | null; + campaign_name: string | null; + proof_name: string | null; + version: number | null; + created_at: string; +} + +export interface ErrorItemResponse { + id: string; + proof_version_id: string; + error_summary: string | null; + campaign_name: string | null; + proof_name: string | null; + version: number | null; + created_at: string; +} + +class ApiService { + private msalInstance: IPublicClientApplication | null = null; + + setMsalInstance(instance: IPublicClientApplication) { + this.msalInstance = instance; + } + + private async getHeaders(): Promise { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (this.msalInstance) { + const token = await getAccessToken(this.msalInstance); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + return headers; + } + + private async fetch(endpoint: string, options: RequestInit = {}): Promise { + const headers = await this.getHeaders(); + const response = await fetch(`${API_URL}/api${endpoint}`, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + // Campaign endpoints + async getCampaigns(): Promise { + return this.fetch('/campaigns'); + } + + async getCampaign(id: string): Promise { + return this.fetch(`/campaigns/${id}`); + } + + async createCampaign(data: { + name: string; + workfront_id?: string; + client_lead?: string; + agency_lead?: string; + brand_guidelines?: string; + }): Promise { + return this.fetch('/campaigns', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateCampaign(id: string, data: { + name?: string; + workfront_id?: string; + client_lead?: string; + agency_lead?: string; + brand_guidelines?: string; + status?: string; + }): Promise { + return this.fetch(`/campaigns/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteCampaign(id: string): Promise { + return this.fetch(`/campaigns/${id}`, { + method: 'DELETE', + }); + } + + // Proof endpoints + async getProofs(campaignId: string): Promise { + return this.fetch(`/campaigns/${campaignId}/proofs`); + } + + async getProof(id: string): Promise { + return this.fetch(`/proofs/${id}`); + } + + async deleteProof(id: string): Promise { + return this.fetch(`/proofs/${id}`, { + method: 'DELETE', + }); + } + + // Audit endpoints + async flagProofVersion(proofId: string, version: number, data: { + agent_flagged: string; + comments?: string; + }): Promise { + return this.fetch(`/proofs/${proofId}/versions/${version}/flag`, { + method: 'POST', + body: JSON.stringify({ + proof_version_id: '', // Will be set by backend + ...data, + }), + }); + } + + async resolveProofVersion(proofId: string, version: number, data: { + agent: string; + issue?: string; + resolution?: string; + }): Promise { + return this.fetch(`/proofs/${proofId}/versions/${version}/resolve`, { + method: 'POST', + body: JSON.stringify({ + proof_version_id: '', // Will be set by backend + ...data, + }), + }); + } + + async getFlaggedItems(): Promise { + return this.fetch('/audit/flagged'); + } + + async getResolvedItems(): Promise { + return this.fetch('/audit/resolved'); + } + + async getErrorItems(): Promise { + return this.fetch('/audit/errors'); + } + + // Analytics endpoint + async getAnalytics(): Promise { + return this.fetch('/analytics'); + } + + // Helper to convert API response to frontend format + convertCampaignToFrontend(campaign: CampaignResponse) { + return { + name: campaign.name, + workfrontId: campaign.workfront_id || '', + clientLead: campaign.client_lead || '', + agency: campaign.agency || '', + agencyLead: campaign.agency_lead || '', + proofs: campaign.proofs, + status: campaign.status as 'In Progress' | 'Completed', + lastModified: campaign.updated_at, + brandGuidelines: campaign.brand_guidelines || 'Barclays', + _id: campaign.id, + }; + } + + convertProofToFrontend(proof: ProofResponse) { + const latestVersion = proof.versions[0]; + return { + proofName: proof.proof_name, + channel: proof.channel || '', + subChannel: proof.sub_channel || '', + proofType: proof.proof_type || '', + status: latestVersion?.overall_status === 'Analysis Error' ? 'error' : 'completed' as 'completed' | 'analyzing' | 'error' | 'loading', + overallStatus: latestVersion?.overall_status as any, + versions: proof.versions.map(v => ({ + version: v.version, + timestamp: v.created_at.split('T')[0], + workfrontId: v.workfront_id || '', + proofPreviewUrl: v.thumbnail_url || '', + feedback: v.agent_review || {} as AgentReview, + overallStatus: v.overall_status as any, + })), + _id: proof.id, + }; + } + + convertFlaggedItemToFrontend(item: FlaggedItemResponse): FlaggedItem { + return { + id: item.id, + campaignName: item.campaign_name || '', + proofName: item.proof_name || '', + version: item.version || 1, + submitter: item.submitter_name || '', + submitAgency: item.submitter_agency || '', + agentFlagged: item.agent_flagged, + comments: item.comments || '', + timestamp: item.created_at, + }; + } + + convertResolvedItemToFrontend(item: ResolvedItemResponse): ResolvedItem { + return { + id: item.id, + campaignName: item.campaign_name || '', + proofName: item.proof_name || '', + version: item.version || 1, + submitter: item.submitter_name || '', + submitAgency: item.submitter_agency || '', + agent: item.agent, + issue: item.issue || '', + resolution: item.resolution || '', + timestamp: item.created_at, + }; + } + + convertErrorItemToFrontend(item: ErrorItemResponse): ErrorItem { + return { + id: item.id, + campaignName: item.campaign_name || '', + proofName: item.proof_name || '', + version: item.version || 1, + submitter: '', + submitAgency: '', + errorSummary: item.error_summary || '', + timestamp: item.created_at, + }; + } +} + +export const apiService = new ApiService(); +export default apiService; diff --git a/frontend/services/geminiService.ts b/frontend/services/geminiService.ts index d9adfb3..fdd2d28 100644 --- a/frontend/services/geminiService.ts +++ b/frontend/services/geminiService.ts @@ -6,16 +6,38 @@ import { getAccessToken } from './authService'; const WS_URL = import.meta.env.VITE_BACKEND_WS_URL || 'ws://localhost:8000/ws/analyze'; const HTTP_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; +/** + * Options for proof analysis with optional database persistence. + */ +export interface AnalyzeProofOptions { + campaignId?: string; + proofName?: string; + channel?: string; + subChannel?: string; + proofType?: string; +} + +/** + * Result of proof analysis, including optional database IDs if persisted. + */ +export interface AnalyzeProofResult { + review: AgentReview; + proofId?: string; + versionId?: string; +} + /** * Analyze a proof using the backend WebSocket API. * Provides real-time updates as each agent completes. * Now requires MSAL instance to acquire access token. + * Optionally pass campaign info to persist results to database. */ export const analyzeProof = async ( file: File, onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void, - msalInstance: IPublicClientApplication -): Promise => { + msalInstance: IPublicClientApplication, + options?: AnalyzeProofOptions +): Promise => { // Acquire token before connecting const accessToken = await getAccessToken(msalInstance); if (!accessToken) { @@ -31,13 +53,32 @@ export const analyzeProof = async ( const reader = new FileReader(); reader.onloadend = () => { const base64Data = (reader.result as string).split(',')[1]; - ws.send(JSON.stringify({ + const message: Record = { type: 'analyze', file_data: base64Data, file_type: file.type, is_wip: false, access_token: accessToken - })); + }; + + // Include campaign info for database persistence if provided + if (options?.campaignId) { + message.campaign_id = options.campaignId; + } + if (options?.proofName) { + message.proof_name = options.proofName; + } + if (options?.channel) { + message.channel = options.channel; + } + if (options?.subChannel) { + message.sub_channel = options.subChannel; + } + if (options?.proofType) { + message.proof_type = options.proofType; + } + + ws.send(JSON.stringify(message)); }; reader.onerror = () => { ws.close(); @@ -69,7 +110,11 @@ export const analyzeProof = async ( // Analysis complete - resolve with full result resolved = true; ws.close(); - resolve(message.result as AgentReview); + resolve({ + review: message.result as AgentReview, + proofId: message.proof_id, + versionId: message.version_id, + }); break; case 'error':