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}