File size: 8,783 Bytes
54f0281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6f5fcf
 
54f0281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5f80f7
 
 
54f0281
 
d5f80f7
54f0281
 
 
 
 
 
 
 
 
 
 
469c3b7
 
54f0281
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// Thin typed fetch wrapper. All API types come from the generated contracts (shared/schemas).
// Mock mode (synthetic cases, in-browser): VITE_MOCK=1, ?mock=1 (sticky per tab), or the API is unreachable / has no cases.
import type {
  AskRequest, AskResponse, AssessmentRecorded, AssessmentSummary, AttemptSubmit, DebriefResponse, Health,
  Case, HintRequest, HintResponse, NextCase, ReviewRating, SessionCreate, SessionCreated, SubmitResult, SusResult, SusSubmit,
} from '../types/contracts';

import { apiRoot, normalizeBase, resolveUrl } from './base';
import { gateKindFromBody, useGate, type GateKind } from './access';

/** Public path of the app ("/" or e.g. "/blindspot/"), from Vite's `base` (VITE_BASE_PATH). */
export const BASE = normalizeBase(import.meta.env.BASE_URL);
/** API root, no trailing slash: VITE_API_BASE if set, else `${BASE}api`. */
export const API_BASE = apiRoot(BASE, import.meta.env.VITE_API_BASE as string | undefined);
/** Re-roots a server-provided URL ("/api/cases/x/image", "/mock/x.png") for this deployment. */
export const assetUrl = (u: string) => resolveUrl(u, BASE, API_BASE);
// Cookies (the access-code session) must travel with cross-origin API calls when VITE_API_BASE points elsewhere.
const CREDENTIALS: RequestCredentials = /^https?:/i.test(API_BASE) ? 'include' : 'same-origin';

export type ApiMode = 'real' | 'mock';
let mode: ApiMode = import.meta.env.VITE_MOCK === '1' ? 'mock' : 'real';
let modeReason = import.meta.env.VITE_MOCK === '1' ? 'VITE_MOCK=1' : '';

export class ApiError extends Error {
  status: number;
  /** Set when the server asks for an access or reviewer code. */
  gate: GateKind | null;
  constructor(status: number, message: string, gate: GateKind | null = null) {
    super(message);
    this.status = status;
    this.gate = gate;
  }
}

export const isGateError = (e: unknown, kind?: GateKind): e is ApiError =>
  e instanceof ApiError && !!e.gate && (!kind || e.gate === kind);

export function apiMode(): { mode: ApiMode; reason: string } {
  return { mode, reason: modeReason };
}

const MOCK_KEY = 'blindspot.mock';

/** Decide real vs mock once at startup. Never throws. */
export async function initApiMode(): Promise<ApiMode> {
  if (import.meta.env.VITE_MOCK === '1') return mode;
  try {
    const q = new URLSearchParams(location.search).get('mock');
    if (q === '1') sessionStorage.setItem(MOCK_KEY, '1');
    if (q === '0') sessionStorage.removeItem(MOCK_KEY);
    if (sessionStorage.getItem(MOCK_KEY) === '1') {
      mode = 'mock';
      modeReason = '?mock=1';
      return mode;
    }
  } catch {
    /* storage blocked: fall through to the health probe */
  }
  try {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 1500);
    const res = await fetch(`${API_BASE}/health`, { signal: ctrl.signal, credentials: CREDENTIALS });
    clearTimeout(timer);
    // A private preview may gate even /health: the API is there, it just wants the access code first.
    if (res.status === 401 || res.status === 403) {
      const gate = gateKindFromBody(res.status, await res.json().catch(() => null));
      if (gate === 'access') { useGate.getState().raise('access'); return mode; }
    }
    const h = res.ok ? ((await res.json()) as Health) : null;
    if (!h?.ok) { mode = 'mock'; modeReason = 'API unreachable'; }
    else if (!h.cases) { mode = 'mock'; modeReason = 'API has no cases loaded'; }
    else await probeAccess();
  } catch {
    mode = 'mock';
    modeReason = 'API unreachable';
  }
  return mode;
}

/** Private preview: GET /access says whether a code is configured and passed; show the gate before any page asks. */
async function probeAccess(): Promise<void> {
  try {
    const res = await fetch(`${API_BASE}/access`, { credentials: CREDENTIALS });
    if (!res.ok) return; // older servers: the first gated call raises the gate instead
    const st = (await res.json()) as { access_required?: boolean; access_granted?: boolean };
    if (st.access_required && !st.access_granted) useGate.getState().raise('access');
  } catch {
    /* best effort */
  }
}

