22.4 KiB · text History 7a00c02
//! 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.
pub(crate) 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(())
}

/// Start an outing in `area_id` as `user_id` (who is always going), with
/// whichever of `attendees` are members, and draw the first ballot.
pub(crate) async fn start(
    db: &DatabaseConnection,
    area_id: Uuid,
    user_id: Uuid,
    attendees: Vec<Uuid>,
    options: Option<i32>,
    on: Option<chrono::NaiveDate>,
    at: Option<chrono::NaiveTime>,
) -> Result<Uuid, StatusCode> {
    access::require(db, area_id, user_id, Role::Voter).await?;
    let people: HashSet<Uuid> = area_people(db, area_id).await?.into_iter().map(|u| u.id).collect();
    let mut attendees: Vec<Uuid> = attendees.into_iter().filter(|u| people.contains(u)).collect();
    if !attendees.contains(&user_id) {
        attendees.push(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(on.unwrap_or_else(today)),
        meal_time: Set(at.unwrap_or_else(default_meal_time)),
        status: Set(Status::Open),
        option_count: Set(options.unwrap_or(DEFAULT_OPTIONS).clamp(1, MAX_OPTIONS)),
        rolls: Set(1),
        lunch_id: Set(None),
        created_by: Set(Some(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"))?;
    Ok(outing.id)
}

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 fields = Fields(pairs);
    let id = start(
        state.db.as_ref(),
        area_id,
        user.user.id,
        fields.uuids("attendee"),
        fields.one("options").and_then(|n| n.parse().ok()),
        fields.one("start").and_then(|d| d.parse().ok()),
        fields.time("time"),
    )
    .await?;

    let url = format!("/outings/{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.
pub(crate) 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()))
}

/// Something a member does to an open outing. One implementation behind
/// both the web buttons and the app's API, so they can't drift.
#[derive(Debug, Clone, Copy)]
pub(crate) enum Action {
    Join,
    Leave,
    /// Toggle your vote on an option. Voting means you're going.
    Vote(Uuid),
    /// Swap every option nobody has voted for, drawn for whoever is going
    /// now, so people who joined since the last roll count.
    Reroll,
    /// Settle it: log the lunch for everyone going and close the outing.
    Decide(Uuid),
    Cancel,
}

/// Apply `action` as `user_id` and announce the change to everyone watching.
pub(crate) async fn act(db: &DatabaseConnection, id: Uuid, user_id: Uuid, action: Action) -> Result<(), StatusCode> {
    match action {
        Action::Join => {
            load_open(db, id, user_id).await?;
            join(db, id, user_id).await?;
        }
        Action::Leave => {
            load_open(db, id, user_id).await?;
            outing_attendees::Entity::delete_many()
                .filter(outing_attendees::Column::OutingId.eq(id))
                .filter(outing_attendees::Column::UserId.eq(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_id))
                .exec(db)
                .await
                .map_err(internal("clear votes failed"))?;
        }
        Action::Vote(restaurant_id) => {
            load_open(db, id, user_id).await?;
            active_option(db, id, restaurant_id).await?;
            let removed = outing_votes::Entity::delete_by_id((id, user_id, restaurant_id))
                .exec(db)
                .await
                .map_err(internal("unvote failed"))?
                .rows_affected;
            if removed == 0 {
                join(db, id, user_id).await?;
                outing_votes::Entity::insert(outing_votes::ActiveModel {
                    outing_id: Set(id),
                    user_id: Set(user_id),
                    restaurant_id: Set(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"))?;
            }
        }
        Action::Reroll => {
            let txn = db.begin().await.map_err(internal("begin failed"))?;
            let outing = load_open(&txn, id, 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"))?;
        }
        Action::Decide(restaurant_id) => {
            let outing = load_open(db, id, user_id).await?;
            active_option(db, id, 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_id);
            }
            let lunch_id = record_lunch(db, restaurant_id, outing.on_date, &attendees, 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"))?;
        }
        Action::Cancel => {
            let mut active: outings::ActiveModel = load_open(db, id, user_id).await?.into();
            active.status = Set(Status::Cancelled);
            active.update(db).await.map_err(internal("cancel outing failed"))?;
        }
    }
    live::notify(db, id).await.map_err(internal("notify failed"))
}

#[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)
}

/// htmx: apply an action and return the fresh panel.
async fn act_and_render(state: &AppState, id: Uuid, viewer: &users::Model, action: Action) -> Result<Html<String>, StatusCode> {
    act(state.db.as_ref(), id, viewer.id, action).await?;
    render_panel(state, id, viewer).await
}

pub async fn join_outing(State(state): State<AppState>, user: UserContext, Path(id): Path<Uuid>) -> Result<Html<String>, StatusCode> {
    act_and_render(&state, id, &user.user, Action::Join).await
}

pub async fn leave_outing(State(state): State<AppState>, user: UserContext, Path(id): Path<Uuid>) -> Result<Html<String>, StatusCode> {
    act_and_render(&state, id, &user.user, Action::Leave).await
}

pub async fn vote(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
    Form(form): Form<OptionForm>,
) -> Result<Html<String>, StatusCode> {
    act_and_render(&state, id, &user.user, Action::Vote(form.restaurant_id)).await
}

pub async fn reroll(State(state): State<AppState>, user: UserContext, Path(id): Path<Uuid>) -> Result<Html<String>, StatusCode> {
    act_and_render(&state, id, &user.user, Action::Reroll).await
}

pub async fn decide(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
    Form(form): Form<OptionForm>,
) -> Result<Html<String>, StatusCode> {
    act_and_render(&state, id, &user.user, Action::Decide(form.restaurant_id)).await
}

pub async fn cancel(State(state): State<AppState>, user: UserContext, Path(id): Path<Uuid>) -> Result<Html<String>, StatusCode> {
    act_and_render(&state, id, &user.user, Action::Cancel).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)
}