15.5 KiB · text History 6a8dcc6
//! 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, NaiveTime, Utc};
use minijinja::context;
use sea_orm::*;
use uuid::Uuid;

use serde::Serialize;

use super::areas::authorize;
use super::{internal, render, Fields};
use crate::access;
use crate::entities::area_members::Role;
use crate::entities::rankings::{Rank, RankKind};
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;

/// When lunch is, unless the planner says otherwise. Places closed then
/// aren't drawn.
pub(crate) fn default_meal_time() -> NaiveTime {
    NaiveTime::from_hms_opt(12, 0, 0).expect("valid time")
}

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

/// The area's active members — the only people who can be "going".
pub(crate) async fn area_people(db: &DatabaseConnection, area_id: Uuid) -> Result<Vec<users::Model>, StatusCode> {
    Ok(access::members(db, area_id)
        .await
        .map_err(internal("list members failed"))?
        .into_iter()
        .map(|(u, _)| u)
        .collect())
}

/// Someone offered in a "who's going" picker.
#[derive(Debug, Serialize)]
pub(crate) struct Person {
    id: Uuid,
    username: String,
    me: bool,
    friend: bool,
    /// Lunches in this area you've both been on.
    shared: usize,
}

/// Who to offer in "who's going", instead of every member: you, then members
/// who are your friends or have eaten with you here, most shared lunches
/// first. Everyone else in the area comes back separately, for a "show all"
/// fold.
pub(crate) async fn picker(
    db: &DatabaseConnection,
    area_id: Uuid,
    me: Uuid,
) -> Result<(Vec<Person>, Vec<Person>), StatusCode> {
    let people = area_people(db, area_id).await?;
    let friends = crate::friends::friend_ids(db, me).await.map_err(internal("load friends failed"))?;

    let my_lunches: Vec<Uuid> = lunch_attendees::Entity::find()
        .inner_join(lunches::Entity)
        .join(JoinType::InnerJoin, lunches::Relation::Restaurants.def())
        .filter(restaurants::Column::AreaId.eq(area_id))
        .filter(lunch_attendees::Column::UserId.eq(me))
        .all(db)
        .await
        .map_err(internal("load my lunches failed"))?
        .into_iter()
        .map(|a| a.lunch_id)
        .collect();
    let mut shared: HashMap<Uuid, usize> = HashMap::new();
    for a in lunch_attendees::Entity::find()
        .filter(lunch_attendees::Column::LunchId.is_in(my_lunches))
        .all(db)
        .await
        .map_err(internal("load shared lunches failed"))?
    {
        *shared.entry(a.user_id).or_default() += 1;
    }

    let (mut close, mut rest): (Vec<Person>, Vec<Person>) = people
        .into_iter()
        .map(|u| Person {
            me: u.id == me,
            friend: friends.contains(&u.id),
            shared: if u.id == me { 0 } else { shared.get(&u.id).copied().unwrap_or(0) },
            id: u.id,
            username: u.username,
        })
        .partition(|p| p.me || p.friend || p.shared > 0);
    close.sort_by(|a, b| b.me.cmp(&a.me).then(b.shared.cmp(&a.shared)).then_with(|| a.username.cmp(&b.username)));
    rest.sort_by(|a, b| a.username.cmp(&b.username));
    Ok((close, rest))
}

/// The (user, restaurant) pairs among `ranks` whose "to try" has been done:
/// that person was on a logged lunch there after they marked it — a later
/// date, or the same date but logged after the mark. Re-marking "to try"
/// moves the mark, so it starts counting again.
pub(crate) async fn tried(
    db: &impl ConnectionTrait,
    ranks: &[rankings::Model],
) -> Result<HashSet<(Uuid, Uuid)>, StatusCode> {
    let to_try: Vec<&rankings::Model> = ranks.iter().filter(|r| r.rank == RankKind::ToTry).collect();
    if to_try.is_empty() {
        return Ok(HashSet::new());
    }
    let marked: HashMap<(Uuid, Uuid), &rankings::Model> =
        to_try.iter().map(|r| ((r.user_id, r.restaurant_id), *r)).collect();
    let attended = lunch_attendees::Entity::find()
        .find_also_related(lunches::Entity)
        .filter(lunch_attendees::Column::UserId.is_in(to_try.iter().map(|r| r.user_id)))
        .filter(lunches::Column::RestaurantId.is_in(to_try.iter().map(|r| r.restaurant_id)))
        .all(db)
        .await
        .map_err(internal("load tried lunches failed"))?;
    Ok(attended
        .into_iter()
        .filter_map(|(a, l)| {
            let l = l?;
            let key = (a.user_id, l.restaurant_id);
            let mark = marked.get(&key)?;
            let day = mark.updated_at.date_naive();
            (l.went_on > day || (l.went_on == day && l.created_at >= mark.updated_at)).then_some(key)
        })
        .collect())
}