/** Exported for endpoints that live outside this file (src/api/sessionOptions.ts); prefer the typed `api` below. */
export async function request<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
  const method = init?.method ?? 'GET';
  if (mode === 'mock') {
    const { mockRequest } = await import('./mock');
    try {
      return (await mockRequest(method, path, init?.body)) as T;
    } catch (e) {
      const status = (e as { status?: number }).status ?? 500;
      throw new ApiError(status, (e as Error).message);
    }
  }
  const res = await fetch(`${API_BASE}${path}`, {
    method,
    credentials: CREDENTIALS,
    headers: { 'Content-Type': 'application/json' },
    body: init?.body === undefined ? undefined : JSON.stringify(init.body),
  });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    let body: unknown = null;
    try { body = JSON.parse(text); } catch { /* not JSON */ }
    const gate = gateKindFromBody(res.status, body);
    // The app-wide gate covers every page; reviewer prompts are shown by the page that asked.
    if (gate === 'access') useGate.getState().raise('access');
    throw new ApiError(res.status, `${res.status} ${res.statusText}: ${text}`, gate);
  }
  return (await res.json()) as T;
}

const post = <T,>(path: string, body: unknown) => request<T>(path, { method: 'POST', body });

export function isSubmitResult(r: SubmitResult | AssessmentRecorded): r is SubmitResult {
  return (r as AssessmentRecorded).recorded !== true;
}

/** POST the access or reviewer code. The server sets the cookie; resolves false on a wrong code. */
export async function submitCode(kind: GateKind, code: string): Promise<{ ok: boolean; status: number }> {
  const res = await fetch(`${API_BASE}${kind === 'access' ? '/access' : '/review/access'}`, {
    method: 'POST',
    credentials: CREDENTIALS,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code }),
  });
  return { ok: res.ok, status: res.status };
}

/** GET /access: whether codes are configured and whether this browser's cookies pass them. */
export type AccessStatus = { access_required?: boolean; access_granted?: boolean; review_required?: boolean; review_granted?: boolean; base_path?: string };

export const api = {
  health: () => request<Health>('/health'),
  access: () => request<AccessStatus>('/access'),
  createSession: (body: SessionCreate) => post<SessionCreated>('/sessions', body),
  next: (sid: string) => request<NextCase>(`/sessions/${sid}/next`),
  hint: (aid: string, body: HintRequest) => post<HintResponse>(`/attempts/${aid}/hint`, body),
  submit: (aid: string, body: AttemptSubmit) => post<SubmitResult | AssessmentRecorded>(`/attempts/${aid}/submit`, body),
  debrief: (aid: string) => request<DebriefResponse>(`/attempts/${aid}/debrief`),
  ask: (aid: string, body: AskRequest) => post<AskResponse>(`/attempts/${aid}/ask`, body),
  /** Learner "This seems wrong" flag. Not yet in §13 — see CONTRACT CHANGE REQUEST in docs/PROGRESS.md. */
  flagDebrief: (aid: string, comment: string) => post<{ ok: boolean }>(`/attempts/${aid}/flag`, { comment }),
  rate: (body: ReviewRating) => post<{ ok: boolean }>('/review/ratings', body),
  summary: (sid: string) => request<AssessmentSummary>(`/sessions/${sid}/summary`),
  /** Dashboard payloads are loosely typed by the API; pages pass them through src/dashboard/types.ts guards. */
  /** `qs` e.g. "?modality=ct" (round 4): the reading log filtered to one scan type; the server echoes `modality`. */
  learnerDashboard: (lid: string, qs = '') => request<unknown>(`/learners/${encodeURIComponent(lid)}/dashboard${qs}`),
  cohortDashboard: (qs = '') => request<unknown>(`/cohort/dashboard${qs}`),
  /** Review queue (SPEC §11.1); shapes guarded in src/review/types.ts. */
  reviewItems: (type: 'debrief' | 'card', limit = 50) => request<unknown>(`/review/items?type=${type}&limit=${limit}`),
  reviewExportUrl: `${API_BASE}/review/export.csv`,
  sus: (body: SusSubmit) => post<SusResult>('/sus', body),
  /** Full case with ground truth. Reviewer-only (the attempt is long submitted); never used by the reading room. */
  devCase: (caseId: string) => request<Case>(`/dev/cases/${encodeURIComponent(caseId)}`),
  about: () => request<unknown>('/about'),
  imageUrl: (caseId: string) => `${API_BASE}/cases/${caseId}/image`,
  /** Zone outlines for "Show anatomy" (after submit only). Loosely typed; guarded in src/viewer/anatomy.ts. */
  anatomy: (aid: string) => request<unknown>(`/attempts/${aid}/anatomy`),
  devOverlayUrl: (caseId: string, layers: string) => `${API_BASE}/dev/cases/${caseId}/overlay?layers=${layers}`,
};