10.1 KiB · text History 6a8dcc6
//! 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, and only its members can see it (`crate::access`).
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::access;
use crate::entities::area_members::Role;
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)
}

/// The area, if `user_id` holds at least `min` in it; see [`access::require`].
pub(crate) async fn authorize(
    db: &DatabaseConnection,
    area_id: Uuid,
    user_id: Uuid,
    min: Role,
) -> Result<(areas::Model, Role), StatusCode> {
    let role = access::require(db, area_id, user_id, min).await?;
    Ok((load_area(db, area_id).await?, role))
}

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

/// The areas `user_id` is a member of, by name.
pub async fn my_areas(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<areas::Model>, StatusCode> {
    Ok(access::member_areas(db, user_id)
        .await
        .map_err(internal("list areas failed"))?
        .into_iter()
        .map(|(a, _)| a)
        .collect())
}

pub async fn list_areas(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let mine = access::member_areas(db, user.user.id).await.map_err(internal("list areas failed"))?;
    let mut counts: HashMap<Uuid, u64> = HashMap::new();
    for r in restaurants::Entity::find()
        .filter(restaurants::Column::Archived.eq(false))
        .filter(restaurants::Column::AreaId.is_in(mine.iter().map(|(a, _)| a.id)))
        .all(db)
        .await
        .map_err(internal("count restaurants failed"))?
    {
        *counts.entry(r.area_id).or_default() += 1;
    }
    let rows: Vec<AreaRow> = mine
        .into_iter()
        .map(|(a, role)| AreaRow { restaurants: counts.get(&a.id).copied().unwrap_or(0), area: a, role })
        .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();
    // Re-submitting the name of one of your own areas just goes to it.
    if let Some(existing) = my_areas(db, user.user.id).await?.into_iter().find(|a| a.name == name) {
        return Ok(Redirect::to(&format!("/areas/{}", existing.id)));
    }
    let id = Uuid::now_v7();
    let txn = db.begin().await.map_err(internal("begin failed"))?;
    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(&txn)
    .await
    .map_err(internal("insert area failed"))?;
    access::grant(&txn, id, user.user.id, Role::Admin).await.map_err(internal("grant admin failed"))?;
    txn.commit().await.map_err(internal("commit 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>,
    /// Your "to try" here has been tried; time to rank it properly.
    tried: bool,
    cuisines: Vec<String>,
    added_by: Option<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 of your areas' names.
    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, role) = authorize(db, id, user.user.id, Role::Voter).await?;
    let members = access::members(db, id).await.map_err(internal("list members failed"))?;
    let names: HashMap<Uuid, &str> = members.iter().map(|(u, _)| (u.id, u.username.as_str())).collect();
    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_rows = 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"))?;
    let tried = super::lunch::tried(db, &my_rows).await?;
    let my_ranks: HashMap<Uuid, Rank> = my_rows.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()),
                tried: tried.contains(&(user.user.id, r.id)),
                cuisines: r.cuisine_tags(),
                added_by: r.added_by.and_then(|u| names.get(&u)).map(|n| (*n).to_owned()),
                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(),
            role => role,
            member_count => members.len(),
            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, _) = authorize(db, id, user.user.id, Role::Admin).await?;
    if name == area.name {
        return Ok(Redirect::to(&format!("/areas/{id}")));
    }
    // Two of your own areas with one name would be indistinguishable in your
    // list; other people's areas don't matter (and aren't revealed).
    let taken = my_areas(db, user.user.id).await?.iter().any(|a| a.name == name);
    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();
    authorize(db, id, user.user.id, Role::Editor).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}")))
}