16.5 KiB · text History 3d2ee7c
//! Restaurants: added from the map, ranked by each person.
use std::collections::HashMap;

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

use super::areas::{authorize, load_area};
use super::{internal, non_empty, render};
use chrono::NaiveDate;

use crate::entities::rankings::{Rank, RankKind};
use crate::osm::ElementRef;
use crate::entities::{lunch_attendees, lunches, rankings, restaurants, users};
use crate::middleware::UserContext;
use crate::access;
use crate::entities::area_members::Role;
use crate::AppState;

/// The restaurant, if `user_id` holds at least `min` in its area. Like the
/// area itself, a restaurant in an area you're not in is a 404.
async fn load_restaurant(
    db: &DatabaseConnection,
    id: Uuid,
    user_id: Uuid,
    min: Role,
) -> Result<(restaurants::Model, Role), StatusCode> {
    let restaurant = restaurants::Entity::find_by_id(id)
        .one(db)
        .await
        .map_err(internal("load restaurant failed"))?
        .ok_or(StatusCode::NOT_FOUND)?;
    let role = access::require(db, restaurant.area_id, user_id, min).await?;
    Ok((restaurant, role))
}

pub async fn new_restaurant(
    State(state): State<AppState>,
    user: UserContext,
    Path(area_id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let (area, _) = authorize(db, area_id, user.user.id, Role::Editor).await?;
    // Show what's already here so the map isn't blank context.
    let markers: Vec<minijinja::Value> = restaurants::Entity::find()
        .filter(restaurants::Column::AreaId.eq(area_id))
        .filter(restaurants::Column::Archived.eq(false))
        .all(db)
        .await
        .map_err(internal("list restaurants failed"))?
        .into_iter()
        .map(|r| context! { name => r.name, lat => r.lat, lon => r.lon, url => format!("/restaurants/{}", r.id) })
        .collect();
    render(
        &state,
        "restaurants/new.html",
        context! { title => "Add a restaurant", user => user.user, area => area, markers => markers },
    )
}

#[derive(Debug, Deserialize)]
pub struct RestaurantForm {
    pub area_id: Uuid,
    pub name: String,
    pub osm_ref: Option<String>,
    pub lat: f64,
    pub lon: f64,
    pub cuisine: Option<String>,
    pub address: Option<String>,
    pub website: Option<String>,
    pub opening_hours: Option<String>,
    pub notes: Option<String>,
    pub rank: Option<String>,
}

pub async fn create_restaurant(
    State(state): State<AppState>,
    user: UserContext,
    Form(form): Form<RestaurantForm>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let name = non_empty(Some(form.name)).ok_or(StatusCode::BAD_REQUEST)?;
    authorize(db, form.area_id, user.user.id, Role::Editor).await?;
    let osm_ref = non_empty(form.osm_ref);
    let rank = form.rank.as_deref().and_then(RankKind::parse);

    // The same OSM element added twice to an area is the same restaurant:
    // keep the first and just record this person's rank on it.
    let existing = match &osm_ref {
        Some(r) => restaurants::Entity::find()
            .filter(restaurants::Column::AreaId.eq(form.area_id))
            .filter(restaurants::Column::OsmRef.eq(r.as_str()))
            .one(db)
            .await
            .map_err(internal("load restaurant failed"))?,
        None => None,
    };
    let id = match existing {
        Some(r) => r.id,
        None => {
            let id = Uuid::now_v7();
            restaurants::ActiveModel {
                id: Set(id),
                area_id: Set(form.area_id),
                name: Set(name),
                osm_ref: Set(osm_ref),
                lat: Set(form.lat),
                lon: Set(form.lon),
                cuisine: Set(non_empty(form.cuisine)),
                address: Set(non_empty(form.address)),
                website: Set(non_empty(form.website)),
                opening_hours: Set(non_empty(form.opening_hours)),
                notes: Set(non_empty(form.notes).unwrap_or_default()),
                archived: Set(false),
                added_by: Set(Some(user.user.id)),
                created_at: Set(Utc::now().into()),
            }
            .insert(db)
            .await
            .map_err(internal("insert restaurant failed"))?;
            id
        }
    };
    if let Some(kind) = rank {
        apply_rank(db, user.user.id, id, kind, None).await?;
    }
    Ok(Redirect::to(&format!("/restaurants/{id}")))
}

/// Set someone's rank on a restaurant. An obsession starts today unless
/// `since` says otherwise — and re-picking Obsessed without a date keeps the
/// one already recorded, so clicking it again doesn't reset the clock.
async fn apply_rank(
    db: &DatabaseConnection,
    user_id: Uuid,
    restaurant_id: Uuid,
    kind: RankKind,
    since: Option<NaiveDate>,
) -> Result<Rank, StatusCode> {
    let today = Utc::now().date_naive();
    let rank = match kind {
        RankKind::Obsessed => {
            let since = match since {
                Some(d) => d.min(today),
                None => rankings::Entity::find_by_id((user_id, restaurant_id))
                    .one(db)
                    .await
                    .map_err(internal("load rank failed"))?
                    .and_then(|r| r.rank().since())
                    .unwrap_or(today),
            };
            Rank::Obsessed { since }
        }
        other => Rank::from_parts(other, None, today),
    };
    rankings::Entity::insert(rankings::ActiveModel {
        user_id: Set(user_id),
        restaurant_id: Set(restaurant_id),
        rank: Set(rank.kind()),
        obsessed_since: Set(rank.since()),
        updated_at: Set(Utc::now().into()),
    })
    .on_conflict(
        OnConflict::columns([rankings::Column::UserId, rankings::Column::RestaurantId])
            .update_columns([
                rankings::Column::Rank,
                rankings::Column::ObsessedSince,
                rankings::Column::UpdatedAt,
            ])
            .to_owned(),
    )
    .exec_without_returning(db)
    .await
    .map_err(internal("upsert rank failed"))?;
    Ok(rank)
}

pub async fn show_restaurant(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let (restaurant, role) = load_restaurant(db, id, user.user.id, Role::Voter).await?;
    let area = load_area(db, restaurant.area_id).await?;

    let ranks: HashMap<Uuid, Rank> = rankings::Entity::find()
        .filter(rankings::Column::RestaurantId.eq(id))
        .all(db)
        .await
        .map_err(internal("load rankings failed"))?
        .into_iter()
        .map(|r| (r.user_id, r.rank()))
        .collect();
    // Names for history and credit come from everyone, since past diners
    // and contributors may have left the area; the ranks shown are members'.
    let names: HashMap<Uuid, String> = users::Entity::find()
        .all(db)
        .await
        .map_err(internal("list users failed"))?
        .into_iter()
        .map(|u| (u.id, u.username))
        .collect();
    let members = access::members(db, restaurant.area_id).await.map_err(internal("list members failed"))?;
    let everyone: Vec<minijinja::Value> = members
        .iter()
        .map(|(u, _)| {
            let rank = ranks.get(&u.id);
            context! { username => u.username, rank => rank, since => rank.and_then(|r| r.since()) }
        })
        .collect();

    let visits = lunches::Entity::find()
        .filter(lunches::Column::RestaurantId.eq(id))
        .order_by_desc(lunches::Column::WentOn)
        .find_with_related(lunch_attendees::Entity)
        .all(db)
        .await
        .map_err(internal("load lunches failed"))?;
    let visits: Vec<minijinja::Value> = visits
        .into_iter()
        .map(|(l, att)| {
            let who: Vec<&str> = att.iter().filter_map(|a| names.get(&a.user_id).map(String::as_str)).collect();
            context! { id => l.id, went_on => l.went_on, attendees => who }
        })
        .collect();

    let today = super::lunch::today();
    let lunch_today = today.and_time(super::lunch::default_meal_time());
    let hours = restaurant
        .opening_hours
        .as_deref()
        .map(|h| context! { raw => h, at_lunch => crate::hours::state_at(h, lunch_today) });

    render(
        &state,
        "restaurants/show.html",
        context! {
            title => restaurant.name.clone(),
            role => role,
            added_by => restaurant.added_by.and_then(|u| names.get(&u)),
            today => today,
            hours => hours,
            cuisines => restaurant.cuisine_tags(),
            user => user.user,
            my_rank => ranks.get(&user.user.id),
            my_since => ranks.get(&user.user.id).and_then(|r| r.since()),
            restaurant => restaurant,
            area => area,
            everyone => everyone,
            visits => visits,
        },
    )
}

#[derive(Debug, Deserialize)]
pub struct RankForm {
    pub rank: String,
    /// Only for `obsessed`: when it started. Blank keeps the current date.
    pub since: Option<String>,
}

/// htmx: set the viewer's rank, return the re-rendered rank widget.
pub async fn set_rank(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
    Form(form): Form<RankForm>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let kind = RankKind::parse(&form.rank).ok_or(StatusCode::BAD_REQUEST)?;
    let since = match non_empty(form.since) {
        Some(d) => Some(d.parse::<NaiveDate>().map_err(|_| StatusCode::BAD_REQUEST)?),
        None => None,
    };
    load_restaurant(db, id, user.user.id, Role::Voter).await?;
    let rank = apply_rank(db, user.user.id, id, kind, since).await?;
    render(
        &state,
        "restaurants/_rank.html",
        context! { restaurant_id => id, my_rank => rank, my_since => rank.since() },
    )
}

#[derive(Debug, Deserialize)]
pub struct EditForm {
    pub notes: Option<String>,
    pub cuisine: Option<String>,
    pub website: Option<String>,
    pub opening_hours: Option<String>,
}

pub async fn update_restaurant(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
    Form(form): Form<EditForm>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let (restaurant, _) = load_restaurant(db, id, user.user.id, Role::Editor).await?;
    let mut active: restaurants::ActiveModel = restaurant.into();
    active.notes = Set(non_empty(form.notes).unwrap_or_default());
    active.cuisine = Set(non_empty(form.cuisine));
    active.website = Set(non_empty(form.website));
    active.opening_hours = Set(non_empty(form.opening_hours));
    active.update(db).await.map_err(internal("update restaurant failed"))?;
    Ok(Redirect::to(&format!("/restaurants/{id}")))
}

/// Bring restaurants up to date with OSM. Hours are replaced whenever OSM
/// has them, since they change; cuisine, website and address are only filled
/// where blank, since someone may have corrected them here. Returns how many
/// restaurants changed.
pub(crate) async fn refresh_from_osm(
    db: &DatabaseConnection,
    osm: &crate::osm::OsmClient,
    list: Vec<restaurants::Model>,
) -> Result<usize, StatusCode> {
    let refs: Vec<ElementRef> = list
        .iter()
        .filter_map(|r| Some(ElementRef { osm_ref: r.osm_ref.clone()?, lat: r.lat, lon: r.lon }))
        .collect();
    if refs.is_empty() {
        return Ok(0);
    }
    let mut found = osm.tags_for(&refs).await;
    let mut changed = 0;
    for r in list {
        let Some(tags) = r.osm_ref.as_ref().and_then(|o| found.remove(o)) else { continue };
        let d = crate::osm::details(&tags);
        let next = restaurants::Model {
            opening_hours: d.opening_hours.or(r.opening_hours.clone()),
            cuisine: r.cuisine.clone().or(d.cuisine),
            website: r.website.clone().or(d.website),
            address: r.address.clone().or(d.address),
            ..r.clone()
        };
        if next != r {
            let mut active: restaurants::ActiveModel = r.into();
            active.opening_hours = Set(next.opening_hours);
            active.cuisine = Set(next.cuisine);
            active.website = Set(next.website);
            active.address = Set(next.address);
            active.update(db).await.map_err(internal("refresh restaurant failed"))?;
            changed += 1;
        }
    }
    Ok(changed)
}

pub async fn refresh_restaurant(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let (restaurant, _) = load_restaurant(db, id, user.user.id, Role::Editor).await?;
    refresh_from_osm(db, &state.osm, vec![restaurant]).await?;
    Ok(Redirect::to(&format!("/restaurants/{id}")))
}

#[derive(Debug, Deserialize)]
pub struct SimilarQuery {
    /// Metres; 1500 (a walk) unless asked.
    pub radius: Option<u32>,
}

/// htmx: places with the same cuisine around this one — those already in
/// the area, nearest first, and OSM places nearby that aren't yet, which
/// editors can add in one click.
pub async fn similar(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
    Query(q): Query<SimilarQuery>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let (restaurant, role) = load_restaurant(db, id, user.user.id, Role::Voter).await?;
    let tags = restaurant.cuisine_tags();
    let radius = q.radius.unwrap_or(1500).clamp(250, 10_000);

    let others: Vec<restaurants::Model> = restaurants::Entity::find()
        .filter(restaurants::Column::AreaId.eq(restaurant.area_id))
        .filter(restaurants::Column::Archived.eq(false))
        .filter(restaurants::Column::Id.ne(id))
        .all(db)
        .await
        .map_err(internal("list restaurants failed"))?;
    let in_area_refs: std::collections::HashSet<String> =
        others.iter().filter_map(|r| r.osm_ref.clone()).chain(restaurant.osm_ref.clone()).collect();
    let my_ranks: HashMap<Uuid, Rank> = rankings::Entity::find()
        .filter(rankings::Column::UserId.eq(user.user.id))
        .filter(rankings::Column::RestaurantId.is_in(others.iter().map(|r| r.id)))
        .all(db)
        .await
        .map_err(internal("load rankings failed"))?
        .into_iter()
        .map(|r| (r.restaurant_id, r.rank()))
        .collect();
    let mut in_area: Vec<(i64, minijinja::Value)> = others
        .iter()
        .filter(|r| r.cuisine_tags().iter().any(|t| tags.contains(t)))
        .map(|r| {
            let d = crate::osm::haversine_m(restaurant.lat, restaurant.lon, r.lat, r.lon).round() as i64;
            (d, context! { restaurant => r, distance_m => d, my_rank => my_ranks.get(&r.id), cuisines => r.cuisine_tags() })
        })
        .collect();
    in_area.sort_by_key(|(d, _)| *d);

    let nearby: Vec<crate::osm::Place> = if tags.is_empty() {
        vec![]
    } else {
        state
            .osm
            .similar(restaurant.lat, restaurant.lon, radius, &tags)
            .await
            .into_iter()
            .filter(|p| p.osm_ref.as_ref().is_none_or(|r| !in_area_refs.contains(r)))
            // Hand-added restaurants have no OSM ref to match on; the same
            // name at (nearly) the same spot is the same place.
            .filter(|p| {
                !others.iter().chain(std::iter::once(&restaurant)).any(|r| {
                    r.name.eq_ignore_ascii_case(&p.name)
                        && crate::osm::haversine_m(r.lat, r.lon, p.lat, p.lon) < 150.0
                })
            })
            .collect()
    };

    render(
        &state,
        "restaurants/_similar.html",
        context! {
            restaurant => restaurant,
            cuisines => tags,
            radius => radius,
            in_area => in_area.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
            nearby => nearby,
            can_add => role >= Role::Editor,
        },
    )
}

/// Closed for good, or just off the rotation: archived places stay in the
/// history but are never suggested.
pub async fn toggle_archive(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let (restaurant, _) = load_restaurant(db, id, user.user.id, Role::Editor).await?;
    let archived = !restaurant.archived;
    let mut active: restaurants::ActiveModel = restaurant.into();
    active.archived = Set(archived);
    active.update(db).await.map_err(internal("archive restaurant failed"))?;
    Ok(Redirect::to(&format!("/restaurants/{id}")))
}