Some checks failed
Auto Tag / auto-tag (push) Successful in 5s
Release / build-linux-arm64 (push) Failing after 2m19s
Test / rust-fmt-check (push) Failing after 2m18s
Release / build-macos-arm64 (push) Successful in 7m41s
Test / rust-clippy (push) Successful in 12m4s
Test / rust-tests (push) Successful in 12m40s
Test / frontend-typecheck (push) Successful in 1m43s
Test / frontend-tests (push) Successful in 1m21s
Release / build-linux-amd64 (push) Successful in 20m49s
Release / build-windows-amd64 (push) Successful in 13m59s
- Fix download icons (PDF/DOCX) not visible in dark theme by adding text-foreground class - Fix "Read-only file system" error by using Downloads directory for exports with proper fallback - Fix Search button visibility in History page by changing variant and adding icon - Fix domain-only filtering in History page by adding missing filter.domain handling - Enhance audit log to capture full transmitted data (provider details, messages, content previews) - Add dirs crate dependency for cross-platform directory detection - Add success/error feedback for document exports with file path display - Update Security page to display pretty-printed JSON audit details Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
206 lines
6.2 KiB
Rust
206 lines
6.2 KiB
Rust
use tauri::State;
|
|
|
|
use crate::db::models::AuditEntry;
|
|
use crate::docs::{exporter, generate_postmortem_markdown, generate_rca_markdown};
|
|
use crate::state::AppState;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Document record returned to the frontend.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Document {
|
|
pub id: String,
|
|
pub issue_id: String,
|
|
pub doc_type: String,
|
|
pub title: String,
|
|
pub content_md: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn generate_rca(
|
|
issue_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Document, String> {
|
|
let issue_detail = super::db::get_issue(issue_id.clone(), state.clone()).await?;
|
|
|
|
let content_md = generate_rca_markdown(&issue_detail);
|
|
|
|
let doc_id = uuid::Uuid::now_v7().to_string();
|
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
|
|
let document = Document {
|
|
id: doc_id.clone(),
|
|
issue_id: issue_id.clone(),
|
|
doc_type: "rca".to_string(),
|
|
title: format!("RCA: {}", issue_detail.issue.title),
|
|
content_md: content_md.clone(),
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
|
|
// Audit - capture document metadata
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
let audit_details = serde_json::json!({
|
|
"issue_id": issue_id,
|
|
"issue_title": issue_detail.issue.title,
|
|
"doc_type": "rca",
|
|
"doc_title": document.title,
|
|
"content_length": content_md.len(),
|
|
"content_preview": if content_md.len() > 300 {
|
|
format!("{}...", &content_md[..300])
|
|
} else {
|
|
content_md.clone()
|
|
},
|
|
});
|
|
let entry = AuditEntry::new(
|
|
"generate_rca".to_string(),
|
|
"document".to_string(),
|
|
doc_id,
|
|
audit_details.to_string(),
|
|
);
|
|
let _ = db.execute(
|
|
"INSERT INTO audit_log (id, timestamp, action, entity_type, entity_id, user_id, details) \
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
rusqlite::params![
|
|
entry.id,
|
|
entry.timestamp,
|
|
entry.action,
|
|
entry.entity_type,
|
|
entry.entity_id,
|
|
entry.user_id,
|
|
entry.details
|
|
],
|
|
);
|
|
|
|
Ok(document)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn generate_postmortem(
|
|
issue_id: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Document, String> {
|
|
let issue_detail = super::db::get_issue(issue_id.clone(), state.clone()).await?;
|
|
|
|
let content_md = generate_postmortem_markdown(&issue_detail);
|
|
|
|
let doc_id = uuid::Uuid::now_v7().to_string();
|
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
|
|
let document = Document {
|
|
id: doc_id.clone(),
|
|
issue_id: issue_id.clone(),
|
|
doc_type: "postmortem".to_string(),
|
|
title: format!("Post-Mortem: {}", issue_detail.issue.title),
|
|
content_md: content_md.clone(),
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
|
|
// Audit - capture document metadata
|
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
|
let audit_details = serde_json::json!({
|
|
"issue_id": issue_id,
|
|
"issue_title": issue_detail.issue.title,
|
|
"doc_type": "postmortem",
|
|
"doc_title": document.title,
|
|
"content_length": content_md.len(),
|
|
"content_preview": if content_md.len() > 300 {
|
|
format!("{}...", &content_md[..300])
|
|
} else {
|
|
content_md.clone()
|
|
},
|
|
});
|
|
let entry = AuditEntry::new(
|
|
"generate_postmortem".to_string(),
|
|
"document".to_string(),
|
|
doc_id,
|
|
audit_details.to_string(),
|
|
);
|
|
let _ = db.execute(
|
|
"INSERT INTO audit_log (id, timestamp, action, entity_type, entity_id, user_id, details) \
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
rusqlite::params![
|
|
entry.id,
|
|
entry.timestamp,
|
|
entry.action,
|
|
entry.entity_type,
|
|
entry.entity_id,
|
|
entry.user_id,
|
|
entry.details
|
|
],
|
|
);
|
|
|
|
Ok(document)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_document(doc_id: String, content_md: String) -> Result<(), String> {
|
|
// Documents are generated on-demand and held in memory / frontend state.
|
|
// This is a no-op placeholder. In a future version with a documents table,
|
|
// this would persist updates.
|
|
if doc_id.is_empty() || content_md.is_empty() {
|
|
return Err("doc_id and content_md are required".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn export_document(
|
|
title: String,
|
|
content_md: String,
|
|
format: String,
|
|
output_dir: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<String, String> {
|
|
use std::path::PathBuf;
|
|
|
|
// Determine the output directory
|
|
let base_dir = if output_dir.is_empty() || output_dir == "." {
|
|
// Try to use the Downloads directory, fall back to app data dir
|
|
dirs::download_dir()
|
|
.unwrap_or_else(|| {
|
|
let app_data = state.app_data_dir.clone();
|
|
app_data.join("exports")
|
|
})
|
|
} else {
|
|
PathBuf::from(&output_dir)
|
|
};
|
|
|
|
// Ensure the directory exists
|
|
std::fs::create_dir_all(&base_dir).map_err(|e| {
|
|
format!("Failed to create export directory {}: {}", base_dir.display(), e)
|
|
})?;
|
|
|
|
let safe_title: String = title
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_alphanumeric() || c == '-' || c == '_' {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let output_path = match format.as_str() {
|
|
"markdown" | "md" => {
|
|
let path = base_dir.join(format!("{safe_title}.md"));
|
|
exporter::export_markdown(&content_md, path.to_str().unwrap())
|
|
.map_err(|e| e.to_string())?;
|
|
path.to_string_lossy().to_string()
|
|
}
|
|
"pdf" => {
|
|
let path = base_dir.join(format!("{safe_title}.pdf"));
|
|
exporter::export_pdf(&content_md, &title, path.to_str().unwrap())
|
|
.map_err(|e| e.to_string())?;
|
|
path.to_string_lossy().to_string()
|
|
}
|
|
_ => return Err(format!("Unsupported export format: {format}")),
|
|
};
|
|
|
|
Ok(output_path)
|
|
}
|