//! 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 },
)
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
15.5 KiB · text
History
6a8dcc6