7.8 KiB · text History 02765fe
//! JSON API for the Android app (and scripts). Everything except sign-in
//! sits behind `Authorization: Bearer <token>` (middleware::api_auth_middleware).
//!
//! Each endpoint returns the same data its web page renders, built by the
//! same function (`area_view`, `restaurant_view`, `panel_context`, …), and
//! goes through the same permission checks — members-only areas are 404s
//! here too. Errors are bare status codes.
//!
//! Tokens come from the Devices page's QR, or from `POST /api/auth/login`
//! with a username and password.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use chrono::{NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::error;
use uuid::Uuid;

use super::outings::Action;
use super::{areas, lunch, outings, restaurants};
use crate::auth::{self, api_tokens, LoginRequest};
use crate::entities::rankings::RankKind;
use crate::entities::users;
use crate::middleware::ApiUser;
use crate::AppState;

#[derive(Serialize)]
struct Me {
    id: Uuid,
    username: String,
    email: String,
}

impl From<&users::Model> for Me {
    fn from(u: &users::Model) -> Self {
        Me { id: u.id, username: u.username.clone(), email: u.email.clone() }
    }
}

type ApiResult = Result<Json<Value>, StatusCode>;

fn json_of(v: impl Serialize) -> ApiResult {
    serde_json::to_value(v).map(Json).map_err(|e| {
        error!(error = %e, "serialize api response failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })
}

// ---------- sign-in ----------

#[derive(Deserialize)]
pub struct LoginBody {
    pub username: String,
    pub password: String,
    /// Shown on the Devices page, e.g. "Pixel 8".
    pub device: Option<String>,
}

/// The username/password flow: check the password, mint a token labelled
/// with the device, return it once. The QR flow mints the same kind of
/// token from the Devices page instead.
pub async fn login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> ApiResult {
    let user = auth::authenticate_user(
        &state.db,
        LoginRequest { username: body.username.trim().to_string(), password: body.password },
    )
    .await
    .map_err(|e| {
        error!(error = %e, "api login failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?
    .ok_or(StatusCode::UNAUTHORIZED)?;
    let label = body.device.map(|d| d.trim().chars().take(100).collect::<String>()).filter(|d| !d.is_empty());
    let (_, token) = api_tokens::create(state.db.as_ref(), user.id, label.or(Some("Android app".into())), None)
        .await
        .map_err(|e| {
            error!(error = %e, "mint api token failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
    json_of(json!({ "token": token, "user": Me::from(&user) }))
}

/// Sign this device out: revoke the token it's using.
pub async fn logout(State(state): State<AppState>, api: ApiUser) -> Result<StatusCode, StatusCode> {
    api_tokens::revoke_token(state.db.as_ref(), &api.token).await.map_err(|e| {
        error!(error = %e, "revoke api token failed");
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    Ok(StatusCode::NO_CONTENT)
}

pub async fn profile(api: ApiUser) -> ApiResult {
    json_of(json!({ "user": Me::from(&api.user) }))
}

// ---------- areas ----------

pub async fn list_areas(State(state): State<AppState>, api: ApiUser) -> ApiResult {
    let rows = areas::area_rows(state.db.as_ref(), api.user.id).await?;
    json_of(json!({ "areas": rows }))
}

pub async fn show_area(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
    json_of(areas::area_view(state.db.as_ref(), id, api.user.id).await?)
}

/// "Who's going": you, friends and people you've eaten with here first.
pub async fn people(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
    areas::authorize(state.db.as_ref(), id, api.user.id, crate::entities::area_members::Role::Voter).await?;
    let (people, others) = lunch::picker(state.db.as_ref(), id, api.user.id).await?;
    json_of(json!({ "people": people, "others": others }))
}

#[derive(Deserialize, Default)]
pub struct StartBody {
    #[serde(default)]
    pub attendees: Vec<Uuid>,
    pub options: Option<i32>,
    pub date: Option<NaiveDate>,
    /// "12:30"
    pub time: Option<String>,
}

pub async fn start_outing(
    State(state): State<AppState>,
    api: ApiUser,
    Path(area_id): Path<Uuid>,
    Json(body): Json<StartBody>,
) -> ApiResult {
    let at = match body.time.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
        Some(t) => Some(
            NaiveTime::parse_from_str(t, "%H:%M")
                .or_else(|_| NaiveTime::parse_from_str(t, "%H:%M:%S"))
                .map_err(|_| StatusCode::BAD_REQUEST)?,
        ),
        None => None,
    };
    let db = state.db.as_ref();
    let id = outings::start(db, area_id, api.user.id, body.attendees, body.options, body.date, at).await?;
    let outing = outings::load_outing(db, id, api.user.id).await?;
    json_of(outings::panel_context(db, &outing, &api.user).await?)
}

// ---------- restaurants ----------

pub async fn show_restaurant(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
    json_of(restaurants::restaurant_view(state.db.as_ref(), id, api.user.id).await?)
}

#[derive(Deserialize)]
pub struct RankBody {
    pub rank: String,
    /// Only for "obsessed": when it started.
    pub since: Option<NaiveDate>,
}

pub async fn set_rank(
    State(state): State<AppState>,
    api: ApiUser,
    Path(id): Path<Uuid>,
    Json(body): Json<RankBody>,
) -> ApiResult {
    let kind = RankKind::parse(&body.rank).ok_or(StatusCode::BAD_REQUEST)?;
    let db = state.db.as_ref();
    restaurants::load_restaurant(db, id, api.user.id, crate::entities::area_members::Role::Voter).await?;
    let rank = restaurants::apply_rank(db, api.user.id, id, kind, body.since).await?;
    json_of(json!({ "rank": rank, "since": rank.since() }))
}

#[derive(Deserialize, Default)]
pub struct VisitBody {
    pub went_on: Option<NaiveDate>,
    #[serde(default)]
    pub attendees: Vec<Uuid>,
}

/// "I went": log a lunch here (today unless `went_on` says otherwise).
pub async fn log_visit(
    State(state): State<AppState>,
    api: ApiUser,
    Path(id): Path<Uuid>,
    Json(body): Json<VisitBody>,
) -> Result<StatusCode, StatusCode> {
    lunch::log_visit(state.db.as_ref(), api.user.id, id, body.went_on, body.attendees).await?;
    Ok(StatusCode::NO_CONTENT)
}

// ---------- outings ----------

pub async fn show_outing(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
    let db = state.db.as_ref();
    let outing = outings::load_outing(db, id, api.user.id).await?;
    json_of(outings::panel_context(db, &outing, &api.user).await?)
}

#[derive(Deserialize, Default)]
pub struct ActBody {
    pub restaurant_id: Option<Uuid>,
}

/// `join`, `leave`, `vote`, `reroll`, `decide` or `cancel`; `vote` and
/// `decide` take a `restaurant_id`. Returns the outing as it now stands.
pub async fn act(
    State(state): State<AppState>,
    api: ApiUser,
    Path((id, action)): Path<(Uuid, String)>,
    body: Option<Json<ActBody>>,
) -> Result<impl IntoResponse, StatusCode> {
    let option = || body.as_ref().and_then(|b| b.restaurant_id).ok_or(StatusCode::BAD_REQUEST);
    let action = match action.as_str() {
        "join" => Action::Join,
        "leave" => Action::Leave,
        "vote" => Action::Vote(option()?),
        "reroll" => Action::Reroll,
        "decide" => Action::Decide(option()?),
        "cancel" => Action::Cancel,
        _ => return Err(StatusCode::NOT_FOUND),
    };
    let db = state.db.as_ref();
    outings::act(db, id, api.user.id, action).await?;
    let outing = outings::load_outing(db, id, api.user.id).await?;
    json_of(outings::panel_context(db, &outing, &api.user).await?)
}