8.5 KiB · text History 6dd7389
//! Areas: the regions restaurants are grouped under — the office, or a city
//! someone is travelling to. Everything else (the map, suggestions, history)
//! happens inside one.
use std::collections::HashMap;

use axum::extract::{Form, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, Redirect};
use chrono::{NaiveDate, Utc};
use minijinja::context;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::{internal, non_empty, render};
use crate::entities::rankings::Rank;
use crate::entities::{areas, lunches, rankings, restaurants};
use crate::middleware::UserContext;
use crate::AppState;

pub(crate) async fn load_area(db: &DatabaseConnection, id: Uuid) -> Result<areas::Model, StatusCode> {
    areas::Entity::find_by_id(id)
        .one(db)
        .await
        .map_err(internal("load area failed"))?
        .ok_or(StatusCode::NOT_FOUND)
}

#[derive(Serialize)]
struct AreaRow {
    area: areas::Model,
    restaurants: u64,
}

pub async fn all_areas(db: &DatabaseConnection) -> Result<Vec<areas::Model>, StatusCode> {
    areas::Entity::find()
        .order_by_asc(areas::Column::Name)
        .all(db)
        .await
        .map_err(internal("list areas failed"))
}

pub async fn list_areas(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let mut counts: HashMap<Uuid, u64> = HashMap::new();
    for r in restaurants::Entity::find()
        .filter(restaurants::Column::Archived.eq(false))
        .all(db)
        .await
        .map_err(internal("count restaurants failed"))?
    {
        *counts.entry(r.area_id).or_default() += 1;
    }
    let rows: Vec<AreaRow> = all_areas(db)
        .await?
        .into_iter()
        .map(|a| AreaRow { restaurants: counts.get(&a.id).copied().unwrap_or(0), area: a })
        .collect();
    render(&state, "areas/list.html", context! { title => "Areas", user => user.user, areas => rows })
}

