20.3 KiB · text History 6dd7389
//! 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::{active_users, 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::{live, suggest, AppState};

pub const DEFAULT_OPTIONS: i32 = 3;
pub const MAX_OPTIONS: i32 = 6;

async fn load_outing(db: &impl ConnectionTrait, id: Uuid) -> Result<outings::Model, StatusCode> {
    outings::Entity::find_by_id(id)
        .one(db)
        .await
        .map_err(internal("load outing failed"))?
        .ok_or(StatusCode::NOT_FOUND)
}

/// Mutations only make sense while the outing is still being decided.
async fn load_open(db: &impl ConnectionTrait, id: Uuid) -> Result<outings::Model, StatusCode> {
    let outing = load_outing(db, 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();
    load_area(db, area_id).await?;
    let fields = Fields(pairs);
    let people: HashSet<Uuid> = active_users(db).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).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).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>,
) -> impl IntoResponse {
    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.
    ([("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).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).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).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).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).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).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)
}