9.8 KiB · text History 25c4237
//! Going out: pick who's going, get suggestions (one lunch, or a run of meals
//! for a trip), and log where you actually went.
use std::collections::{HashMap, HashSet};

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

use super::areas::load_area;
use super::{internal, render};
use crate::entities::{lunch_attendees, lunches, rankings, restaurants, users};
use crate::middleware::UserContext;
use crate::suggest::{self, Candidate, HISTORY_WINDOW_DAYS};
use crate::AppState;

/// Longest plan we'll draw up in one go — two working weeks, or a long trip.
const MAX_MEALS: usize = 14;

fn today() -> NaiveDate {
    Utc::now().date_naive()
}

async fn active_users(db: &DatabaseConnection) -> Result<Vec<users::Model>, StatusCode> {
    users::Entity::find()
        .filter(users::Column::IsActive.eq(true))
        .order_by_asc(users::Column::Username)
        .all(db)
        .await
        .map_err(internal("list users failed"))
}

/// Forms with a repeated `attendee` field arrive as raw pairs; axum's `Form`
/// can't put repeated keys into a struct, but it can into a list of pairs.
struct Fields(Vec<(String, String)>);

impl Fields {
    fn one(&self, key: &str) -> Option<&str> {
        self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty())
    }

    fn uuids(&self, key: &str) -> Vec<Uuid> {
        let set: HashSet<Uuid> =
            self.0.iter().filter(|(k, _)| k == key).filter_map(|(_, v)| v.parse().ok()).collect();
        set.into_iter().collect()
    }
}

pub async fn show_planner(
    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?;
    let people = active_users(db).await?;
    render(
        &state,
        "lunch/plan.html",
        context! {
            title => format!("Lunch · {}", area.name),
            user => user.user,
            area => area,
            people => people,
            today => today(),
            max_meals => MAX_MEALS,
        },
    )
}

/// htmx: draw up a plan and return the result fragment. Each call is a fresh
/// draw, so the same button doubles as "re-roll".
pub async fn make_plan(
    State(state): State<AppState>,
    user: UserContext,
    Path(area_id): Path<Uuid>,
    Form(pairs): Form<Vec<(String, String)>>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let fields = Fields(pairs);
    let area = load_area(db, area_id).await?;

    let people = active_users(db).await?;
    let wanted: HashSet<Uuid> = fields.uuids("attendee").into_iter().collect();
    let going: Vec<&users::Model> = people.iter().filter(|u| wanted.contains(&u.id)).collect();
    let meals = fields.one("meals").and_then(|m| m.parse().ok()).unwrap_or(1usize).clamp(1, MAX_MEALS);
    let start = fields.one("start").and_then(|d| d.parse().ok()).unwrap_or_else(today);

    if going.is_empty() {
        return render(&state, "lunch/_plan.html", context! { area => area, empty_group => true });
    }

    let list = 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"))?;
    let ids: Vec<Uuid> = list.iter().map(|r| r.id).collect();
    let names: HashMap<Uuid, &str> = going.iter().map(|u| (u.id, u.username.as_str())).collect();

    let mut ranks: HashMap<Uuid, Vec<(String, _)>> = HashMap::new();
    for r in rankings::Entity::find()
        .filter(rankings::Column::RestaurantId.is_in(ids.clone()))
        .filter(rankings::Column::UserId.is_in(names.keys().copied()))
        .all(db)
        .await
        .map_err(internal("load rankings failed"))?
    {
        ranks.entry(r.restaurant_id).or_default().push((names[&r.user_id].to_string(), r.rank));
    }

    let since = start - Days::new(HISTORY_WINDOW_DAYS as u64);
    let mut visits: HashMap<Uuid, Vec<NaiveDate>> = HashMap::new();
    for l in lunches::Entity::find()
        .filter(lunches::Column::RestaurantId.is_in(ids))
        .filter(lunches::Column::WentOn.gte(since))
        .all(db)
        .await
        .map_err(internal("load lunches failed"))?
    {
        visits.entry(l.restaurant_id).or_default().push(l.went_on);
    }

    let candidates: Vec<Candidate> = list
        .iter()
        .map(|r| Candidate {
            id: r.id,
            name: r.name.clone(),
            ranks: ranks.remove(&r.id).unwrap_or_default(),
            visits: visits.remove(&r.id).unwrap_or_default(),
        })
        .collect();

    let plan = suggest::plan(&candidates, going.len(), start, meals, rand::random::<f64>);
    let details: HashMap<Uuid, &restaurants::Model> = list.iter().map(|r| (r.id, r)).collect();
    let plan: Vec<minijinja::Value> = plan
        .into_iter()
        .map(|m| {
            let pick = m.pick.as_ref().map(|p| details[&p.id]);
            context! { on => m.on, pick => m.pick, restaurant => pick, field => m.field }
        })
        .collect();

    render(
        &state,
        "lunch/_plan.html",
        context! {
            user => user.user,
            area => area,
            plan => plan,
            going => going,
            restaurant_count => list.len(),
        },
    )
}