/// The suggestion inputs for `going` in an area, as of `on`: the area's
/// restaurants still in rotation, and each one as a [`Candidate`] carrying the
/// group's ranks and the area's recent history.
pub(crate) async fn load_field(
    db: &impl ConnectionTrait,
    area_id: Uuid,
    going: &[&users::Model],
    on: NaiveDate,
) -> Result<(Vec<restaurants::Model>, Vec<Candidate>), StatusCode> {
    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 rows = 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"))?;
    let been = tried(db, &rows).await?;
    let mut ranks: HashMap<Uuid, Vec<(String, Rank)>> = HashMap::new();
    for r in rows {
        // A "to try" that's been tried no longer argues for going.
        let rank = if been.contains(&(r.user_id, r.restaurant_id)) { Rank::Neutral } else { r.rank() };
        ranks.entry(r.restaurant_id).or_default().push((names[&r.user_id].to_string(), rank));
    }

    let since = on - 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 = 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(),
            hours: r.opening_hours.clone(),
        })
        .collect();
    Ok((list, candidates))
}

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, _) = authorize(db, area_id, user.user.id, Role::Voter).await?;
    let (people, others) = picker(db, area_id, user.user.id).await?;
    render(
        &state,
        "lunch/plan.html",
        context! {
            title => format!("Lunch · {}", area.name),
            user => user.user,
            area => area,
            people => people,
            others => others,
            today => today(),
            meal_time => default_meal_time().format("%H:%M").to_string(),
            max_meals => MAX_MEALS,
            default_options => super::outings::DEFAULT_OPTIONS,
            max_options => super::outings::MAX_OPTIONS,
        },
    )
}

/// 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, _) = authorize(db, area_id, user.user.id, Role::Voter).await?;

    let people = area_people(db, area_id).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);
    let meal_time = fields.time("time").unwrap_or_else(default_meal_time);

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

    let (list, candidates) = load_field(db, area_id, &going, start).await?;

    let plan = suggest::plan(&candidates, going.len(), start, meal_time, 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,
            meal_time => meal_time.format("%H:%M").to_string(),
            going => going,
            restaurant_count => list.len(),
        },
    )
}

/// Write a lunch and who went, atomically. Returns the lunch's id.
pub(crate) async fn record_lunch(
    db: &impl TransactionTrait,
    restaurant_id: Uuid,
    went_on: NaiveDate,
    attendees: &[Uuid],
    logged_by: Uuid,
) -> Result<Uuid, StatusCode> {
    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(logged_by)),
        created_at: Set(Utc::now().into()),
    }
    .insert(&txn)
    .await
    .map_err(internal("insert lunch failed"))?;
    lunch_attendees::Entity::insert_many(
        attendees.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(id)
}

/// 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)?;
    access::require(db, restaurant.area_id, user.user.id, Role::Voter).await?;
    // Only the area's members can have been there, as far as this area knows.
    let members: HashSet<Uuid> = area_people(db, restaurant.area_id).await?.into_iter().map(|u| u.id).collect();
    let mut attendees: Vec<Uuid> = fields.uuids("attendee").into_iter().filter(|u| members.contains(u)).collect();
    if attendees.is_empty() {
        attendees.push(user.user.id);
    }

    record_lunch(db, restaurant_id, went_on, &attendees, user.user.id).await?;

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

/// Remove a mistaken entry: whoever logged it, or an editor.
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, restaurant) = 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)?;
    let restaurant = restaurant.ok_or(StatusCode::NOT_FOUND)?;
    let role = access::require(db, restaurant.area_id, user.user.id, Role::Voter).await?;
    if role < Role::Editor && lunch.logged_by != Some(user.user.id) {
        return Err(StatusCode::FORBIDDEN);
    }
    lunches::Entity::delete_by_id(id).exec(db).await.map_err(internal("delete lunch failed"))?;
    Ok(Redirect::to(&format!("/areas/{}/history", restaurant.area_id)))
}

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, role) = authorize(db, area_id, user.user.id, Role::Voter).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,
                can_delete => role >= Role::Editor || l.logged_by == Some(user.user.id),
            }
        })
        .collect();
    render(
        &state,
        "lunch/history.html",
        context! { title => format!("History · {}", area.name), user => user.user, area => area, lunches => lunches },
    )
}