//! 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, Fields};
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;
pub(crate) fn today() -> NaiveDate {
Utc::now().date_naive()
}
pub(crate) 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"))
}
/// 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 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 = 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(),
})
.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 = 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,
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 = 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, candidates) = load_field(db, area_id, &going, start).await?;
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(),
},
)
}
/// 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)?;
let mut attendees = fields.uuids("attendee");
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)))
}
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 },
)
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
10.2 KiB · text
History
504ebaf