feat(portal): add case management workflow
This commit is contained in:
@@ -382,6 +382,70 @@ struct InvestigationPack {
|
|||||||
markdown: String,
|
markdown: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
struct CaseItem {
|
||||||
|
case_id: String,
|
||||||
|
candidate_id: String,
|
||||||
|
title: String,
|
||||||
|
status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
owner: Option<String>,
|
||||||
|
created_at_utc: String,
|
||||||
|
updated_at_utc: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
summary: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
decision: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
struct CaseFile {
|
||||||
|
cases: BTreeMap<String, CaseItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CreateCaseRequest {
|
||||||
|
candidate_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
owner: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CaseStatusRequest {
|
||||||
|
status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
owner: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
decision: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CaseResponse {
|
||||||
|
ok: bool,
|
||||||
|
case: CaseItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CaseListResponse {
|
||||||
|
ok: bool,
|
||||||
|
cases: Vec<CaseItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CaseDetailsResponse {
|
||||||
|
ok: bool,
|
||||||
|
case: CaseItem,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
investigation_pack: Option<InvestigationPack>,
|
||||||
|
markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct HealthResponse {
|
struct HealthResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
@@ -939,6 +1003,14 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
if method == Method::Post && path == "/api/incident-review" {
|
if method == Method::Post && path == "/api/incident-review" {
|
||||||
return handle_incident_review(request, args);
|
return handle_incident_review(request, args);
|
||||||
}
|
}
|
||||||
|
if method == Method::Post && path == "/api/cases" {
|
||||||
|
return handle_create_case(request, args, snapshot_cache);
|
||||||
|
}
|
||||||
|
if method == Method::Post {
|
||||||
|
if let Some(case_id) = parse_case_status_path(&path) {
|
||||||
|
return handle_case_status(request, args, &case_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if method == Method::Post && path == "/api/telemetry" {
|
if method == Method::Post && path == "/api/telemetry" {
|
||||||
return handle_telemetry_ingest(request, args);
|
return handle_telemetry_ingest(request, args);
|
||||||
}
|
}
|
||||||
@@ -951,6 +1023,9 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
if let Some(candidate_id) = parse_investigation_pack_path(&path) {
|
if let Some(candidate_id) = parse_investigation_pack_path(&path) {
|
||||||
return handle_investigation_pack(request, args, snapshot_cache, &url, &candidate_id);
|
return handle_investigation_pack(request, args, snapshot_cache, &url, &candidate_id);
|
||||||
}
|
}
|
||||||
|
if let Some(case_id) = parse_case_path(&path) {
|
||||||
|
return handle_case_details(request, args, snapshot_cache, &url, &case_id);
|
||||||
|
}
|
||||||
if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) {
|
if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) {
|
||||||
return handle_evidence_screenshot(request, args, &evidence_id, download);
|
return handle_evidence_screenshot(request, args, &evidence_id, download);
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1104,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
let incident_state = load_incident_state_best_effort(args);
|
let incident_state = load_incident_state_best_effort(args);
|
||||||
respond_json(request, &build_incidents(&snapshot, &incident_state))
|
respond_json(request, &build_incidents(&snapshot, &incident_state))
|
||||||
}
|
}
|
||||||
|
"/api/cases" => respond_json(request, &build_case_list(args)),
|
||||||
"/api/links" => respond_json(request, &links()),
|
"/api/links" => respond_json(request, &links()),
|
||||||
_ => respond_text(
|
_ => respond_text(
|
||||||
request,
|
request,
|
||||||
@@ -1234,6 +1310,21 @@ fn parse_investigation_pack_path(path: &str) -> Option<String> {
|
|||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_case_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/cases/")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_case_status_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/cases/")
|
||||||
|
.and_then(|value| value.strip_suffix("/status"))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
||||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||||
if let Some(cached) = guard.as_ref() {
|
if let Some(cached) = guard.as_ref() {
|
||||||
@@ -5591,6 +5682,84 @@ fn handle_investigation_pack(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_create_case(
|
||||||
|
mut request: Request,
|
||||||
|
args: &Cli,
|
||||||
|
snapshot_cache: &SnapshotCache,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut body = String::new();
|
||||||
|
request
|
||||||
|
.as_reader()
|
||||||
|
.take(32 * 1024)
|
||||||
|
.read_to_string(&mut body)?;
|
||||||
|
match apply_create_case(args, &cached_snapshot(args, snapshot_cache), &body) {
|
||||||
|
Ok(response) => respond_json(request, &response),
|
||||||
|
Err(err) => respond_json_status(
|
||||||
|
request,
|
||||||
|
StatusCode(400),
|
||||||
|
&json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": err.to_string()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_case_status(mut request: Request, args: &Cli, case_id: &str) -> Result<()> {
|
||||||
|
let mut body = String::new();
|
||||||
|
request
|
||||||
|
.as_reader()
|
||||||
|
.take(32 * 1024)
|
||||||
|
.read_to_string(&mut body)?;
|
||||||
|
match apply_case_status(args, case_id, &body) {
|
||||||
|
Ok(response) => respond_json(request, &response),
|
||||||
|
Err(err) => respond_json_status(
|
||||||
|
request,
|
||||||
|
StatusCode(400),
|
||||||
|
&json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": err.to_string()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_case_details(
|
||||||
|
request: Request,
|
||||||
|
args: &Cli,
|
||||||
|
snapshot_cache: &SnapshotCache,
|
||||||
|
url: &str,
|
||||||
|
case_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
match build_case_details(args, &cached_snapshot(args, snapshot_cache), case_id) {
|
||||||
|
Ok(details) => {
|
||||||
|
let format = query_param(url, "format")
|
||||||
|
.unwrap_or_else(|| "json".to_string())
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(format.as_str(), "md" | "markdown") {
|
||||||
|
let filename = format!("case-{}.md", safe_download_stem(&details.case.case_id));
|
||||||
|
respond_text_download(
|
||||||
|
request,
|
||||||
|
StatusCode(200),
|
||||||
|
&details.markdown,
|
||||||
|
"text/markdown; charset=utf-8",
|
||||||
|
&filename,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
respond_json(request, &details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => respond_json_status(
|
||||||
|
request,
|
||||||
|
StatusCode(404),
|
||||||
|
&json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": err.to_string()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> {
|
fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> {
|
||||||
if !telemetry_authorized(&request, args) {
|
if !telemetry_authorized(&request, args) {
|
||||||
return respond_json_status(
|
return respond_json_status(
|
||||||
@@ -5803,6 +5972,179 @@ fn apply_incident_review(args: &Cli, actor: &str, body: &str) -> Result<Incident
|
|||||||
Ok(IncidentReviewResponse { ok: true, review })
|
Ok(IncidentReviewResponse { ok: true, review })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_create_case(args: &Cli, snapshot: &Snapshot, body: &str) -> Result<CaseResponse> {
|
||||||
|
let request: CreateCaseRequest =
|
||||||
|
serde_json::from_str(body).map_err(|err| anyhow!("invalid case JSON: {err}"))?;
|
||||||
|
let candidate_id = validate_short_token(&request.candidate_id, "candidate_id", 128)?;
|
||||||
|
let incident_reviews = load_incident_review_best_effort(args);
|
||||||
|
let incident_review_audit = load_incident_review_audit_best_effort(args);
|
||||||
|
let pack = build_investigation_pack(
|
||||||
|
snapshot,
|
||||||
|
&candidate_id,
|
||||||
|
&incident_reviews,
|
||||||
|
&incident_review_audit,
|
||||||
|
)?;
|
||||||
|
if pack.current_review_status != "CONFIRMED" {
|
||||||
|
return Err(anyhow!("case can be created only from CONFIRMED candidate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut case_file = load_cases(args)?;
|
||||||
|
if let Some(existing) = case_file
|
||||||
|
.cases
|
||||||
|
.values()
|
||||||
|
.find(|item| item.candidate_id == candidate_id && item.status != "ARCHIVED")
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
return Ok(CaseResponse {
|
||||||
|
ok: true,
|
||||||
|
case: existing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = now();
|
||||||
|
let title = sanitize_optional_text(request.title, 160)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| format!("Дело по кандидату {}", pack.candidate_id));
|
||||||
|
let owner = sanitize_optional_text(request.owner, 80)
|
||||||
|
.or_else(|| pack.owner.clone())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let summary = sanitize_optional_text(request.summary, 1000)
|
||||||
|
.or_else(|| pack.reasons.first().cloned())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let case_id = unique_case_id(&case_file, &pack.candidate_id, &now);
|
||||||
|
let case = CaseItem {
|
||||||
|
case_id: case_id.clone(),
|
||||||
|
candidate_id: pack.candidate_id,
|
||||||
|
title,
|
||||||
|
status: "OPEN".to_string(),
|
||||||
|
owner,
|
||||||
|
created_at_utc: now.clone(),
|
||||||
|
updated_at_utc: now,
|
||||||
|
summary,
|
||||||
|
decision: None,
|
||||||
|
};
|
||||||
|
case_file.cases.insert(case_id, case.clone());
|
||||||
|
save_cases(args, &case_file)?;
|
||||||
|
Ok(CaseResponse { ok: true, case })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_case_status(args: &Cli, case_id: &str, body: &str) -> Result<CaseResponse> {
|
||||||
|
let case_id = validate_short_token(case_id, "case_id", 128)?;
|
||||||
|
let request: CaseStatusRequest =
|
||||||
|
serde_json::from_str(body).map_err(|err| anyhow!("invalid case status JSON: {err}"))?;
|
||||||
|
let status = validate_case_status(&request.status)?;
|
||||||
|
let mut case_file = load_cases(args)?;
|
||||||
|
let Some(case) = case_file.cases.get_mut(&case_id) else {
|
||||||
|
return Err(anyhow!("case not found: {case_id}"));
|
||||||
|
};
|
||||||
|
case.status = status.to_string();
|
||||||
|
if let Some(owner) = sanitize_optional_text(request.owner, 80) {
|
||||||
|
case.owner = (!owner.is_empty()).then_some(owner);
|
||||||
|
}
|
||||||
|
if let Some(summary) = sanitize_optional_text(request.summary, 1000) {
|
||||||
|
case.summary = (!summary.is_empty()).then_some(summary);
|
||||||
|
}
|
||||||
|
if let Some(decision) = sanitize_optional_text(request.decision, 1000) {
|
||||||
|
case.decision = (!decision.is_empty()).then_some(decision);
|
||||||
|
}
|
||||||
|
case.updated_at_utc = now();
|
||||||
|
let case = case.clone();
|
||||||
|
save_cases(args, &case_file)?;
|
||||||
|
Ok(CaseResponse { ok: true, case })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_case_list(args: &Cli) -> CaseListResponse {
|
||||||
|
let mut cases = load_cases_best_effort(args)
|
||||||
|
.cases
|
||||||
|
.into_values()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
cases.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.updated_at_utc
|
||||||
|
.cmp(&left.updated_at_utc)
|
||||||
|
.then_with(|| left.case_id.cmp(&right.case_id))
|
||||||
|
});
|
||||||
|
CaseListResponse { ok: true, cases }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_case_details(
|
||||||
|
args: &Cli,
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
case_id: &str,
|
||||||
|
) -> Result<CaseDetailsResponse> {
|
||||||
|
let case_id = validate_short_token(case_id, "case_id", 128)?;
|
||||||
|
let case_file = load_cases(args)?;
|
||||||
|
let case = case_file
|
||||||
|
.cases
|
||||||
|
.get(&case_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("case not found: {case_id}"))?;
|
||||||
|
let incident_reviews = load_incident_review_best_effort(args);
|
||||||
|
let incident_review_audit = load_incident_review_audit_best_effort(args);
|
||||||
|
let investigation_pack = build_investigation_pack(
|
||||||
|
snapshot,
|
||||||
|
&case.candidate_id,
|
||||||
|
&incident_reviews,
|
||||||
|
&incident_review_audit,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
let markdown = render_case_markdown(&case, investigation_pack.as_ref());
|
||||||
|
Ok(CaseDetailsResponse {
|
||||||
|
ok: true,
|
||||||
|
case,
|
||||||
|
investigation_pack,
|
||||||
|
markdown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_case_id(case_file: &CaseFile, candidate_id: &str, timestamp: &str) -> String {
|
||||||
|
let mut case_id = incident_id("case", candidate_id, timestamp);
|
||||||
|
let mut counter = 1usize;
|
||||||
|
while case_file.cases.contains_key(&case_id) {
|
||||||
|
counter += 1;
|
||||||
|
case_id = incident_id("case", candidate_id, &format!("{timestamp}:{counter}"));
|
||||||
|
}
|
||||||
|
case_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_case_markdown(case: &CaseItem, pack: Option<&InvestigationPack>) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
text.push_str("# Карточка дела\n\n");
|
||||||
|
text.push_str("## Сведения о деле\n\n");
|
||||||
|
text.push_str(&format!("- Дело: {}\n", case.case_id));
|
||||||
|
text.push_str(&format!("- Кандидат: {}\n", case.candidate_id));
|
||||||
|
text.push_str(&format!("- Название: {}\n", case.title));
|
||||||
|
text.push_str(&format!("- Статус: {}\n", case.status));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Ответственный: {}\n",
|
||||||
|
case.owner.as_deref().unwrap_or("-")
|
||||||
|
));
|
||||||
|
text.push_str(&format!("- Создано: {}\n", case.created_at_utc));
|
||||||
|
text.push_str(&format!("- Обновлено: {}\n", case.updated_at_utc));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Резюме: {}\n",
|
||||||
|
case.summary.as_deref().unwrap_or("-")
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Решение: {}\n",
|
||||||
|
case.decision.as_deref().unwrap_or("-")
|
||||||
|
));
|
||||||
|
text.push_str("\n## Пакет расследования\n\n");
|
||||||
|
if let Some(pack) = pack {
|
||||||
|
text.push_str(&pack.markdown);
|
||||||
|
} else {
|
||||||
|
text.push_str("- Пакет расследования недоступен для текущего кандидата.\n");
|
||||||
|
}
|
||||||
|
text.push_str("\n## Решение по делу\n\n");
|
||||||
|
text.push_str(
|
||||||
|
case.decision
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Решение пока не зафиксировано. Дело требует ручного рассмотрения."),
|
||||||
|
);
|
||||||
|
text.push('\n');
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile {
|
fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile {
|
||||||
match load_incident_state(args) {
|
match load_incident_state(args) {
|
||||||
Ok(state) => state,
|
Ok(state) => state,
|
||||||
@@ -5833,6 +6175,16 @@ fn load_incident_review_audit_best_effort(args: &Cli) -> Vec<IncidentReviewAudit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_cases_best_effort(args: &Cli) -> CaseFile {
|
||||||
|
match load_cases(args) {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("detmir-portal cases read failed: {err:#}");
|
||||||
|
CaseFile::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
|
fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
|
||||||
let path = incident_state_path(args);
|
let path = incident_state_path(args);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -5870,6 +6222,15 @@ fn load_incident_review_audit(args: &Cli) -> Result<Vec<IncidentReviewAuditEntry
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_cases(args: &Cli) -> Result<CaseFile> {
|
||||||
|
let path = cases_path(args);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(CaseFile::default());
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
|
fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
|
||||||
fs::create_dir_all(&args.state_dir)
|
fs::create_dir_all(&args.state_dir)
|
||||||
.with_context(|| format!("create {}", args.state_dir.display()))?;
|
.with_context(|| format!("create {}", args.state_dir.display()))?;
|
||||||
@@ -5881,6 +6242,18 @@ fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn save_cases(args: &Cli, state: &CaseFile) -> Result<()> {
|
||||||
|
let path = cases_path(args);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, serde_json::to_vec_pretty(state)?)
|
||||||
|
.with_context(|| format!("write {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn save_incident_review(args: &Cli, state: &IncidentReviewFile) -> Result<()> {
|
fn save_incident_review(args: &Cli, state: &IncidentReviewFile) -> Result<()> {
|
||||||
let path = incident_review_path(args);
|
let path = incident_review_path(args);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -5936,6 +6309,10 @@ fn incident_review_audit_path(args: &Cli) -> PathBuf {
|
|||||||
.join("incident_review_audit.jsonl")
|
.join("incident_review_audit.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cases_path(args: &Cli) -> PathBuf {
|
||||||
|
args.state_dir.join("data").join("cases.json")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse {
|
fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse {
|
||||||
let generated_at_utc = now();
|
let generated_at_utc = now();
|
||||||
let db_available = args.dlp_db_path.exists();
|
let db_available = args.dlp_db_path.exists();
|
||||||
@@ -6696,6 +7073,17 @@ fn validate_incident_review_status(value: &str) -> Result<&'static str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_case_status(value: &str) -> Result<&'static str> {
|
||||||
|
match value.trim() {
|
||||||
|
"OPEN" => Ok("OPEN"),
|
||||||
|
"IN_PROGRESS" => Ok("IN_PROGRESS"),
|
||||||
|
"RESOLVED" => Ok("RESOLVED"),
|
||||||
|
"REJECTED" => Ok("REJECTED"),
|
||||||
|
"ARCHIVED" => Ok("ARCHIVED"),
|
||||||
|
_ => Err(anyhow!("unsupported case status")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sanitize_optional_text(value: Option<String>, max_len: usize) -> Option<String> {
|
fn sanitize_optional_text(value: Option<String>, max_len: usize) -> Option<String> {
|
||||||
value
|
value
|
||||||
.map(|text| sanitize_text(&text, max_len))
|
.map(|text| sanitize_text(&text, max_len))
|
||||||
@@ -8400,6 +8788,88 @@ mod tests {
|
|||||||
assert!(pack.trust_kpi_snapshot.is_object());
|
assert!(pack.trust_kpi_snapshot.is_object());
|
||||||
assert!(pack.agent_quality_snapshot.is_object());
|
assert!(pack.agent_quality_snapshot.is_object());
|
||||||
assert!(pack.business_risk_snapshot.is_object());
|
assert!(pack.business_risk_snapshot.is_object());
|
||||||
|
let case_dir = tempfile::tempdir().unwrap();
|
||||||
|
let case_args = Cli {
|
||||||
|
bind: "127.0.0.1:0".to_string(),
|
||||||
|
status_cmd: "true".to_string(),
|
||||||
|
check_cmd: "true".to_string(),
|
||||||
|
failed_units_cmd: "true".to_string(),
|
||||||
|
worktime_url: "http://127.0.0.1".to_string(),
|
||||||
|
one_c_url: "http://127.0.0.1".to_string(),
|
||||||
|
workforce_policy_path: case_dir.path().join("workforce-policy.json"),
|
||||||
|
ueba_policy_path: case_dir.path().join("ueba-policy.yaml"),
|
||||||
|
timeout_seconds: 1,
|
||||||
|
state_dir: case_dir.path().join("state"),
|
||||||
|
dlp_db_path: case_dir.path().join("dlp.sqlite"),
|
||||||
|
evidence_root: case_dir.path().to_path_buf(),
|
||||||
|
readiness_bundle_dir: case_dir.path().join("readiness-bundle"),
|
||||||
|
evidence_limit: 10,
|
||||||
|
evidence_max_bytes: 1024,
|
||||||
|
json_smoke: false,
|
||||||
|
evidence_only: false,
|
||||||
|
evidence_upload_token: None,
|
||||||
|
telemetry_api_key: "test-key".to_string(),
|
||||||
|
telemetry_store_path: case_dir.path().join("telemetry.jsonl"),
|
||||||
|
expected_nodes_path: case_dir.path().join("expected_nodes.json"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
apply_create_case(
|
||||||
|
&case_args,
|
||||||
|
&snapshot,
|
||||||
|
&json!({
|
||||||
|
"candidate_id": candidate_id,
|
||||||
|
"title": "Case must fail before confirmation"
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let mut confirmed_reviews = IncidentReviewFile::default();
|
||||||
|
confirmed_reviews.reviews.insert(
|
||||||
|
candidate_id.to_string(),
|
||||||
|
IncidentReviewState {
|
||||||
|
candidate_id: candidate_id.to_string(),
|
||||||
|
status: "CONFIRMED".to_string(),
|
||||||
|
reviewer: Some("operator".to_string()),
|
||||||
|
comment: Some("confirmed for case".to_string()),
|
||||||
|
updated_at: "2026-06-04T12:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
save_incident_review(&case_args, &confirmed_reviews).unwrap();
|
||||||
|
let case_response = apply_create_case(
|
||||||
|
&case_args,
|
||||||
|
&snapshot,
|
||||||
|
&json!({
|
||||||
|
"candidate_id": candidate_id,
|
||||||
|
"title": "Проверка KPI",
|
||||||
|
"owner": "operator",
|
||||||
|
"summary": "manual case"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(case_response.case.status, "OPEN");
|
||||||
|
assert!(cases_path(&case_args).ends_with("data/cases.json"));
|
||||||
|
let list = build_case_list(&case_args);
|
||||||
|
assert_eq!(list.cases.len(), 1);
|
||||||
|
let status_response = apply_case_status(
|
||||||
|
&case_args,
|
||||||
|
&case_response.case.case_id,
|
||||||
|
&json!({
|
||||||
|
"status": "RESOLVED",
|
||||||
|
"decision": "issue resolved"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status_response.case.status, "RESOLVED");
|
||||||
|
let details =
|
||||||
|
build_case_details(&case_args, &snapshot, &case_response.case.case_id).unwrap();
|
||||||
|
assert!(details.investigation_pack.is_some());
|
||||||
|
assert!(details.markdown.contains("# Карточка дела"));
|
||||||
|
assert!(details.markdown.contains("## Пакет расследования"));
|
||||||
|
assert_eq!(validate_case_status("ARCHIVED").unwrap(), "ARCHIVED");
|
||||||
|
assert!(validate_case_status("AUTO").is_err());
|
||||||
assert!(
|
assert!(
|
||||||
build_investigation_pack(
|
build_investigation_pack(
|
||||||
&snapshot,
|
&snapshot,
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ async function postJson(path, payload) {
|
|||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
const s = String(status || "UNKNOWN").toLowerCase();
|
const s = String(status || "UNKNOWN").toLowerCase();
|
||||||
if (s === "ok" || s === "true" || s === "low" || s === "false_positive") return "status-ok";
|
if (s === "ok" || s === "true" || s === "low" || s === "false_positive" || s === "resolved") return "status-ok";
|
||||||
if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed") return "status-warn";
|
if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed" || s === "open" || s === "in_progress") return "status-warn";
|
||||||
if (s === "degraded" || s === "high" || s === "confirmed") return "status-degraded";
|
if (s === "degraded" || s === "high" || s === "confirmed" || s === "rejected" || s === "archived") return "status-degraded";
|
||||||
if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail";
|
if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail";
|
||||||
return "status-unknown";
|
return "status-unknown";
|
||||||
}
|
}
|
||||||
@@ -1280,6 +1280,7 @@ function renderIncidents(data) {
|
|||||||
const incidents = Array.isArray(data) ? data : data.incidents;
|
const incidents = Array.isArray(data) ? data : data.incidents;
|
||||||
const evidence = Array.isArray(data) ? null : data.evidence;
|
const evidence = Array.isArray(data) ? null : data.evidence;
|
||||||
const reports = Array.isArray(data) ? state.reports : data.reports;
|
const reports = Array.isArray(data) ? state.reports : data.reports;
|
||||||
|
const cases = Array.isArray(data?.cases?.cases) ? data.cases.cases : [];
|
||||||
return `
|
return `
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -1299,6 +1300,7 @@ function renderIncidents(data) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
${renderAutoInvestigationCard(reports)}
|
${renderAutoInvestigationCard(reports)}
|
||||||
|
${renderCases(cases)}
|
||||||
<section class="card evidence-card">
|
<section class="card evidence-card">
|
||||||
<h3>Материалы: скриншоты, хеши, файлы</h3>
|
<h3>Материалы: скриншоты, хеши, файлы</h3>
|
||||||
${renderDlpEvidence(evidence)}
|
${renderDlpEvidence(evidence)}
|
||||||
@@ -1306,6 +1308,79 @@ function renderIncidents(data) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCases(cases) {
|
||||||
|
const rows = Array.isArray(cases) ? cases.slice(0, 20) : [];
|
||||||
|
return `
|
||||||
|
<section class="card cases-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>Дела</h3>
|
||||||
|
<p class="muted">Ручные дела, созданные из подтвержденных кандидатов. Автоматически дела не создаются.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${statusClass(rows.length ? "OPEN" : "OK")}">${rows.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дело</th>
|
||||||
|
<th>Кандидат</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Ответственный</th>
|
||||||
|
<th>Решение</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows.length ? rows.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${ui(item.title || item.case_id)}</strong><br><code>${ui(item.case_id || "-")}</code><br><span class="muted small">${ui(item.created_at_utc || "-")} · ${ui(item.updated_at_utc || "-")}</span></td>
|
||||||
|
<td><code>${ui(item.candidate_id || "-")}</code><br><span class="muted small">${ui(item.summary || "резюме не задано")}</span></td>
|
||||||
|
<td><span class="badge ${statusClass(item.status)}">${ui(caseStatusText(item.status))}</span></td>
|
||||||
|
<td>${ui(item.owner || "ответственный не указан")}</td>
|
||||||
|
<td>${ui(item.decision || "решение не зафиксировано")}</td>
|
||||||
|
<td>${renderCaseActions(item)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("") : `
|
||||||
|
<tr>
|
||||||
|
<td>Нет дел</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td><span class="badge status-ok">OK</span></td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>Создайте дело из подтвержденного кандидата.</td>
|
||||||
|
<td>-</td>
|
||||||
|
</tr>
|
||||||
|
`}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaseActions(item) {
|
||||||
|
const id = item?.case_id || "";
|
||||||
|
const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown`;
|
||||||
|
return `
|
||||||
|
<div class="button-row compact-actions">
|
||||||
|
<button class="small-button" data-case-status="IN_PROGRESS" data-case-id="${escapeHtml(id)}">В работу</button>
|
||||||
|
<button class="small-button" data-case-status="RESOLVED" data-case-id="${escapeHtml(id)}">Решено</button>
|
||||||
|
<button class="small-button" data-case-status="REJECTED" data-case-id="${escapeHtml(id)}">Отклонить</button>
|
||||||
|
<button class="small-button" data-case-status="ARCHIVED" data-case-id="${escapeHtml(id)}">В архив</button>
|
||||||
|
</div>
|
||||||
|
<a class="small-button investigation-pack-button" href="${escapeHtml(packUrl)}" download>Скачать карточку дела</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function caseStatusText(status) {
|
||||||
|
const value = String(status || "OPEN").toUpperCase();
|
||||||
|
if (value === "IN_PROGRESS") return "В работе";
|
||||||
|
if (value === "RESOLVED") return "Решено";
|
||||||
|
if (value === "REJECTED") return "Отклонено";
|
||||||
|
if (value === "ARCHIVED") return "Архив";
|
||||||
|
return "Открыто";
|
||||||
|
}
|
||||||
|
|
||||||
function renderKpiCards(items) {
|
function renderKpiCards(items) {
|
||||||
return `<div class="summary-grid kpi-grid">${(items || []).map(item => `
|
return `<div class="summary-grid kpi-grid">${(items || []).map(item => `
|
||||||
<article class="card kpi-card">
|
<article class="card kpi-card">
|
||||||
@@ -1805,6 +1880,7 @@ function renderCandidateReview(item) {
|
|||||||
|
|
||||||
function renderCandidateReviewActions(item) {
|
function renderCandidateReviewActions(item) {
|
||||||
const id = item?.id || "";
|
const id = item?.id || "";
|
||||||
|
const reviewStatus = String(item?.incident_review?.status || "NEW").toUpperCase();
|
||||||
const actions = [
|
const actions = [
|
||||||
["IN_REVIEW", "В проверку"],
|
["IN_REVIEW", "В проверку"],
|
||||||
["CONFIRMED", "Подтвердить"],
|
["CONFIRMED", "Подтвердить"],
|
||||||
@@ -1812,11 +1888,15 @@ function renderCandidateReviewActions(item) {
|
|||||||
["POSTPONED", "Отложить"],
|
["POSTPONED", "Отложить"],
|
||||||
];
|
];
|
||||||
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`;
|
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`;
|
||||||
|
const createCase = reviewStatus === "CONFIRMED"
|
||||||
|
? `<button class="small-button investigation-pack-button primary" data-create-case="true" data-candidate-id="${escapeHtml(id)}">Создать дело</button>`
|
||||||
|
: "";
|
||||||
return `
|
return `
|
||||||
<div class="button-row compact-actions">${actions.map(([status, label]) => `
|
<div class="button-row compact-actions">${actions.map(([status, label]) => `
|
||||||
<button class="small-button" data-review-status="${escapeHtml(status)}" data-candidate-id="${escapeHtml(id)}">${ui(label)}</button>
|
<button class="small-button" data-review-status="${escapeHtml(status)}" data-candidate-id="${escapeHtml(id)}">${ui(label)}</button>
|
||||||
`).join("")}</div>
|
`).join("")}</div>
|
||||||
<a class="small-button investigation-pack-button" href="${escapeHtml(packUrl)}" download>Скачать пакет расследования</a>
|
<a class="small-button investigation-pack-button" href="${escapeHtml(packUrl)}" download>Скачать пакет расследования</a>
|
||||||
|
${createCase}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1986,8 +2066,9 @@ async function refresh() {
|
|||||||
const data = await loadJson("/incidents");
|
const data = await loadJson("/incidents");
|
||||||
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
|
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
|
||||||
const reports = await loadJson("/reports").catch(() => state.reports || {});
|
const reports = await loadJson("/reports").catch(() => state.reports || {});
|
||||||
|
const cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
|
||||||
state.reports = reports;
|
state.reports = reports;
|
||||||
content.innerHTML = renderIncidents({ incidents: data, evidence, reports });
|
content.innerHTML = renderIncidents({ incidents: data, evidence, reports, cases });
|
||||||
}
|
}
|
||||||
if (state.tab === "perimeter") {
|
if (state.tab === "perimeter") {
|
||||||
const data = await loadJson("/owner");
|
const data = await loadJson("/owner");
|
||||||
@@ -2062,6 +2143,18 @@ document.addEventListener("click", event => {
|
|||||||
candidateReviewAction(button).catch(showError);
|
candidateReviewAction(button).catch(showError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", event => {
|
||||||
|
const button = event.target.closest("[data-create-case]");
|
||||||
|
if (!button) return;
|
||||||
|
createCaseAction(button).catch(showError);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", event => {
|
||||||
|
const button = event.target.closest("[data-case-status]");
|
||||||
|
if (!button) return;
|
||||||
|
caseStatusAction(button).catch(showError);
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("click", event => {
|
document.addEventListener("click", event => {
|
||||||
const button = event.target.closest("[data-print-report]");
|
const button = event.target.closest("[data-print-report]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
@@ -2175,6 +2268,38 @@ async function candidateReviewAction(button) {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createCaseAction(button) {
|
||||||
|
const candidateId = button.dataset.candidateId;
|
||||||
|
const title = window.prompt("Название дела", `Дело по кандидату ${candidateId}`);
|
||||||
|
if (title === null || title.trim() === "") return;
|
||||||
|
const owner = window.prompt("Ответственный по делу", "");
|
||||||
|
if (owner === null) return;
|
||||||
|
const summary = window.prompt("Краткое резюме дела", "");
|
||||||
|
if (summary === null) return;
|
||||||
|
button.disabled = true;
|
||||||
|
await postJson("/cases", {
|
||||||
|
candidate_id: candidateId,
|
||||||
|
title,
|
||||||
|
owner,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
state.tab = "incidents";
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function caseStatusAction(button) {
|
||||||
|
const caseId = button.dataset.caseId;
|
||||||
|
const status = button.dataset.caseStatus;
|
||||||
|
const decision = window.prompt("Решение или комментарий по делу", "");
|
||||||
|
if (decision === null) return;
|
||||||
|
button.disabled = true;
|
||||||
|
await postJson(`/cases/${encodeURIComponent(caseId)}/status`, {
|
||||||
|
status,
|
||||||
|
decision,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
applySecurityMode(state.tab);
|
applySecurityMode(state.tab);
|
||||||
refresh().catch(showError);
|
refresh().catch(showError);
|
||||||
setInterval(() => refresh().catch(showError), 60000);
|
setInterval(() => refresh().catch(showError), 60000);
|
||||||
|
|||||||
@@ -222,6 +222,60 @@ Markdown-версия содержит разделы:
|
|||||||
Кнопка `Скачать пакет расследования` выгружает Markdown-пакет по выбранному
|
Кнопка `Скачать пакет расследования` выгружает Markdown-пакет по выбранному
|
||||||
кандидату без создания инцидента.
|
кандидату без создания инцидента.
|
||||||
|
|
||||||
|
## Case Management
|
||||||
|
|
||||||
|
Подтвержденный кандидат можно вручную перевести в дело. Дело не создается
|
||||||
|
автоматически: оператор должен нажать `Создать дело` у кандидата со статусом
|
||||||
|
`CONFIRMED`.
|
||||||
|
|
||||||
|
Сущность `case`:
|
||||||
|
|
||||||
|
- `case_id`;
|
||||||
|
- `candidate_id`;
|
||||||
|
- `title`;
|
||||||
|
- `status`;
|
||||||
|
- `owner`;
|
||||||
|
- `created_at_utc`;
|
||||||
|
- `updated_at_utc`;
|
||||||
|
- `summary`;
|
||||||
|
- `decision`.
|
||||||
|
|
||||||
|
Статусы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OPEN
|
||||||
|
IN_PROGRESS
|
||||||
|
RESOLVED
|
||||||
|
REJECTED
|
||||||
|
ARCHIVED
|
||||||
|
```
|
||||||
|
|
||||||
|
API:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/cases
|
||||||
|
GET /api/cases
|
||||||
|
GET /api/cases/{case_id}
|
||||||
|
GET /api/cases/{case_id}?format=markdown
|
||||||
|
POST /api/cases/{case_id}/status
|
||||||
|
```
|
||||||
|
|
||||||
|
Хранение:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<state_dir>/data/cases.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- дело создается только из `CONFIRMED` кандидата;
|
||||||
|
- повторное создание для того же кандидата возвращает существующее активное
|
||||||
|
дело, если оно не архивировано;
|
||||||
|
- дело содержит ссылку на кандидата, решение и текущий Investigation Pack;
|
||||||
|
- Markdown-экспорт карточки дела включает пакет расследования, статус
|
||||||
|
проверки, audit review и решение по делу;
|
||||||
|
- создание/смена статуса дела не создает обычный инцидент автоматически.
|
||||||
|
|
||||||
## Markdown
|
## Markdown
|
||||||
|
|
||||||
Оперативный отчет содержит раздел:
|
Оперативный отчет содержит раздел:
|
||||||
|
|||||||
Reference in New Issue
Block a user