opus-submitter/polylan_submitter/src/services/apiService.ts

316 lines
8.4 KiB
TypeScript

import type {
SteamCollection,
SteamCollectionItem,
Submission,
PuzzleResponse,
SubmissionFile,
UserInfo,
TournamentSubmissions,
TournamentPuzzleResults
} from '../types'
// API Configuration
const API_BASE_URL = '/api'
// API Response Types
interface ApiResponse<T> {
data?: T
error?: string
status: number
}
interface PaginatedResponse<T> {
items: T[]
count: number
}
interface SubmissionStats {
total_submissions: number
total_responses: number
needs_validation: number
validated_submissions: number
validation_rate: number
}
// API Service Class
export class ApiService {
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
const data = await response.json()
if (!response.ok) {
return {
error: data.detail || `HTTP ${response.status}`,
status: response.status
}
}
return {
data,
status: response.status
}
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Network error',
status: 0
}
}
}
private async uploadRequest<T>(
endpoint: string,
formData: FormData
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
body: formData,
})
const data = await response.json()
if (!response.ok) {
return {
error: data.detail || `HTTP ${response.status}`,
status: response.status
}
}
return {
data,
status: response.status
}
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Network error',
status: 0
}
}
}
// Puzzle endpoints
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
}
async getCollection(): Promise<ApiResponse<SteamCollection>> {
return this.request<SteamCollection>('/submissions/collection')
}
async getTopSubmissions(limit = 5): Promise<ApiResponse<TournamentSubmissions>> {
return this.request<TournamentSubmissions>(`/results/top-submissions?limit=${limit}`)
}
async getPuzzleResults(limit = 5): Promise<ApiResponse<TournamentPuzzleResults>> {
return this.request<TournamentPuzzleResults>(`/results/puzzle-results?limit=${limit}`)
}
// Submission endpoints
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
return this.request<PaginatedResponse<Submission>>(
`/submissions/submissions?limit=${limit}&offset=${offset}`
)
}
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${id}`)
}
async createSubmission(
submissionData: {
notes?: string
manual_validation_requested?: boolean
responses: Array<{
puzzle_id: number
puzzle_name: string
cost?: number
cycles?: number
area?: number
needs_manual_validation?: boolean
ocr_confidence_cost?: number
ocr_confidence_cycles?: number
ocr_confidence_area?: number
}>
},
files: File[]
): Promise<ApiResponse<Submission>> {
const formData = new FormData()
// Add JSON data
formData.append('data', JSON.stringify(submissionData))
// Add files
files.forEach((file) => {
formData.append('files', file)
})
return this.uploadRequest<Submission>('/submissions/submissions', formData)
}
// Admin endpoints (require staff permissions)
async validateResponse(
responseId: number,
validationData: {
validated_cost?: number
validated_cycles?: number
validated_area?: number
}
): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
method: 'PUT',
body: JSON.stringify(validationData),
})
}
async autoValidateResponses(responseId: number): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate/auto`, {
method: 'PUT',
})
}
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
}
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
method: 'POST',
})
}
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
method: 'DELETE',
})
}
// Statistics endpoint
async getStats(): Promise<ApiResponse<SubmissionStats>> {
return this.request<SubmissionStats>('/submissions/stats')
}
// Health check
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
return this.request<{ status: string; service: string }>('/health')
}
// User info
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
return this.request<UserInfo>('/user')
}
}
// Singleton instance
export const apiService = new ApiService()
// Helper functions for common operations
export const puzzleHelpers = {
async loadPuzzles(): Promise<SteamCollectionItem[]> {
const response = await apiService.getPuzzles()
if (response.error) {
console.error('Failed to load puzzles:', response.error)
return []
}
return response.data || []
},
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
if (!name) return null
// Try exact match first
let match = puzzles.find(p =>
p.title.toLowerCase() === name.toLowerCase()
)
if (!match) {
// Try partial match
match = puzzles.find(p =>
p.title.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(p.title.toLowerCase())
)
}
return match || null
}
}
export const submissionHelpers = {
async createFromFiles(
files: SubmissionFile[],
puzzles: SteamCollectionItem[],
notes?: string,
manualValidationRequested?: boolean
): Promise<ApiResponse<Submission>> {
const responses = files.map(item => {
const puzzle = puzzleHelpers.findPuzzleByName(puzzles, item.ocrData?.puzzle || '')
if (!puzzle) { return }
return {
puzzle_id: puzzle.id,
puzzle_name: item.ocrData?.puzzle || '',
cost: item.ocrData?.cost,
cycles: item.ocrData?.cycles,
area: item.ocrData?.area,
needs_manual_validation: (item.ocrData?.confidence.overall ?? 0) <= 0.8,
ocr_confidence_cost: item.ocrData?.confidence?.cost || 0.0,
ocr_confidence_cycles: item.ocrData?.confidence?.cycles || 0.0,
ocr_confidence_area: item.ocrData?.confidence?.area || 0.0
}
}).filter(item => item !== undefined)
// Extract actual File objects for upload
const fileObjects = files.map(f => f.file)
return apiService.createSubmission({
notes,
manual_validation_requested: manualValidationRequested,
responses
}, fileObjects)
},
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
const response = await apiService.getSubmissions(limit, offset)
if (response.error) {
console.error('Failed to load submissions:', response.error)
return []
}
return response.data?.items || []
}
}
// Error handling utilities
export const errorHelpers = {
getErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null && 'detail' in error) {
return String((error as any).detail)
}
return 'An unknown error occurred'
},
isNetworkError(error: unknown): boolean {
return typeof error === 'string' && error.includes('Network')
},
isValidationError(status: number): boolean {
return status === 400
},
isAuthError(status: number): boolean {
return status === 401 || status === 403
}
}