pub async fn new_area(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Html<String>, StatusCode> {
    render(&state, "areas/new.html", context! { title => "New area", user => user.user })
}

#[derive(Debug, Deserialize)]
pub struct AreaForm {
    pub name: String,
    pub lat: f64,
    pub lon: f64,
    pub zoom: Option<i32>,
}

pub async fn create_area(
    State(state): State<AppState>,
    user: UserContext,
    Form(form): Form<AreaForm>,
) -> Result<Redirect, StatusCode> {
    let name = non_empty(Some(form.name)).ok_or(StatusCode::BAD_REQUEST)?;
    if !(-90.0..=90.0).contains(&form.lat) || !(-180.0..=180.0).contains(&form.lon) {
        return Err(StatusCode::BAD_REQUEST);
    }
    let db = state.db.as_ref();
    // Names are unique; re-submitting one just goes to it.
    if let Some(existing) = areas::Entity::find()
        .filter(areas::Column::Name.eq(name.as_str()))
        .one(db)
        .await
        .map_err(internal("load area failed"))?
    {
        return Ok(Redirect::to(&format!("/areas/{}", existing.id)));
    }
    let id = Uuid::now_v7();
    areas::ActiveModel {
        id: Set(id),
        name: Set(name),
        lat: Set(form.lat),
        lon: Set(form.lon),
        zoom: Set(form.zoom.unwrap_or(15).clamp(3, 19)),
        created_by: Set(Some(user.user.id)),
        created_at: Set(Utc::now().into()),
    }
    .insert(db)
    .await
    .map_err(internal("insert area failed"))?;
    Ok(Redirect::to(&format!("/areas/{id}")))
}

/// One restaurant as listed in its area, from the viewer's point of view.
#[derive(Serialize)]
struct RestaurantRow {
    #[serde(flatten)]
    restaurant: restaurants::Model,
    my_rank: Option<Rank>,
    my_since: Option<NaiveDate>,
    cuisines: Vec<String>,
    visits: usize,
    last_visit: Option<NaiveDate>,
}

/// One-shot notices after a redirect back to the area page.
#[derive(Debug, Deserialize)]
pub struct AreaNotice {
    /// How many restaurants a refresh from OSM changed.
    pub refreshed: Option<usize>,
    /// "taken": a rename collided with another area's name.
    pub error: Option<String>,
}

pub async fn show_area(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
    Query(notice): Query<AreaNotice>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let area = load_area(db, id).await?;
    let list = restaurants::Entity::find()
        .filter(restaurants::Column::AreaId.eq(id))
        .order_by_asc(restaurants::Column::Archived)
        .order_by_asc(restaurants::Column::Name)
        .all(db)
        .await
        .map_err(internal("list restaurants failed"))?;
    let ids: Vec<Uuid> = list.iter().map(|r| r.id).collect();

    let my_ranks: HashMap<Uuid, Rank> = rankings::Entity::find()
        .filter(rankings::Column::UserId.eq(user.user.id))
        .filter(rankings::Column::RestaurantId.is_in(ids.clone()))
        .all(db)
        .await
        .map_err(internal("load rankings failed"))?
        .into_iter()
        .map(|r| (r.restaurant_id, r.rank()))
        .collect();

    let mut history: HashMap<Uuid, Vec<NaiveDate>> = HashMap::new();
    for l in lunches::Entity::find()
        .filter(lunches::Column::RestaurantId.is_in(ids))
        .all(db)
        .await
        .map_err(internal("load lunches failed"))?
    {
        history.entry(l.restaurant_id).or_default().push(l.went_on);
    }

    let rows: Vec<RestaurantRow> = list
        .into_iter()
        .map(|r| {
            let visits = history.get(&r.id);
            RestaurantRow {
                my_rank: my_ranks.get(&r.id).copied(),
                my_since: my_ranks.get(&r.id).and_then(|k| k.since()),
                cuisines: r.cuisine_tags(),
                visits: visits.map_or(0, Vec::len),
                last_visit: visits.and_then(|v| v.iter().max().copied()),
                restaurant: r,
            }
        })
        .collect();

    let markers: Vec<minijinja::Value> = rows
        .iter()
        .filter(|r| !r.restaurant.archived)
        .map(|r| {
            context! {
                name => r.restaurant.name,
                lat => r.restaurant.lat,
                lon => r.restaurant.lon,
                rank => r.my_rank,
                url => format!("/restaurants/{}", r.restaurant.id),
            }
        })
        .collect();

    let open_outings = super::outings::open_in_area(db, id).await?;

    render(
        &state,
        "areas/show.html",
        context! {
            title => area.name.clone(),
            open_outings => open_outings,
            refreshed => notice.refreshed,
            error => notice.error,
            osm_linked => rows.iter().filter(|r| r.restaurant.osm_ref.is_some()).count(),
            user => user.user,
            area => area,
            restaurants => rows,
            markers => markers,
        },
    )
}

#[derive(Debug, Deserialize)]
pub struct RenameForm {
    pub name: String,
}

pub async fn rename_area(
    State(state): State<AppState>,
    _user: UserContext,
    Path(id): Path<Uuid>,
    Form(form): Form<RenameForm>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let name = non_empty(Some(form.name)).ok_or(StatusCode::BAD_REQUEST)?;
    let area = load_area(db, id).await?;
    if name == area.name {
        return Ok(Redirect::to(&format!("/areas/{id}")));
    }
    // Names are unique; say so rather than 500 on the constraint.
    let taken = areas::Entity::find()
        .filter(areas::Column::Name.eq(name.as_str()))
        .one(db)
        .await
        .map_err(internal("load area failed"))?
        .is_some();
    if taken {
        return Ok(Redirect::to(&format!("/areas/{id}?error=taken")));
    }
    let mut active: areas::ActiveModel = area.into();
    active.name = Set(name);
    active.update(db).await.map_err(internal("rename area failed"))?;
    Ok(Redirect::to(&format!("/areas/{id}")))
}

/// Refresh every OSM-linked restaurant in the area (hours, and any blank
/// cuisine/website/address) — how restaurants added before hours were
/// tracked pick them up.
pub async fn refresh_area(
    State(state): State<AppState>,
    _user: UserContext,
    Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    load_area(db, id).await?;
    let list = restaurants::Entity::find()
        .filter(restaurants::Column::AreaId.eq(id))
        .filter(restaurants::Column::OsmRef.is_not_null())
        .all(db)
        .await
        .map_err(internal("list restaurants failed"))?;
    let changed = super::restaurants::refresh_from_osm(db, &state.osm, list).await?;
    Ok(Redirect::to(&format!("/areas/{id}?refreshed={changed}")))
}