use chrono::Utc;
use std::collections::HashSet;
use crate::error::ServerError;
use crate::events::{AgentEvent, AgentEventType};
use crate::models::kanban::{KanbanBoard, KanbanColumn};
use crate::models::task::{Task, TaskPriority};
use crate::rpc::error::RpcError;
use crate::state::AppState;
pub(super) fn default_workspace_id() -> String {
"default".into()
}
pub(super) async fn emit_kanban_workspace_event(
state: &AppState,
workspace_id: &str,
entity: &str,
action: &str,
resource_id: Option<&str>,
source: &str,
) {
state
.event_bus
.emit(AgentEvent {
event_type: AgentEventType::WorkspaceUpdated,
agent_id: format!("kanban-{}", source),
workspace_id: workspace_id.to_string(),
data: serde_json::json!({
"scope": "kanban",
"entity": entity,
"action": action,
"resourceId": resource_id,
"source": source,
}),
timestamp: Utc::now(),
})
.await;
}
pub(super) async fn ensure_workspace_exists(
state: &AppState,
workspace_id: &str,
) -> Result<(), ServerError> {
if workspace_id == "default" {
state.workspace_store.ensure_default().await?;
return Ok(());
}
if state.workspace_store.get(workspace_id).await?.is_some() {
Ok(())
} else {
Err(ServerError::NotFound(format!(
"Workspace {} not found",
workspace_id
)))
}
}
pub(super) async fn resolve_board(
state: &AppState,
workspace_id: &str,
board_id: Option<&str>,
) -> Result<KanbanBoard, RpcError> {
ensure_workspace_exists(state, workspace_id).await?;
if let Some(board_id) = board_id {
match state.kanban_store.get(board_id).await? {
Some(board) if board.workspace_id == workspace_id => return Ok(board),
Some(board) => {
tracing::warn!(
board_id = %board_id,
board_workspace_id = %board.workspace_id,
requested_workspace_id = %workspace_id,
"kanban board workspace mismatch; falling back to workspace default board"
);
}
None => {
tracing::warn!(
board_id = %board_id,
requested_workspace_id = %workspace_id,
"kanban board not found; falling back to workspace default board"
);
}
}
}
state
.kanban_store
.ensure_default_board(workspace_id)
.await
.map_err(Into::into)
}
pub(super) async fn tasks_for_board(
state: &AppState,
board: &KanbanBoard,
) -> Result<Vec<Task>, RpcError> {
Ok(state
.task_store
.list_by_workspace(&board.workspace_id)
.await?
.into_iter()
.filter(|task| task.board_id.as_deref() == Some(board.id.as_str()))
.collect())
}
pub(super) async fn next_position_in_column(
state: &AppState,
workspace_id: &str,
board_id: &str,
column_id: &str,
) -> Result<i64, RpcError> {
let count = state
.task_store
.list_by_workspace(workspace_id)
.await?
.into_iter()
.filter(|task| {
task.board_id.as_deref() == Some(board_id)
&& task.column_id.as_deref().unwrap_or("backlog") == column_id
})
.count();
Ok(count as i64)
}
pub(super) fn ensure_column_exists(board: &KanbanBoard, column_id: &str) -> Result<(), RpcError> {
if board.columns.iter().any(|column| column.id == column_id) {
Ok(())
} else {
Err(RpcError::NotFound(format!(
"Column {} not found",
column_id
)))
}
}
pub(super) fn build_columns_from_names(names: &[String]) -> Result<Vec<KanbanColumn>, RpcError> {
if names.is_empty() {
return Err(RpcError::BadRequest(
"columns cannot be an empty array".to_string(),
));
}
let mut seen = HashSet::new();
let mut columns = Vec::with_capacity(names.len());
for (index, name) in names.iter().enumerate() {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(RpcError::BadRequest(
"column names cannot be blank".to_string(),
));
}
let id = slugify(trimmed);
if !seen.insert(id.clone()) {
return Err(RpcError::BadRequest(format!(
"duplicate column id generated from name: {}",
trimmed
)));
}
columns.push(KanbanColumn {
id,
name: trimmed.to_string(),
color: None,
position: index as i64,
stage: "backlog".to_string(),
automation: None,
visible: Some(true),
width: None,
});
}
Ok(columns)
}
pub(super) fn normalize_columns(columns: Vec<KanbanColumn>) -> Result<Vec<KanbanColumn>, RpcError> {
if columns.is_empty() {
return Err(RpcError::BadRequest(
"columns cannot be an empty array".to_string(),
));
}
let mut seen = HashSet::new();
let mut normalized = Vec::with_capacity(columns.len());
for (index, mut column) in columns.into_iter().enumerate() {
column.id = column.id.trim().to_string();
column.name = column.name.trim().to_string();
if column.id.is_empty() || column.name.is_empty() {
return Err(RpcError::BadRequest(
"column id and name cannot be blank".to_string(),
));
}
if !seen.insert(column.id.clone()) {
return Err(RpcError::BadRequest(format!(
"duplicate column id: {}",
column.id
)));
}
column.position = index as i64;
normalized.push(column);
}
Ok(normalized)
}
pub(super) fn parse_priority(priority: Option<&str>) -> Result<Option<TaskPriority>, RpcError> {
match priority {
Some(priority) => TaskPriority::from_str(priority)
.map(Some)
.ok_or_else(|| RpcError::BadRequest(format!("Invalid priority: {}", priority))),
None => Ok(None),
}
}
pub(super) fn slugify(value: &str) -> String {
value
.split_whitespace()
.map(|segment| segment.to_ascii_lowercase())
.collect::<Vec<_>>()
.join("-")
}