Connect frontend to PostgreSQL database via API

- Replace all localStorage-based state management with API calls
- Load campaigns, proofs, and audit items from database
- Persist proof analysis results to database via WebSocket
- Add dropdown options CRUD API endpoints (channels, sub-channels, proof types)
- Create DropdownRepository for managing dropdown options
- Update Analytics component to fetch data from API
- Remove demo data and localStorage persistence code

Frontend changes:
- App.tsx: Initialize apiService with MSAL, use API for all CRUD operations
- apiService.ts: Add dropdown options API methods
- Analytics.tsx: Fetch stats from /api/analytics

Backend changes:
- New dropdown_repository.py for dropdown CRUD
- routes.py: Add 7 dropdown endpoints

🤖 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-18 13:50:37 -06:00
parent 6ff69cc308
commit c07c66a583
6 changed files with 778 additions and 472 deletions

View file

@ -28,6 +28,7 @@ from app.repositories import (
ProofRepository,
UserRepository,
AuditRepository,
DropdownRepository,
)
router = APIRouter()
@ -486,3 +487,106 @@ async def list_users(
)
for u in users
]
# Dropdown options endpoints
@router.get("/dropdown-options", response_model=DropdownOptionsResponse)
async def get_dropdown_options(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Get all dropdown options as hierarchical structure."""
repo = DropdownRepository(db)
options = await repo.get_all_hierarchical()
return DropdownOptionsResponse(**options)
@router.post("/dropdown-options/channels", status_code=201)
async def add_channel(
name: str = Query(..., description="Channel name"),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Add a new channel."""
repo = DropdownRepository(db)
await repo.add_channel(name)
await db.commit()
return {"message": f"Channel '{name}' added successfully"}
@router.post("/dropdown-options/channels/{channel}/sub-channels", status_code=201)
async def add_sub_channel(
channel: str,
name: str = Query(..., description="Sub-channel name"),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Add a sub-channel under a channel."""
repo = DropdownRepository(db)
result = await repo.add_sub_channel(channel, name)
if not result:
raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found")
await db.commit()
return {"message": f"Sub-channel '{name}' added to '{channel}'"}
@router.post("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types", status_code=201)
async def add_proof_type(
channel: str,
sub_channel: str,
name: str = Query(..., description="Proof type name"),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Add a proof type under a sub-channel."""
repo = DropdownRepository(db)
result = await repo.add_proof_type(channel, sub_channel, name)
if not result:
raise HTTPException(status_code=404, detail=f"Channel '{channel}' or sub-channel '{sub_channel}' not found")
await db.commit()
return {"message": f"Proof type '{name}' added to '{channel}/{sub_channel}'"}
@router.delete("/dropdown-options/channels/{channel}", status_code=204)
async def delete_channel(
channel: str,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a channel and all its sub-channels and proof types."""
repo = DropdownRepository(db)
success = await repo.remove_channel(channel)
if not success:
raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found")
await db.commit()
@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}", status_code=204)
async def delete_sub_channel(
channel: str,
sub_channel: str,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a sub-channel and all its proof types."""
repo = DropdownRepository(db)
success = await repo.remove_sub_channel(channel, sub_channel)
if not success:
raise HTTPException(status_code=404, detail=f"Sub-channel '{sub_channel}' not found in channel '{channel}'")
await db.commit()
@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types/{proof_type}", status_code=204)
async def delete_proof_type(
channel: str,
sub_channel: str,
proof_type: str,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a proof type."""
repo = DropdownRepository(db)
success = await repo.remove_proof_type(channel, sub_channel, proof_type)
if not success:
raise HTTPException(status_code=404, detail=f"Proof type '{proof_type}' not found")
await db.commit()

View file

@ -2,10 +2,12 @@ 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
from app.repositories.dropdown_repository import DropdownRepository
__all__ = [
"CampaignRepository",
"ProofRepository",
"UserRepository",
"AuditRepository",
"DropdownRepository",
]

View file

@ -0,0 +1,202 @@
"""Repository for dropdown options (channels, sub-channels, proof types)."""
import uuid
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import DropdownOption
class DropdownRepository:
"""Repository for managing dropdown options."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_all_hierarchical(self) -> dict:
"""
Get all dropdown options as a hierarchical structure.
Returns: { channels: { channel_name: { sub_channel_name: [proof_types] } } }
"""
# Get all channels (top-level, no parent)
stmt = select(DropdownOption).where(
DropdownOption.option_type == "channel",
DropdownOption.parent_id.is_(None)
).options(selectinload(DropdownOption.children).selectinload(DropdownOption.children))
result = await self.session.execute(stmt)
channels = result.scalars().all()
# Build hierarchical structure
hierarchy: dict[str, dict[str, list[str]]] = {}
for channel in channels:
channel_name = channel.value
hierarchy[channel_name] = {}
for sub_channel in channel.children:
sub_channel_name = sub_channel.value
hierarchy[channel_name][sub_channel_name] = [
pt.value for pt in sub_channel.children
]
return {"channels": hierarchy, "campaigns": []} # campaigns not used from dropdown options
async def add_channel(self, name: str) -> DropdownOption:
"""Add a new channel (top-level option)."""
option = DropdownOption(
option_type="channel",
value=name,
parent_id=None
)
self.session.add(option)
await self.session.flush()
return option
async def add_sub_channel(self, channel_name: str, sub_channel_name: str) -> Optional[DropdownOption]:
"""Add a sub-channel under a channel."""
# Find the parent channel
stmt = select(DropdownOption).where(
DropdownOption.option_type == "channel",
DropdownOption.value == channel_name
)
result = await self.session.execute(stmt)
channel = result.scalar_one_or_none()
if not channel:
return None
option = DropdownOption(
option_type="sub_channel",
value=sub_channel_name,
parent_id=channel.id
)
self.session.add(option)
await self.session.flush()
return option
async def add_proof_type(self, channel_name: str, sub_channel_name: str, proof_type_name: str) -> Optional[DropdownOption]:
"""Add a proof type under a sub-channel."""
# Find the parent sub-channel by traversing from channel
stmt = select(DropdownOption).where(
DropdownOption.option_type == "channel",
DropdownOption.value == channel_name
)
result = await self.session.execute(stmt)
channel = result.scalar_one_or_none()
if not channel:
return None
# Find sub-channel
stmt = select(DropdownOption).where(
DropdownOption.option_type == "sub_channel",
DropdownOption.value == sub_channel_name,
DropdownOption.parent_id == channel.id
)
result = await self.session.execute(stmt)
sub_channel = result.scalar_one_or_none()
if not sub_channel:
return None
option = DropdownOption(
option_type="proof_type",
value=proof_type_name,
parent_id=sub_channel.id
)
self.session.add(option)
await self.session.flush()
return option
async def remove_channel(self, channel_name: str) -> bool:
"""Remove a channel and all its children (cascades)."""
stmt = select(DropdownOption).where(
DropdownOption.option_type == "channel",
DropdownOption.value == channel_name
).options(selectinload(DropdownOption.children).selectinload(DropdownOption.children))
result = await self.session.execute(stmt)
channel = result.scalar_one_or_none()
if not channel:
return False
# Delete proof types, then sub-channels, then channel
for sub_channel in channel.children:
for proof_type in sub_channel.children:
await self.session.delete(proof_type)
await self.session.delete(sub_channel)
await self.session.delete(channel)
await self.session.flush()
return True
async def remove_sub_channel(self, channel_name: str, sub_channel_name: str) -> bool:
"""Remove a sub-channel and its proof types."""
stmt = select(DropdownOption).where(
DropdownOption.option_type == "channel",
DropdownOption.value == channel_name
)
result = await self.session.execute(stmt)
channel = result.scalar_one_or_none()
if not channel:
return False
stmt = select(DropdownOption).where(
DropdownOption.option_type == "sub_channel",
DropdownOption.value == sub_channel_name,
DropdownOption.parent_id == channel.id
).options(selectinload(DropdownOption.children))
result = await self.session.execute(stmt)
sub_channel = result.scalar_one_or_none()
if not sub_channel:
return False
# Delete proof types, then sub-channel
for proof_type in sub_channel.children:
await self.session.delete(proof_type)
await self.session.delete(sub_channel)
await self.session.flush()
return True
async def remove_proof_type(self, channel_name: str, sub_channel_name: str, proof_type_name: str) -> bool:
"""Remove a proof type."""
stmt = select(DropdownOption).where(
DropdownOption.option_type == "channel",
DropdownOption.value == channel_name
)
result = await self.session.execute(stmt)
channel = result.scalar_one_or_none()
if not channel:
return False
stmt = select(DropdownOption).where(
DropdownOption.option_type == "sub_channel",
DropdownOption.value == sub_channel_name,
DropdownOption.parent_id == channel.id
)
result = await self.session.execute(stmt)
sub_channel = result.scalar_one_or_none()
if not sub_channel:
return False
stmt = select(DropdownOption).where(
DropdownOption.option_type == "proof_type",
DropdownOption.value == proof_type_name,
DropdownOption.parent_id == sub_channel.id
)
result = await self.session.execute(stmt)
proof_type = result.scalar_one_or_none()
if not proof_type:
return False
await self.session.delete(proof_type)
await self.session.flush()
return True

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,12 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { UploadIcon } from './icons/UploadIcon';
import { TrendingUpIcon } from './icons/TrendingUpIcon';
import { BugIcon } from './icons/BugIcon';
import { ClockIcon } from './icons/ClockIcon';
import { LightbulbIcon } from './icons/LightbulbIcon';
import apiService, { AnalyticsResponse } from '../services/apiService';
const stats = [
{ name: 'Proofs Uploaded', value: '57', icon: UploadIcon },
{ name: 'Pass Rate', value: '76%', icon: TrendingUpIcon },
{ name: 'Issues Found', value: '34', icon: BugIcon },
{ name: 'Time Saved', value: '93 hours', icon: ClockIcon },
];
// Agent performance is still static for now - would need separate API
const agentPerformance = [
{ name: 'Legal Agent', passRate: 85, avgIssues: 1.2, trend: 'up' },
{ name: 'Brand Agent', passRate: 68, avgIssues: 2.5, trend: 'down' },
@ -34,6 +29,35 @@ const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend }
};
export const Analytics: React.FC = () => {
const [analytics, setAnalytics] = useState<AnalyticsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadAnalytics = async () => {
try {
const data = await apiService.getAnalytics();
setAnalytics(data);
} catch (error) {
console.error('Failed to load analytics:', error);
} finally {
setIsLoading(false);
}
};
loadAnalytics();
}, []);
// Calculate stats from API data
const passRate = analytics && analytics.total_reviews > 0
? Math.round((analytics.passed / analytics.total_reviews) * 100)
: 0;
const stats = [
{ name: 'Proofs Reviewed', value: analytics?.total_reviews?.toString() || '0', icon: UploadIcon },
{ name: 'Pass Rate', value: `${passRate}%`, icon: TrendingUpIcon },
{ name: 'Failed Reviews', value: analytics?.failed?.toString() || '0', icon: BugIcon },
{ name: 'Legal Review Required', value: analytics?.legal_review?.toString() || '0', icon: ClockIcon },
];
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
<header className="mb-8">

View file

@ -311,6 +311,52 @@ class ApiService {
timestamp: item.created_at,
};
}
// Dropdown options endpoints
async getDropdownOptions(): Promise<DropdownOptionsResponse> {
return this.fetch<DropdownOptionsResponse>('/dropdown-options');
}
async addChannel(name: string): Promise<void> {
await this.fetch<void>(`/dropdown-options/channels?name=${encodeURIComponent(name)}`, {
method: 'POST',
});
}
async addSubChannel(channel: string, name: string): Promise<void> {
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels?name=${encodeURIComponent(name)}`, {
method: 'POST',
});
}
async addProofType(channel: string, subChannel: string, name: string): Promise<void> {
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}/proof-types?name=${encodeURIComponent(name)}`, {
method: 'POST',
});
}
async deleteChannel(channel: string): Promise<void> {
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}`, {
method: 'DELETE',
});
}
async deleteSubChannel(channel: string, subChannel: string): Promise<void> {
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}`, {
method: 'DELETE',
});
}
async deleteProofType(channel: string, subChannel: string, proofType: string): Promise<void> {
await this.fetch<void>(`/dropdown-options/channels/${encodeURIComponent(channel)}/sub-channels/${encodeURIComponent(subChannel)}/proof-types/${encodeURIComponent(proofType)}`, {
method: 'DELETE',
});
}
}
export interface DropdownOptionsResponse {
campaigns: string[];
channels: Record<string, Record<string, string[]>>;
}
export const apiService = new ApiService();