//! Group management routes. //! //! GET /api/v1/groups — list all groups //! POST /api/v1/groups — create group (admin) //! GET /api/v1/groups/:id — get group detail + members //! PUT /api/v1/groups/:id — update group (admin) //! DELETE /api/v1/groups/:id — delete group (admin) //! POST /api/v1/groups/:id/users/:user_id — add user to group (admin) //! DELETE /api/v1/groups/:id/users/:user_id — remove user from group (admin) use axum::{ extract::{Path, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use pm_auth::rbac::AuthUser; use pm_core::{ audit::{log_event, AuditAction}, models::{CreateGroupRequest, Group, UpdateGroupRequest}, }; use serde_json::{json, Value}; use uuid::Uuid; use crate::AppState; pub fn router() -> Router { Router::new() .route("/", get(list_groups).post(create_group)) .route( "/{id}", get(get_group).put(update_group).delete(delete_group), ) .route( "/{id}/users/{user_id}", post(add_user_to_group).delete(remove_user_from_group), ) } async fn list_groups( State(state): State, _auth: AuthUser, ) -> Result>, (StatusCode, Json)> { sqlx::query_as::<_, Group>( "SELECT id, name, description, created_at, updated_at FROM groups ORDER BY name", ) .fetch_all(&state.db) .await .map(Json) .map_err(|e| { tracing::error!(error = %e, "Failed to list groups"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), ) }) } async fn create_group( State(state): State, auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } let id: Uuid = sqlx::query_scalar("INSERT INTO groups (name, description) VALUES ($1, $2) RETURNING id") .bind(&req.name) .bind(req.description.as_deref().unwrap_or("")) .fetch_one(&state.db) .await .map_err(|e| { let msg = if e.to_string().contains("unique") { "Group name already exists".to_string() } else { "Database error".to_string() }; ( StatusCode::CONFLICT, Json(json!({ "error": { "code": "conflict", "message": msg } })), ) })?; log_event( &state.db, AuditAction::GroupCreated, Some(auth.user_id), Some(&auth.username), Some("group"), Some(&id.to_string()), json!({ "name": req.name }), None, None, ) .await; Ok(Json(json!({ "id": id, "message": "Group created" }))) } async fn get_group( State(state): State, _auth: AuthUser, Path(id): Path, ) -> Result, (StatusCode, Json)> { let group: Option = sqlx::query_as( "SELECT id, name, description, created_at, updated_at FROM groups WHERE id = $1", ) .bind(id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = %e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), ) })?; let group = group.ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "Group not found" } })), ) })?; // Fetch member counts let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM host_groups WHERE group_id = $1") .bind(id) .fetch_one(&state.db) .await .unwrap_or(0); let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_groups WHERE group_id = $1") .bind(id) .fetch_one(&state.db) .await .unwrap_or(0); Ok(Json( json!({ "group": group, "host_count": host_count, "user_count": user_count }), )) } async fn update_group( State(state): State, auth: AuthUser, Path(id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } let rows = sqlx::query( "UPDATE groups SET name = COALESCE($1, name), description = COALESCE($2, description), updated_at = NOW() WHERE id = $3" ) .bind(req.name.as_deref()) .bind(req.description.as_deref()) .bind(id) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))? .rows_affected(); if rows == 0 { return Err(( StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "Group not found" } })), )); } Ok(Json(json!({ "message": "Group updated" }))) } async fn delete_group( State(state): State, auth: AuthUser, Path(id): Path, ) -> Result, (StatusCode, Json)> { if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } let rows = sqlx::query("DELETE FROM groups WHERE id = $1") .bind(id) .execute(&state.db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })), ) })? .rows_affected(); if rows == 0 { return Err(( StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "Group not found" } })), )); } log_event( &state.db, AuditAction::GroupDeleted, Some(auth.user_id), Some(&auth.username), Some("group"), Some(&id.to_string()), json!({}), None, None, ) .await; Ok(Json(json!({ "message": "Group deleted" }))) } async fn add_user_to_group( State(state): State, auth: AuthUser, Path((id, user_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } sqlx::query( "INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", ) .bind(user_id) .bind(id) .execute(&state.db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })), ) })?; log_event( &state.db, AuditAction::GroupMembershipChanged, Some(auth.user_id), Some(&auth.username), Some("user_group"), Some(&id.to_string()), json!({ "user_id": user_id, "action": "added" }), None, None, ) .await; Ok(Json(json!({ "message": "User added to group" }))) } async fn remove_user_from_group( State(state): State, auth: AuthUser, Path((id, user_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, Json)> { if !auth.role.can_write() { return Err(( StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })), )); } sqlx::query("DELETE FROM user_groups WHERE user_id = $1 AND group_id = $2") .bind(user_id) .bind(id) .execute(&state.db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })), ) })?; log_event( &state.db, AuditAction::GroupMembershipChanged, Some(auth.user_id), Some(&auth.username), Some("user_group"), Some(&id.to_string()), json!({ "user_id": user_id, "action": "removed" }), None, None, ) .await; Ok(Json(json!({ "message": "User removed from group" }))) }