/// Record that a group went somewhere. Anyone can log for the group.
pub async fn log_lunch(
    State(state): State<AppState>,
    user: UserContext,
    Form(pairs): Form<Vec<(String, String)>>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let fields = Fields(pairs);
    let restaurant_id: Uuid =
        fields.one("restaurant_id").and_then(|v| v.parse().ok()).ok_or(StatusCode::BAD_REQUEST)?;
    let went_on: NaiveDate = fields.one("went_on").and_then(|d| d.parse().ok()).unwrap_or_else(today);
    let restaurant = restaurants::Entity::find_by_id(restaurant_id)
        .one(db)
        .await
        .map_err(internal("load restaurant failed"))?
        .ok_or(StatusCode::NOT_FOUND)?;
    let mut attendees = fields.uuids("attendee");
    if attendees.is_empty() {
        attendees.push(user.user.id);
    }

    let txn = db.begin().await.map_err(internal("begin failed"))?;
    let id = Uuid::now_v7();
    lunches::ActiveModel {
        id: Set(id),
        restaurant_id: Set(restaurant_id),
        went_on: Set(went_on),
        logged_by: Set(Some(user.user.id)),
        created_at: Set(Utc::now().into()),
    }
    .insert(&txn)
    .await
    .map_err(internal("insert lunch failed"))?;
    lunch_attendees::Entity::insert_many(
        attendees.into_iter().map(|u| lunch_attendees::ActiveModel { lunch_id: Set(id), user_id: Set(u) }),
    )
    .exec_without_returning(&txn)
    .await
    .map_err(internal("insert attendees failed"))?;
    txn.commit().await.map_err(internal("commit failed"))?;

    Ok(Redirect::to(&format!("/areas/{}/history", restaurant.area_id)))
}

pub async fn delete_lunch(
    State(state): State<AppState>,
    _user: UserContext,
    Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let lunch = lunches::Entity::find_by_id(id)
        .find_also_related(restaurants::Entity)
        .one(db)
        .await
        .map_err(internal("load lunch failed"))?
        .ok_or(StatusCode::NOT_FOUND)?;
    lunches::Entity::delete_by_id(id).exec(db).await.map_err(internal("delete lunch failed"))?;
    Ok(Redirect::to(&match lunch.1 {
        Some(r) => format!("/areas/{}/history", r.area_id),
        None => "/areas".to_string(),
    }))
}

pub async fn show_history(
    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?;
    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 rows = lunches::Entity::find()
        .inner_join(restaurants::Entity)
        .filter(restaurants::Column::AreaId.eq(area_id))
        .order_by_desc(lunches::Column::WentOn)
        .order_by_desc(lunches::Column::CreatedAt)
        .limit(200)
        .all(db)
        .await
        .map_err(internal("load lunches failed"))?;
    let lunch_ids: Vec<Uuid> = rows.iter().map(|l| l.id).collect();
    let mut attendees: HashMap<Uuid, Vec<&str>> = HashMap::new();
    let attendee_rows = lunch_attendees::Entity::find()
        .filter(lunch_attendees::Column::LunchId.is_in(lunch_ids))
        .all(db)
        .await
        .map_err(internal("load attendees failed"))?;
    for a in &attendee_rows {
        if let Some(n) = names.get(&a.user_id) {
            attendees.entry(a.lunch_id).or_default().push(n);
        }
    }
    let rest_ids: Vec<Uuid> = rows.iter().map(|l| l.restaurant_id).collect();
    let rest_names: HashMap<Uuid, String> = restaurants::Entity::find()
        .filter(restaurants::Column::Id.is_in(rest_ids))
        .all(db)
        .await
        .map_err(internal("load restaurants failed"))?
        .into_iter()
        .map(|r| (r.id, r.name))
        .collect();
    let lunches: Vec<minijinja::Value> = rows
        .iter()
        .map(|l| {
            let mut who = attendees.remove(&l.id).unwrap_or_default();
            who.sort_unstable();
            context! {
                id => l.id,
                went_on => l.went_on,
                restaurant_id => l.restaurant_id,
                restaurant => rest_names.get(&l.restaurant_id),
                attendees => who,
            }
        })
        .collect();
    render(
        &state,
        "lunch/history.html",
        context! { title => format!("History · {}", area.name), user => user.user, area => area, lunches => lunches },
    )
}