//! Projects over the API (hub spec §8.2, §12.1): the same rules as the //! pages, through the functions they use. use super::{caller, forbidden, not_found, ApiError, Caller}; use crate::db::projects as pdb; use crate::web::projects as wp; use crate::Shared; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use hub_api::{ Board, CardMove, CardsArchive, CardsArchived, CardsMove, CardsMoved, NewCard, NewProject, Project, ProjectCard, ProjectColumn, ProjectEdit, }; fn project_of(app: &Shared, p: &pdb::Project) -> Project { Project { owner: p.owner.clone(), number: p.number, title: p.title.clone(), description: p.description.clone(), public: p.public, closed: p.closed, updated_ms: p.updated_ms, url: format!("{}/projects/{}/{}", app.config.base_url().trim_end_matches('/'), p.owner, p.number), } } fn card_of(v: &wp::CardView) -> ProjectCard { ProjectCard { id: v.card.id, kind: v.card.kind.clone(), reference: v.reference.clone(), title: v.title.clone(), state: v.state.clone(), labels: v.labels.iter().map(|l| l.name.clone()).collect(), assignees: v.assignees.clone(), archived_ms: v.card.archived_ms, } } async fn rights(app: &Shared, c: &Caller, owner: &str) -> Result { wp::rights_of(app, c.account.as_ref(), owner).await?.ok_or_else(|| not_found("no such owner")) } /// A project the caller may see. async fn visible(app: &Shared, c: &Caller, owner: &str, number: i64) -> Result<(wp::Rights, pdb::Project), ApiError> { let r = rights(app, c, owner).await?; let p = pdb::get(&app.db, owner, number).await?.filter(|p| p.public || r.member).ok_or_else(|| not_found("no such project"))?; Ok((r, p)) } /// A project the caller may change: a write token, edit rights, and open unless `closed_ok`. async fn changeable( app: &Shared, headers: &HeaderMap, owner: &str, number: i64, closed_ok: bool, ) -> Result<(Caller, pdb::Project), ApiError> { let c = caller(app, headers).await?; c.require("repo:write")?; let (r, p) = visible(app, &c, owner, number).await?; if !r.edit { return Err(forbidden("only the owner (or the organisation's members) may change this project")); } if p.closed && !closed_ok { return Err(ApiError(StatusCode::CONFLICT, "this project is closed; reopen it to change it".into())); } Ok((c, p)) } /// A column by id or by name (case-insensitive); the first when absent. async fn column(app: &Shared, project_id: i64, spec: Option<&str>) -> Result { let cols = pdb::columns(&app.db, project_id).await?; let found = match spec.map(str::trim).filter(|s| !s.is_empty()) { None => cols.into_iter().next(), Some(s) => cols.into_iter().find(|c| c.id.to_string() == s || c.name.eq_ignore_ascii_case(s)), }; found.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no such column".into())) } /// `GET /projects/{owner}`. pub async fn list(State(app): State, headers: HeaderMap, Path(owner): Path) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let r = rights(&app, &c, &owner).await?; let list = pdb::of_owner(&app.db, &owner).await?; Ok(Json(list.iter().filter(|p| p.public || r.member).map(|p| project_of(&app, p)).collect())) } /// `POST /projects/{owner}`. pub async fn create( State(app): State, headers: HeaderMap, Path(owner): Path, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; c.require("repo:write")?; if !rights(&app, &c, &owner).await?.edit { return Err(forbidden("only the owner (or the organisation's members) may create projects")); } let title = body.title.trim(); if title.is_empty() || title.chars().count() > 100 { return Err(ApiError(StatusCode::BAD_REQUEST, "a project needs a title of up to 100 characters".into())); } let p = pdb::create(&app.db, &owner, title, body.description.trim(), !body.private).await?; crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.created", &format!("{owner}/{}", p.number), title).await.ok(); Ok((StatusCode::CREATED, Json(project_of(&app, &p)))) } /// `GET /projects/{owner}/{n}`. pub async fn board( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; board_of(&app, &c, &owner, number).await } /// `PATCH /projects/{owner}/{n}`. pub async fn edit( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, Json(body): Json, ) -> Result, ApiError> { let (c, p) = changeable(&app, &headers, &owner, number, true).await?; // A transfer (#125) first: the other fields then apply where it went. let (owner, number) = match body.owner.as_deref().map(str::trim).filter(|to| !to.is_empty() && *to != owner) { Some(to) => match wp::rights_of(&app, c.account.as_ref(), to).await? { Some(r) if r.edit => { let moved = pdb::transfer(&app.db, p.id, to).await?; let detail = format!("{owner}/{number} to {to}/{}", moved.number); crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.transferred", &detail, &p.title).await.ok(); (moved.owner, moved.number) } _ => return Err(ApiError(StatusCode::FORBIDDEN, format!("you cannot make projects under {to}"))), }, None => (owner, number), }; let p = pdb::get(&app.db, &owner, number).await?.unwrap_or(p); let title = body.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or(&p.title).chars().take(100).collect::(); pdb::set( &app.db, p.id, &title, body.description.as_deref().map(str::trim).unwrap_or(&p.description), body.private.map(|x| !x).unwrap_or(p.public), body.closed.unwrap_or(p.closed), ) .await?; let fresh = pdb::get(&app.db, &owner, number).await?.unwrap_or(p); Ok(Json(project_of(&app, &fresh))) } /// A board's columns as `GET /projects/{owner}/{n}` gives them, for the /// column calls to answer with (#366). async fn board_of(app: &Shared, c: &Caller, owner: &str, number: i64) -> Result, ApiError> { let (_, p) = visible(app, c, owner, number).await?; let (columns, _) = wp::board_columns(app, c.account.as_ref(), owner, &p, false).await?; Ok(Json(Board { project: project_of(app, &p), columns: columns .iter() .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) .collect(), })) } /// A column's name: trimmed, 1 to 40 characters, as the board page has it. fn column_name(name: &str) -> Result<&str, ApiError> { let name = name.trim(); if name.is_empty() || name.chars().count() > 40 { return Err(ApiError(StatusCode::BAD_REQUEST, "a column has a name of 1 to 40 characters".into())); } Ok(name) } /// `POST /projects/{owner}/{n}/columns` (#366): add a column at the right; /// answers the board. pub async fn add_column( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, Json(body): Json, ) -> Result, ApiError> { let (c, p) = changeable(&app, &headers, &owner, number, false).await?; let name = column_name(&body.name)?; if pdb::columns(&app.db, p.id).await?.iter().any(|col| col.name.eq_ignore_ascii_case(name)) { return Err(ApiError(StatusCode::CONFLICT, format!("the board has a column {name} already"))); } pdb::add_column(&app.db, p.id, name).await?; board_of(&app, &c, &owner, number).await } /// `PATCH /projects/{owner}/{n}/columns/{column}` (#366): rename or move a /// column named by id or name; answers the board. pub async fn edit_column( State(app): State, headers: HeaderMap, Path((owner, number, spec)): Path<(String, i64, String)>, Json(body): Json, ) -> Result, ApiError> { let (c, p) = changeable(&app, &headers, &owner, number, false).await?; let col = named_column(&app, p.id, &spec).await?; if body.name.is_none() && body.position.is_none() { return Err(ApiError(StatusCode::BAD_REQUEST, "say what to change: name or position".into())); } if let Some(name) = body.name.as_deref() { let name = column_name(name)?; let others = pdb::columns(&app.db, p.id).await?; if others.iter().any(|o| o.id != col.id && o.name.eq_ignore_ascii_case(name)) { return Err(ApiError(StatusCode::CONFLICT, format!("the board has a column {name} already"))); } pdb::rename_column(&app.db, p.id, col.id, name).await?; } if let Some(pos) = body.position { pdb::place_column(&app.db, p.id, col.id, pos).await?; } board_of(&app, &c, &owner, number).await } /// `DELETE /projects/{owner}/{n}/columns/{column}` (#366): its cards go to /// the first remaining column; a board keeps at least one. Answers the board. pub async fn remove_column( State(app): State, headers: HeaderMap, Path((owner, number, spec)): Path<(String, i64, String)>, ) -> Result, ApiError> { let (c, p) = changeable(&app, &headers, &owner, number, false).await?; let col = named_column(&app, p.id, &spec).await?; if !pdb::remove_column(&app.db, p.id, col.id).await? { return Err(ApiError(StatusCode::CONFLICT, "a board keeps at least one column".into())); } board_of(&app, &c, &owner, number).await } /// A column named by id or name, or 404. async fn named_column(app: &Shared, project_id: i64, spec: &str) -> Result { let cols = pdb::columns(&app.db, project_id).await?; cols.into_iter() .find(|c| c.id.to_string() == spec.trim() || c.name.eq_ignore_ascii_case(spec.trim())) .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no column {spec}"))) } /// `GET /projects/{owner}/{n}/cards`: the cards by column, as the board /// has them. #[derive(serde::Deserialize, Default)] pub struct CardsQuery { /// The archived cards instead (#293). #[serde(default)] archived: bool, } /// `GET /projects/{owner}/{n}/cards`, `?archived=true` for the archived ones. pub async fn cards( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, Query(q): Query, ) -> Result>, ApiError> { if !q.archived { return Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns)); } let c = caller(&app, &headers).await?; let (_, p) = visible(&app, &c, &owner, number).await?; let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, true).await?; Ok(Json( columns .iter() .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) .collect(), )) } /// `POST /projects/{owner}/{n}/cards`. pub async fn add_card( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let (c, p) = changeable(&app, &headers, &owner, number, false).await?; let col = column(&app, p.id, body.column.as_deref()).await?; let actor = c.account.as_ref().map(|a| a.username.clone()).unwrap_or_default(); let card = match wp::add_card(&app, c.account.as_ref(), &owner, p.id, col.id, &body.reference, "", &body.note, &actor).await? { Ok(card) => card, Err(why) => return Err(ApiError(StatusCode::BAD_REQUEST, why)), }; // Answer with the card as the board shows it. let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; let view = columns .iter() .flat_map(|col| col.cards.iter()) .find(|v| v.card.id == card.id) .map(card_of) .ok_or_else(|| not_found("no such card"))?; Ok((StatusCode::CREATED, Json(view))) } /// `PATCH /projects/{owner}/{n}/cards/{id}`: move to another column. pub async fn move_card( State(app): State, headers: HeaderMap, Path((owner, number, id)): Path<(String, i64, i64)>, Json(body): Json, ) -> Result { let (_, p) = changeable(&app, &headers, &owner, number, false).await?; let col = column(&app, p.id, Some(&body.column)).await?; if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id) { return Err(not_found("no such card")); } pdb::move_card(&app.db, p.id, id, col.id).await?; Ok(StatusCode::NO_CONTENT) } /// Whether `card` is the one `name` names: its id, or its issue or /// proposal as `12`, `#12`, `name#12` or `owner/name#12`. fn names_card(name: &str, card: &ProjectCard) -> bool { let name = name.trim(); if name.is_empty() { return false; } if name.parse::().is_ok_and(|id| id == card.id) { return true; } let Some((_, number)) = card.reference.rsplit_once('#') else { return false }; match name.rsplit_once('#') { None => name == number, Some(("", n)) => n == number, Some((repo, n)) => n == number && (card.reference == format!("{repo}#{n}") || card.reference.ends_with(&format!("/{repo}#{n}"))), } } /// `POST /projects/{owner}/{n}/cards/move` (#289): a column's cards to /// another at once, with exceptions and a state filter; one audit entry. pub async fn move_cards( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, Json(body): Json, ) -> Result, ApiError> { let (c, p) = changeable(&app, &headers, &owner, number, false).await?; let from = column(&app, p.id, Some(&body.from)).await?; let to = column(&app, p.id, Some(&body.column)).await?; if from.id == to.id { return Err(ApiError(StatusCode::BAD_REQUEST, "the cards are in that column already".into())); } let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; let cards: Vec = columns.iter().filter(|col| col.column.id == from.id).flat_map(|col| col.cards.iter().map(card_of)).collect(); let states: Vec = body.state.iter().map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).collect(); let (moved, kept): (Vec, Vec) = cards.into_iter().partition(|card| { !body.except.iter().any(|e| names_card(e, card)) && (states.is_empty() || states.iter().any(|s| card.state.eq_ignore_ascii_case(s))) }); if !body.dry_run && !moved.is_empty() { let ids: Vec = moved.iter().map(|c| c.id).collect(); pdb::move_cards(&app.db, p.id, &ids, to.id).await?; let detail = format!("{} card(s) from {} to {}", moved.len(), from.name, to.name); crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.cards_moved", &format!("{owner}/{number}"), &detail).await.ok(); } Ok(Json(CardsMoved { moved, kept, dry_run: body.dry_run })) } /// `POST /projects/{owner}/{n}/cards/archive` (#293): a column's cards off /// the board at once, kept, with #289's exceptions and state filter and an /// age; one audit entry. pub async fn archive_cards( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, Json(body): Json, ) -> Result, ApiError> { let (c, p) = changeable(&app, &headers, &owner, number, false).await?; let from = column(&app, p.id, Some(&body.from)).await?; if body.older_days.is_some_and(|d| !(0..=3650).contains(&d)) { return Err(ApiError(StatusCode::BAD_REQUEST, "older_days is 0 to 3650".into())); } let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; let now = crate::db::now_ms(); let states: Vec = body.state.iter().map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).collect(); let (archived, kept): (Vec<&wp::CardView>, Vec<&wp::CardView>) = columns.iter().filter(|col| col.column.id == from.id).flat_map(|col| col.cards.iter()).partition(|v| { let card = card_of(v); !body.except.iter().any(|e| names_card(e, &card)) && (states.is_empty() || states.iter().any(|s| card.state.eq_ignore_ascii_case(s))) && body.older_days.is_none_or(|d| pdb::older_than(&v.card, d, now)) }); if !body.dry_run && !archived.is_empty() { let ids: Vec = archived.iter().map(|v| v.card.id).collect(); pdb::archive_cards(&app.db, p.id, &ids).await?; let detail = format!("{} card(s) of {}", ids.len(), from.name); crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok(); } let stamp = |v: &wp::CardView| ProjectCard { archived_ms: (!body.dry_run).then_some(now), ..card_of(v) }; Ok(Json(CardsArchived { archived: archived.into_iter().map(stamp).collect(), kept: kept.into_iter().map(card_of).collect(), dry_run: body.dry_run, })) } /// `POST /projects/{owner}/{n}/cards/{id}/restore` (#293): an archived /// card back on the board, at the end of its column. pub async fn restore_card( State(app): State, headers: HeaderMap, Path((owner, number, id)): Path<(String, i64, i64)>, ) -> Result { let (_, p) = changeable(&app, &headers, &owner, number, false).await?; if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id && c.archived_ms.is_some()) { return Err(not_found("no such archived card")); } pdb::restore_card(&app.db, p.id, id).await?; Ok(StatusCode::NO_CONTENT) } /// `DELETE /projects/{owner}/{n}/cards/{id}`. pub async fn remove_card( State(app): State, headers: HeaderMap, Path((owner, number, id)): Path<(String, i64, i64)>, ) -> Result { let (_, p) = changeable(&app, &headers, &owner, number, false).await?; if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id) { return Err(not_found("no such card")); } pdb::remove_card(&app.db, p.id, id).await?; Ok(StatusCode::NO_CONTENT) }