diff --git a/README.md b/README.md index 120290c..fcd177d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,16 @@ - [x] Route_station - [x] Team - [ ] Rating view - - [ ] make plan how i want to handle station-login, then write tests! + - [x] make plan how i want to handle station-login, then write tests! + - [ ] simple rating entry + - [x] "new group here" (team_id, auto ARRIVED_AT) + - [x] create + - [x] delete + - [ ] if no group currently here -> ask to start + - [x] "group started" (auto STARTED_AT) + - [x] "group finished" (auto LEFT_AT + - [ ] "group rated" (points, notes) // also updateable + - [ ] improve messages, especially for `/s` - [ ] Highscore list ## Fancy features diff --git a/migration.sql b/migration.sql index 91e7259..9ea7920 100644 --- a/migration.sql +++ b/migration.sql @@ -35,8 +35,8 @@ CREATE TABLE team ( ); CREATE TABLE rating ( - team_id INTEGER, - station_id INTEGER, + team_id INTEGER NOT NULL, + station_id INTEGER NOT NULL, points INTEGER, notes TEXT, arrived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/src/admin/station/mod.rs b/src/admin/station/mod.rs index 7b6c833..daa4e2d 100644 --- a/src/admin/station/mod.rs +++ b/src/admin/station/mod.rs @@ -1,4 +1,9 @@ -use crate::{admin::route::Route, AppState}; +use super::team::Team; +use crate::{ + admin::route::Route, + models::rating::{Rating, TeamsAtStationLocation}, + AppState, +}; use axum::Router; use chrono::{DateTime, Local, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -70,6 +75,144 @@ impl Station { Ok(()) } + pub(crate) async fn new_team_waiting( + &self, + db: &SqlitePool, + team: &Team, + ) -> Result<(), String> { + let teams = TeamsAtStationLocation::for_station(db, &self).await; + + if !teams.not_yet_here.contains(team) { + return Err(format!("Kann Team nicht der Warteschlange hinzufügen, weil das Team {} nicht zu deiner Station kommen soll.", team.name)); + } + + Rating::create(db, &self, team).await?; + + Ok(()) + } + + pub(crate) async fn remove_team_waiting( + &self, + db: &SqlitePool, + team: &Team, + ) -> Result<(), String> { + let teams = TeamsAtStationLocation::for_station(db, &self).await; + + let waiting_teams: Vec<&Team> = teams.waiting.iter().map(|(team, _)| team).collect(); + + if !waiting_teams.contains(&team) { + return Err(format!("Kann Team nicht von der Warteschlange gelöscht werden, weil das Team {} nicht in der Warteschlange ist.", team.name)); + } + + Rating::delete(db, &self, team).await?; + + Ok(()) + } + + pub(crate) async fn team_starting(&self, db: &SqlitePool, team: &Team) -> Result<(), String> { + let teams = TeamsAtStationLocation::for_station(db, &self).await; + + let waiting_teams: Vec<&Team> = teams.waiting.iter().map(|(team, _)| team).collect(); + + if !waiting_teams.contains(&team) { + return Err(format!( + "Team kann nicht starten, weil das Team {} nicht in der Warteschlange ist.", + team.name + )); + } + + sqlx::query!( + "UPDATE rating SET started_at = CURRENT_TIMESTAMP WHERE team_id = ? AND station_id = ?", + team.id, + self.id + ) + .execute(db) + .await + .unwrap(); + + Ok(()) + } + + pub(crate) async fn remove_team_doing( + &self, + db: &SqlitePool, + team: &Team, + ) -> Result<(), String> { + let teams = TeamsAtStationLocation::for_station(db, &self).await; + + let doing_teams: Vec<&Team> = teams.doing.iter().map(|(team, _)| team).collect(); + + if !doing_teams.contains(&team) { + return Err(format!( + "Team kann nicht zur Warteschlange hinzugefügt werden, weil das Team {} aktuell nicht an deiner Station arbeitet.", + team.name + )); + } + + sqlx::query!( + "UPDATE rating SET started_at = NULL WHERE team_id = ? AND station_id = ?", + team.id, + self.id + ) + .execute(db) + .await + .unwrap(); + + Ok(()) + } + + pub(crate) async fn team_finished(&self, db: &SqlitePool, team: &Team) -> Result<(), String> { + let teams = TeamsAtStationLocation::for_station(db, &self).await; + + let doing_teams: Vec<&Team> = teams.doing.iter().map(|(team, _)| team).collect(); + + if !doing_teams.contains(&team) { + return Err(format!( + "Team kann nicht beenden, weil das Team {} nicht an der Station arbeitet.", + team.name + )); + } + + sqlx::query!( + "UPDATE rating SET left_at = CURRENT_TIMESTAMP WHERE team_id = ? AND station_id = ?", + team.id, + self.id + ) + .execute(db) + .await + .unwrap(); + + Ok(()) + } + + pub(crate) async fn remove_team_left( + &self, + db: &SqlitePool, + team: &Team, + ) -> Result<(), String> { + let teams = TeamsAtStationLocation::for_station(db, &self).await; + + let left_teams: Vec<&Team> = teams.left.iter().map(|(team, _)| team).collect(); + + if !left_teams.contains(&team) { + return Err(format!( + "Team kann nicht zur Arbeitsposition hinzugefügt werden, weil das Team {} aktuell nicht feritg ist", + team.name + )); + } + + sqlx::query!( + "UPDATE rating SET left_at = NULL WHERE team_id = ? AND station_id = ?", + team.id, + self.id + ) + .execute(db) + .await + .unwrap(); + + Ok(()) + } + async fn update_name(&self, db: &SqlitePool, name: &str) { sqlx::query!("UPDATE station SET name = ? WHERE id = ?", name, self.id) .execute(db) @@ -167,6 +310,19 @@ impl Station { let datetime_utc = DateTime::::from_naive_utc_and_offset(last_login.clone(), Utc); Some(datetime_utc.with_timezone(&Local)) } + + pub(crate) async fn teams(&self, db: &SqlitePool) -> Vec { + sqlx::query_as::<_, Team>( + "SELECT DISTINCT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.route_id +FROM team t +JOIN route_station rs ON t.route_id = rs.route_id +WHERE rs.station_id = ?;", + ) + .bind(self.id) + .fetch_all(db) + .await + .unwrap() + } } pub(super) fn routes() -> Router { diff --git a/src/admin/team/mod.rs b/src/admin/team/mod.rs index 3f6fd01..8cff7cd 100644 --- a/src/admin/team/mod.rs +++ b/src/admin/team/mod.rs @@ -1,6 +1,6 @@ use crate::{ - AppState, admin::{route::Route, station::Station}, + AppState, }; use axum::Router; use serde::{Deserialize, Serialize}; @@ -8,7 +8,7 @@ use sqlx::{FromRow, SqlitePool}; mod web; -#[derive(FromRow, Debug, Serialize, Deserialize)] +#[derive(FromRow, Debug, Serialize, Deserialize, PartialEq)] pub(crate) struct Team { pub(crate) id: i64, pub(crate) name: String, diff --git a/src/lib.rs b/src/lib.rs index 2437418..f188f43 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ extern crate rust_i18n; i18n!("locales", fallback = "de-AT"); use admin::station::Station; -use axum::{Router, body::Body, extract::FromRef, response::Response, routing::get}; +use axum::{body::Body, extract::FromRef, response::Response, routing::get, Router}; use partials::page; use sqlx::SqlitePool; use std::sync::Arc; @@ -12,6 +12,7 @@ use tokio::net::TcpListener; use tower_sessions::{MemoryStore, SessionManagerLayer}; pub(crate) mod admin; +pub(crate) mod models; mod partials; pub(crate) mod station; @@ -121,7 +122,7 @@ pub async fn start(listener: TcpListener, db: SqlitePool) { let state = AppState { db: Arc::new(db) }; let app = Router::new() - .nest("/s", station::routes()) // TODO: maybe switch to "/" + .nest("/s/{id}/{code}", station::routes()) // TODO: maybe switch to "/" .nest("/admin", admin::routes()) .route("/pico.css", get(serve_pico_css)) .route("/style.css", get(serve_my_css)) diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..1b0e95d --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1 @@ +pub(crate) mod rating; diff --git a/src/models/rating.rs b/src/models/rating.rs new file mode 100644 index 0000000..36ded2c --- /dev/null +++ b/src/models/rating.rs @@ -0,0 +1,137 @@ +use crate::{admin::team::Team, Station}; +use chrono::{DateTime, Local, NaiveDateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; + +#[derive(FromRow, Debug, Serialize, Deserialize)] +pub(crate) struct Rating { + pub(crate) team_id: i64, + pub(crate) station_id: i64, + pub(crate) points: Option, + notes: Option, + arrived_at: NaiveDateTime, + started_at: Option, + left_at: Option, +} + +impl Rating { + pub(crate) async fn create( + db: &SqlitePool, + station: &Station, + team: &Team, + ) -> Result<(), String> { + sqlx::query!( + "INSERT INTO rating(team_id, station_id) VALUES (?, ?)", + team.id, + station.id + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + pub(crate) async fn delete( + db: &SqlitePool, + station: &Station, + team: &Team, + ) -> Result<(), String> { + sqlx::query!( + "DELETE FROM rating WHERE team_id = ? AND station_id = ?", + team.id, + station.id + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + pub(crate) async fn all_with_station(db: &SqlitePool, station: &Station) -> Vec { + sqlx::query_as::<_, Self>( + "SELECT team_id, station_id, points, notes, arrived_at, started_at, left_at FROM rating WHERE station_id = ?;", + ) + .bind(station.id) + .fetch_all(db) + .await + .unwrap() + } + + pub async fn find_by_team_and_station( + db: &SqlitePool, + team: &Team, + station: &Station, + ) -> Option { + sqlx::query_as!(Self, "SELECT team_id, station_id, points, notes, arrived_at, started_at, left_at FROM rating WHERE team_id = ? AND station_id = ?", team.id, station.id) + .fetch_one(db) + .await + .ok() + } + + pub(crate) fn local_time_arrived_at(&self) -> String { + let datetime_utc = DateTime::::from_naive_utc_and_offset(self.arrived_at.clone(), Utc); + let datetime_local = datetime_utc.with_timezone(&Local); + + datetime_local.format("%H:%M").to_string() + } + pub(crate) fn local_time_doing(&self) -> String { + let Some(started_at) = self.started_at else { + return String::from("noch nicht gestartet"); + }; + let datetime_utc = DateTime::::from_naive_utc_and_offset(started_at.clone(), Utc); + let datetime_local = datetime_utc.with_timezone(&Local); + + datetime_local.format("%H:%M").to_string() + } + + pub(crate) fn local_time_left(&self) -> String { + let Some(left_at) = self.left_at else { + return String::from("noch nicht fertig"); + }; + let datetime_utc = DateTime::::from_naive_utc_and_offset(left_at.clone(), Utc); + let datetime_local = datetime_utc.with_timezone(&Local); + + datetime_local.format("%H:%M").to_string() + } +} + +pub(crate) struct TeamsAtStationLocation { + pub(crate) total_teams: i64, + pub(crate) not_yet_here: Vec, + pub(crate) waiting: Vec<(Team, Rating)>, + pub(crate) doing: Vec<(Team, Rating)>, + pub(crate) left: Vec<(Team, Rating)>, +} + +impl TeamsAtStationLocation { + pub(crate) async fn for_station(db: &SqlitePool, station: &Station) -> TeamsAtStationLocation { + let teams = station.teams(&db).await; + let total_teams = teams.len() as i64; + + let mut not_yet_here = Vec::new(); + let mut waiting = Vec::new(); + let mut doing = Vec::new(); + let mut left = Vec::new(); + + for team in teams { + match Rating::find_by_team_and_station(db, &team, &station).await { + Some(rating) => { + if rating.left_at.is_some() { + left.push((team, rating)); + } else if rating.started_at.is_some() { + doing.push((team, rating)); + } else { + waiting.push((team, rating)); + } + } + None => not_yet_here.push(team), + } + } + + TeamsAtStationLocation { + total_teams, + not_yet_here, + waiting, + doing, + left, + } + } +} diff --git a/src/station.rs b/src/station.rs index 91a8a72..4a00a45 100644 --- a/src/station.rs +++ b/src/station.rs @@ -1,76 +1,19 @@ -use crate::{AppState, Station, partials}; -use axum::{Router, extract::State, routing::get}; -use maud::{Markup, html}; +use crate::{ + admin::team::Team, err, models::rating::TeamsAtStationLocation, partials, succ, AppState, + Station, +}; +use axum::{ + extract::State, + response::{IntoResponse, Redirect}, + routing::{get, post}, + Form, Router, +}; +use maud::{html, Markup}; +use serde::Deserialize; use sqlx::SqlitePool; use std::sync::Arc; use tower_sessions::Session; -//async fn view( -// State(db): State>, -// session: Session, -// jar: CookieJar, -// pjar: PrivateCookieJar, -// axum::extract::Path(id): axum::extract::Path, -//) -> Result<(CookieJar, PrivateCookieJar, Markup), (CookieJar, PrivateCookieJar, impl IntoResponse)> -//{ -// // Station selector -// let (mut jar, current_station_cookie) = get_station_cookie(&db, jar).await; -// if current_station_cookie.is_none() { -// jar = jar.add(Cookie::new("station_id", id.to_string())); -// } -// if let Some(current_station_cookie) = current_station_cookie { -// if current_station_cookie.id != id { -// // user has a cookie, which is a different station than she is trying to access -// if let Some(station) = Station::find_by_id(&db, id).await { -// jar = jar.remove(Cookie::from("station_id")); -// // trying to access valid station id -// err!(session, "Du hast versucht eine neue Station zu öffnen obwohl du bereits eine andere Station offen hattest. Welche möchtest du nun verwenden?"); -// return Ok(( -// jar, -// pjar, -// decide_between_stations(¤t_station_cookie, &station, session).await, -// )); -// } else { -// // user trying to access _in_valid station id -> make her aware + redirect to old -// err!(session, "Du hast versucht eine Station öffnen, die es nicht gibt. Nachdem du vorher schonmal eine andere Station (die es gibt) geöffnet hattest, bist du nun zu dieser weitergeleitet worden. Wenn du das nicht willst, logg dich bitte aus."); -// return Err(( -// jar, -// pjar, -// Redirect::to(&format!("/s/{}", current_station_cookie.id)), -// )); -// } -// } -// } -// let station = Station::find_by_id(&db, id).await.unwrap(); -// -// let mut pjar = pjar; -// -// // PW Checker -// if let Some(pw) = pjar.get("pw") { -// if pw.value() != station.pw { -// pjar = pjar.remove(Cookie::from("station_id")); -// err!(session, "Du hattest einen falschen Code für Station {} gespeichert. Bitte gibt den richtigen ein:", station.name ); -// return Err((jar, pjar, Redirect::to(&format!("/s/code",)))); -// } -// } else { -// return Err((jar, pjar, Redirect::to(&format!("/s/code",)))); -// } -// -// let content = html! { -// nav { -// ul { -// li { strong { (format!("Station {}", station.name)) } } -// } -// ul { -// li { a href="/s/station-logout" { "Logout" } } -// } -// } -// h1 { "test" } -// }; -// -// Ok((jar, pjar, partials::page(content, session, false).await)) -//} - async fn view( State(db): State>, session: Session, @@ -85,13 +28,279 @@ async fn view( return partials::page(content, session, false).await; }; + let teams = TeamsAtStationLocation::for_station(&db, &station).await; + + //pub(crate) not_yet_here: Vec, + //pub(crate) waiting: Vec<(Team, Rating)>, + //pub(crate) doing: Vec<(Team, Rating)>, + //pub(crate) left: Vec<(Team, Rating)>, + let content = html! { h1 { (format!("Station {}", station.name)) } + article { + "Insgesamt sollten " + (teams.total_teams) + " Teams zu deiner Station kommen." + progress value=(teams.total_teams-teams.not_yet_here.len() as i64) max=(teams.total_teams) {} + } + h2 { "Teams aktuell bei dir" } + @if !teams.waiting.is_empty() { + (teams.waiting.len()) + " Teams warten an deiner Station:" + ol { + @for (team, rating) in teams.waiting { + li { + (team.name) + " (seit " + (rating.local_time_arrived_at()) + ")" + a href=(format!("/s/{id}/{code}/remove-waiting/{}", team.id)) + onclick="return confirm('Bist du sicher, dass das Team noch nicht bei dir ist? Das kann _NICHT_ mehr rückgängig gemacht werden.');" { + "🗑️" + } + a href=(format!("/s/{id}/{code}/team-starting/{}", team.id)) { + button { "Team startet" } + } + } + } + } + } + @if !teams.not_yet_here.is_empty() { + form action=(format!("/s/{id}/{code}/new-waiting")) method="post" { + fieldset role="group" { + select name="team_id" aria-label="Team auswählen" required { + @for team in &teams.not_yet_here { + option value=(team.id) { + (team.name) + } + } + } + input type="submit" value="Neues Team da"; + } + } + } + @if !teams.doing.is_empty() { + (teams.doing.len()) + " Teams arbeiten an deiner Station:" + ol { + @for (team, rating) in teams.doing { + li { + (team.name) + " (seit " + (rating.local_time_doing()) + ")" + a href=(format!("/s/{id}/{code}/remove-doing/{}", team.id)) + onclick="return confirm('Bist du sicher, dass das Team noch nicht bei dir arbeitet? Das Team wird zurück auf die Warte-Position gesetzt');" { + "🗑️" + } + a href=(format!("/s/{id}/{code}/team-finished/{}", team.id)) { + button { "Team fertig" } + } + } + } + } + } + @if !teams.left.is_empty() { + h2 { "Teams die bei dir waren" } + (teams.left.len()) + " Teams waren schon bei dir" + ol { + @for (team, rating) in teams.left { + li { + (team.name) + " (gegangen um " + (rating.local_time_left()) + ")" + a href=(format!("/s/{id}/{code}/remove-left/{}", team.id)) + onclick="return confirm('Bist du sicher, dass das Team noch nicht bei dir fertig ist? Das Team wird zurück auf die Arbeits-Position gesetzt');" { + "🗑️" + } + } + } + } + + } + }; partials::page(content, session, false).await } -pub(super) fn routes() -> Router { - Router::new().route("/{id}/{code}", get(view)) +#[derive(Deserialize)] +struct NewWaitingForm { + team_id: i64, +} +async fn new_waiting( + State(db): State>, + session: Session, + axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>, + Form(form): Form, +) -> impl IntoResponse { + let Some(station) = Station::login(&db, id, &code).await else { + err!( + session, + "Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben." + ); + return Redirect::to("/s/{id}/{code}"); + }; + let Some(team) = Team::find_by_id(&db, form.team_id).await else { + err!(session, "Konnte das Team der Warteschlange nicht hinzufügen, weil ein Team mit ID {} nicht existiert", form.team_id); + return Redirect::to("/s/{id}/{code}"); + }; + + match station.new_team_waiting(&db, &team).await { + Ok(()) => succ!(session, "Team der Warteschlange hinzugefügt"), + Err(e) => err!(session, "{e}"), + } + + Redirect::to(&format!("/s/{id}/{code}")) +} + +async fn remove_waiting( + State(db): State>, + session: Session, + axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, +) -> impl IntoResponse { + let Some(station) = Station::login(&db, id, &code).await else { + err!( + session, + "Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben." + ); + return Redirect::to("/s/{id}/{code}"); + }; + + let Some(team) = Team::find_by_id(&db, team_id).await else { + err!(session, "Konnte das Team nicht von der Warteschlange entfernen, weil ein Team mit ID {} nicht existiert", team_id); + return Redirect::to("/s/{id}/{code}"); + }; + + match station.remove_team_waiting(&db, &team).await { + Ok(()) => succ!(session, "Team der Warteschlange gelöscht"), + Err(e) => err!(session, "{e}"), + } + + Redirect::to(&format!("/s/{id}/{code}")) +} + +async fn team_starting( + State(db): State>, + session: Session, + axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, +) -> impl IntoResponse { + let Some(station) = Station::login(&db, id, &code).await else { + err!( + session, + "Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben." + ); + return Redirect::to("/s/{id}/{code}"); + }; + + let Some(team) = Team::find_by_id(&db, team_id).await else { + err!( + session, + "Team kann nicht starten, weil ein Team mit ID {} nicht existiert", + team_id + ); + return Redirect::to("/s/{id}/{code}"); + }; + + match station.team_starting(&db, &team).await { + Ok(()) => succ!(session, "Team der Warteschlange gelöscht"), + Err(e) => err!(session, "{e}"), + } + + Redirect::to(&format!("/s/{id}/{code}")) +} + +async fn remove_doing( + State(db): State>, + session: Session, + axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, +) -> impl IntoResponse { + let Some(station) = Station::login(&db, id, &code).await else { + err!( + session, + "Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben." + ); + return Redirect::to("/s/{id}/{code}"); + }; + + let Some(team) = Team::find_by_id(&db, team_id).await else { + err!(session, "Konnte das Team nicht zur Warteschlange hinzufügen, weil ein Team mit ID {} nicht existiert", team_id); + return Redirect::to("/s/{id}/{code}"); + }; + + match station.remove_team_doing(&db, &team).await { + Ok(()) => succ!(session, "Team zur Warteschlange hinzugefügt"), + Err(e) => err!(session, "{e}"), + } + + Redirect::to(&format!("/s/{id}/{code}")) +} + +async fn team_finished( + State(db): State>, + session: Session, + axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, +) -> impl IntoResponse { + let Some(station) = Station::login(&db, id, &code).await else { + err!( + session, + "Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben." + ); + return Redirect::to("/s/{id}/{code}"); + }; + + let Some(team) = Team::find_by_id(&db, team_id).await else { + err!( + session, + "Team kann nicht beenden, weil ein Team mit ID {} nicht existiert", + team_id + ); + return Redirect::to("/s/{id}/{code}"); + }; + + match station.team_finished(&db, &team).await { + Ok(()) => succ!(session, "Team erfolgreich beendet"), + Err(e) => err!(session, "{e}"), + } + + Redirect::to(&format!("/s/{id}/{code}")) +} + +async fn remove_left( + State(db): State>, + session: Session, + axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>, +) -> impl IntoResponse { + let Some(station) = Station::login(&db, id, &code).await else { + err!( + session, + "Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben." + ); + return Redirect::to("/s/{id}/{code}"); + }; + + let Some(team) = Team::find_by_id(&db, team_id).await else { + err!(session, "Konnte das Team nicht zur Arbeits-Position hinzufügen, weil ein Team mit ID {} nicht existiert", team_id); + return Redirect::to("/s/{id}/{code}"); + }; + + match station.remove_team_left(&db, &team).await { + Ok(()) => succ!(session, "Team zur Arbeitsposition hinzugefügt"), + Err(e) => err!(session, "{e}"), + } + + Redirect::to(&format!("/s/{id}/{code}")) +} + +pub(super) fn routes() -> Router { + Router::new() + .route("/", get(view)) + .route("/new-waiting", post(new_waiting)) + .route("/remove-waiting/{team_id}", get(remove_waiting)) + .route("/team-starting/{team_id}", get(team_starting)) + .route("/remove-doing/{team_id}", get(remove_doing)) + .route("/team-finished/{team_id}", get(team_finished)) + .route("/remove-left/{team_id}", get(remove_left)) }