import type { AdminManagedUser, AdminSupportTicket, AdminTicketsResponse, AdminUsersResponse, } from "../../lib/api"; export interface AdminDataWarning { key: string; message: string; detail: string; } function asRecord(value: unknown): Record { return value !== null && typeof value === "object" ? (value as Record) : {}; } function warn( warnings: AdminDataWarning[], key: string, message: string, detail: string, ): void { warnings.push({ key, message, detail }); } function stringField( source: Record, field: string, fallback: string, warnings: AdminDataWarning[], label: string, ): string { const value = source[field]; if (typeof value === "string") return value; if (value == null) { warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`, `${field}=null`, ); return fallback; } warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`, `${field}=${typeof value}`, ); return String(value); } function booleanField( source: Record, field: string, fallback: boolean, warnings: AdminDataWarning[], label: string, ): boolean { const value = source[field]; if (typeof value === "boolean") return value; if (value == null) { warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`, `${field}=null`, ); return fallback; } warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`, `${field}=${typeof value}`, ); return fallback; } function numberField( source: Record, field: string, fallback: number, warnings: AdminDataWarning[], label: string, ): number { const value = source[field]; if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() !== "") { const parsed = Number(value); if (Number.isFinite(parsed)) { warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`, `${field}=string`, ); return parsed; } } warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`, `${field}=${value == null ? "null" : typeof value}`, ); return fallback; } function cohortIdsField( source: Record, warnings: AdminDataWarning[], label: string, ): string[] { const value = source.cohort_ids; if (!Array.isArray(value)) { warn( warnings, `${label}.cohort_ids`, `${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`, `cohort_ids=${value == null ? "null" : typeof value}`, ); return []; } const normalized = value.filter( (item): item is string => typeof item === "string", ); if (normalized.length !== value.length) { warn( warnings, `${label}.cohort_ids.items`, `${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`, `kept=${normalized.length}, raw=${value.length}`, ); } return normalized; } function numberRecordField( source: Record, field: string, warnings: AdminDataWarning[], label: string, ): Record { const value = source[field]; if (value == null) { warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`, `${field}=null`, ); return {}; } if (typeof value !== "object" || Array.isArray(value)) { warn( warnings, `${label}.${field}`, `${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`, `${field}=${typeof value}`, ); return {}; } const normalized: Record = {}; for (const [key, raw] of Object.entries(value as Record)) { if (typeof raw === "number" && Number.isFinite(raw)) { normalized[key] = raw; continue; } const parsed = typeof raw === "string" ? Number(raw) : Number.NaN; if (Number.isFinite(parsed)) { normalized[key] = parsed; warn( warnings, `${label}.${field}.${key}`, `${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`, `${field}.${key}=string`, ); continue; } warn( warnings, `${label}.${field}.${key}`, `${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`, `${field}.${key}=${raw == null ? "null" : typeof raw}`, ); } return normalized; } function roleField( source: Record, warnings: AdminDataWarning[], label: string, ): AdminManagedUser["role"] { const value = source.role; if (value === "learner" || value === "teacher" || value === "admin") { return value; } warn( warnings, `${label}.role`, `${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`, `role=${String(value)}`, ); return "learner"; } function accountStatusField( source: Record, warnings: AdminDataWarning[], label: string, ): AdminManagedUser["account_status"] { const value = source.account_status; if (value === "pending" || value === "approved" || value === "suspended") { return value; } warn( warnings, `${label}.account_status`, `${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`, `account_status=${String(value)}`, ); return "pending"; } function userSourceField( source: Record, parentSource: AdminUsersResponse["source"], ): AdminManagedUser["source"] { const value = source.source; return value === "server_session_registry" || value === "database" ? value : parentSource; } export function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): { response: AdminUsersResponse; warnings: AdminDataWarning[]; } { const warnings: AdminDataWarning[] = []; const root = asRecord(rawResponse); const source: AdminUsersResponse["source"] = root.source === "server_session_registry" ? "server_session_registry" : "database"; const durable = typeof root.durable === "boolean" ? root.durable : source === "database"; const rawUsers = Array.isArray(root.users) ? root.users : []; if (!Array.isArray(root.users)) { warn( warnings, "admin.users", "/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.", `users=${root.users == null ? "null" : typeof root.users}`, ); } const users = rawUsers.map((item, index): AdminManagedUser => { const sourceRecord = asRecord(item); const label = `users[${index}]`; const userId = stringField( sourceRecord, "user_id", `unknown-${index + 1}`, warnings, label, ); return { user_id: userId, email: stringField(sourceRecord, "email", userId, warnings, label), display_name: stringField( sourceRecord, "display_name", userId, warnings, label, ), role: roleField(sourceRecord, warnings, label), admin_access: booleanField( sourceRecord, "admin_access", false, warnings, label, ), super_admin: booleanField( sourceRecord, "super_admin", false, warnings, label, ), account_status: accountStatusField(sourceRecord, warnings, label), cohort_ids: cohortIdsField(sourceRecord, warnings, label), affiliation: stringField( sourceRecord, "affiliation", "", warnings, label, ), active_sessions: numberField( sourceRecord, "active_sessions", 0, warnings, label, ), created_at: numberField(sourceRecord, "created_at", 0, warnings, label), last_seen_at: numberField( sourceRecord, "last_seen_at", 0, warnings, label, ), source: userSourceField(sourceRecord, source), }; }); return { response: { source, durable, users }, warnings }; } export function normalizeAdminTicketsResponse( rawResponse: AdminTicketsResponse, ): { response: AdminTicketsResponse; warnings: AdminDataWarning[]; } { const warnings: AdminDataWarning[] = []; const root = asRecord(rawResponse); const source: AdminTicketsResponse["source"] = root.source === "database" ? "database" : "unavailable"; const durable = typeof root.durable === "boolean" ? root.durable : source === "database"; const rawTickets = Array.isArray(root.tickets) ? root.tickets : []; if (!Array.isArray(root.tickets)) { warn( warnings, "admin.tickets", "/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.", `tickets=${root.tickets == null ? "null" : typeof root.tickets}`, ); } const summaryRoot = asRecord(root.summary); if ( !root.summary || typeof root.summary !== "object" || Array.isArray(root.summary) ) { warn( warnings, "admin.tickets.summary", "/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.", `summary=${root.summary == null ? "null" : typeof root.summary}`, ); } return { response: { source, durable, generated_at: numberField( root, "generated_at", 0, warnings, "admin.tickets", ), tickets: rawTickets as AdminSupportTicket[], summary: { total: numberField( summaryRoot, "total", rawTickets.length, warnings, "admin.tickets.summary", ), open_count: numberField( summaryRoot, "open_count", 0, warnings, "admin.tickets.summary", ), high_priority_count: numberField( summaryRoot, "high_priority_count", 0, warnings, "admin.tickets.summary", ), stale_count: numberField( summaryRoot, "stale_count", 0, warnings, "admin.tickets.summary", ), by_category: numberRecordField( summaryRoot, "by_category", warnings, "admin.tickets.summary", ), by_priority: numberRecordField( summaryRoot, "by_priority", warnings, "admin.tickets.summary", ), by_status: numberRecordField( summaryRoot, "by_status", warnings, "admin.tickets.summary", ), }, }, warnings, }; }