| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use super::{caller, forbidden, not_found, ApiError, Caller}; |
| 5 | | use crate::db::projects as pdb; |
| 6 | | use crate::web::projects as wp; |
| 7 | | use crate::Shared; |
| 8 | | use axum::extract::{Path, Query, State}; |
| 9 | | use axum::http::{HeaderMap, StatusCode}; |
| 10 | | use axum::Json; |
| 11 | | use hub_api::{ |
| 12 | | Board, CardMove, CardsArchive, CardsArchived, CardsMove, CardsMoved, NewCard, NewProject, Project, ProjectCard, ProjectColumn, |
| 13 | | ProjectEdit, |
| 14 | | }; |
| 15 | |
|
| 16 | 21× | fn project_of(app: &Shared, p: &pdb::Project) -> Project { |
| 17 | 21× | Project { |
| 18 | 21× | owner: p.owner.clone(), |
| 19 | 21× | number: p.number, |
| 20 | 21× | title: p.title.clone(), |
| 21 | 21× | description: p.description.clone(), |
| 22 | 21× | public: p.public, |
| 23 | 21× | closed: p.closed, |
| 24 | 21× | updated_ms: p.updated_ms, |
| 25 | 21× | url: format!("{}/projects/{}/{}", app.config.base_url().trim_end_matches('/'), p.owner, p.number), |
| 26 | 21× | } |
| 27 | 21× | } |
| 28 | |
|
| 29 | 54× | fn card_of(v: &wp::CardView) -> ProjectCard { |
| 30 | | ProjectCard { |
| 31 | 54× | id: v.card.id, |
| 32 | 54× | kind: v.card.kind.clone(), |
| 33 | 54× | reference: v.reference.clone(), |
| 34 | 54× | title: v.title.clone(), |
| 35 | 54× | state: v.state.clone(), |
| 36 | 54× | labels: v.labels.iter().map(|l| l.name.clone()).collect(), |
| 37 | 54× | assignees: v.assignees.clone(), |
| 38 | 54× | archived_ms: v.card.archived_ms, |
| 39 | | } |
| 40 | 54× | } |
| 41 | |
|
| 42 | 65× | async fn rights(app: &Shared, c: &Caller, owner: &str) -> Result<wp::Rights, ApiError> { |
| 43 | 65× | wp::rights_of(app, c.account.as_ref(), owner).await?.ok_or_else(|| not_found("no such owner")) |
| 44 | 65× | } |
| 45 | |
|
| 46 | |
|
| 47 | 58× | async fn visible(app: &Shared, c: &Caller, owner: &str, number: i64) -> Result<(wp::Rights, pdb::Project), ApiError> { |
| 48 | 58× | let r = rights(app, c, owner).await?; |
| 49 | 58× | let p = pdb::get(&app.db, owner, number).await?.filter(|p| p.public || r.member).ok_or_else(|| not_found("no such project"))?; |
| 50 | 57× | Ok((r, p)) |
| 51 | 58× | } |
| 52 | |
|
| 53 | |
|
| 54 | 43× | async fn changeable( |
| 55 | 43× | app: &Shared, |
| 56 | 43× | headers: &HeaderMap, |
| 57 | 43× | owner: &str, |
| 58 | 43× | number: i64, |
| 59 | 43× | closed_ok: bool, |
| 60 | 43× | ) -> Result<(Caller, pdb::Project), ApiError> { |
| 61 | 43× | let c = caller(app, headers).await?; |
| 62 | 43× | c.require("repo:write")?; |
| 63 | 43× | let (r, p) = visible(app, &c, owner, number).await?; |
| 64 | 43× | if !r.edit { |
| 65 | 4× | return Err(forbidden("only the owner (or the organisation's members) may change this project")); |
| 66 | 39× | } |
| 67 | 39× | if p.closed && !closed_ok { |
| 68 | 1× | return Err(ApiError(StatusCode::CONFLICT, "this project is closed; reopen it to change it".into())); |
| 69 | 38× | } |
| 70 | 38× | Ok((c, p)) |
| 71 | 43× | } |
| 72 | |
|
| 73 | |
|
| 74 | 24× | async fn column(app: &Shared, project_id: i64, spec: Option<&str>) -> Result<pdb::Column, ApiError> { |
| 75 | 24× | let cols = pdb::columns(&app.db, project_id).await?; |
| 76 | 24× | let found = match spec.map(str::trim).filter(|s| !s.is_empty()) { |
| 77 | 6× | None => cols.into_iter().next(), |
| 78 | 47× | Some(s) => cols.into_iter().find(|c| c.id.to_string() == s || c.name.eq_ignore_ascii_case(s)), |
| 79 | | }; |
| 80 | 24× | found.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no such column".into())) |
| 81 | 24× | } |
| 82 | |
|
| 83 | |
|
| 84 | 2× | pub async fn list(State(app): State<Shared>, headers: HeaderMap, Path(owner): Path<String>) -> Result<Json<Vec<Project>>, ApiError> { |
| 85 | 2× | let c = caller(&app, &headers).await?; |
| 86 | 2× | let r = rights(&app, &c, &owner).await?; |
| 87 | 2× | let list = pdb::of_owner(&app.db, &owner).await?; |
| 88 | 2× | Ok(Json(list.iter().filter(|p| p.public || r.member).map(|p| project_of(&app, p)).collect())) |
| 89 | 2× | } |
| 90 | |
|
| 91 | |
|
| 92 | 5× | pub async fn create( |
| 93 | 5× | State(app): State<Shared>, |
| 94 | 5× | headers: HeaderMap, |
| 95 | 5× | Path(owner): Path<String>, |
| 96 | 5× | Json(body): Json<NewProject>, |
| 97 | 5× | ) -> Result<(StatusCode, Json<Project>), ApiError> { |
| 98 | 5× | let c = caller(&app, &headers).await?; |
| 99 | 5× | c.require("repo:write")?; |
| 100 | 5× | if !rights(&app, &c, &owner).await?.edit { |
| 101 | 1× | return Err(forbidden("only the owner (or the organisation's members) may create projects")); |
| 102 | 3× | } |
| 103 | 3× | let title = body.title.trim(); |
| 104 | 3× | if title.is_empty() || title.chars().count() > 100 { |
| 105 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "a project needs a title of up to 100 characters".into())); |
| 106 | 3× | } |
| 107 | 3× | let p = pdb::create(&app.db, &owner, title, body.description.trim(), !body.private).await?; |
| 108 | 3× | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.created", &format!("{owner}/{}", p.number), title).await.ok(); |
| 109 | 3× | Ok((StatusCode::CREATED, Json(project_of(&app, &p)))) |
| 110 | 5× | } |
| 111 | |
|
| 112 | |
|
| 113 | 10× | pub async fn board( |
| 114 | 10× | State(app): State<Shared>, |
| 115 | 10× | headers: HeaderMap, |
| 116 | 10× | Path((owner, number)): Path<(String, i64)>, |
| 117 | 10× | ) -> Result<Json<Board>, ApiError> { |
| 118 | 10× | let c = caller(&app, &headers).await?; |
| 119 | 10× | board_of(&app, &c, &owner, number).await |
| 120 | 10× | } |
| 121 | |
|
| 122 | |
|
| 123 | 4× | pub async fn edit( |
| 124 | 4× | State(app): State<Shared>, |
| 125 | 4× | headers: HeaderMap, |
| 126 | 4× | Path((owner, number)): Path<(String, i64)>, |
| 127 | 4× | Json(body): Json<ProjectEdit>, |
| 128 | 4× | ) -> Result<Json<Project>, ApiError> { |
| 129 | 4× | let (c, p) = changeable(&app, &headers, &owner, number, true).await?; |
| 130 | | |
| 131 | 4× | let (owner, number) = match body.owner.as_deref().map(str::trim).filter(|to| !to.is_empty() && *to != owner) { |
| 132 | 0× | Some(to) => match wp::rights_of(&app, c.account.as_ref(), to).await? { |
| 133 | 0× | Some(r) if r.edit => { |
| 134 | 0× | let moved = pdb::transfer(&app.db, p.id, to).await?; |
| 135 | 0× | let detail = format!("{owner}/{number} to {to}/{}", moved.number); |
| 136 | 0× | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.transferred", &detail, &p.title).await.ok(); |
| 137 | 0× | (moved.owner, moved.number) |
| 138 | | } |
| 139 | 0× | _ => return Err(ApiError(StatusCode::FORBIDDEN, format!("you cannot make projects under {to}"))), |
| 140 | | }, |
| 141 | 4× | None => (owner, number), |
| 142 | | }; |
| 143 | 4× | let p = pdb::get(&app.db, &owner, number).await?.unwrap_or(p); |
| 144 | 4× | let title = body.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or(&p.title).chars().take(100).collect::<String>(); |
| 145 | 4× | pdb::set( |
| 146 | 4× | &app.db, |
| 147 | 4× | p.id, |
| 148 | 4× | &title, |
| 149 | 4× | body.description.as_deref().map(str::trim).unwrap_or(&p.description), |
| 150 | 4× | body.private.map(|x| !x).unwrap_or(p.public), |
| 151 | 4× | body.closed.unwrap_or(p.closed), |
| 152 | | ) |
| 153 | 4× | .await?; |
| 154 | 4× | let fresh = pdb::get(&app.db, &owner, number).await?.unwrap_or(p); |
| 155 | 4× | Ok(Json(project_of(&app, &fresh))) |
| 156 | 4× | } |
| 157 | |
|
| 158 | |
|
| 159 | |
|
| 160 | 14× | async fn board_of(app: &Shared, c: &Caller, owner: &str, number: i64) -> Result<Json<Board>, ApiError> { |
| 161 | 14× | let (_, p) = visible(app, c, owner, number).await?; |
| 162 | 13× | let (columns, _) = wp::board_columns(app, c.account.as_ref(), owner, &p, false).await?; |
| 163 | | Ok(Json(Board { |
| 164 | 13× | project: project_of(app, &p), |
| 165 | 13× | columns: columns |
| 166 | 13× | .iter() |
| 167 | 42× | .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) |
| 168 | 13× | .collect(), |
| 169 | | })) |
| 170 | 14× | } |
| 171 | |
|
| 172 | |
|
| 173 | 5× | fn column_name(name: &str) -> Result<&str, ApiError> { |
| 174 | 5× | let name = name.trim(); |
| 175 | 5× | if name.is_empty() || name.chars().count() > 40 { |
| 176 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "a column has a name of 1 to 40 characters".into())); |
| 177 | 4× | } |
| 178 | 4× | Ok(name) |
| 179 | 5× | } |
| 180 | |
|
| 181 | |
|
| 182 | |
|
| 183 | 4× | pub async fn add_column( |
| 184 | 4× | State(app): State<Shared>, |
| 185 | 4× | headers: HeaderMap, |
| 186 | 4× | Path((owner, number)): Path<(String, i64)>, |
| 187 | 4× | Json(body): Json<hub_api::NewColumn>, |
| 188 | 4× | ) -> Result<Json<Board>, ApiError> { |
| 189 | 4× | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 190 | 3× | let name = column_name(&body.name)?; |
| 191 | 7× | if pdb::columns(&app.db, p.id).await?.iter().any(|col| col.name.eq_ignore_ascii_case(name)) { |
| 192 | 1× | return Err(ApiError(StatusCode::CONFLICT, format!("the board has a column {name} already"))); |
| 193 | 1× | } |
| 194 | 1× | pdb::add_column(&app.db, p.id, name).await?; |
| 195 | 1× | board_of(&app, &c, &owner, number).await |
| 196 | 4× | } |
| 197 | |
|
| 198 | |
|
| 199 | |
|
| 200 | 5× | pub async fn edit_column( |
| 201 | 5× | State(app): State<Shared>, |
| 202 | 5× | headers: HeaderMap, |
| 203 | 5× | Path((owner, number, spec)): Path<(String, i64, String)>, |
| 204 | 5× | Json(body): Json<hub_api::ColumnEdit>, |
| 205 | 5× | ) -> Result<Json<Board>, ApiError> { |
| 206 | 5× | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 207 | 5× | let col = named_column(&app, p.id, &spec).await?; |
| 208 | 4× | if body.name.is_none() && body.position.is_none() { |
| 209 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "say what to change: name or position".into())); |
| 210 | 3× | } |
| 211 | 3× | if let Some(name) = body.name.as_deref() { |
| 212 | 2× | let name = column_name(name)?; |
| 213 | 2× | let others = pdb::columns(&app.db, p.id).await?; |
| 214 | 7× | if others.iter().any(|o| o.id != col.id && o.name.eq_ignore_ascii_case(name)) { |
| 215 | 1× | return Err(ApiError(StatusCode::CONFLICT, format!("the board has a column {name} already"))); |
| 216 | 1× | } |
| 217 | 1× | pdb::rename_column(&app.db, p.id, col.id, name).await?; |
| 218 | 1× | } |
| 219 | 2× | if let Some(pos) = body.position { |
| 220 | 2× | pdb::place_column(&app.db, p.id, col.id, pos).await?; |
| 221 | 0× | } |
| 222 | 2× | board_of(&app, &c, &owner, number).await |
| 223 | 5× | } |
| 224 | |
|
| 225 | |
|
| 226 | |
|
| 227 | 1× | pub async fn remove_column( |
| 228 | 1× | State(app): State<Shared>, |
| 229 | 1× | headers: HeaderMap, |
| 230 | 1× | Path((owner, number, spec)): Path<(String, i64, String)>, |
| 231 | 1× | ) -> Result<Json<Board>, ApiError> { |
| 232 | 1× | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 233 | 1× | let col = named_column(&app, p.id, &spec).await?; |
| 234 | 1× | if !pdb::remove_column(&app.db, p.id, col.id).await? { |
| 235 | 0× | return Err(ApiError(StatusCode::CONFLICT, "a board keeps at least one column".into())); |
| 236 | 1× | } |
| 237 | 1× | board_of(&app, &c, &owner, number).await |
| 238 | 1× | } |
| 239 | |
|
| 240 | |
|
| 241 | 6× | async fn named_column(app: &Shared, project_id: i64, spec: &str) -> Result<pdb::Column, ApiError> { |
| 242 | 6× | let cols = pdb::columns(&app.db, project_id).await?; |
| 243 | 6× | cols.into_iter() |
| 244 | 21× | .find(|c| c.id.to_string() == spec.trim() || c.name.eq_ignore_ascii_case(spec.trim())) |
| 245 | 6× | .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no column {spec}"))) |
| 246 | 6× | } |
| 247 | |
|
| 248 | |
|
| 249 | |
|
| 250 | | #[derive(serde::Deserialize, Default)] |
| 251 | | pub struct CardsQuery { |
| 252 | | |
| 253 | | #[serde(default)] |
| 254 | | archived: bool, |
| 255 | | } |
| 256 | |
|
| 257 | |
|
| 258 | 2× | pub async fn cards( |
| 259 | 2× | State(app): State<Shared>, |
| 260 | 2× | headers: HeaderMap, |
| 261 | 2× | Path((owner, number)): Path<(String, i64)>, |
| 262 | 2× | Query(q): Query<CardsQuery>, |
| 263 | 2× | ) -> Result<Json<Vec<ProjectColumn>>, ApiError> { |
| 264 | 2× | if !q.archived { |
| 265 | 1× | return Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns)); |
| 266 | 1× | } |
| 267 | 1× | let c = caller(&app, &headers).await?; |
| 268 | 1× | let (_, p) = visible(&app, &c, &owner, number).await?; |
| 269 | 1× | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, true).await?; |
| 270 | | Ok(Json( |
| 271 | 1× | columns |
| 272 | 1× | .iter() |
| 273 | 3× | .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) |
| 274 | 1× | .collect(), |
| 275 | | )) |
| 276 | 2× | } |
| 277 | |
|
| 278 | |
|
| 279 | 13× | pub async fn add_card( |
| 280 | 13× | State(app): State<Shared>, |
| 281 | 13× | headers: HeaderMap, |
| 282 | 13× | Path((owner, number)): Path<(String, i64)>, |
| 283 | 13× | Json(body): Json<NewCard>, |
| 284 | 13× | ) -> Result<(StatusCode, Json<ProjectCard>), ApiError> { |
| 285 | 13× | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 286 | 11× | let col = column(&app, p.id, body.column.as_deref()).await?; |
| 287 | 10× | let actor = c.account.as_ref().map(|a| a.username.clone()).unwrap_or_default(); |
| 288 | 10× | let card = match wp::add_card(&app, c.account.as_ref(), &owner, p.id, col.id, &body.reference, "", &body.note, &actor).await? { |
| 289 | 6× | Ok(card) => card, |
| 290 | 4× | Err(why) => return Err(ApiError(StatusCode::BAD_REQUEST, why)), |
| 291 | | }; |
| 292 | | |
| 293 | 6× | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
| 294 | 6× | let view = columns |
| 295 | 6× | .iter() |
| 296 | 10× | .flat_map(|col| col.cards.iter()) |
| 297 | 9× | .find(|v| v.card.id == card.id) |
| 298 | 6× | .map(card_of) |
| 299 | 6× | .ok_or_else(|| not_found("no such card"))?; |
| 300 | 6× | Ok((StatusCode::CREATED, Json(view))) |
| 301 | 13× | } |
| 302 | |
|
| 303 | |
|
| 304 | 2× | pub async fn move_card( |
| 305 | 2× | State(app): State<Shared>, |
| 306 | 2× | headers: HeaderMap, |
| 307 | 2× | Path((owner, number, id)): Path<(String, i64, i64)>, |
| 308 | 2× | Json(body): Json<CardMove>, |
| 309 | 2× | ) -> Result<StatusCode, ApiError> { |
| 310 | 2× | let (_, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 311 | 2× | let col = column(&app, p.id, Some(&body.column)).await?; |
| 312 | 2× | if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id) { |
| 313 | 0× | return Err(not_found("no such card")); |
| 314 | 2× | } |
| 315 | 2× | pdb::move_card(&app.db, p.id, id, col.id).await?; |
| 316 | 2× | Ok(StatusCode::NO_CONTENT) |
| 317 | 2× | } |
| 318 | |
|
| 319 | |
|
| 320 | |
|
| 321 | 4× | fn names_card(name: &str, card: &ProjectCard) -> bool { |
| 322 | 4× | let name = name.trim(); |
| 323 | 4× | if name.is_empty() { |
| 324 | 0× | return false; |
| 325 | 4× | } |
| 326 | 4× | if name.parse::<i64>().is_ok_and(|id| id == card.id) { |
| 327 | 0× | return true; |
| 328 | 4× | } |
| 329 | 4× | let Some((_, number)) = card.reference.rsplit_once('#') else { return false }; |
| 330 | 4× | match name.rsplit_once('#') { |
| 331 | 0× | None => name == number, |
| 332 | 4× | Some(("", n)) => n == number, |
| 333 | 2× | Some((repo, n)) => n == number && (card.reference == format!("{repo}#{n}") || card.reference.ends_with(&format!("/{repo}#{n}"))), |
| 334 | | } |
| 335 | 4× | } |
| 336 | |
|
| 337 | |
|
| 338 | |
|
| 339 | 5× | pub async fn move_cards( |
| 340 | 5× | State(app): State<Shared>, |
| 341 | 5× | headers: HeaderMap, |
| 342 | 5× | Path((owner, number)): Path<(String, i64)>, |
| 343 | 5× | Json(body): Json<CardsMove>, |
| 344 | 5× | ) -> Result<Json<CardsMoved>, ApiError> { |
| 345 | 5× | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 346 | 4× | let from = column(&app, p.id, Some(&body.from)).await?; |
| 347 | 4× | let to = column(&app, p.id, Some(&body.column)).await?; |
| 348 | 4× | if from.id == to.id { |
| 349 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "the cards are in that column already".into())); |
| 350 | 3× | } |
| 351 | 3× | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
| 352 | 3× | let cards: Vec<ProjectCard> = |
| 353 | 9× | columns.iter().filter(|col| col.column.id == from.id).flat_map(|col| col.cards.iter().map(card_of)).collect(); |
| 354 | 3× | let states: Vec<String> = body.state.iter().map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).collect(); |
| 355 | 6× | let (moved, kept): (Vec<ProjectCard>, Vec<ProjectCard>) = cards.into_iter().partition(|card| { |
| 356 | 6× | !body.except.iter().any(|e| names_card(e, card)) && (states.is_empty() || states.iter().any(|s| card.state.eq_ignore_ascii_case(s))) |
| 357 | 6× | }); |
| 358 | 3× | if !body.dry_run && !moved.is_empty() { |
| 359 | 1× | let ids: Vec<i64> = moved.iter().map(|c| c.id).collect(); |
| 360 | 1× | pdb::move_cards(&app.db, p.id, &ids, to.id).await?; |
| 361 | 1× | let detail = format!("{} card(s) from {} to {}", moved.len(), from.name, to.name); |
| 362 | 1× | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.cards_moved", &format!("{owner}/{number}"), &detail).await.ok(); |
| 363 | 2× | } |
| 364 | 3× | Ok(Json(CardsMoved { moved, kept, dry_run: body.dry_run })) |
| 365 | 5× | } |
| 366 | |
|
| 367 | |
|
| 368 | |
|
| 369 | |
|
| 370 | 3× | pub async fn archive_cards( |
| 371 | 3× | State(app): State<Shared>, |
| 372 | 3× | headers: HeaderMap, |
| 373 | 3× | Path((owner, number)): Path<(String, i64)>, |
| 374 | 3× | Json(body): Json<CardsArchive>, |
| 375 | 3× | ) -> Result<Json<CardsArchived>, ApiError> { |
| 376 | 3× | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 377 | 3× | let from = column(&app, p.id, Some(&body.from)).await?; |
| 378 | 3× | if body.older_days.is_some_and(|d| !(0..=3650).contains(&d)) { |
| 379 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "older_days is 0 to 3650".into())); |
| 380 | 2× | } |
| 381 | 2× | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
| 382 | 2× | let now = crate::db::now_ms(); |
| 383 | 2× | let states: Vec<String> = body.state.iter().map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).collect(); |
| 384 | 2× | let (archived, kept): (Vec<&wp::CardView>, Vec<&wp::CardView>) = |
| 385 | 6× | columns.iter().filter(|col| col.column.id == from.id).flat_map(|col| col.cards.iter()).partition(|v| { |
| 386 | 4× | let card = card_of(v); |
| 387 | 4× | !body.except.iter().any(|e| names_card(e, &card)) |
| 388 | 4× | && (states.is_empty() || states.iter().any(|s| card.state.eq_ignore_ascii_case(s))) |
| 389 | 4× | && body.older_days.is_none_or(|d| pdb::older_than(&v.card, d, now)) |
| 390 | 4× | }); |
| 391 | 2× | if !body.dry_run && !archived.is_empty() { |
| 392 | 1× | let ids: Vec<i64> = archived.iter().map(|v| v.card.id).collect(); |
| 393 | 1× | pdb::archive_cards(&app.db, p.id, &ids).await?; |
| 394 | 1× | let detail = format!("{} card(s) of {}", ids.len(), from.name); |
| 395 | 1× | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok(); |
| 396 | 1× | } |
| 397 | 2× | let stamp = |v: &wp::CardView| ProjectCard { archived_ms: (!body.dry_run).then_some(now), ..card_of(v) }; |
| 398 | 2× | Ok(Json(CardsArchived { |
| 399 | 2× | archived: archived.into_iter().map(stamp).collect(), |
| 400 | 2× | kept: kept.into_iter().map(card_of).collect(), |
| 401 | 2× | dry_run: body.dry_run, |
| 402 | 2× | })) |
| 403 | 3× | } |
| 404 | |
|
| 405 | |
|
| 406 | |
|
| 407 | 3× | pub async fn restore_card( |
| 408 | 3× | State(app): State<Shared>, |
| 409 | 3× | headers: HeaderMap, |
| 410 | 3× | Path((owner, number, id)): Path<(String, i64, i64)>, |
| 411 | 3× | ) -> Result<StatusCode, ApiError> { |
| 412 | 3× | let (_, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 413 | 8× | if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id && c.archived_ms.is_some()) { |
| 414 | 1× | return Err(not_found("no such archived card")); |
| 415 | 1× | } |
| 416 | 1× | pdb::restore_card(&app.db, p.id, id).await?; |
| 417 | 1× | Ok(StatusCode::NO_CONTENT) |
| 418 | 3× | } |
| 419 | |
|
| 420 | |
|
| 421 | 3× | pub async fn remove_card( |
| 422 | 3× | State(app): State<Shared>, |
| 423 | 3× | headers: HeaderMap, |
| 424 | 3× | Path((owner, number, id)): Path<(String, i64, i64)>, |
| 425 | 3× | ) -> Result<StatusCode, ApiError> { |
| 426 | 3× | let (_, p) = changeable(&app, &headers, &owner, number, false).await?; |
| 427 | 5× | if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id) { |
| 428 | 1× | return Err(not_found("no such card")); |
| 429 | 2× | } |
| 430 | 2× | pdb::remove_card(&app.db, p.id, id).await?; |
| 431 | 2× | Ok(StatusCode::NO_CONTENT) |
| 432 | 3× | } |