//! 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, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use hub_api::{Board, CardMove, 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() } } 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?; let (_, p) = visible(&app, &c, &owner, number).await?; let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p).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(), })) } /// `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 (_, p) = changeable(&app, &headers, &owner, number, true).await?; 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))) } /// `GET /projects/{owner}/{n}/cards`: the cards by column, as the board /// has them. pub async fn cards( State(app): State, headers: HeaderMap, Path((owner, number)): Path<(String, i64)>, ) -> Result>, ApiError> { Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns)) } /// `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).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) } /// `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) }