316 lines
8.4 KiB
TypeScript
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
|
|
}
|
|
}
|