modcomms/frontend/services/apiService.ts
michael 220a97ab57 Wire up Errors tab in Auditing: auto-create ErrorItem on Analysis Error
- Create ErrorItem record when proof analysis results in "Analysis Error" status
- Add submitter_name/submitter_agency fields to ErrorItemResponse schema
- Eager-load proof creator and agency in error items query to avoid N+1
- Populate submitter fields from proof creator in the API route
- Update frontend ErrorItemResponse type and conversion to map submitter fields
- Fix ErrorsTable proof name styling to blue link (text-active-blue) matching Flags tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:55:16 -06:00

421 lines
14 KiB
TypeScript
Executable file

import { IPublicClientApplication } from '@azure/msal-browser';
import { getAccessToken } from './authService';
import type { AgentReview, FlaggedItem, ResolvedItem, ErrorItem, PDFPage } 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;
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 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();
}
// 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 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({
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,
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',
});
}
// 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 interface DropdownOptionsResponse {
campaigns: string[];
channels: Record<string, Record<string, string[]>>;
brand_guidelines: string[];
}
export interface AgencyResponse {
id: string;
name: string;
}
export const apiService = new ApiService();
export default apiService;