Adds a server-side CSV export covering all campaign, proof, and version data including agent RAG statuses. The export respects the active agency filter so oversight admins can scope the download to a single agency. - backend: `CampaignRepository.get_export_rows()` — flat join across Campaign → Proof → ProofVersion with Agency and User, extracts agent RAG statuses from the `agent_review` JSONB column - backend: `GET /api/export/campaigns-csv` endpoint gated to super_admin / oversight_admin, streams a dated CSV file - frontend: `apiService.downloadCampaignsCsv(agencyId?)` — fetches blob and triggers browser download - frontend: threads `selectedAgencyId` prop from App → Campaigns → CampaignList so the export uses the active filter - frontend: Export CSV button in CampaignList header, visible only to super_admin / oversight_admin, with spinner while downloading Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
588 lines
20 KiB
TypeScript
Executable file
588 lines
20 KiB
TypeScript
Executable file
import { IPublicClientApplication } from '@azure/msal-browser';
|
|
import { getAccessToken } from './authService';
|
|
import type {
|
|
AgentReview, FlaggedItem, ResolvedItem, ErrorItem, PDFPage,
|
|
KnowledgeBaseListItem, KnowledgeBaseDetail, SourceDocument,
|
|
ProcessingJob, SpecVersionListItem, SpecVersionDetail, DiffResult,
|
|
} 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_by: 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;
|
|
is_identical_file: boolean | 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 AgencyAnalyticsItem {
|
|
agency_id: string;
|
|
agency_name: string;
|
|
total_reviews: number;
|
|
passed: number;
|
|
failed: number;
|
|
errors: number;
|
|
legal_review: number;
|
|
}
|
|
|
|
export interface AgencyAnalyticsResponse {
|
|
agencies: AgencyAnalyticsItem[];
|
|
}
|
|
|
|
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;
|
|
submitter_name: string | null;
|
|
submitter_agency: 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();
|
|
}
|
|
|
|
// Current user endpoint
|
|
async getMe(): Promise<CurrentUserResponse> {
|
|
return this.fetch<CurrentUserResponse>('/me');
|
|
}
|
|
|
|
// Campaign endpoints
|
|
async getCampaigns(agencyId?: string): Promise<CampaignResponse[]> {
|
|
const params = agencyId ? `?agency_id=${agencyId}` : '';
|
|
return this.fetch<CampaignResponse[]>(`/campaigns${params}`);
|
|
}
|
|
|
|
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 getFile(storageKey: string): Promise<File> {
|
|
const headers = await this.getHeaders();
|
|
const response = await fetch(`${API_URL}/api/files/${storageKey}`, { headers });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch file: HTTP ${response.status}`);
|
|
}
|
|
const blob = await response.blob();
|
|
const filename = storageKey.split('/').pop() || 'proof';
|
|
return new File([blob], filename, { type: blob.type });
|
|
}
|
|
|
|
async getPdfPages(storageKey: string, maxPages: number = 10): Promise<PDFPage[]> {
|
|
const headers = await this.getHeaders();
|
|
const response = await fetch(
|
|
`${API_URL}/api/files/${storageKey}/pages?max_pages=${maxPages}`,
|
|
{ headers }
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch PDF pages: HTTP ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
return data.pages as PDFPage[];
|
|
}
|
|
|
|
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(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(data),
|
|
});
|
|
}
|
|
|
|
async getFlaggedItems(agencyId?: string): Promise<FlaggedItemResponse[]> {
|
|
const params = agencyId ? `?agency_id=${agencyId}` : '';
|
|
return this.fetch<FlaggedItemResponse[]>(`/audit/flagged${params}`);
|
|
}
|
|
|
|
async getResolvedItems(agencyId?: string): Promise<ResolvedItemResponse[]> {
|
|
const params = agencyId ? `?agency_id=${agencyId}` : '';
|
|
return this.fetch<ResolvedItemResponse[]>(`/audit/resolved${params}`);
|
|
}
|
|
|
|
async getErrorItems(agencyId?: string): Promise<ErrorItemResponse[]> {
|
|
const params = agencyId ? `?agency_id=${agencyId}` : '';
|
|
return this.fetch<ErrorItemResponse[]>(`/audit/errors${params}`);
|
|
}
|
|
|
|
// Analytics endpoints
|
|
async getAnalytics(agencyId?: string): Promise<AnalyticsResponse> {
|
|
const params = agencyId ? `?agency_id=${agencyId}` : '';
|
|
return this.fetch<AnalyticsResponse>(`/analytics${params}`);
|
|
}
|
|
|
|
async getAnalyticsByAgency(): Promise<AgencyAnalyticsResponse> {
|
|
return this.fetch<AgencyAnalyticsResponse>('/analytics/by-agency');
|
|
}
|
|
|
|
// 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',
|
|
createdBy: campaign.created_by || null,
|
|
_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,
|
|
fileStorageKey: v.file_storage_key || '',
|
|
isIdenticalFile: v.is_identical_file || false,
|
|
})),
|
|
_id: proof.id,
|
|
fileStorageKey: latestVersion?.file_storage_key || '',
|
|
};
|
|
}
|
|
|
|
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: item.submitter_name || '',
|
|
submitAgency: item.submitter_agency || '',
|
|
errorSummary: item.error_summary || '',
|
|
timestamp: item.created_at,
|
|
};
|
|
}
|
|
|
|
// Dropdown options endpoints
|
|
async getDropdownOptions(): Promise<DropdownOptionsResponse> {
|
|
const response = await this.fetch<DropdownOptionsResponse>('/dropdown-options');
|
|
// Debug logging
|
|
console.log('[DEBUG API Response] Raw dropdown options:', JSON.stringify(response, null, 2));
|
|
console.log('[DEBUG API Response] Social.Meta proof types:', response.channels?.Social?.Meta);
|
|
return response;
|
|
}
|
|
|
|
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',
|
|
});
|
|
}
|
|
|
|
// User management endpoints (super_admin only)
|
|
async getUsers(): Promise<UserManagementResponse[]> {
|
|
return this.fetch<UserManagementResponse[]>('/users');
|
|
}
|
|
|
|
async updateUser(userId: string, data: { role?: string; agency_id?: string | null }): Promise<UserManagementResponse> {
|
|
return this.fetch<UserManagementResponse>(`/users/${userId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async getUserChangeHistory(userId: string): Promise<UserChangeLogEntry[]> {
|
|
return this.fetch<UserChangeLogEntry[]>(`/users/${userId}/change-history`);
|
|
}
|
|
|
|
async createAgency(name: string): Promise<AgencyResponse> {
|
|
return this.fetch<AgencyResponse>('/agencies', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
}
|
|
|
|
// Agency endpoints
|
|
async getAgencies(): Promise<AgencyResponse[]> {
|
|
return this.fetch<AgencyResponse[]>('/agencies');
|
|
}
|
|
|
|
// Support email endpoint
|
|
async sendSupportEmail(data: {
|
|
message: string;
|
|
subject: string;
|
|
user_name?: string;
|
|
user_email?: string;
|
|
}): Promise<{ success: boolean; message: string }> {
|
|
return this.fetch('/support/email', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Export endpoints
|
|
async downloadCampaignsCsv(agencyId?: string): Promise<void> {
|
|
const headers = await this.getHeaders();
|
|
const params = agencyId ? `?agency_id=${agencyId}` : '';
|
|
const response = await fetch(`${API_URL}/api/export/campaigns-csv${params}`, { headers });
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const disposition = response.headers.get('Content-Disposition') || '';
|
|
const match = disposition.match(/filename="([^"]+)"/);
|
|
a.download = match ? match[1] : 'campaigns_export.csv';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Knowledge Base endpoints
|
|
async getKnowledgeBases(): Promise<KnowledgeBaseListItem[]> {
|
|
return this.fetch<KnowledgeBaseListItem[]>('/knowledge-base');
|
|
}
|
|
|
|
async getKnowledgeBase(kbId: string): Promise<KnowledgeBaseDetail> {
|
|
return this.fetch<KnowledgeBaseDetail>(`/knowledge-base/${kbId}`);
|
|
}
|
|
|
|
async uploadSourceDocument(kbId: string, file: File): Promise<SourceDocument> {
|
|
const headers = await this.getHeaders();
|
|
// Remove Content-Type so browser sets multipart boundary
|
|
delete (headers as Record<string, string>)['Content-Type'];
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const response = await fetch(`${API_URL}/api/knowledge-base/${kbId}/documents`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async removeSourceDocument(kbId: string, docId: string): Promise<void> {
|
|
return this.fetch<void>(`/knowledge-base/${kbId}/documents/${docId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
async triggerProcessing(kbId: string): Promise<ProcessingJob> {
|
|
return this.fetch<ProcessingJob>(`/knowledge-base/${kbId}/process`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async getProcessingJob(kbId: string, jobId: string): Promise<ProcessingJob> {
|
|
return this.fetch<ProcessingJob>(`/knowledge-base/${kbId}/jobs/${jobId}`);
|
|
}
|
|
|
|
async getSpecVersions(kbId: string): Promise<SpecVersionListItem[]> {
|
|
return this.fetch<SpecVersionListItem[]>(`/knowledge-base/${kbId}/versions`);
|
|
}
|
|
|
|
async getSpecVersion(kbId: string, versionId: string): Promise<SpecVersionDetail> {
|
|
return this.fetch<SpecVersionDetail>(`/knowledge-base/${kbId}/versions/${versionId}`);
|
|
}
|
|
|
|
async getSpecDiff(kbId: string, versionIdA: string, versionIdB: string): Promise<DiffResult> {
|
|
return this.fetch<DiffResult>(`/knowledge-base/${kbId}/versions/${versionIdA}/diff/${versionIdB}`);
|
|
}
|
|
|
|
async activateSpecVersion(kbId: string, versionId: string): Promise<SpecVersionDetail> {
|
|
return this.fetch<SpecVersionDetail>(`/knowledge-base/${kbId}/versions/${versionId}/activate`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
}
|
|
|
|
export interface DropdownOptionsResponse {
|
|
campaigns: string[];
|
|
channels: Record<string, Record<string, string[]>>;
|
|
brand_guidelines: string[];
|
|
}
|
|
|
|
export interface AgencyResponse {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface CurrentUserResponse {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: string;
|
|
agency_id: string | null;
|
|
agency_name: string | null;
|
|
}
|
|
|
|
export interface UserManagementResponse {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: string;
|
|
agency: string | null;
|
|
agency_id: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface UserChangeLogEntry {
|
|
id: string;
|
|
change_type: string;
|
|
field_changed: string | null;
|
|
old_value: string | null;
|
|
new_value: string | null;
|
|
changed_by_name: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export const apiService = new ApiService();
|
|
export default apiService;
|