/* B03 다중 파일 업로드 API 클라이언트 */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; export interface UploadedFileResult { input_file_id: number; original_filename: string; file_type: string; relative_path: string; size_bytes: number; metadata: Record; } export interface FileUploadResponse { status: string; project_id: string; files: UploadedFileResult[]; } export interface ChunkSessionCreateResponse { status: string; project_id: string; upload_session_id: string; original_filename: string; file_size_bytes: number; chunk_size_bytes: number; total_chunks: number; completed_chunks: number; /** 같은 내용이 이미 올라와 있어 전송을 건너뛰어도 되는지. */ already_uploaded?: boolean; } export interface ChunkUploadResponse { status: string; upload_session_id: string; chunk_index: number; completed_chunks: number; total_chunks: number; chunk_hash: string; } export interface UploadStatusResponse { status: string; upload_session_id: string; upload_status: string; original_filename: string; file_size_bytes: number; chunk_size_bytes: number; total_chunks: number; completed_chunks: number; completed_chunk_indexes: number[]; } /** * 「지우고 진행할까?」를 물어야 하는 상태(409). 서버가 무엇이 지워지는지 함께 준다. * 되돌릴 수 없는 삭제라 사람이 한 번 보고 정한다(2026-09-08 사용자 지시). */ export class ReplaceOutputsConfirmRequired extends Error { readonly targets: string[]; constructor(message: string, targets: string[]) { super(message); this.name = "ReplaceOutputsConfirmRequired"; this.targets = targets; } } async function readJsonOrThrow(response: Response): Promise { const payload = (await response.json()) as T & { message?: string; confirm?: string; targets?: string[]; }; if (!response.ok) { if (response.status === 409 && payload.confirm === "replace_outputs") { throw new ReplaceOutputsConfirmRequired( payload.message ?? "이미 만들어 둔 결과가 지워집니다.", payload.targets ?? [], ); } throw new Error(payload.message ?? `HTTP ${response.status}`); } return payload; } export async function uploadProjectFiles( projectId: string, files: readonly File[], confirmReplace = false, ): Promise { const formData = new FormData(); for (const file of files) formData.append("files", file, file.name); if (confirmReplace) formData.append("confirm_replace", "true"); const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, { method: "POST", credentials: "include", body: formData, signal: controller.signal, }); return await readJsonOrThrow(response); } finally { window.clearTimeout(timeoutId); } } export async function createUploadSession( projectId: string, file: File, chunkSizeBytes: number, fingerprint?: string | null, completeUpload = false, lasFree = false, confirmReplace = false, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ original_filename: file.name, size_bytes: file.size, chunk_size_bytes: chunkSizeBytes, fingerprint: fingerprint ?? null, complete_upload: completeUpload, las_free: lasFree, confirm_replace: confirmReplace, }), }); return await readJsonOrThrow(response); } export async function uploadFileChunk( projectId: string, sessionId: string, chunkIndex: number, chunk: Blob, ): Promise { const formData = new FormData(); formData.append("session_id", sessionId); formData.append("chunk_index", String(chunkIndex)); formData.append("chunk_data", chunk, `${sessionId}-${chunkIndex}.chunk`); const response = await fetch(`${API_BASE_URL}/projects/${projectId}/chunks`, { method: "POST", credentials: "include", body: formData, }); return await readJsonOrThrow(response); } export async function finalizeUploadSession( projectId: string, sessionId: string, totalChunks: number, completeUpload: boolean, fingerprint?: string | null, lasFree = false, confirmReplace = false, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, total_chunks: totalChunks, complete_upload: completeUpload, fingerprint: fingerprint ?? null, las_free: lasFree, confirm_replace: confirmReplace, }), }); return await readJsonOrThrow(response); } export async function fetchUploadStatus( projectId: string, sessionId: string, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, { method: "GET", credentials: "include", }); return await readJsonOrThrow(response); } export interface UploadOverviewFile { input_file_id: number; file_type: string; original_filename: string; file_size_mb: number; status: string; uploaded_at: string | null; /** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */ relative_path: string | null; /** 업로드 분석 결과 — 카드 미리보기(범위 사각형·노선 선)의 재료. */ metadata?: Record | null; } export interface UploadOverviewSession { upload_session_id: string; original_filename: string; file_size_bytes: number; total_chunks: number; completed_chunks: number; progress_percent: number; updated_at: string | null; } /** 지금 분석이 도는 중이면 **누가 시작했는지**. 도는 중이 아니면 `null`. */ export interface UploadLockInfo { running: boolean; user_id: number | null; name: string | null; email: string | null; started_at: string | null; } export interface UploadOverviewResponse { status: string; files: UploadOverviewFile[]; pending_sessions: UploadOverviewSession[]; required_complete: boolean; analysis_complete: boolean; /** 서버가 새 자료를 막는 동안 **왜·누구 때문에** 막혔는지(2026-09-09, 계획서 0-8). */ analysis_lock?: UploadLockInfo | null; } /** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */ export async function fetchUploadOverview(projectId: string): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, { method: "GET", credentials: "include", }); return await readJsonOrThrow(response); } export interface WF1AnalysisStatus { project_id: string; status: "pending" | "in_progress" | "completed" | "failed"; model_count: number; progress_percent: number; current_stage: string; message: string; error?: string; } export async function checkWF1AnalysisStatus(projectId: string): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, { method: "GET", credentials: "include", }); return await readJsonOrThrow(response); }