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}