Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { Level, SessionMode } from "@/shared/types/generated";
|
||||
import type { Level, SessionMode, StationSnapshot } from "@/shared/types/generated";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
|
|
@ -21,7 +21,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new ApiError(response.status, body.detail ?? response.statusText);
|
||||
const detail = body.detail;
|
||||
const message = typeof detail === "string"
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail.map((item: { loc?: unknown[]; msg?: string }) => {
|
||||
const path = item.loc?.slice(1).join(".");
|
||||
return `${path ? `${path}: ` : ""}${item.msg ?? "некорректное значение"}`;
|
||||
}).join("; ")
|
||||
: response.statusText;
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
|
@ -39,17 +48,36 @@ export interface Health {
|
|||
export interface ScenarioSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
outcome?: "card" | "consultation" | "transfer_region";
|
||||
type?: string;
|
||||
level: Level;
|
||||
topics: string[];
|
||||
topics?: string[];
|
||||
modes: SessionMode[];
|
||||
signs: string[];
|
||||
incident_code: string | null;
|
||||
dds: string | null;
|
||||
ticket: number | null;
|
||||
position: number | null;
|
||||
ekp_group: number | null;
|
||||
can_manage: boolean;
|
||||
signs?: string[];
|
||||
incident_code?: string | null;
|
||||
dds?: string | null;
|
||||
ticket?: number | null;
|
||||
position?: number | null;
|
||||
ekp_group?: number | null;
|
||||
can_manage?: boolean;
|
||||
source?: "system" | "trainee";
|
||||
}
|
||||
|
||||
export interface ScenarioSubmission {
|
||||
id: string;
|
||||
author_name: string;
|
||||
title: string;
|
||||
incident_type: string;
|
||||
level: Level;
|
||||
description: string;
|
||||
address: string;
|
||||
victims: number | null;
|
||||
kio: Record<string, unknown> | null;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
review_comment: string;
|
||||
scenario_id: string | null;
|
||||
created_at: string;
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
|
|
@ -59,13 +87,75 @@ export interface SessionInfo {
|
|||
attempt: number;
|
||||
trainee_id: string | null;
|
||||
group_id: string | null;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
end_reason: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveSessionInfo {
|
||||
session_id: string;
|
||||
trainee_name: string | null;
|
||||
scenario_id: string;
|
||||
scenario_title: string;
|
||||
mode: SessionMode;
|
||||
exercise: "call" | "card" | "dds";
|
||||
started_at: string | null;
|
||||
elapsed_seconds: number;
|
||||
dds_card_total: number;
|
||||
dds_open_cards: number;
|
||||
dds_overdue_cards: number;
|
||||
dds_work_overdue_cards: number;
|
||||
dds_statuses: Record<string, string>;
|
||||
dds_snapshot: StationSnapshot | null;
|
||||
}
|
||||
|
||||
export const useActiveSessions = () =>
|
||||
useQuery({
|
||||
queryKey: ["sessions", "active"],
|
||||
queryFn: () => request<ActiveSessionInfo[]>("/api/sessions/active"),
|
||||
refetchInterval: 2_000,
|
||||
});
|
||||
|
||||
export const useHealth = () =>
|
||||
useQuery({ queryKey: ["health"], queryFn: () => request<Health>("/api/health"), refetchInterval: 5_000 });
|
||||
|
||||
export const useScenarios = () =>
|
||||
useQuery({ queryKey: ["scenarios"], queryFn: () => request<ScenarioSummary[]>("/api/scenarios") });
|
||||
export const useScenarios = (enabled = true, roleScope = "instructor") =>
|
||||
useQuery({
|
||||
queryKey: ["scenarios", roleScope],
|
||||
queryFn: () => request<ScenarioSummary[]>("/api/scenarios"),
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const useScenarioSubmissions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: ["scenario-submissions"],
|
||||
queryFn: () => request<ScenarioSubmission[]>("/api/scenario-submissions"),
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const useCreateScenarioSubmission = () => {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { title: string; level: Level; kio: Record<string, unknown> }) => request<ScenarioSubmission>("/api/scenario-submissions", {
|
||||
method: "POST", body: JSON.stringify(payload),
|
||||
}),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ["scenario-submissions"] }),
|
||||
});
|
||||
};
|
||||
|
||||
export const useReviewScenarioSubmission = () => {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { id: string; decision: "approve" | "reject"; comment?: string }) =>
|
||||
request<ScenarioSubmission>(`/api/scenario-submissions/${payload.id}/review`, {
|
||||
method: "POST", body: JSON.stringify({ decision: payload.decision, comment: payload.comment ?? "" }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ["scenario-submissions"] });
|
||||
client.invalidateQueries({ queryKey: ["scenarios"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSessions = (params: { trainee?: string; group?: string; mode?: SessionMode } = {}) => {
|
||||
const query = new URLSearchParams(
|
||||
|
|
@ -112,6 +202,12 @@ export interface Profile {
|
|||
interview_ms: number | null;
|
||||
facts_got: number | null;
|
||||
}[];
|
||||
recommendations: {
|
||||
code: string;
|
||||
title: string;
|
||||
recommendation: string;
|
||||
occurrences: number;
|
||||
}[];
|
||||
hints_total: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ interface ChannelOptions<In, Out> {
|
|||
/** Ключ безопасного идемпотентного события, которое можно повторить после
|
||||
* краткого разрыва. null запрещает буферизацию команды. */
|
||||
queueKey?: (event: Out) => string | null;
|
||||
/** Подтверждение, что серверный durable snapshot уже содержит команду. */
|
||||
isAcknowledged?: (incoming: In, pending: Out) => boolean;
|
||||
}
|
||||
|
||||
const RECONNECT_MIN_MS = 500;
|
||||
|
|
@ -30,7 +32,7 @@ const RECONNECT_MAX_MS = 5_000;
|
|||
const BUFFER_MAX_AGE_MS = 35_000;
|
||||
const BUFFER_MAX_ITEMS = 100;
|
||||
|
||||
interface Buffered<Out> { key: string; event: Out; at: number }
|
||||
interface Buffered<Out> { key: string; commandId: string; event: Out; at: number }
|
||||
|
||||
export class Channel<In extends { type: string }, Out extends { type: string }> {
|
||||
private socket: WebSocket | null = null;
|
||||
|
|
@ -61,15 +63,25 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
this.options.onStatus?.("open");
|
||||
const now = Date.now();
|
||||
const pending = this.buffered.filter((item) => now - item.at <= BUFFER_MAX_AGE_MS);
|
||||
this.buffered = [];
|
||||
for (const item of pending) socket.send(JSON.stringify(item.event));
|
||||
this.buffered = pending;
|
||||
for (const item of pending) socket.send(this.encode(item));
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
if (typeof message.data !== "string") {
|
||||
this.options.onBinary?.(message.data as ArrayBuffer);
|
||||
return;
|
||||
}
|
||||
this.options.onEvent?.(JSON.parse(message.data) as In);
|
||||
const event = JSON.parse(message.data) as In;
|
||||
if (event.type === "command.ack" && typeof (event as { command_id?: unknown }).command_id === "string") {
|
||||
const commandId = (event as unknown as { command_id: string }).command_id;
|
||||
this.buffered = this.buffered.filter((item) => item.commandId !== commandId);
|
||||
}
|
||||
if (this.options.isAcknowledged) {
|
||||
this.buffered = this.buffered.filter(
|
||||
(item) => !this.options.isAcknowledged!(event, item.event),
|
||||
);
|
||||
}
|
||||
this.options.onEvent?.(event);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
this.socket = null;
|
||||
|
|
@ -91,13 +103,14 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
}
|
||||
|
||||
send(event: Out): boolean {
|
||||
if (this.isOpen) {
|
||||
const key = this.options.queueKey?.(event) ?? null;
|
||||
if (!key) {
|
||||
if (!this.isOpen) return false;
|
||||
this.socket!.send(JSON.stringify(event));
|
||||
return true;
|
||||
}
|
||||
const key = this.options.queueKey?.(event) ?? null;
|
||||
if (!key) return false;
|
||||
const existing = this.buffered.find((item) => item.key === key);
|
||||
let pending: Buffered<Out>;
|
||||
if (existing) {
|
||||
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
|
||||
// но правки разных полей не должны затереть друг друга.
|
||||
|
|
@ -112,13 +125,24 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
existing.event = event;
|
||||
}
|
||||
existing.at = Date.now();
|
||||
pending = existing;
|
||||
} else {
|
||||
this.buffered.push({ key, event, at: Date.now() });
|
||||
pending = { key, commandId: crypto.randomUUID(), event, at: Date.now() };
|
||||
this.buffered.push(pending);
|
||||
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
|
||||
}
|
||||
// Keep a replay-safe command (for at most BUFFER_MAX_AGE_MS) until an event
|
||||
// emitted after the server's durable checkpoint proves it was applied. A
|
||||
// send() on an open socket is not itself an acknowledgement: the connection
|
||||
// may die before the server receives it or the response reaches this browser.
|
||||
if (this.isOpen) this.socket!.send(this.encode(pending));
|
||||
return true;
|
||||
}
|
||||
|
||||
private encode(item: Buffered<Out>): string {
|
||||
return JSON.stringify({ ...item.event, _command_id: item.commandId });
|
||||
}
|
||||
|
||||
sendBinary(frame: ArrayBuffer): boolean {
|
||||
if (!this.isOpen) return false;
|
||||
this.socket!.send(frame);
|
||||
|
|
@ -139,6 +163,13 @@ export const callChannel = (
|
|||
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
|
||||
...options,
|
||||
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
||||
isAcknowledged: (incoming, pending) => {
|
||||
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|
||||
|| incoming.source !== "operator") return false;
|
||||
return Object.entries(pending.fields).every(([key, value]) =>
|
||||
sameJsonValue(value, incoming.fields[key]),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
|
||||
|
|
@ -154,12 +185,34 @@ export const stationChannel = (
|
|||
...options,
|
||||
queueKey: (event) => {
|
||||
if (event.type === "card.reply") return `card.reply:${event.card_id}`;
|
||||
if (event.type === "crew.select") return "crew.select";
|
||||
if (event.type === "zone.decision") return "zone.decision";
|
||||
return null;
|
||||
// Non-replacement station actions are each their own replayable command.
|
||||
// The server deduplicates by this ID inside its persisted session snapshot.
|
||||
return `command:${crypto.randomUUID()}`;
|
||||
},
|
||||
isAcknowledged: (incoming, pending) => incoming.type === "station.state"
|
||||
&& pending.type === "card.reply"
|
||||
&& incoming.snapshot.card_id === pending.card_id
|
||||
&& incoming.snapshot.reply_text === pending.text,
|
||||
});
|
||||
|
||||
/** Преподаватель: только передача, входящих в этом канале нет. */
|
||||
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
|
||||
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);
|
||||
|
||||
function sameJsonValue(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((item, index) => sameJsonValue(item, right[index]));
|
||||
}
|
||||
if (left && right && typeof left === "object" && typeof right === "object") {
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const keys = Object.keys(leftRecord);
|
||||
return keys.length === Object.keys(rightRecord).length
|
||||
&& keys.every((key) => key in rightRecord
|
||||
&& sameJsonValue(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue