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