Skip to main content

boarddown_server/
server.rs

1use std::{net::SocketAddr, path::PathBuf, sync::Arc};
2
3use axum::{
4    Router,
5    extract::State,
6    routing::{get, post, put, delete},
7    Json,
8    http::StatusCode,
9};
10use tower_http::cors::{Any, CorsLayer};
11use boarddown_fs::FilesystemStorage;
12use boarddown_core::{BoardId, TaskId, Storage, Board, Column, Task, Status, ColumnRef};
13use crate::ws::SyncState;
14
15#[derive(Clone)]
16pub struct AppState {
17    pub storage: Arc<FilesystemStorage>,
18    pub sync: SyncState,
19}
20
21pub async fn start(addr: SocketAddr, path: PathBuf) -> anyhow::Result<()> {
22    let storage = FilesystemStorage::new(&path);
23    storage.init().await
24        .map_err(|e| anyhow::anyhow!("Failed to init storage: {}", e))?;
25    
26    let state = AppState {
27        storage: Arc::new(storage),
28        sync: SyncState::new(),
29    };
30
31    let app = create_router(state);
32
33    let listener = tokio::net::TcpListener::bind(addr).await?;
34    println!("BoardDown server listening on {}", addr);
35    println!("Serving from: {}", path.display());
36    
37    axum::serve(listener, app).await?;
38    
39    Ok(())
40}
41
42fn create_router(state: AppState) -> Router {
43    let cors = CorsLayer::new()
44        .allow_origin(Any)
45        .allow_methods(Any)
46        .allow_headers(Any);
47
48    let sync_router = crate::routes::sync::router();
49    
50    Router::new()
51        .route("/api/boards", get(list_boards))
52        .route("/api/boards/:id", get(get_board).post(create_board).delete(delete_board))
53        .route("/api/boards/:id/tasks", get(list_tasks).post(create_task))
54        .route("/api/boards/:id/tasks/:task_id", get(get_task).put(update_task).delete(delete_task))
55        .route("/api/boards/:id/tasks/:task_id/move", post(move_task))
56        .merge(sync_router)
57        .layer(cors)
58        .with_state(state)
59}
60
61async fn list_boards(
62    State(state): State<AppState>,
63) -> Result<Json<Vec<String>>, AppError> {
64    let boards = state.storage.list_boards().await?;
65    Ok(Json(boards.iter().map(|id| id.0.clone()).collect()))
66}
67
68async fn create_board(
69    State(state): State<AppState>,
70    axum::extract::Path(id): axum::extract::Path<String>,
71    Json(body): Json<CreateBoardRequest>,
72) -> Result<Json<BoardResponse>, AppError> {
73    let board_id = BoardId(id.clone());
74    
75    match state.storage.load_board(&board_id).await {
76        Ok(_) => return Err(AppError::Conflict(format!("Board '{}' already exists", id))),
77        Err(boarddown_core::Error::BoardNotFound(_)) => {},
78        Err(boarddown_core::Error::Storage(_)) => {},
79        Err(e) => return Err(AppError::from(e)),
80    }
81    
82    let board = Board::with_default_columns(&id, &body.title);
83    
84    state.storage.save_board(&board).await?;
85    
86    Ok(Json(board_to_response(&board)))
87}
88
89async fn get_board(
90    State(state): State<AppState>,
91    axum::extract::Path(id): axum::extract::Path<String>,
92) -> Result<Json<BoardResponse>, AppError> {
93    let board = state.storage.load_board(&BoardId(id)).await?;
94    Ok(Json(board_to_response(&board)))
95}
96
97async fn delete_board(
98    State(state): State<AppState>,
99    axum::extract::Path(id): axum::extract::Path<String>,
100) -> Result<StatusCode, AppError> {
101    state.storage.delete_board(&BoardId(id)).await?;
102    Ok(StatusCode::NO_CONTENT)
103}
104
105async fn list_tasks(
106    State(state): State<AppState>,
107    axum::extract::Path(board_id): axum::extract::Path<String>,
108) -> Result<Json<Vec<TaskResponse>>, AppError> {
109    let board = state.storage.load_board(&BoardId(board_id)).await?;
110    let tasks: Vec<TaskResponse> = board.tasks.values()
111        .map(|t| task_to_response(t))
112        .collect();
113    Ok(Json(tasks))
114}
115
116async fn create_task(
117    State(state): State<AppState>,
118    axum::extract::Path(board_id): axum::extract::Path<String>,
119    Json(body): Json<CreateTaskRequest>,
120) -> Result<Json<TaskResponse>, AppError> {
121    let mut board = state.storage.load_board(&BoardId(board_id)).await?;
122    
123    let task_num = board.tasks.len() as u64 + 1;
124    let prefix = board.metadata.id_prefix.as_deref().unwrap_or(&board.id.0);
125    let task_id = TaskId::new(prefix, task_num);
126    
127    let column = body.column.unwrap_or_else(|| {
128        board.columns.first().map(|c| c.name.clone()).unwrap_or_else(|| "Todo".to_string())
129    });
130    
131    let task = boarddown_core::TaskBuilder::default()
132        .id(task_id.clone())
133        .title(&body.title)
134        .column(&column)
135        .build()
136        .map_err(|e| AppError::BadRequest(e))?;
137    
138    board.tasks.insert(task_id.clone(), task.clone());
139    state.storage.save_board(&board).await?;
140    
141    Ok(Json(task_to_response(&task)))
142}
143
144async fn get_task(
145    State(state): State<AppState>,
146    axum::extract::Path((board_id, task_id)): axum::extract::Path<(String, String)>,
147) -> Result<Json<TaskResponse>, AppError> {
148    let board = state.storage.load_board(&BoardId(board_id)).await?;
149    let task_id = TaskId::parse(&task_id)
150        .map_err(|e| AppError::BadRequest(e.to_string()))?;
151    
152    let task = board.tasks.get(&task_id)
153        .ok_or_else(|| AppError::NotFound(format!("Task '{}' not found", task_id)))?;
154    
155    Ok(Json(task_to_response(task)))
156}
157
158async fn update_task(
159    State(state): State<AppState>,
160    axum::extract::Path((board_id, task_id)): axum::extract::Path<(String, String)>,
161    Json(body): Json<UpdateTaskRequest>,
162) -> Result<Json<TaskResponse>, AppError> {
163    let mut board = state.storage.load_board(&BoardId(board_id)).await?;
164    let task_id = TaskId::parse(&task_id)
165        .map_err(|e| AppError::BadRequest(e.to_string()))?;
166    
167    let task = board.tasks.get_mut(&task_id)
168        .ok_or_else(|| AppError::NotFound(format!("Task '{}' not found", task_id)))?;
169    
170    if let Some(title) = body.title {
171        task.title = title;
172    }
173    
174    if let Some(status_str) = body.status {
175        task.status = status_str.parse::<Status>().map_err(|e| AppError::BadRequest(e))?;
176    }
177    
178    if let Some(assignee) = body.assignee {
179        task.metadata.assign = Some(assignee);
180    }
181    
182    if let Some(tags) = body.tags {
183        task.metadata.tags = tags;
184    }
185    
186    task.updated_at = chrono::Utc::now();
187    
188    let response = task_to_response(task);
189    state.storage.save_board(&board).await?;
190    
191    Ok(Json(response))
192}
193
194async fn delete_task(
195    State(state): State<AppState>,
196    axum::extract::Path((board_id, task_id)): axum::extract::Path<(String, String)>,
197) -> Result<StatusCode, AppError> {
198    let mut board = state.storage.load_board(&BoardId(board_id)).await?;
199    let task_id = TaskId::parse(&task_id)
200        .map_err(|e| AppError::BadRequest(e.to_string()))?;
201    
202    board.tasks.remove(&task_id)
203        .ok_or_else(|| AppError::NotFound(format!("Task '{}' not found", task_id)))?;
204    
205    state.storage.save_board(&board).await?;
206    
207    Ok(StatusCode::NO_CONTENT)
208}
209
210async fn move_task(
211    State(state): State<AppState>,
212    axum::extract::Path((board_id, task_id)): axum::extract::Path<(String, String)>,
213    Json(body): Json<MoveTaskRequest>,
214) -> Result<Json<TaskResponse>, AppError> {
215    let mut board = state.storage.load_board(&BoardId(board_id)).await?;
216    let task_id = TaskId::parse(&task_id)
217        .map_err(|e| AppError::BadRequest(e.to_string()))?;
218    
219    let target_column = body.to_column.clone();
220    
221    if !board.columns.iter().any(|c| c.name == target_column) {
222        return Err(AppError::BadRequest(format!("Column '{}' does not exist", target_column)));
223    }
224    
225    let task = board.tasks.get_mut(&task_id)
226        .ok_or_else(|| AppError::NotFound(format!("Task '{}' not found", task_id)))?;
227    
228    task.column = ColumnRef::Name(target_column);
229    task.updated_at = chrono::Utc::now();
230    
231    let response = task_to_response(task);
232    state.storage.save_board(&board).await?;
233    
234    Ok(Json(response))
235}
236
237fn board_to_response(board: &Board) -> BoardResponse {
238    BoardResponse {
239        id: board.id.0.clone(),
240        title: board.title.clone(),
241        columns: board.columns.iter().map(|c| ColumnResponse {
242            name: c.name.clone(),
243            order: c.order,
244        }).collect(),
245        tasks: board.tasks.values().map(task_to_response).collect(),
246        created_at: board.created_at.to_rfc3339(),
247        updated_at: board.updated_at.to_rfc3339(),
248    }
249}
250
251fn task_to_response(task: &Task) -> TaskResponse {
252    TaskResponse {
253        id: task.id.to_string(),
254        title: task.title.clone(),
255        status: serde_json::to_string(&task.status).unwrap_or_default().trim_matches('"').to_string(),
256        column: task.column.to_string(),
257        assignee: task.metadata.assign.clone(),
258        tags: task.metadata.tags.clone(),
259        dependencies: task.dependencies.iter().map(|d| d.to_string()).collect(),
260        created_at: task.created_at.to_rfc3339(),
261        updated_at: task.updated_at.to_rfc3339(),
262    }
263}
264
265#[derive(serde::Serialize, serde::Deserialize)]
266pub struct CreateBoardRequest {
267    pub title: String,
268}
269
270#[derive(serde::Serialize, serde::Deserialize)]
271pub struct BoardResponse {
272    pub id: String,
273    pub title: String,
274    pub columns: Vec<ColumnResponse>,
275    pub tasks: Vec<TaskResponse>,
276    pub created_at: String,
277    pub updated_at: String,
278}
279
280#[derive(serde::Serialize, serde::Deserialize)]
281pub struct ColumnResponse {
282    pub name: String,
283    pub order: usize,
284}
285
286#[derive(serde::Serialize, serde::Deserialize)]
287pub struct TaskResponse {
288    pub id: String,
289    pub title: String,
290    pub status: String,
291    pub column: String,
292    pub assignee: Option<String>,
293    pub tags: Vec<String>,
294    pub dependencies: Vec<String>,
295    pub created_at: String,
296    pub updated_at: String,
297}
298
299#[derive(serde::Serialize, serde::Deserialize)]
300pub struct CreateTaskRequest {
301    pub title: String,
302    pub column: Option<String>,
303}
304
305#[derive(serde::Serialize, serde::Deserialize)]
306pub struct UpdateTaskRequest {
307    pub title: Option<String>,
308    pub status: Option<String>,
309    pub assignee: Option<String>,
310    pub tags: Option<Vec<String>>,
311}
312
313#[derive(serde::Serialize, serde::Deserialize)]
314pub struct MoveTaskRequest {
315    pub to_column: String,
316}
317
318#[derive(Debug)]
319pub enum AppError {
320    NotFound(String),
321    BadRequest(String),
322    Conflict(String),
323    Internal(String),
324}
325
326impl axum::response::IntoResponse for AppError {
327    fn into_response(self) -> axum::response::Response {
328        let (status, message) = match self {
329            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
330            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
331            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg),
332            AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
333        };
334        
335        let body = Json(serde_json::json!({ "error": message }));
336        (status, body).into_response()
337    }
338}
339
340impl From<boarddown_core::Error> for AppError {
341    fn from(err: boarddown_core::Error) -> Self {
342        match err {
343            boarddown_core::Error::BoardNotFound(id) => AppError::NotFound(format!("Board '{}' not found", id)),
344            boarddown_core::Error::TaskNotFound(id) => AppError::NotFound(format!("Task '{}' not found", id)),
345            boarddown_core::Error::Storage(msg) => AppError::Internal(msg),
346            _ => AppError::Internal(err.to_string()),
347        }
348    }
349}