8.4 KiB · text History 25c4237
//! Restaurants: added from the map, ranked by each person.
use std::collections::HashMap;

use axum::extract::{Form, Path, 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::load_area;
use super::{internal, non_empty, render};
use crate::entities::rankings::Rank;
use crate::entities::{lunch_attendees, lunches, rankings, restaurants, users};
use crate::middleware::UserContext;
use crate::AppState;

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

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 = load_area(db, area_id).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 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)?;
    load_area(db, form.area_id).await?;
    let osm_ref = non_empty(form.osm_ref);
    let rank = form.rank.as_deref().and_then(Rank::parse);

    // The same OSM element added twice 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::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)),
                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(rank) = rank {
        upsert_rank(db, user.user.id, id, rank).await?;
    }
    Ok(Redirect::to(&format!("/restaurants/{id}")))
}

async fn upsert_rank(db: &DatabaseConnection, user_id: Uuid, restaurant_id: Uuid, rank: Rank) -> Result<(), StatusCode> {
    rankings::Entity::insert(rankings::ActiveModel {
        user_id: Set(user_id),
        restaurant_id: Set(restaurant_id),
        rank: Set(rank),
        updated_at: Set(Utc::now().into()),
    })
    .on_conflict(
        OnConflict::columns([rankings::Column::UserId, rankings::Column::RestaurantId])
            .update_columns([rankings::Column::Rank, rankings::Column::UpdatedAt])
            .to_owned(),
    )
    .exec_without_returning(db)
    .await
    .map_err(internal("upsert rank failed"))?;
    Ok(())
}

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 = load_restaurant(db, id).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();
    let people = users::Entity::find()
        .filter(users::Column::IsActive.eq(true))
        .order_by_asc(users::Column::Username)
        .all(db)
        .await
        .map_err(internal("list users failed"))?;
    let names: HashMap<Uuid, String> = people.iter().map(|u| (u.id, u.username.clone())).collect();
    let everyone: Vec<minijinja::Value> = people
        .iter()
        .map(|u| context! { username => u.username, rank => ranks.get(&u.id) })
        .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();

    render(
        &state,
        "restaurants/show.html",
        context! {
            title => restaurant.name.clone(),
            user => user.user,
            my_rank => ranks.get(&user.user.id),
            restaurant => restaurant,
            area => area,
            everyone => everyone,
            visits => visits,
        },
    )
}

#[derive(Debug, Deserialize)]
pub struct RankForm {
    pub rank: 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 rank = Rank::parse(&form.rank).ok_or(StatusCode::BAD_REQUEST)?;
    load_restaurant(db, id).await?;
    upsert_rank(db, user.user.id, id, rank).await?;
    render(&state, "restaurants/_rank.html", context! { restaurant_id => id, my_rank => rank })
}

#[derive(Debug, Deserialize)]
pub struct EditForm {
    pub notes: Option<String>,
    pub cuisine: Option<String>,
    pub website: 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 mut active: restaurants::ActiveModel = load_restaurant(db, id).await?.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.update(db).await.map_err(internal("update restaurant failed"))?;
    Ok(Redirect::to(&format!("/restaurants/{id}")))
}

/// 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).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}")))
}