use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use crate as x0x;
use super::super::state::AppState;
use super::super::{api_error, bad_request, forbidden, not_found};
pub(in crate::server) fn parse_group_scoped_task_list_id(id: &str) -> Option<GroupScopedId> {
let parts: Vec<&str> = id.split('.').collect();
if parts.len() != 5 {
return None;
}
if parts[0] != "x0x" || parts[1] != "group" || parts[3] != "symphony" {
return None;
}
let group_id = parts[2];
let list_id = parts[4];
if group_id.is_empty() || list_id.is_empty() {
return Some(GroupScopedId::malformed());
}
Some(GroupScopedId {
group_id: group_id.to_string(),
list_id: list_id.to_string(),
})
}
#[derive(Debug, PartialEq, Eq)]
pub(in crate::server) struct GroupScopedId {
pub(in crate::server) group_id: String,
#[allow(dead_code)]
pub(in crate::server) list_id: String,
}
impl GroupScopedId {
pub(in crate::server) fn malformed() -> Self {
Self {
group_id: String::new(),
list_id: String::new(),
}
}
pub(in crate::server) fn is_malformed(&self) -> bool {
self.group_id.is_empty()
}
}
pub(in crate::server) async fn ensure_task_list_access(
state: &Arc<AppState>,
id: &str,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let Some(scoped) = parse_group_scoped_task_list_id(id) else {
return Ok(());
};
if scoped.is_malformed() {
return Err(forbidden("malformed group-scoped task-list id"));
}
let local_agent_hex = hex::encode(state.agent.agent_id().as_bytes());
let groups = state.named_groups.read().await;
let Some(info) = groups.get(&scoped.group_id) else {
return Err(forbidden("not a member of task-list group"));
};
let member = info.members_v2.get(&local_agent_hex);
let allowed = member.is_some_and(|m| matches!(m.state, x0x::groups::GroupMemberState::Active));
if allowed {
Ok(())
} else {
Err(forbidden("not a member of task-list group"))
}
}
#[derive(Debug, Deserialize)]
pub(in crate::server) struct CreateTaskListRequest {
pub(in crate::server) name: String,
pub(in crate::server) topic: String,
}
#[derive(Debug, Deserialize)]
pub(in crate::server) struct AddTaskRequest {
pub(in crate::server) title: String,
#[serde(default)]
pub(in crate::server) description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(in crate::server) struct UpdateTaskRequest {
pub(in crate::server) action: String, }
#[derive(Debug, Serialize)]
pub(in crate::server) struct TaskListEntry {
pub(in crate::server) id: String,
pub(in crate::server) topic: String,
}
#[derive(Debug, Serialize)]
pub(in crate::server) struct TaskEntry {
pub(in crate::server) id: String,
pub(in crate::server) title: String,
pub(in crate::server) description: String,
pub(in crate::server) state: String,
pub(in crate::server) assignee: Option<String>,
pub(in crate::server) priority: u8,
}
pub(in crate::server) async fn list_task_lists(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let ids: Vec<String> = state.task_lists.read().await.keys().cloned().collect();
let mut entries = Vec::with_capacity(ids.len());
for id in ids {
if ensure_task_list_access(&state, &id).await.is_ok() {
entries.push(TaskListEntry {
id: id.clone(),
topic: id, });
}
}
Json(serde_json::json!({ "ok": true, "task_lists": entries }))
}
pub(in crate::server) async fn create_task_list(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTaskListRequest>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &req.topic).await {
return denied;
}
match state.agent.create_task_list(&req.name, &req.topic).await {
Ok(handle) => {
let id = req.topic.clone();
state.task_lists.write().await.insert(id.clone(), handle);
(
StatusCode::CREATED,
Json(serde_json::json!({ "ok": true, "id": id })),
)
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
pub(in crate::server) async fn list_tasks(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &id).await {
return denied;
}
let lists = state.task_lists.read().await;
let Some(handle) = lists.get(&id) else {
return not_found("task list not found");
};
match handle.list_tasks().await {
Ok(tasks) => {
let entries: Vec<TaskEntry> = tasks
.into_iter()
.map(|t| TaskEntry {
id: format!("{}", t.id),
title: t.title,
description: t.description,
state: format!("{}", t.state),
assignee: t.assignee.map(|a| format!("{a}")),
priority: t.priority,
})
.collect();
(
StatusCode::OK,
Json(serde_json::json!({ "ok": true, "tasks": entries })),
)
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
pub(in crate::server) async fn add_task(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<AddTaskRequest>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &id).await {
return denied;
}
let lists = state.task_lists.read().await;
let Some(handle) = lists.get(&id) else {
return not_found("task list not found");
};
match handle
.add_task(req.title, req.description.unwrap_or_default())
.await
{
Ok(task_id) => (
StatusCode::CREATED,
Json(serde_json::json!({ "ok": true, "task_id": format!("{task_id}") })),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
pub(in crate::server) async fn update_task(
State(state): State<Arc<AppState>>,
Path((id, tid)): Path<(String, String)>,
Json(req): Json<UpdateTaskRequest>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &id).await {
return denied;
}
let lists = state.task_lists.read().await;
let Some(handle) = lists.get(&id) else {
return not_found("task list not found");
};
let task_id_bytes: [u8; 32] = match hex::decode(&tid) {
Ok(bytes) if bytes.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
arr
}
_ => {
return bad_request("invalid task ID (expected 64 hex chars)");
}
};
let task_id = x0x::crdt::TaskId::from_bytes(task_id_bytes);
let result = match req.action.as_str() {
"claim" => handle.claim_task(task_id).await,
"complete" => handle.complete_task(task_id).await,
_ => {
return bad_request("action must be 'claim' or 'complete'");
}
};
match result {
Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_recognizes_well_formed_scoped_id() {
let parsed = parse_group_scoped_task_list_id("x0x.group.acme-corp.symphony.inbox");
let scoped = parsed.expect("well-formed scoped id parses");
assert!(!scoped.is_malformed());
assert_eq!(scoped.group_id, "acme-corp");
}
#[test]
fn parser_treats_plain_id_as_none() {
assert_eq!(parse_group_scoped_task_list_id("plain-topic"), None);
assert_eq!(parse_group_scoped_task_list_id("inbox"), None);
assert_eq!(parse_group_scoped_task_list_id(""), None);
assert_eq!(parse_group_scoped_task_list_id("x0x.group.acme"), None);
assert_eq!(
parse_group_scoped_task_list_id("x0x.group.acme.symphony.inbox.extra"),
None
);
}
#[test]
fn parser_rejects_wrong_prefix_as_plain() {
assert_eq!(
parse_group_scoped_task_list_id("x0x.grop.acme.symphony.inbox"),
None
);
assert_eq!(
parse_group_scoped_task_list_id("foo.group.acme.symphony.inbox"),
None
);
assert_eq!(
parse_group_scoped_task_list_id("x0x.group.acme.secure.inbox"),
None );
}
#[test]
fn parser_flags_empty_group_or_list_as_malformed() {
let empty_group = parse_group_scoped_task_list_id("x0x.group..symphony.inbox");
let scoped = empty_group.expect("scoped shape with empty group is Some");
assert!(scoped.is_malformed(), "empty group_id ⇒ malformed ⇒ deny");
let empty_list = parse_group_scoped_task_list_id("x0x.group.acme.symphony.");
let scoped = empty_list.expect("scoped shape with empty list is Some");
assert!(scoped.is_malformed(), "empty list_id ⇒ malformed ⇒ deny");
}
}