From 7de1109024e2ca98792bf04c43a4dc118d28897e Mon Sep 17 00:00:00 2001 From: linhdmn Date: Sun, 17 May 2026 17:11:18 +0700 Subject: [PATCH] feat: Phase 5 team rollout - team model, permissions, onboarding, shared graph Implements team management features for LeanKG v2: - Team model with members, roles (admin/contributor/viewer), and permissions - Graph read/write permissions per team for shared graph management - Onboarding workflow with invite tokens, accept, and setup - Team CLI commands: create, list, show, update, delete, add/remove members - Invite management: invite, accept, revoke with expiration - Permission checking for graph access control - REST API endpoints for all team operations Models: - Team: id, name, description, owner_id, graph_read_users, graph_write_users, members - TeamMember: user_id, role, joined_at - TeamInvite: token, team_id, email, role, expires_at, accepted status - GraphPermission: Read, Write, Admin enum Database schema added: - teams table with owner index - team_invites table with team and token indexes Test coverage for new models included. --- src/cli/mod.rs | 133 ++++++++++++++++++ src/db/mod.rs | 325 ++++++++++++++++++++++++++++++++++++++++++++ src/db/models.rs | 94 +++++++++++++ src/db/schema.rs | 31 +++++ src/main.rs | 210 ++++++++++++++++++++++++++++ src/web/handlers.rs | 273 +++++++++++++++++++++++++++++++++++++ src/web/mod.rs | 32 ++++- 7 files changed, 1097 insertions(+), 1 deletion(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5bb5267..6ec2b7e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -338,6 +338,11 @@ pub enum CLICommand { #[arg(long, default_value = "production")] env: String, }, + /// Team management commands + Team { + #[command(subcommand)] + command: TeamCommand, + }, } #[derive(Subcommand, Debug)] @@ -453,3 +458,131 @@ pub enum IncidentCommand { id: String, }, } + +#[derive(Subcommand, Debug)] +pub enum TeamCommand { + /// Create a new team + Create { + /// Team name + #[arg(long)] + name: String, + /// Team description + #[arg(long)] + description: String, + /// Owner user ID + #[arg(long)] + owner: String, + }, + /// List all teams + List, + /// Show team details + Show { + /// Team ID + id: String, + }, + /// Update team information + Update { + /// Team ID + #[arg(long)] + id: String, + /// New name (optional) + #[arg(long)] + name: Option, + /// New description (optional) + #[arg(long)] + description: Option, + }, + /// Delete a team + Delete { + /// Team ID + #[arg(long)] + id: String, + }, + /// Add member to team + AddMember { + /// Team ID + #[arg(long)] + team: String, + /// User ID to add + #[arg(long)] + user: String, + /// Role: admin, contributor, viewer + #[arg(long, default_value = "viewer")] + role: String, + }, + /// Remove member from team + RemoveMember { + /// Team ID + #[arg(long)] + team: String, + /// User ID to remove + #[arg(long)] + user: String, + }, + /// Generate invite link for team + Invite { + /// Team ID + #[arg(long)] + team: String, + /// Role for invitee + #[arg(long, default_value = "viewer")] + role: String, + /// Email for invitee (optional) + #[arg(long)] + email: Option, + /// Invite expiration in hours (default: 48) + #[arg(long, default_value = "48")] + expires_hours: u64, + }, + /// Accept team invite + Accept { + /// Invite token + #[arg(long)] + token: String, + /// User ID accepting invite + #[arg(long)] + user: String, + }, + /// List pending invites for team + Invites { + /// Team ID + #[arg(long)] + team: String, + }, + /// Revoke team invite + RevokeInvite { + /// Invite token + #[arg(long)] + token: String, + }, + /// Set graph read permissions for team + SetReadUsers { + /// Team ID + #[arg(long)] + team: String, + /// Comma-separated list of user IDs + #[arg(long)] + users: String, + }, + /// Set graph write permissions for team + SetWriteUsers { + /// Team ID + #[arg(long)] + team: String, + /// Comma-separated list of user IDs + #[arg(long)] + users: String, + }, + /// Check if user has permission + CheckPermission { + /// Team ID + #[arg(long)] + team: String, + /// User ID to check + #[arg(long)] + user: String, + /// Require write permission + #[arg(long)] + write: bool, + }, +} diff --git a/src/db/mod.rs b/src/db/mod.rs index e23a7b5..0dc5fce 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1478,3 +1478,328 @@ pub fn get_service_metadata( updated_at: row[14].as_i64().unwrap_or(0), })) } + +// ============================================================================ +// Team CRUD +// ============================================================================ + +pub fn create_team( + db: &CozoDb, + team: &models::Team, +) -> Result> { + let query = r#"?[id, name, description, owner_id, created_at, updated_at, graph_read_users, graph_write_users, members] <- [[$id, $name, $desc, $owner, $cat, $uat, $read_users, $write_users, $members]] :put teams {id, name, description, owner_id, created_at, updated_at, graph_read_users, graph_write_users, members}"#; + let mut params = std::collections::BTreeMap::new(); + params.insert("id".to_string(), serde_json::Value::String(team.id.clone())); + params.insert( + "name".to_string(), + serde_json::Value::String(team.name.clone()), + ); + params.insert( + "desc".to_string(), + serde_json::Value::String(team.description.clone()), + ); + params.insert( + "owner".to_string(), + serde_json::Value::String(team.owner_id.clone()), + ); + params.insert( + "cat".to_string(), + serde_json::Value::Number(team.created_at.into()), + ); + params.insert( + "uat".to_string(), + serde_json::Value::Number(team.updated_at.into()), + ); + params.insert( + "read_users".to_string(), + serde_json::Value::String(serde_json::to_string(&team.graph_read_users)?), + ); + params.insert( + "write_users".to_string(), + serde_json::Value::String(serde_json::to_string(&team.graph_write_users)?), + ); + params.insert( + "members".to_string(), + serde_json::Value::String(serde_json::to_string(&team.members)?), + ); + + db.run_script(query, params)?; + Ok(team.clone()) +} + +pub fn get_team(db: &CozoDb, id: &str) -> Result, Box> { + let query = r#"?[id, name, description, owner_id, created_at, updated_at, graph_read_users, graph_write_users, members] := *teams[id, name, description, owner_id, created_at, updated_at, graph_read_users, graph_write_users, members], id = $id"#; + let mut params = std::collections::BTreeMap::new(); + params.insert("id".to_string(), serde_json::Value::String(id.to_string())); + + let result = db.run_script(query, params)?; + if result.rows.is_empty() { + return Ok(None); + } + Ok(Some(row_to_team(&result.rows[0]))) +} + +pub fn update_team( + db: &CozoDb, + team: &models::Team, +) -> Result> { + create_team(db, team) +} + +pub fn delete_team(db: &CozoDb, id: &str) -> Result<(), Box> { + let query = r#":delete teams where id = $id"#; + let mut params = std::collections::BTreeMap::new(); + params.insert("id".to_string(), serde_json::Value::String(id.to_string())); + db.run_script(query, params)?; + Ok(()) +} + +pub fn list_teams(db: &CozoDb) -> Result, Box> { + let query = r#"?[id, name, description, owner_id, created_at, updated_at, graph_read_users, graph_write_users, members] := *teams[id, name, description, owner_id, created_at, updated_at, graph_read_users, graph_write_users, members]"#; + let result = db.run_script(query, Default::default())?; + Ok(result.rows.iter().map(|r| row_to_team(r)).collect()) +} + +fn row_to_team(row: &[serde_json::Value]) -> models::Team { + let graph_read_users: Vec = + serde_json::from_str(row[6].as_str().unwrap_or("[]")).unwrap_or_default(); + let graph_write_users: Vec = + serde_json::from_str(row[7].as_str().unwrap_or("[]")).unwrap_or_default(); + let members: Vec = + serde_json::from_str(row[8].as_str().unwrap_or("[]")).unwrap_or_default(); + + models::Team { + id: row[0].as_str().unwrap_or("").to_string(), + name: row[1].as_str().unwrap_or("").to_string(), + description: row[2].as_str().unwrap_or("").to_string(), + owner_id: row[3].as_str().unwrap_or("").to_string(), + created_at: row[4].as_i64().unwrap_or(0), + updated_at: row[5].as_i64().unwrap_or(0), + graph_read_users, + graph_write_users, + members, + } +} + +// ============================================================================ +// Team Invite CRUD +// ============================================================================ + +pub fn create_team_invite( + db: &CozoDb, + invite: &models::TeamInvite, +) -> Result> { + let query = r#"?[token, team_id, email, role, created_by, created_at, expires_at, accepted, accepted_by] <- [[$token, $tid, $email, $role, $by, $cat, $exp, $acc, $accept]] :put team_invites {token, team_id, email, role, created_by, created_at, expires_at, accepted, accepted_by}"#; + let mut params = std::collections::BTreeMap::new(); + params.insert( + "token".to_string(), + serde_json::Value::String(invite.token.clone()), + ); + params.insert( + "tid".to_string(), + serde_json::Value::String(invite.team_id.clone()), + ); + params.insert( + "email".to_string(), + invite + .email + .as_ref() + .map(|s| serde_json::Value::String(s.clone())) + .unwrap_or(serde_json::Value::Null), + ); + params.insert( + "role".to_string(), + serde_json::Value::String(invite.role.clone()), + ); + params.insert( + "by".to_string(), + serde_json::Value::String(invite.created_by.clone()), + ); + params.insert( + "cat".to_string(), + serde_json::Value::Number(invite.created_at.into()), + ); + params.insert( + "exp".to_string(), + serde_json::Value::Number(invite.expires_at.into()), + ); + params.insert("acc".to_string(), serde_json::Value::Bool(invite.accepted)); + params.insert( + "accept".to_string(), + invite + .accepted_by + .as_ref() + .map(|s| serde_json::Value::String(s.clone())) + .unwrap_or(serde_json::Value::Null), + ); + + db.run_script(query, params)?; + Ok(invite.clone()) +} + +pub fn get_team_invite( + db: &CozoDb, + token: &str, +) -> Result, Box> { + let query = r#"?[token, team_id, email, role, created_by, created_at, expires_at, accepted, accepted_by] := *team_invites[token, team_id, email, role, created_by, created_at, expires_at, accepted, accepted_by], token = $token"#; + let mut params = std::collections::BTreeMap::new(); + params.insert( + "token".to_string(), + serde_json::Value::String(token.to_string()), + ); + + let result = db.run_script(query, params)?; + if result.rows.is_empty() { + return Ok(None); + } + Ok(Some(row_to_team_invite(&result.rows[0]))) +} + +pub fn get_team_invites( + db: &CozoDb, + team_id: &str, +) -> Result, Box> { + let query = r#"?[token, team_id, email, role, created_by, created_at, expires_at, accepted, accepted_by] := *team_invites[token, team_id, email, role, created_by, created_at, expires_at, accepted, accepted_by], team_id = $tid"#; + let mut params = std::collections::BTreeMap::new(); + params.insert( + "tid".to_string(), + serde_json::Value::String(team_id.to_string()), + ); + + let result = db.run_script(query, params)?; + Ok(result.rows.iter().map(|r| row_to_team_invite(r)).collect()) +} + +pub fn accept_team_invite( + db: &CozoDb, + token: &str, + user_id: &str, +) -> Result> { + let invite = get_team_invite(db, token)?.ok_or("Invite not found")?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + if now > invite.expires_at { + return Err("Invite has expired".into()); + } + if invite.accepted { + return Err("Invite already accepted".into()); + } + + let updated_invite = models::TeamInvite { + accepted: true, + accepted_by: Some(user_id.to_string()), + ..invite + }; + create_team_invite(db, &updated_invite) +} + +pub fn delete_team_invite(db: &CozoDb, token: &str) -> Result<(), Box> { + let query = r#":delete team_invites where token = $token"#; + let mut params = std::collections::BTreeMap::new(); + params.insert( + "token".to_string(), + serde_json::Value::String(token.to_string()), + ); + db.run_script(query, params)?; + Ok(()) +} + +fn row_to_team_invite(row: &[serde_json::Value]) -> models::TeamInvite { + models::TeamInvite { + token: row[0].as_str().unwrap_or("").to_string(), + team_id: row[1].as_str().unwrap_or("").to_string(), + email: row[2].as_str().map(String::from), + role: row[3].as_str().unwrap_or("").to_string(), + created_by: row[4].as_str().unwrap_or("").to_string(), + created_at: row[5].as_i64().unwrap_or(0), + expires_at: row[6].as_i64().unwrap_or(0), + accepted: row[7].as_bool().unwrap_or(false), + accepted_by: row[8].as_str().map(String::from), + } +} + +// ============================================================================ +// Permission checking helpers +// ============================================================================ + +pub fn check_graph_permission( + db: &CozoDb, + team_id: &str, + user_id: &str, + require_write: bool, +) -> Result> { + let team = get_team(db, team_id)?; + let team = team.as_ref().ok_or("Team not found")?; + + if team.graph_write_users.contains(&user_id.to_string()) { + return Ok(true); + } + if !require_write && team.graph_read_users.contains(&user_id.to_string()) { + return Ok(true); + } + if team.owner_id == user_id { + return Ok(true); + } + Ok(false) +} + +pub fn add_team_member( + db: &CozoDb, + team_id: &str, + user_id: &str, + role: &str, +) -> Result> { + let team = get_team(db, team_id)?.ok_or("Team not found")?; + + let member = models::TeamMember { + user_id: user_id.to_string(), + role: role.to_string(), + joined_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + }; + + let mut updated_team = team.clone(); + updated_team.members.push(member); + + if role == "viewer" && !updated_team.graph_read_users.contains(&user_id.to_string()) { + updated_team.graph_read_users.push(user_id.to_string()); + } else if role != "viewer" + && !updated_team + .graph_write_users + .contains(&user_id.to_string()) + { + updated_team.graph_write_users.push(user_id.to_string()); + } + + updated_team.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + update_team(db, &updated_team) +} + +pub fn remove_team_member( + db: &CozoDb, + team_id: &str, + user_id: &str, +) -> Result> { + let team = get_team(db, team_id)?.ok_or("Team not found")?; + + let mut updated_team = team.clone(); + updated_team.members.retain(|m| m.user_id != user_id); + updated_team.graph_read_users.retain(|u| u != user_id); + updated_team.graph_write_users.retain(|u| u != user_id); + updated_team.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + update_team(db, &updated_team) +} diff --git a/src/db/models.rs b/src/db/models.rs index 82eef87..38d225e 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -488,6 +488,53 @@ impl std::fmt::Display for Role { } } +/// Team member entry with role +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TeamMember { + pub user_id: String, + pub role: String, + pub joined_at: i64, +} + +/// Team model for shared graph management +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Team { + pub id: String, + pub name: String, + pub description: String, + pub owner_id: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(default)] + pub graph_read_users: Vec, + #[serde(default)] + pub graph_write_users: Vec, + #[serde(default)] + pub members: Vec, +} + +/// Invite token for team onboarding +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TeamInvite { + pub token: String, + pub team_id: String, + pub email: Option, + pub role: String, + pub created_by: String, + pub created_at: i64, + pub expires_at: i64, + pub accepted: bool, + pub accepted_by: Option, +} + +/// Permission scope for graph access +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum GraphPermission { + Read, + Write, + Admin, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthContext { pub client_id: String, @@ -688,4 +735,51 @@ mod tests { assert_eq!(entry.environment, "production"); assert!(entry.element_qualified.is_none()); } + + #[test] + fn test_team_creation() { + let team = Team { + id: "team-1".to_string(), + name: "Platform Team".to_string(), + description: "Core platform services".to_string(), + owner_id: "user-1".to_string(), + created_at: 1000, + updated_at: 1000, + graph_read_users: vec!["user-2".to_string()], + graph_write_users: vec!["user-1".to_string(), "user-2".to_string()], + members: vec![ + TeamMember { + user_id: "user-1".to_string(), + role: "admin".to_string(), + joined_at: 1000, + }, + TeamMember { + user_id: "user-2".to_string(), + role: "contributor".to_string(), + joined_at: 1001, + }, + ], + }; + assert_eq!(team.id, "team-1"); + assert_eq!(team.members.len(), 2); + assert!(team.graph_write_users.contains(&"user-1".to_string())); + } + + #[test] + fn test_team_invite() { + let invite = TeamInvite { + token: "abc123".to_string(), + team_id: "team-1".to_string(), + email: Some("new@example.com".to_string()), + role: "contributor".to_string(), + created_by: "user-1".to_string(), + created_at: 1000, + expires_at: 2000, + accepted: false, + accepted_by: None, + }; + assert_eq!(invite.team_id, "team-1"); + assert!(invite.email.is_some()); + assert!(!invite.accepted); + } } diff --git a/src/db/schema.rs b/src/db/schema.rs index f2b6b3c..f5d16d1 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -185,6 +185,37 @@ fn init_schema(db: &CozoDb) -> Result<(), Box> { } } + // Create teams table for shared graph management + if !existing_relations.contains("teams") { + let create_teams = r#":create teams {id: String, name: String, description: String, owner_id: String, created_at: Int, updated_at: Int, graph_read_users: String, graph_write_users: String, members: String}"#; + if let Err(e) = db.run_script(create_teams, Default::default()) { + tracing::warn!("Failed to create teams: {:?}", e); + } + let team_indexes = [r#":create teams::owner_index {ref: (owner_id), compressed: true}"#]; + for idx in &team_indexes { + if let Err(e) = db.run_script(idx, Default::default()) { + tracing::debug!("teams index note: {:?}", e); + } + } + } + + // Create team_invites table for onboarding workflow + if !existing_relations.contains("team_invites") { + let create_invites = r#":create team_invites {token: String, team_id: String, email: String?, role: String, created_by: String, created_at: Int, expires_at: Int, accepted: Bool, accepted_by: String?}"#; + if let Err(e) = db.run_script(create_invites, Default::default()) { + tracing::warn!("Failed to create team_invites: {:?}", e); + } + let invite_indexes = [ + r#":create team_invites::team_index {ref: (team_id), compressed: true}"#, + r#":create team_invites::token_index {ref: (token), compressed: true, unique: true}"#, + ]; + for idx in &invite_indexes { + if let Err(e) = db.run_script(idx, Default::default()) { + tracing::debug!("team_invites index note: {:?}", e); + } + } + } + Ok(()) } diff --git a/src/main.rs b/src/main.rs index 6b1c5ee..fea728e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -449,6 +449,9 @@ async fn main() -> Result<(), Box> { cli::CLICommand::Incident { command } => { handle_incident_command(command)?; } + cli::CLICommand::Team { command } => { + handle_team_command(command)?; + } cli::CLICommand::Note { target, content, @@ -3149,6 +3152,213 @@ fn handle_incident_command( Ok(()) } +fn handle_team_command(command: cli::TeamCommand) -> Result<(), Box> { + let project_path = find_project_root()?; + let db_path = project_path.join(".leankg"); + let db = db::schema::init_db(&db_path)?; + + match command { + cli::TeamCommand::Create { + name, + description, + owner, + } => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let team = db::models::Team { + id: format!("TEAM-{}", uuid::Uuid::new_v4()), + name, + description, + owner_id: owner, + created_at: now, + updated_at: now, + graph_read_users: vec![], + graph_write_users: vec![], + members: vec![], + }; + db::create_team(&db, &team)?; + println!("Created team '{}' ({})", team.name, team.id); + println!(" Owner: {}", team.owner_id); + } + cli::TeamCommand::List => { + let teams = db::list_teams(&db)?; + if teams.is_empty() { + println!("No teams found"); + } else { + for t in teams { + println!("\nTeam: {} ({})", t.name, t.id); + println!(" Owner: {}", t.owner_id); + println!(" Members: {}", t.members.len()); + println!(" Read users: {}", t.graph_read_users.len()); + println!(" Write users: {}", t.graph_write_users.len()); + } + } + } + cli::TeamCommand::Show { id } => match db::get_team(&db, &id)? { + Some(t) => { + println!("Team Details:"); + println!(" ID: {}", t.id); + println!(" Name: {}", t.name); + println!(" Description: {}", t.description); + println!(" Owner: {}", t.owner_id); + println!(" Created: {}", t.created_at); + println!(" Updated: {}", t.updated_at); + println!(" Members ({}):", t.members.len()); + for m in &t.members { + println!(" - {} ({})", m.user_id, m.role); + } + println!(" Graph Read Users: {:?}", t.graph_read_users); + println!(" Graph Write Users: {:?}", t.graph_write_users); + } + None => { + println!("Team '{}' not found", id); + } + }, + cli::TeamCommand::Update { + id, + name, + description, + } => { + if let Some(mut t) = db::get_team(&db, &id)? { + if let Some(n) = name { + t.name = n; + } + if let Some(d) = description { + t.description = d; + } + t.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + db::update_team(&db, &t)?; + println!("Updated team '{}'", id); + } else { + println!("Team '{}' not found", id); + } + } + cli::TeamCommand::Delete { id } => { + db::delete_team(&db, &id)?; + println!("Deleted team '{}'", id); + } + cli::TeamCommand::AddMember { team, user, role } => { + let t = db::add_team_member(&db, &team, &user, &role)?; + println!("Added '{}' to team '{}' as {}", user, team, role); + println!(" Team now has {} members", t.members.len()); + } + cli::TeamCommand::RemoveMember { team, user } => { + let t = db::remove_team_member(&db, &team, &user)?; + println!("Removed '{}' from team '{}'", user, team); + println!(" Team now has {} members", t.members.len()); + } + cli::TeamCommand::Invite { + team, + role, + email, + expires_hours, + } => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let expires_at = now + (expires_hours as i64 * 3600); + let token = uuid::Uuid::new_v4().to_string().replace("-", ""); + let invite = db::models::TeamInvite { + token: token.clone(), + team_id: team, + email, + role, + created_by: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()), + created_at: now, + expires_at, + accepted: false, + accepted_by: None, + }; + db::create_team_invite(&db, &invite)?; + println!("Created invite token: {}", token); + println!(" Expires in {} hours", expires_hours); + } + cli::TeamCommand::Accept { token, user } => { + let invite = db::accept_team_invite(&db, &token, &user)?; + println!("Accepted invite for team '{}'", invite.team_id); + println!("User '{}' is now a {}", user, invite.role); + } + cli::TeamCommand::Invites { team } => { + let invites = db::get_team_invites(&db, &team)?; + if invites.is_empty() { + println!("No pending invites for team '{}'", team); + } else { + for inv in invites { + let status = if inv.accepted { "ACCEPTED" } else { "PENDING" }; + println!("\nInvite: {} [{}]", inv.token, status); + println!(" Role: {}", inv.role); + println!(" Created: {}", inv.created_at); + println!(" Expires: {}", inv.expires_at); + if let Some(ref email) = inv.email { + println!(" Email: {}", email); + } + if let Some(ref accepted_by) = inv.accepted_by { + println!(" Accepted: {}", accepted_by); + } + } + } + } + cli::TeamCommand::RevokeInvite { token } => { + db::delete_team_invite(&db, &token)?; + println!("Revoked invite '{}'", token); + } + cli::TeamCommand::SetReadUsers { team, users } => { + if let Some(mut t) = db::get_team(&db, &team)? { + t.graph_read_users = users.split(',').map(|s| s.trim().to_string()).collect(); + t.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + db::update_team(&db, &t)?; + println!("Updated graph read users for team '{}'", team); + println!(" Users: {:?}", t.graph_read_users); + } else { + println!("Team '{}' not found", team); + } + } + cli::TeamCommand::SetWriteUsers { team, users } => { + if let Some(mut t) = db::get_team(&db, &team)? { + t.graph_write_users = users.split(',').map(|s| s.trim().to_string()).collect(); + t.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + db::update_team(&db, &t)?; + println!("Updated graph write users for team '{}'", team); + println!(" Users: {:?}", t.graph_write_users); + } else { + println!("Team '{}' not found", team); + } + } + cli::TeamCommand::CheckPermission { team, user, write } => { + let has_perm = db::check_graph_permission(&db, &team, &user, write)?; + if has_perm { + println!( + "User '{}' has {} permission on team '{}'", + user, + if write { "write" } else { "read" }, + team + ); + } else { + println!( + "User '{}' does NOT have {} permission on team '{}'", + user, + if write { "write" } else { "read" }, + team + ); + } + } + } + + Ok(()) +} + fn add_note(target: &str, content: &str, env: &str) -> Result<(), Box> { let project_path = find_project_root()?; let db_path = project_path.join(".leankg"); diff --git a/src/web/handlers.rs b/src/web/handlers.rs index a93c3fc..8dfeb81 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -4097,3 +4097,276 @@ pub async fn api_conflicts( Err(e) => ApiResponse::::error(&e.to_string()), } } + +// ============================================================================ +// Team API handlers +// ============================================================================ + +#[derive(Deserialize)] +pub struct CreateTeamRequest { + pub name: String, + pub description: String, + pub owner_id: String, +} + +#[derive(Deserialize)] +pub struct UpdateTeamRequest { + pub name: Option, + pub description: Option, +} + +#[derive(Deserialize)] +pub struct AddMemberRequest { + pub user_id: String, + pub role: String, +} + +#[derive(Deserialize)] +pub struct CreateInviteRequest { + pub role: String, + pub email: Option, + pub expires_hours: Option, +} + +#[derive(Deserialize)] +pub struct AcceptInviteRequest { + pub user_id: String, +} + +#[derive(Deserialize)] +pub struct SetPermissionsRequest { + pub read_users: Option>, + pub write_users: Option>, +} + +#[derive(Deserialize)] +pub struct CheckPermissionRequest { + pub user_id: String, + pub require_write: bool, +} + +pub async fn api_teams(State(state): State) -> impl IntoResponse { + match state.get_db() { + Ok(db) => match db::list_teams(&db) { + Ok(teams) => ApiResponse::success(serde_json::json!({ "teams": teams })), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_create_team( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let team = db::models::Team { + id: format!("TEAM-{}", uuid::Uuid::new_v4()), + name: req.name, + description: req.description, + owner_id: req.owner_id, + created_at: now, + updated_at: now, + graph_read_users: vec![], + graph_write_users: vec![], + members: vec![], + }; + match db::create_team(&db, &team) { + Ok(created) => ApiResponse::success(serde_json::json!({ "team": created })), + Err(e) => ApiResponse::error(&e.to_string()), + } + } + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_get_team( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => match db::get_team(&db, &id) { + Ok(Some(team)) => ApiResponse::success(serde_json::json!({ "team": team })), + Ok(None) => ApiResponse::error("Team not found"), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_update_team( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => match db::get_team(&db, &id) { + Ok(Some(mut team)) => { + if let Some(name) = req.name { + team.name = name; + } + if let Some(desc) = req.description { + team.description = desc; + } + team.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + match db::update_team(&db, &team) { + Ok(updated) => ApiResponse::success(serde_json::json!({ "team": updated })), + Err(e) => ApiResponse::error(&e.to_string()), + } + } + Ok(None) => ApiResponse::error("Team not found"), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_delete_team( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => { + if let Err(e) = db::delete_team(&db, &id) { + return ApiResponse::error(&e.to_string()); + } + ApiResponse::success(serde_json::json!({ "deleted": true })) + } + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_add_team_member( + State(state): State, + Path((team_id, user_id)): Path<(String, String)>, + Json(req): Json, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => match db::add_team_member(&db, &team_id, &user_id, &req.role) { + Ok(team) => ApiResponse::success(serde_json::json!({ "team": team })), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_remove_team_member( + State(state): State, + Path((team_id, user_id)): Path<(String, String)>, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => match db::remove_team_member(&db, &team_id, &user_id) { + Ok(team) => ApiResponse::success(serde_json::json!({ "team": team })), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_team_invites( + State(state): State, + Path(team_id): Path, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => match db::get_team_invites(&db, &team_id) { + Ok(invites) => ApiResponse::success(serde_json::json!({ "invites": invites })), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_create_team_invite( + State(state): State, + Path(team_id): Path, + Json(req): Json, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let expires_hours = req.expires_hours.unwrap_or(48); + let expires_at = now + (expires_hours as i64 * 3600); + let token = uuid::Uuid::new_v4().to_string().replace("-", ""); + let invite = db::models::TeamInvite { + token, + team_id, + email: req.email, + role: req.role, + created_by: "system".to_string(), + created_at: now, + expires_at, + accepted: false, + accepted_by: None, + }; + match db::create_team_invite(&db, &invite) { + Ok(created) => ApiResponse::success(serde_json::json!({ "invite": created })), + Err(e) => ApiResponse::error(&e.to_string()), + } + } + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_accept_team_invite( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => match db::accept_team_invite(&db, &token, &req.user_id) { + Ok(invite) => ApiResponse::success(serde_json::json!({ "invite": invite })), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_revoke_team_invite( + State(state): State, + Path(token): Path, +) -> impl IntoResponse { + match state.get_db() { + Ok(db) => { + if let Err(e) = db::delete_team_invite(&db, &token) { + return ApiResponse::error(&e.to_string()); + } + ApiResponse::success(serde_json::json!({ "revoked": true })) + } + Err(e) => ApiResponse::error(&e.to_string()), + } +} + +pub async fn api_team_permissions( + State(state): State, + Path(team_id): Path, + Query(params): Query>, +) -> impl IntoResponse { + let user_id = params.get("user_id").cloned().unwrap_or_default(); + let require_write = params + .get("require_write") + .and_then(|v| v.parse().ok()) + .unwrap_or(false); + + match state.get_db() { + Ok(db) => match db::check_graph_permission(&db, &team_id, &user_id, require_write) { + Ok(has_permission) => ApiResponse::success(serde_json::json!({ + "has_permission": has_permission, + "user_id": user_id, + "require_write": require_write + })), + Err(e) => ApiResponse::error(&e.to_string()), + }, + Err(e) => ApiResponse::error(&e.to_string()), + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 8cc049e..f66f35c 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -5,7 +5,7 @@ use axum::{ body::Body, http::{header, StatusCode}, response::{IntoResponse, Response}, - routing::{get, post, put}, + routing::{delete, get, post, put}, Json, Router, }; use std::net::SocketAddr; @@ -338,6 +338,36 @@ pub async fn start_server( .route("/api/file", get(handlers::api_get_file)) .route("/api/incidents", get(handlers::api_incidents)) .route("/api/conflicts", get(handlers::api_conflicts)) + .route("/api/teams", get(handlers::api_teams)) + .route("/api/teams", post(handlers::api_create_team)) + .route("/api/teams/:id", get(handlers::api_get_team)) + .route("/api/teams/:id", put(handlers::api_update_team)) + .route("/api/teams/:id", delete(handlers::api_delete_team)) + .route( + "/api/teams/:id/members", + post(handlers::api_add_team_member), + ) + .route( + "/api/teams/:id/members/:user", + delete(handlers::api_remove_team_member), + ) + .route("/api/teams/:id/invites", get(handlers::api_team_invites)) + .route( + "/api/teams/:id/invites", + post(handlers::api_create_team_invite), + ) + .route( + "/api/teams/invites/:token/accept", + post(handlers::api_accept_team_invite), + ) + .route( + "/api/teams/invites/:token", + delete(handlers::api_revoke_team_invite), + ) + .route( + "/api/teams/:id/permissions", + get(handlers::api_team_permissions), + ) .route("/services", get(handlers::services_page)) .route("/*path", get(fallback_handler)) .with_state(state);