feat(portal): add case management workflow
This commit is contained in:
@@ -382,6 +382,70 @@ struct InvestigationPack {
|
||||
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)]
|
||||
struct HealthResponse {
|
||||
ok: bool,
|
||||
@@ -939,6 +1003,14 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
||||
if method == Method::Post && path == "/api/incident-review" {
|
||||
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" {
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
respond_json(request, &build_incidents(&snapshot, &incident_state))
|
||||
}
|
||||
"/api/cases" => respond_json(request, &build_case_list(args)),
|
||||
"/api/links" => respond_json(request, &links()),
|
||||
_ => respond_text(
|
||||
request,
|
||||
@@ -1234,6 +1310,21 @@ fn parse_investigation_pack_path(path: &str) -> Option<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 {
|
||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||
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<()> {
|
||||
if !telemetry_authorized(&request, args) {
|
||||
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 })
|
||||
}
|
||||
|
||||
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 {
|
||||
match load_incident_state(args) {
|
||||
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> {
|
||||
let path = incident_state_path(args);
|
||||
if !path.exists() {
|
||||
@@ -5870,6 +6222,15 @@ fn load_incident_review_audit(args: &Cli) -> Result<Vec<IncidentReviewAuditEntry
|
||||
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<()> {
|
||||
fs::create_dir_all(&args.state_dir)
|
||||
.with_context(|| format!("create {}", args.state_dir.display()))?;
|
||||
@@ -5881,6 +6242,18 @@ fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
|
||||
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<()> {
|
||||
let path = incident_review_path(args);
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -5936,6 +6309,10 @@ fn incident_review_audit_path(args: &Cli) -> PathBuf {
|
||||
.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 {
|
||||
let generated_at_utc = now();
|
||||
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> {
|
||||
value
|
||||
.map(|text| sanitize_text(&text, max_len))
|
||||
@@ -8400,6 +8788,88 @@ mod tests {
|
||||
assert!(pack.trust_kpi_snapshot.is_object());
|
||||
assert!(pack.agent_quality_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!(
|
||||
build_investigation_pack(
|
||||
&snapshot,
|
||||
|
||||
@@ -23,9 +23,9 @@ async function postJson(path, payload) {
|
||||
|
||||
function statusClass(status) {
|
||||
const s = String(status || "UNKNOWN").toLowerCase();
|
||||
if (s === "ok" || s === "true" || s === "low" || s === "false_positive") return "status-ok";
|
||||
if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed") return "status-warn";
|
||||
if (s === "degraded" || s === "high" || s === "confirmed") return "status-degraded";
|
||||
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" || s === "open" || s === "in_progress") return "status-warn";
|
||||
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";
|
||||
return "status-unknown";
|
||||
}
|
||||
@@ -1280,6 +1280,7 @@ function renderIncidents(data) {
|
||||
const incidents = Array.isArray(data) ? data : data.incidents;
|
||||
const evidence = Array.isArray(data) ? null : data.evidence;
|
||||
const reports = Array.isArray(data) ? state.reports : data.reports;
|
||||
const cases = Array.isArray(data?.cases?.cases) ? data.cases.cases : [];
|
||||
return `
|
||||
<div class="page-head">
|
||||
<div>
|
||||
@@ -1299,6 +1300,7 @@ function renderIncidents(data) {
|
||||
</section>
|
||||
</div>
|
||||
${renderAutoInvestigationCard(reports)}
|
||||
${renderCases(cases)}
|
||||
<section class="card evidence-card">
|
||||
<h3>Материалы: скриншоты, хеши, файлы</h3>
|
||||
${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) {
|
||||
return `<div class="summary-grid kpi-grid">${(items || []).map(item => `
|
||||
<article class="card kpi-card">
|
||||
@@ -1805,6 +1880,7 @@ function renderCandidateReview(item) {
|
||||
|
||||
function renderCandidateReviewActions(item) {
|
||||
const id = item?.id || "";
|
||||
const reviewStatus = String(item?.incident_review?.status || "NEW").toUpperCase();
|
||||
const actions = [
|
||||
["IN_REVIEW", "В проверку"],
|
||||
["CONFIRMED", "Подтвердить"],
|
||||
@@ -1812,11 +1888,15 @@ function renderCandidateReviewActions(item) {
|
||||
["POSTPONED", "Отложить"],
|
||||
];
|
||||
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 `
|
||||
<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>
|
||||
`).join("")}</div>
|
||||
<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 evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
|
||||
const reports = await loadJson("/reports").catch(() => state.reports || {});
|
||||
const cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
|
||||
state.reports = reports;
|
||||
content.innerHTML = renderIncidents({ incidents: data, evidence, reports });
|
||||
content.innerHTML = renderIncidents({ incidents: data, evidence, reports, cases });
|
||||
}
|
||||
if (state.tab === "perimeter") {
|
||||
const data = await loadJson("/owner");
|
||||
@@ -2062,6 +2143,18 @@ document.addEventListener("click", event => {
|
||||
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 => {
|
||||
const button = event.target.closest("[data-print-report]");
|
||||
if (!button) return;
|
||||
@@ -2175,6 +2268,38 @@ async function candidateReviewAction(button) {
|
||||
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);
|
||||
refresh().catch(showError);
|
||||
setInterval(() => refresh().catch(showError), 60000);
|
||||
|
||||
Reference in New Issue
Block a user