612 lines
25 KiB
TypeScript
612 lines
25 KiB
TypeScript
/* =====================================================================
|
|
Vignette — API 클라이언트 (fetch wrapper + SSE 헬퍼)
|
|
백엔드 계약: apps/api/app/routes (auth.py, sessions.py).
|
|
- 모든 요청은 credentials:"include" (BFF __Host-vignette_sid HttpOnly 쿠키).
|
|
- 에러는 ApiError 로 표준화.
|
|
- SSE: POST /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달.
|
|
===================================================================== */
|
|
|
|
import type { components } from "./api.gen";
|
|
|
|
type ApiSchema<Name extends keyof components["schemas"]> = components["schemas"][Name];
|
|
|
|
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
|
|
const PUBLIC_API_ORIGIN_BY_HOST: Record<string, string> = {
|
|
"vignette.chanpaca.net": "https://api-vignette.chanpaca.net",
|
|
"vnet.18ka.net": "https://api-vnet.18ka.net",
|
|
};
|
|
|
|
function defaultApiBase(): string {
|
|
if (typeof window !== "undefined") {
|
|
const host = window.location.hostname;
|
|
const mapped = PUBLIC_API_ORIGIN_BY_HOST[host];
|
|
if (mapped) return mapped;
|
|
if (host.endsWith(".pages.dev")) {
|
|
return "https://api-vignette.chanpaca.net";
|
|
}
|
|
}
|
|
return "/api";
|
|
}
|
|
|
|
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
|
|
const API_BASE: string = configuredApiBase || defaultApiBase();
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly detail: string;
|
|
readonly body: unknown;
|
|
|
|
constructor(status: number, detail: string, body?: unknown) {
|
|
super(`API ${status}: ${detail}`);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.detail = detail;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
|
|
interface RequestOptions {
|
|
method?: HttpMethod;
|
|
/** JSON 직렬화될 요청 바디 */
|
|
body?: unknown;
|
|
/** 추가 헤더 */
|
|
headers?: Record<string, string>;
|
|
/** AbortSignal (취소·타임아웃) */
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
function joinUrl(path: string): string {
|
|
if (/^https?:\/\//.test(path)) return path;
|
|
const base = API_BASE.replace(/\/$/, "");
|
|
const p = path.startsWith("/") ? path : `/${path}`;
|
|
return `${base}${p}`;
|
|
}
|
|
|
|
export function apiUrl(path: string): string {
|
|
return joinUrl(path);
|
|
}
|
|
|
|
export function apiWsUrl(path: string): string {
|
|
const url = new URL(joinUrl(path), window.location.origin);
|
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
return url.toString();
|
|
}
|
|
|
|
async function parseError(res: Response): Promise<ApiError> {
|
|
let detail = res.statusText || "request failed";
|
|
let body: unknown = undefined;
|
|
try {
|
|
const ct = res.headers.get("content-type") ?? "";
|
|
if (ct.includes("application/json")) {
|
|
body = await res.json();
|
|
// FastAPI 표준 에러는 { detail: ... }
|
|
const d = (body as { detail?: unknown }).detail;
|
|
if (typeof d === "string") detail = d;
|
|
else if (d != null) detail = JSON.stringify(d);
|
|
} else {
|
|
const text = await res.text();
|
|
if (text) detail = text;
|
|
body = text;
|
|
}
|
|
} catch {
|
|
/* 본문 파싱 실패는 무시하고 statusText 사용 */
|
|
}
|
|
return new ApiError(res.status, detail, body);
|
|
}
|
|
|
|
/**
|
|
* JSON API 호출. 2xx 가 아니면 ApiError throw.
|
|
* 204/빈 응답은 undefined 반환.
|
|
*/
|
|
export async function apiFetch<T = unknown>(
|
|
path: string,
|
|
options: RequestOptions = {},
|
|
): Promise<T> {
|
|
const { method = "GET", body, headers = {}, signal } = options;
|
|
|
|
const finalHeaders: Record<string, string> = {
|
|
Accept: "application/json",
|
|
...headers,
|
|
};
|
|
|
|
const init: RequestInit = {
|
|
method,
|
|
credentials: "include", // __Host-vignette_sid 쿠키 전송 (BFF)
|
|
signal,
|
|
};
|
|
|
|
if (body !== undefined) {
|
|
finalHeaders["Content-Type"] = "application/json";
|
|
init.body = JSON.stringify(body);
|
|
}
|
|
init.headers = finalHeaders;
|
|
|
|
const res = await fetch(joinUrl(path), init);
|
|
|
|
if (!res.ok) {
|
|
throw await parseError(res);
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
const ct = res.headers.get("content-type") ?? "";
|
|
if (!ct.includes("application/json")) {
|
|
return (await res.text()) as unknown as T;
|
|
}
|
|
const len = res.headers.get("content-length");
|
|
if (len === "0") return undefined as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export const api = {
|
|
get: <T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">) =>
|
|
apiFetch<T>(path, { ...opts, method: "GET" }),
|
|
post: <T = unknown>(path: string, body?: unknown, opts?: Omit<RequestOptions, "method">) =>
|
|
apiFetch<T>(path, { ...opts, method: "POST", body }),
|
|
put: <T = unknown>(path: string, body?: unknown, opts?: Omit<RequestOptions, "method">) =>
|
|
apiFetch<T>(path, { ...opts, method: "PUT", body }),
|
|
del: <T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">) =>
|
|
apiFetch<T>(path, { ...opts, method: "DELETE" }),
|
|
};
|
|
|
|
/* =====================================================================
|
|
백엔드 응답 타입 (apps/api 계약 미러)
|
|
===================================================================== */
|
|
|
|
/** GET /auth/me — auth.py MeResponse */
|
|
export type MeResponse = ApiSchema<"MeResponse">;
|
|
export type ConsentResponse = ApiSchema<"ConsentResponse">;
|
|
export type AuthConfigResponse = ApiSchema<"AuthConfigResponse">;
|
|
|
|
export const authApi = {
|
|
config: () => api.get<AuthConfigResponse>("/auth/config"),
|
|
acceptConsent: () => api.post<ConsentResponse>("/auth/consent", { accepted: true }),
|
|
withdrawConsent: () => api.del<ConsentResponse>("/auth/consent"),
|
|
};
|
|
|
|
export type SessionStage = ApiSchema<"SessionStartResponse">["stage"];
|
|
|
|
/** GET /personas — personas.py PersonaSummary */
|
|
export type PersonaSummary = ApiSchema<"PersonaSummary">;
|
|
|
|
export type PersonaReviewStatus = "draft" | "review" | "approved" | "archived";
|
|
export type PersonaReviewAction = ApiSchema<"PersonaReviewDecisionRequest">["action"];
|
|
export type PersonaReviewSummary = ApiSchema<"PersonaReviewSummary">;
|
|
export type PersonaRevisionRequest = ApiSchema<"PersonaRevisionRequest">;
|
|
|
|
export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">;
|
|
export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">;
|
|
export type PersonaSourceDocumentRequest = ApiSchema<"PersonaSourceDocumentRequest">;
|
|
export type PersonaSourceDocumentResponse = ApiSchema<"PersonaSourceDocumentResponse">;
|
|
export type PersonaGenerationEvidence = ApiSchema<"PersonaGenerationEvidence">;
|
|
export type PersonaDraftGenerateRequest = ApiSchema<"PersonaDraftGenerateRequest">;
|
|
export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateResponse">;
|
|
|
|
/** POST /sessions — sessions.py SessionStartResponse */
|
|
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
|
|
|
|
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
|
export type TurnResponse = ApiSchema<"TurnResponse">;
|
|
export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">;
|
|
export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
|
|
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
|
|
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
|
|
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
|
|
|
|
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
|
|
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
|
|
|
|
export type CrisisResource = ApiSchema<"CrisisResourceResponse">;
|
|
export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">;
|
|
export type SessionArchiveResponse = ApiSchema<"SessionArchiveResponse">;
|
|
|
|
export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">;
|
|
export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
|
|
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
|
|
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
|
|
export type LearnerDashboardFeedbackItem = ApiSchema<"LearnerDashboardFeedbackItem">;
|
|
|
|
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
|
|
|
|
export type SessionDetailResponse = ApiSchema<"SessionDetailResponse">;
|
|
|
|
export type ReviewClient = ApiSchema<"ReviewClient">;
|
|
export type ReviewTechnique = ApiSchema<"ReviewTechnique">;
|
|
export type ReviewNonverbalEvent = ApiSchema<"ReviewNonverbalEvent">;
|
|
export type ReviewNote = ApiSchema<"ReviewNote">;
|
|
|
|
export type ReviewTurn = ApiSchema<"ReviewTurn">;
|
|
|
|
export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">;
|
|
export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">;
|
|
export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">;
|
|
export type ReviewPoint = ApiSchema<"ReviewPoint">;
|
|
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
|
|
|
|
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
|
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
|
|
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
|
|
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
|
|
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
|
|
export type SessionShareResponse = ApiSchema<"SessionShareResponse">;
|
|
export type SessionShareDeleteResponse = ApiSchema<"SessionShareDeleteResponse">;
|
|
|
|
/* =====================================================================
|
|
SSE 헬퍼 — POST /sessions/{id}/stream
|
|
백엔드 이벤트(sse_starlette): "token" | "done" | "ping" | "safety" | "error"
|
|
턴 본문이 필요하므로 EventSource 가 아니라 fetch stream 으로 처리한다.
|
|
===================================================================== */
|
|
|
|
export interface SessionStreamHandlers {
|
|
/** 서버가 요청을 수락했고 learner turn 이 저장 가능한 지점 */
|
|
onOpen?: () => void;
|
|
/** 내담자 AI 토큰 1조각 */
|
|
onToken?: (chunk: string) => void;
|
|
/** 스트림 정상 종료 */
|
|
onDone?: (data: SessionStreamDone) => void;
|
|
/** 안전(위기) 신호 */
|
|
onSafety?: (data: unknown) => void;
|
|
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
|
|
onError?: (err: { detail: string }) => void;
|
|
/** keep-alive ping (Cloudflare 타임아웃 회피용 heartbeat) */
|
|
onPing?: () => void;
|
|
}
|
|
|
|
export interface SessionStreamDone {
|
|
session_id: string;
|
|
stage?: SessionStage;
|
|
effective_openness?: number;
|
|
turn_seq?: number;
|
|
safety_flagged?: boolean;
|
|
crisis_kind?: string;
|
|
crisis_resource?: CrisisResource | null;
|
|
conversation_stopped?: boolean;
|
|
}
|
|
|
|
function safeParse(data: string): unknown {
|
|
try {
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return data;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 내담자 AI 응답 SSE 스트림을 실행한다.
|
|
* 백엔드는 learner turn 을 저장한 뒤 token/done 이벤트를 흘린다.
|
|
*/
|
|
export async function openSessionStream(
|
|
sessionId: string,
|
|
text: string,
|
|
handlers: SessionStreamHandlers,
|
|
): Promise<SessionStreamDone> {
|
|
const res = await fetch(joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`), {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
Accept: "text/event-stream",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ text }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw await parseError(res);
|
|
}
|
|
if (!res.body) {
|
|
throw new ApiError(res.status, "스트림 응답 본문이 없습니다.");
|
|
}
|
|
|
|
handlers.onOpen?.();
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let eventName = "message";
|
|
let dataLines: string[] = [];
|
|
let donePayload: SessionStreamDone | null = null;
|
|
let streamError: ApiError | null = null;
|
|
|
|
const dispatch = () => {
|
|
if (!eventName && dataLines.length === 0) return;
|
|
const data = dataLines.join("\n");
|
|
const event = eventName || "message";
|
|
eventName = "message";
|
|
dataLines = [];
|
|
|
|
if (event === "token") {
|
|
handlers.onToken?.(data);
|
|
return;
|
|
}
|
|
if (event === "done") {
|
|
const parsed = safeParse(data || "{}") as Partial<SessionStreamDone>;
|
|
donePayload = {
|
|
session_id: parsed.session_id ?? sessionId,
|
|
stage: parsed.stage,
|
|
effective_openness: parsed.effective_openness,
|
|
turn_seq: parsed.turn_seq,
|
|
safety_flagged: parsed.safety_flagged,
|
|
crisis_kind: parsed.crisis_kind,
|
|
crisis_resource: parsed.crisis_resource,
|
|
conversation_stopped: parsed.conversation_stopped,
|
|
};
|
|
handlers.onDone?.(donePayload);
|
|
return;
|
|
}
|
|
if (event === "safety") {
|
|
handlers.onSafety?.(safeParse(data || "null"));
|
|
return;
|
|
}
|
|
if (event === "ping") {
|
|
handlers.onPing?.();
|
|
return;
|
|
}
|
|
if (event === "error") {
|
|
const parsed = safeParse(data || "{}") as { detail?: string };
|
|
const detail = parsed.detail ?? "stream error";
|
|
handlers.onError?.({ detail });
|
|
streamError = new ApiError(503, detail, parsed);
|
|
}
|
|
};
|
|
|
|
const processLine = (line: string) => {
|
|
if (line === "") {
|
|
dispatch();
|
|
return;
|
|
}
|
|
if (line.startsWith(":")) return;
|
|
const idx = line.indexOf(":");
|
|
const field = idx === -1 ? line : line.slice(0, idx);
|
|
const rawValue = idx === -1 ? "" : line.slice(idx + 1);
|
|
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
|
if (field === "event") eventName = value;
|
|
else if (field === "data") dataLines.push(value);
|
|
};
|
|
|
|
const processBuffer = (final = false) => {
|
|
const lines = buffer.split(/\r?\n/);
|
|
buffer = final ? "" : (lines.pop() ?? "");
|
|
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
if (final && buffer) processLine(buffer);
|
|
if (final && dataLines.length > 0) dispatch();
|
|
};
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
processBuffer();
|
|
if (streamError) break;
|
|
}
|
|
buffer += decoder.decode();
|
|
processBuffer(true);
|
|
|
|
if (streamError) throw streamError;
|
|
return donePayload ?? { session_id: sessionId };
|
|
}
|
|
|
|
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
|
|
export const personaApi = {
|
|
list: () => api.get<PersonaSummary[]>("/personas"),
|
|
};
|
|
|
|
export const personaReviewApi = {
|
|
list: () => api.get<PersonaReviewSummary[]>("/personas/review"),
|
|
decide: (personaId: string, action: PersonaReviewAction) =>
|
|
api.post<PersonaReviewSummary>(`/personas/review/${encodeURIComponent(personaId)}`, {
|
|
action,
|
|
}),
|
|
createDraft: (payload: PersonaDraftPayload) =>
|
|
api.post<PersonaReviewSummary>("/personas/drafts", payload),
|
|
createSource: (payload: PersonaSourceDocumentRequest) =>
|
|
api.post<PersonaSourceDocumentResponse>("/personas/sources", payload),
|
|
generateDraft: (payload: PersonaDraftGenerateRequest) =>
|
|
api.post<PersonaDraftGenerateResponse>("/personas/drafts/generate", payload),
|
|
getDraft: (personaId: string) =>
|
|
api.get<PersonaDraftDetail>(`/personas/drafts/${encodeURIComponent(personaId)}`),
|
|
updateDraft: (personaId: string, payload: PersonaDraftPayload) =>
|
|
api.put<PersonaReviewSummary>(`/personas/drafts/${encodeURIComponent(personaId)}`, payload),
|
|
reviseApproved: (personaId: string, payload: PersonaRevisionRequest = { submit_for_review: false }) =>
|
|
api.post<PersonaDraftDetail>(`/personas/${encodeURIComponent(personaId)}/revisions`, payload),
|
|
archive: (personaId: string) =>
|
|
api.del<PersonaReviewSummary>(`/personas/${encodeURIComponent(personaId)}`),
|
|
};
|
|
|
|
export const sessionApi = {
|
|
list: () => api.get<LearnerSessionsResponse>("/sessions"),
|
|
dashboard: () => api.get<LearnerDashboardResponse>("/sessions/dashboard"),
|
|
/**
|
|
* 음성 캐스케이드 가용성(STT/TTS provider 키 설정 여부). degraded 시 백엔드가 503을
|
|
* 주므로 상태코드와 무관하게 본문을 읽어 available 만 돌려준다(미설정도 정상 응답으로 취급).
|
|
*/
|
|
voiceHealth: async (): Promise<{ available: boolean; reason: string | null }> => {
|
|
try {
|
|
const r = await fetch(apiUrl("/voice/health"), { credentials: "include" });
|
|
const body = (await r.json()) as { available?: boolean; reason?: string | null };
|
|
return { available: !!body.available, reason: body.reason ?? null };
|
|
} catch {
|
|
return { available: false, reason: "voice health check failed" };
|
|
}
|
|
},
|
|
get: (sessionId: string) =>
|
|
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
|
|
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
|
|
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
|
|
turn: (sessionId: string, text: string) =>
|
|
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
|
|
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>
|
|
api.post<LiveCoachSuggestion>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/live-coach`,
|
|
payload,
|
|
),
|
|
liveCoachHistory: (sessionId: string) =>
|
|
api.get<LiveCoachHistoryResponse>(`/sessions/${encodeURIComponent(sessionId)}/live-coach`),
|
|
end: (sessionId: string) =>
|
|
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
|
|
archive: (sessionId: string) =>
|
|
api.post<SessionArchiveResponse>(`/sessions/${encodeURIComponent(sessionId)}/archive`, {}),
|
|
restore: (sessionId: string) =>
|
|
api.post<SessionArchiveResponse>(`/sessions/${encodeURIComponent(sessionId)}/restore`, {}),
|
|
review: (sessionId: string) =>
|
|
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
|
|
saveWorksheet: (sessionId: string, payload: ReviewCaseWorksheetSaveRequest) =>
|
|
api.put<ReviewCaseWorksheet>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/review/worksheet`,
|
|
payload,
|
|
),
|
|
createShare: (sessionId: string) =>
|
|
api.post<SessionShareResponse>(`/sessions/${encodeURIComponent(sessionId)}/share`, {}),
|
|
revokeShare: (sessionId: string) =>
|
|
api.del<SessionShareDeleteResponse>(`/sessions/${encodeURIComponent(sessionId)}/share`),
|
|
stream: openSessionStream,
|
|
};
|
|
|
|
export type AdminHealthStatus = ApiSchema<"AdminHealthResponse">["status"];
|
|
export type AdminServiceHealth = ApiSchema<"AdminServiceHealth">;
|
|
export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">;
|
|
export type AdminUsageBreakdown = ApiSchema<"AdminUsageBreakdown">;
|
|
export type AdminUsageBudget = ApiSchema<"AdminUsageBudget">;
|
|
export type AdminUsageResponse = ApiSchema<"AdminUsageResponse">;
|
|
export type AdminHealthEvent = ApiSchema<"AdminHealthEvent">;
|
|
export type AdminUptimeServiceSummary = ApiSchema<"AdminUptimeServiceSummary">;
|
|
export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">;
|
|
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
|
|
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
|
|
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
|
|
export type AdminTicketFilters = {
|
|
status?: AdminSupportTicket["status"] | "";
|
|
category?: AdminSupportTicket["category"] | "";
|
|
priority?: AdminSupportTicket["priority"] | "";
|
|
assignedGroup?: string;
|
|
sourcePath?: string;
|
|
staleOnly?: boolean;
|
|
search?: string;
|
|
windowDays?: number;
|
|
};
|
|
|
|
export const adminApi = {
|
|
health: () => api.get<AdminHealthResponse>("/admin/health"),
|
|
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
|
|
uptime: (windowHours = 24) =>
|
|
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
|
|
tickets: (filters: AdminTicketFilters = {}) => {
|
|
const params = new URLSearchParams({ window_days: String(filters.windowDays ?? 30) });
|
|
if (filters.status) params.set("status", filters.status);
|
|
if (filters.category) params.set("category", filters.category);
|
|
if (filters.priority) params.set("priority", filters.priority);
|
|
if (filters.assignedGroup?.trim()) params.set("assigned_group", filters.assignedGroup.trim());
|
|
if (filters.sourcePath?.trim()) params.set("source_path", filters.sourcePath.trim());
|
|
if (filters.staleOnly) params.set("stale_only", "true");
|
|
if (filters.search?.trim()) params.set("search", filters.search.trim());
|
|
return api.get<AdminTicketsResponse>(`/admin/tickets?${params.toString()}`);
|
|
},
|
|
updateTicket: (ticketId: string, body: AdminTicketPatchRequest) =>
|
|
apiFetch<AdminSupportTicket>(`/admin/tickets/${encodeURIComponent(ticketId)}`, {
|
|
method: "PATCH",
|
|
body,
|
|
}),
|
|
};
|
|
|
|
export type AdminManagedUser = ApiSchema<"AdminUserResponse">;
|
|
export type AdminUsersResponse = ApiSchema<"AdminUsersResponse">;
|
|
export type AdminUserCreateRequest = ApiSchema<"AdminUserCreate">;
|
|
export type AdminUserPatchRequest = ApiSchema<"AdminUserPatch">;
|
|
export type AdminUserDeleteResponse = ApiSchema<"AdminUserDeleteResponse">;
|
|
|
|
export const adminUsersApi = {
|
|
list: () => api.get<AdminUsersResponse>("/admin/users"),
|
|
create: (body: AdminUserCreateRequest) =>
|
|
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
|
|
update: (
|
|
userId: string,
|
|
body: AdminUserPatchRequest,
|
|
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
|
|
method: "PATCH",
|
|
body,
|
|
}),
|
|
deactivate: (userId: string) =>
|
|
apiFetch<AdminUserDeleteResponse>(`/admin/users/${encodeURIComponent(userId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
};
|
|
|
|
export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
|
|
|
|
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
|
|
export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
|
|
|
|
export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">;
|
|
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
|
|
export type TeacherSessionReviewStatusRequest = ApiSchema<"TeacherSessionReviewStatusRequest">;
|
|
export type TeacherSessionReviewStatusResponse = ApiSchema<"TeacherSessionReviewStatusResponse">;
|
|
|
|
export const teacherApi = {
|
|
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
|
|
updateSessionReviewStatus: (
|
|
sessionId: string,
|
|
body: TeacherSessionReviewStatusRequest,
|
|
) =>
|
|
apiFetch<TeacherSessionReviewStatusResponse>(
|
|
`/teacher/sessions/${encodeURIComponent(sessionId)}/review-status`,
|
|
{ method: "PUT", body },
|
|
),
|
|
};
|
|
|
|
export type UserProfileResponse = ApiSchema<"UserProfileResponse">;
|
|
export type LegalDocumentsResponse = ApiSchema<"LegalDocumentsResponse">;
|
|
export type NotificationPreferences = ApiSchema<"NotificationPreferences">;
|
|
export type UserPreferencesResponse = ApiSchema<"UserPreferencesResponse">;
|
|
export type UserPreferencesPatchRequest = ApiSchema<"UserPreferencesPatch">;
|
|
export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">;
|
|
export type OnboardingRequest = ApiSchema<"OnboardingRequest">;
|
|
export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
|
|
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">;
|
|
export type UserSupportTicketListItem = ApiSchema<"UserSupportTicketListItem">;
|
|
export type UserSupportTicketsResponse = ApiSchema<"UserSupportTicketsResponse">;
|
|
export type UserPrepostMeasureRequest = ApiSchema<"UserPrepostMeasureRequest">;
|
|
export type UserPrepostMeasureItem = ApiSchema<"UserPrepostMeasureItem">;
|
|
export type UserPrepostMeasuresResponse = ApiSchema<"UserPrepostMeasuresResponse">;
|
|
|
|
export type RoleString = "learner" | "teacher" | "admin" | string;
|
|
|
|
export const userApi = {
|
|
legalDocs: () => api.get<LegalDocumentsResponse>("/users/legal-docs"),
|
|
me: () => api.get<UserProfileResponse>("/users/me"),
|
|
updateMe: (body: UserProfilePatchRequest) =>
|
|
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
|
|
uploadAvatar: async (file: File) => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const res = await fetch(joinUrl("/users/me/avatar"), {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { Accept: "application/json" },
|
|
body: form,
|
|
});
|
|
if (!res.ok) throw await parseError(res);
|
|
return (await res.json()) as AvatarUploadResponse;
|
|
},
|
|
completeOnboarding: (body: OnboardingRequest) =>
|
|
apiFetch<UserProfileResponse>("/users/me/onboarding", { method: "POST", body }),
|
|
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
|
|
updatePreferences: (body: UserPreferencesPatchRequest) =>
|
|
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
|
|
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
|
|
supportTickets: () => api.get<UserSupportTicketsResponse>("/users/support-tickets"),
|
|
prepostMeasures: (pilotId = "phase3-pilot-draft") =>
|
|
api.get<UserPrepostMeasuresResponse>(
|
|
`/users/me/prepost-measures?pilot_id=${encodeURIComponent(pilotId)}`,
|
|
),
|
|
upsertPrepostMeasure: (body: UserPrepostMeasureRequest) =>
|
|
apiFetch<UserPrepostMeasureItem>("/users/me/prepost-measures", { method: "PUT", body }),
|
|
};
|
|
|
|
export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">;
|
|
export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">;
|
|
|
|
export const adminEngineApi = {
|
|
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
|
update: (body: AdminEngineConfigPatchRequest) =>
|
|
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
|
|
};
|