//! Outings: a lunch being decided together.
//!
//! Someone starts one for an area with whoever's going; it draws a ballot of
//! options. People join, and vote approval-style — any option they'd happily
//! go to. **Re-roll keeps every option with a vote** and swaps the rest for
//! places this outing hasn't shown yet, so asking "anything else?" never
//! loses one people liked. Deciding logs the lunch like any other.
//!
//! Every change is announced through [`crate::live`], and the outing page
//! re-fetches its panel on the SSE event, so everyone watching sees votes and
//! re-rolls as they happen.
use std::collections::{HashMap, HashSet};
use std::convert::Infallible;
use axum::extract::{Form, Path, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum_htmx::HxRequest;
use chrono::Utc;
use minijinja::context;
use sea_orm::sea_query::{Expr, OnConflict};
use sea_orm::*;
use serde::Deserialize;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use uuid::Uuid;
use super::areas::load_area;
use super::lunch::{area_people, default_meal_time, load_field, record_lunch, today};
use super::{internal, render, Fields};
use crate::entities::outings::{self, Status};
use crate::entities::rankings::RankKind;
use crate::entities::{outing_attendees, outing_options, outing_votes, rankings, restaurants, users};
use crate::middleware::UserContext;
use crate::entities::area_members::Role;
use crate::{access, live, suggest, AppState};
pub const DEFAULT_OPTIONS: i32 = 3;
pub const MAX_OPTIONS: i32 = 6;
/// The outing, if `user_id` is a member of its area (any role can take part
/// in a lunch); otherwise 404, like the area itself.
async fn load_outing(db: &impl ConnectionTrait, id: Uuid, user_id: Uuid) -> Result<outings::Model, StatusCode> {
let outing = outings::Entity::find_by_id(id)
.one(db)
.await
.map_err(internal("load outing failed"))?
.ok_or(StatusCode::NOT_FOUND)?;
access::require(db, outing.area_id, user_id, Role::Voter).await?;
Ok(outing)
}
/// Mutations only make sense while the outing is still being decided.
async fn load_open(db: &impl ConnectionTrait, id: Uuid, user_id: Uuid) -> Result<outings::Model, StatusCode> {
let outing = load_outing(db, id, user_id).await?;
if outing.status != Status::Open {
return Err(StatusCode::CONFLICT);
}
Ok(outing)
}
async fn going(db: &impl ConnectionTrait, outing_id: Uuid) -> Result<Vec<users::Model>, StatusCode> {
let ids: Vec<Uuid> = outing_attendees::Entity::find()
.filter(outing_attendees::Column::OutingId.eq(outing_id))
.all(db)
.await
.map_err(internal("load attendees failed"))?
.into_iter()
.map(|a| a.user_id)
.collect();
users::Entity::find()
.filter(users::Column::Id.is_in(ids))
.order_by_asc(users::Column::Username)
.all(db)
.await
.map_err(internal("load users failed"))
}
async fn join(db: &impl ConnectionTrait, outing_id: Uuid, user_id: Uuid) -> Result<(), StatusCode> {
outing_attendees::Entity::insert(outing_attendees::ActiveModel {
outing_id: Set(outing_id),
user_id: Set(user_id),
})
.on_conflict(
OnConflict::columns([outing_attendees::Column::OutingId, outing_attendees::Column::UserId])
.do_nothing()
.to_owned(),
)
.exec_without_returning(db)
.await
.map_err(internal("join outing failed"))?;
Ok(())
}
/// Fill the ballot back up to `option_count`, keeping `keep`. New options
/// come from places this outing hasn't shown; only when those run out does
/// it reach back for ones rolled away earlier. Votes on anything not kept go.
async fn roll(
db: &impl ConnectionTrait,
outing: &outings::Model,
keep: &HashSet<Uuid>,
roll_no: i32,
) -> Result<(), StatusCode> {
let people = going(db, outing.id).await?;
let people: Vec<&users::Model> = people.iter().collect();
let (_, candidates) = load_field(db, outing.area_id, &people, outing.on_date).await?;
let field = suggest::score(&candidates, people.len(), outing.on_date.and_time(outing.meal_time));
let shown: HashSet<Uuid> = outing_options::Entity::find()
.filter(outing_options::Column::OutingId.eq(outing.id))
.all(db)
.await
.map_err(internal("load options failed"))?
.into_iter()
.map(|o| o.restaurant_id)
.collect();
let need = (outing.option_count as usize).saturating_sub(keep.len());
let mut picks = suggest::draw_many(&field, need, &shown, rand::random::<f64>);
if picks.len() < need {
let mut exclude = keep.clone();
exclude.extend(picks.iter().map(|p| p.id));
picks.extend(suggest::draw_many(&field, need - picks.len(), &exclude, rand::random::<f64>));
}
let keep: Vec<Uuid> = keep.iter().copied().collect();
outing_options::Entity::update_many()
.col_expr(outing_options::Column::Active, Expr::value(false))
.filter(outing_options::Column::OutingId.eq(outing.id))
.filter(outing_options::Column::RestaurantId.is_not_in(keep.clone()))
.exec(db)
.await
.map_err(internal("retire options failed"))?;
outing_votes::Entity::delete_many()
.filter(outing_votes::Column::OutingId.eq(outing.id))
.filter(outing_votes::Column::RestaurantId.is_not_in(keep))
.exec(db)
.await
.map_err(internal("clear votes failed"))?;
if !picks.is_empty() {
outing_options::Entity::insert_many(picks.iter().map(|p| outing_options::ActiveModel {
outing_id: Set(outing.id),
restaurant_id: Set(p.id),
roll: Set(roll_no),
probability: Set(p.probability),
active: Set(true),
}))
.on_conflict(
OnConflict::columns([outing_options::Column::OutingId, outing_options::Column::RestaurantId])
.update_columns([
outing_options::Column::Roll,
outing_options::Column::Probability,
outing_options::Column::Active,
])
.to_owned(),
)
.exec_without_returning(db)
.await
.map_err(internal("insert options failed"))?;
}
Ok(())
}
pub async fn create_outing(
State(state): State<AppState>,
user: UserContext,
HxRequest(is_htmx): HxRequest,
Path(area_id): Path<Uuid>,
Form(pairs): Form<Vec<(String, String)>>,
) -> Result<Response, StatusCode> {
let db = state.db.as_ref();
access::require(db, area_id, user.user.id, Role::Voter).await?;
let fields = Fields(pairs);
let people: HashSet<Uuid> = area_people(db, area_id).await?.into_iter().map(|u| u.id).collect();
let mut attendees: Vec<Uuid> = fields.uuids("attendee").into_iter().filter(|u| people.contains(u)).collect();
if !attendees.contains(&user.user.id) {
attendees.push(user.user.id);
}
let txn = db.begin().await.map_err(internal("begin failed"))?;
let outing = outings::ActiveModel {
id: Set(Uuid::now_v7()),
area_id: Set(area_id),
on_date: Set(fields.one("start").and_then(|d| d.parse().ok()).unwrap_or_else(today)),
meal_time: Set(fields.time("time").unwrap_or_else(default_meal_time)),
status: Set(Status::Open),
option_count: Set(fields
.one("options")
.and_then(|n| n.parse().ok())
.unwrap_or(DEFAULT_OPTIONS)
.clamp(1, MAX_OPTIONS)),
rolls: Set(1),
lunch_id: Set(None),
created_by: Set(Some(user.user.id)),
created_at: Set(Utc::now().into()),
}
.insert(&txn)
.await
.map_err(internal("insert outing failed"))?;
for u in attendees {
join(&txn, outing.id, u).await?;
}
roll(&txn, &outing, &HashSet::new(), 1).await?;
txn.commit().await.map_err(internal("commit failed"))?;
let url = format!("/outings/{}", outing.id);
// Started from the planner's htmx form: a plain redirect would be
// followed by fetch and swapped into the plan box, so ask for a real
// navigation instead.
Ok(if is_htmx {
[("hx-redirect", url)].into_response()
} else {
Redirect::to(&url).into_response()
})
}
/// Everything the outing panel shows, from `viewer`'s point of view.
async fn panel_context(
db: &DatabaseConnection,
outing: &outings::Model,
viewer: &users::Model,
) -> Result<minijinja::Value, StatusCode> {
let area = load_area(db, outing.area_id).await?;
let people = going(db, outing.id).await?;
let names: HashMap<Uuid, &str> = people.iter().map(|u| (u.id, u.username.as_str())).collect();
let options = outing_options::Entity::find()
.filter(outing_options::Column::OutingId.eq(outing.id))
.all(db)
.await
.map_err(internal("load options failed"))?;
let rolled_away = options.iter().filter(|o| !o.active).count();
let options: Vec<outing_options::Model> = options.into_iter().filter(|o| o.active).collect();
let ids: Vec<Uuid> = options.iter().map(|o| o.restaurant_id).collect();
let places: HashMap<Uuid, restaurants::Model> = restaurants::Entity::find()
.filter(restaurants::Column::Id.is_in(ids.clone()))
.all(db)
.await
.map_err(internal("load restaurants failed"))?
.into_iter()
.map(|r| (r.id, r))
.collect();
// Voters needn't be going (they're joined when they vote, but may have
// left since), so name them from all users.
let voter_names: HashMap<Uuid, String> = users::Entity::find()
.all(db)
.await
.map_err(internal("load users failed"))?
.into_iter()
.map(|u| (u.id, u.username))
.collect();
let mut votes: HashMap<Uuid, Vec<String>> = HashMap::new();
for v in outing_votes::Entity::find()
.filter(outing_votes::Column::OutingId.eq(outing.id))
.all(db)
.await
.map_err(internal("load votes failed"))?
{
if let Some(n) = voter_names.get(&v.user_id) {
votes.entry(v.restaurant_id).or_default().push(n.clone());
}
}
// A Never from someone who joined after the roll isn't reflected in the
// draw; flag it rather than silently re-rolling under people.
let mut vetoes: HashMap<Uuid, Vec<&str>> = HashMap::new();
for r in rankings::Entity::find()
.filter(rankings::Column::RestaurantId.is_in(ids))
.filter(rankings::Column::UserId.is_in(names.keys().copied()))
.filter(rankings::Column::Rank.eq(RankKind::Never))
.all(db)
.await
.map_err(internal("load rankings failed"))?
{
vetoes.entry(r.restaurant_id).or_default().push(names[&r.user_id]);
}
let mut rows: Vec<(usize, f64, minijinja::Value)> = options
.iter()
.filter_map(|o| {
let place = places.get(&o.restaurant_id)?;
let mut voters = votes.remove(&o.restaurant_id).unwrap_or_default();
voters.sort_unstable();
let mine = voters.contains(&viewer.username);
let count = voters.len();
Some((
count,
o.probability,
context! {
restaurant => place,
probability => o.probability,
roll => o.roll,
voters => voters,
votes => count,
mine => mine,
vetoed_by => vetoes.get(&o.restaurant_id),
},
))
})
.collect();
rows.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)));
let leader_votes = rows.first().map_or(0, |r| r.0);
let options: Vec<minijinja::Value> = rows.into_iter().map(|r| r.2).collect();
let all_voted = !options.is_empty() && options.iter().all(|o| o.get_attr("votes").is_ok_and(|v| v.is_true()));
let decided = match outing.lunch_id {
Some(lunch_id) => crate::entities::lunches::Entity::find_by_id(lunch_id)
.find_also_related(restaurants::Entity)
.one(db)
.await
.map_err(internal("load lunch failed"))?
.and_then(|(_, r)| r),
None => None,
};
Ok(context! {
outing => outing,
area => area,
going => people.iter().map(|u| u.username.as_str()).collect::<Vec<_>>(),
am_going => names.contains_key(&viewer.id),
options => options,
leader_votes => leader_votes,
all_voted => all_voted,
rolled_away => rolled_away,
decided => decided,
})
}
async fn render_panel(state: &AppState, outing_id: Uuid, viewer: &users::Model) -> Result<Html<String>, StatusCode> {
let outing = load_outing(state.db.as_ref(), outing_id, viewer.id).await?;
let ctx = panel_context(state.db.as_ref(), &outing, viewer).await?;
render(state, "outings/_panel.html", ctx)
}
pub async fn show_outing(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let outing = load_outing(state.db.as_ref(), id, user.user.id).await?;
let panel = panel_context(state.db.as_ref(), &outing, &user.user).await?;
render(
&state,
"outings/show.html",
context! { title => format!("Lunch · {}", outing.on_date), user => user.user, ..panel },
)
}
pub async fn panel(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
render_panel(&state, id, &user.user).await
}
/// SSE: a `changed` event whenever this outing changes. The page re-fetches
/// the panel rather than taking it from the event, because the panel is
/// rendered per viewer ("Voted" vs "Vote").
pub async fn events(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, StatusCode> {
load_outing(state.db.as_ref(), id, user.user.id).await?;
let stream = BroadcastStream::new(state.live.subscribe()).filter_map(move |msg| match msg {
Ok(changed) if changed == id => Some(changed_event(id)),
Ok(_) => None,
// Lagged: some ids were dropped and one may have been ours.
Err(_) => Some(changed_event(id)),
});
// Ingress-nginx buffers responses by default, which would hold events
// until the buffer fills.
Ok(([("x-accel-buffering", "no")], Sse::new(stream).keep_alive(KeepAlive::default())))
}
/// The data is never read, but it must not be empty: browsers drop SSE
/// events whose data is empty, per the EventSource spec.
fn changed_event(outing_id: Uuid) -> Result<Event, Infallible> {
Ok(Event::default().event("changed").data(outing_id.to_string()))
}
/// Shared tail of every mutation: announce it, re-render for the actor.
async fn changed(state: &AppState, outing_id: Uuid, viewer: &users::Model) -> Result<Html<String>, StatusCode> {
live::notify(state.db.as_ref(), outing_id).await.map_err(internal("notify failed"))?;
render_panel(state, outing_id, viewer).await
}
pub async fn join_outing(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
load_open(state.db.as_ref(), id, user.user.id).await?;
join(state.db.as_ref(), id, user.user.id).await?;
changed(&state, id, &user.user).await
}
pub async fn leave_outing(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
load_open(db, id, user.user.id).await?;
outing_attendees::Entity::delete_many()
.filter(outing_attendees::Column::OutingId.eq(id))
.filter(outing_attendees::Column::UserId.eq(user.user.id))
.exec(db)
.await
.map_err(internal("leave outing failed"))?;
outing_votes::Entity::delete_many()
.filter(outing_votes::Column::OutingId.eq(id))
.filter(outing_votes::Column::UserId.eq(user.user.id))
.exec(db)
.await
.map_err(internal("clear votes failed"))?;
changed(&state, id, &user.user).await
}
#[derive(Debug, Deserialize)]
pub struct OptionForm {
pub restaurant_id: Uuid,
}
async fn active_option(db: &impl ConnectionTrait, outing_id: Uuid, restaurant_id: Uuid) -> Result<(), StatusCode> {
outing_options::Entity::find_by_id((outing_id, restaurant_id))
.filter(outing_options::Column::Active.eq(true))
.one(db)
.await
.map_err(internal("load option failed"))?
.map(|_| ())
.ok_or(StatusCode::NOT_FOUND)
}
/// Toggle the viewer's vote on one option. Voting means you're going.
pub async fn vote(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
Form(form): Form<OptionForm>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
load_open(db, id, user.user.id).await?;
active_option(db, id, form.restaurant_id).await?;
let key = (id, user.user.id, form.restaurant_id);
let removed = outing_votes::Entity::delete_by_id(key)
.exec(db)
.await
.map_err(internal("unvote failed"))?
.rows_affected;
if removed == 0 {
join(db, id, user.user.id).await?;
outing_votes::Entity::insert(outing_votes::ActiveModel {
outing_id: Set(id),
user_id: Set(user.user.id),
restaurant_id: Set(form.restaurant_id),
})
.on_conflict(
OnConflict::columns([
outing_votes::Column::OutingId,
outing_votes::Column::UserId,
outing_votes::Column::RestaurantId,
])
.do_nothing()
.to_owned(),
)
.exec_without_returning(db)
.await
.map_err(internal("vote failed"))?;
}
changed(&state, id, &user.user).await
}
/// Swap every option nobody has voted for. Drawn for whoever is going now,
/// so people who joined since the last roll count.
pub async fn reroll(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let txn = db.begin().await.map_err(internal("begin failed"))?;
let outing = load_open(&txn, id, user.user.id).await?;
let keep: HashSet<Uuid> = outing_votes::Entity::find()
.filter(outing_votes::Column::OutingId.eq(id))
.all(&txn)
.await
.map_err(internal("load votes failed"))?
.into_iter()
.map(|v| v.restaurant_id)
.collect();
let rolls = outing.rolls + 1;
roll(&txn, &outing, &keep, rolls).await?;
let mut active: outings::ActiveModel = outing.into();
active.rolls = Set(rolls);
active.update(&txn).await.map_err(internal("update outing failed"))?;
txn.commit().await.map_err(internal("commit failed"))?;
changed(&state, id, &user.user).await
}
/// Settle it: log the lunch for everyone going and close the outing.
pub async fn decide(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
Form(form): Form<OptionForm>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let outing = load_open(db, id, user.user.id).await?;
active_option(db, id, form.restaurant_id).await?;
let mut attendees: Vec<Uuid> = going(db, id).await?.into_iter().map(|u| u.id).collect();
if attendees.is_empty() {
attendees.push(user.user.id);
}
let lunch_id = record_lunch(db, form.restaurant_id, outing.on_date, &attendees, user.user.id).await?;
let mut active: outings::ActiveModel = outing.into();
active.status = Set(Status::Decided);
active.lunch_id = Set(Some(lunch_id));
active.update(db).await.map_err(internal("decide outing failed"))?;
changed(&state, id, &user.user).await
}
pub async fn cancel(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let mut active: outings::ActiveModel = load_open(db, id, user.user.id).await?.into();
active.status = Set(Status::Cancelled);
active.update(db).await.map_err(internal("cancel outing failed"))?;
changed(&state, id, &user.user).await
}
/// Open outings in an area, newest first, with how many are going — for the
/// area page's "lunch in progress" list.
pub async fn open_in_area(db: &DatabaseConnection, area_id: Uuid) -> Result<Vec<minijinja::Value>, StatusCode> {
let open = outings::Entity::find()
.filter(outings::Column::AreaId.eq(area_id))
.filter(outings::Column::Status.eq(Status::Open))
.order_by_desc(outings::Column::CreatedAt)
.all(db)
.await
.map_err(internal("list outings failed"))?;
let mut out = Vec::with_capacity(open.len());
for o in open {
let people = going(db, o.id).await?;
out.push(context! {
id => o.id,
on_date => o.on_date,
going => people.into_iter().map(|u| u.username).collect::<Vec<_>>(),
});
}
Ok(out)
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
20.8 KiB · text
History
aecd85b