Add PostgreSQL database support with Alembic migrations

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 <noreply@anthropic.com>
This commit is contained in:
michael 2025-12-16 12:27:18 -06:00
parent 321a9ca820
commit 99af0164e6
26 changed files with 2534 additions and 44 deletions

View file

@ -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

View file

@ -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

View file

@ -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/

42
backend/alembic.ini Normal file
View file

@ -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

74
backend/alembic/env.py Normal file
View file

@ -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()

View file

@ -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"}

View file

@ -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')

View file

@ -0,0 +1,3 @@
from app.api.routes import router
__all__ = ["router"]

488
backend/app/api/routes.py Normal file
View file

@ -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
]

165
backend/app/api/schemas.py Normal file
View file

@ -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

View file

@ -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:

View file

@ -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():

View file

@ -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()

View file

@ -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")

View file

@ -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",
]

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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

View file

@ -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())

View file

@ -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()

View file

@ -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:

View file

@ -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

View file

@ -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..."

View file

@ -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:

View file

@ -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<HeadersInit> {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
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<CampaignResponse[]> {
return this.fetch<CampaignResponse[]>('/campaigns');
}
async getCampaign(id: string): Promise<CampaignResponse> {
return this.fetch<CampaignResponse>(`/campaigns/${id}`);
}
async createCampaign(data: {
name: string;
workfront_id?: string;
client_lead?: string;
agency_lead?: string;
brand_guidelines?: string;
}): Promise<CampaignResponse> {
return this.fetch<CampaignResponse>('/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<CampaignResponse> {
return this.fetch<CampaignResponse>(`/campaigns/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteCampaign(id: string): Promise<void> {
return this.fetch<void>(`/campaigns/${id}`, {
method: 'DELETE',
});
}
// Proof endpoints
async getProofs(campaignId: string): Promise<ProofResponse[]> {
return this.fetch<ProofResponse[]>(`/campaigns/${campaignId}/proofs`);
}
async getProof(id: string): Promise<ProofResponse> {
return this.fetch<ProofResponse>(`/proofs/${id}`);
}
async deleteProof(id: string): Promise<void> {
return this.fetch<void>(`/proofs/${id}`, {
method: 'DELETE',
});
}
// Audit endpoints
async flagProofVersion(proofId: string, version: number, data: {
agent_flagged: string;
comments?: string;
}): Promise<FlaggedItemResponse> {
return this.fetch<FlaggedItemResponse>(`/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<ResolvedItemResponse> {
return this.fetch<ResolvedItemResponse>(`/proofs/${proofId}/versions/${version}/resolve`, {
method: 'POST',
body: JSON.stringify({
proof_version_id: '', // Will be set by backend
...data,
}),
});
}
async getFlaggedItems(): Promise<FlaggedItemResponse[]> {
return this.fetch<FlaggedItemResponse[]>('/audit/flagged');
}
async getResolvedItems(): Promise<ResolvedItemResponse[]> {
return this.fetch<ResolvedItemResponse[]>('/audit/resolved');
}
async getErrorItems(): Promise<ErrorItemResponse[]> {
return this.fetch<ErrorItemResponse[]>('/audit/errors');
}
// Analytics endpoint
async getAnalytics(): Promise<AnalyticsResponse> {
return this.fetch<AnalyticsResponse>('/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;

View file

@ -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<AgentReview> => {
msalInstance: IPublicClientApplication,
options?: AnalyzeProofOptions
): Promise<AnalyzeProofResult> => {
// 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<string, any> = {
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':