//! Areas: the regions restaurants are grouped under — the office, or a city
//! someone is travelling to. Everything else (the map, suggestions, history)
//! happens inside one, and only its members can see it (`crate::access`).
use std::collections::HashMap;
use axum::extract::{Form, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, Redirect};
use chrono::{NaiveDate, Utc};
use minijinja::context;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{internal, non_empty, render};
use crate::access;
use crate::entities::area_members::Role;
use crate::entities::rankings::Rank;
use crate::entities::{areas, lunches, rankings, restaurants};
use crate::middleware::UserContext;
use crate::AppState;
pub(crate) async fn load_area(db: &DatabaseConnection, id: Uuid) -> Result<areas::Model, StatusCode> {
areas::Entity::find_by_id(id)
.one(db)
.await
.map_err(internal("load area failed"))?
.ok_or(StatusCode::NOT_FOUND)
}
/// The area, if `user_id` holds at least `min` in it; see [`access::require`].
pub(crate) async fn authorize(
db: &DatabaseConnection,
area_id: Uuid,
user_id: Uuid,
min: Role,
) -> Result<(areas::Model, Role), StatusCode> {
let role = access::require(db, area_id, user_id, min).await?;
Ok((load_area(db, area_id).await?, role))
}
#[derive(Serialize)]
pub(crate) struct AreaRow {
area: areas::Model,
role: Role,
restaurants: u64,
}
/// The areas `user_id` is a member of, by name.
pub async fn my_areas(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<areas::Model>, StatusCode> {
Ok(access::member_areas(db, user_id)
.await
.map_err(internal("list areas failed"))?
.into_iter()
.map(|(a, _)| a)
.collect())
}
/// Your areas with your role and how many restaurants each has in rotation.
pub(crate) async fn area_rows(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<AreaRow>, StatusCode> {
let mine = access::member_areas(db, user_id).await.map_err(internal("list areas failed"))?;
let mut counts: HashMap<Uuid, u64> = HashMap::new();
for r in restaurants::Entity::find()
.filter(restaurants::Column::Archived.eq(false))
.filter(restaurants::Column::AreaId.is_in(mine.iter().map(|(a, _)| a.id)))
.all(db)
.await
.map_err(internal("count restaurants failed"))?
{
*counts.entry(r.area_id).or_default() += 1;
}
Ok(mine
.into_iter()
.map(|(a, role)| AreaRow { restaurants: counts.get(&a.id).copied().unwrap_or(0), area: a, role })
.collect())
}
pub async fn list_areas(
State(state): State<AppState>,
user: UserContext,
) -> Result<Html<String>, StatusCode> {
let rows = area_rows(state.db.as_ref(), user.user.id).await?;
render(&state, "areas/list.html", context! { title => "Areas", user => user.user, areas => rows })
}
pub async fn new_area(
State(state): State<AppState>,
user: UserContext,
) -> Result<Html<String>, StatusCode> {
render(&state, "areas/new.html", context! { title => "New area", user => user.user })
}
#[derive(Debug, Deserialize)]
pub struct AreaForm {
pub name: String,
pub lat: f64,
pub lon: f64,
pub zoom: Option<i32>,
}
pub async fn create_area(
State(state): State<AppState>,
user: UserContext,
Form(form): Form<AreaForm>,
) -> Result<Redirect, StatusCode> {
let name = non_empty(Some(form.name)).ok_or(StatusCode::BAD_REQUEST)?;
if !(-90.0..=90.0).contains(&form.lat) || !(-180.0..=180.0).contains(&form.lon) {
return Err(StatusCode::BAD_REQUEST);
}
let db = state.db.as_ref();
// Re-submitting the name of one of your own areas just goes to it.
if let Some(existing) = my_areas(db, user.user.id).await?.into_iter().find(|a| a.name == name) {
return Ok(Redirect::to(&format!("/areas/{}", existing.id)));
}
let id = Uuid::now_v7();
let txn = db.begin().await.map_err(internal("begin failed"))?;
areas::ActiveModel {
id: Set(id),
name: Set(name),
lat: Set(form.lat),
lon: Set(form.lon),
zoom: Set(form.zoom.unwrap_or(15).clamp(3, 19)),
created_by: Set(Some(user.user.id)),
created_at: Set(Utc::now().into()),
}
.insert(&txn)
.await
.map_err(internal("insert area failed"))?;
access::grant(&txn, id, user.user.id, Role::Admin).await.map_err(internal("grant admin failed"))?;
txn.commit().await.map_err(internal("commit failed"))?;
Ok(Redirect::to(&format!("/areas/{id}")))
}
/// One restaurant as listed in its area, from the viewer's point of view.
#[derive(Serialize)]
pub(crate) struct RestaurantRow {
#[serde(flatten)]
restaurant: restaurants::Model,
my_rank: Option<Rank>,
my_since: Option<NaiveDate>,
/// Your "to try" here has been tried; time to rank it properly.
tried: bool,
cuisines: Vec<String>,
added_by: Option<String>,
visits: usize,
last_visit: Option<NaiveDate>,
/// Open at noon today, by its OSM hours, if it has any.
lunch_today: Option<crate::hours::State>,
}
/// One-shot notices after a redirect back to the area page.
#[derive(Debug, Deserialize)]
pub struct AreaNotice {
/// How many restaurants a refresh from OSM changed.
pub refreshed: Option<usize>,
/// "taken": a rename collided with another of your areas' names.
pub error: Option<String>,
}
/// An area as one member sees it: their role, and every restaurant with
/// their rank, its history and whether it's open at noon today. Shared by
/// the area page and the app's API.
#[derive(Serialize)]
pub(crate) struct AreaView {
pub area: areas::Model,
pub role: Role,
pub member_count: usize,
pub osm_linked: usize,
pub restaurants: Vec<RestaurantRow>,
pub open_outings: Vec<minijinja::Value>,
}
pub(crate) async fn area_view(db: &DatabaseConnection, id: Uuid, user_id: Uuid) -> Result<AreaView, StatusCode> {
let (area, role) = authorize(db, id, user_id, Role::Voter).await?;
let members = access::members(db, id).await.map_err(internal("list members failed"))?;
let names: HashMap<Uuid, &str> = members.iter().map(|(u, _)| (u.id, u.username.as_str())).collect();
let list = restaurants::Entity::find()
.filter(restaurants::Column::AreaId.eq(id))
.order_by_asc(restaurants::Column::Archived)
.order_by_asc(restaurants::Column::Name)
.all(db)
.await
.map_err(internal("list restaurants failed"))?;
let ids: Vec<Uuid> = list.iter().map(|r| r.id).collect();
let my_rows = rankings::Entity::find()
.filter(rankings::Column::UserId.eq(user_id))
.filter(rankings::Column::RestaurantId.is_in(ids.clone()))
.all(db)
.await
.map_err(internal("load rankings failed"))?;
let tried = super::lunch::tried(db, &my_rows).await?;
let my_ranks: HashMap<Uuid, Rank> = my_rows.into_iter().map(|r| (r.restaurant_id, r.rank())).collect();
let mut history: HashMap<Uuid, Vec<NaiveDate>> = HashMap::new();
for l in lunches::Entity::find()
.filter(lunches::Column::RestaurantId.is_in(ids))
.all(db)
.await
.map_err(internal("load lunches failed"))?
{
history.entry(l.restaurant_id).or_default().push(l.went_on);
}
let noon = super::lunch::today().and_time(super::lunch::default_meal_time());
let restaurants: Vec<RestaurantRow> = list
.into_iter()
.map(|r| {
let visits = history.get(&r.id);
RestaurantRow {
my_rank: my_ranks.get(&r.id).copied(),
my_since: my_ranks.get(&r.id).and_then(|k| k.since()),
tried: tried.contains(&(user_id, r.id)),
cuisines: r.cuisine_tags(),
added_by: r.added_by.and_then(|u| names.get(&u)).map(|n| (*n).to_owned()),
visits: visits.map_or(0, Vec::len),
last_visit: visits.and_then(|v| v.iter().max().copied()),
lunch_today: r.opening_hours.as_deref().map(|h| crate::hours::state_at(h, noon)),
restaurant: r,
}
})
.collect();
Ok(AreaView {
open_outings: super::outings::open_in_area(db, id).await?,
osm_linked: restaurants.iter().filter(|r| r.restaurant.osm_ref.is_some()).count(),
member_count: members.len(),
area,
role,
restaurants,
})
}
pub async fn show_area(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
Query(notice): Query<AreaNotice>,
) -> Result<Html<String>, StatusCode> {
let view = area_view(state.db.as_ref(), id, user.user.id).await?;
let markers: Vec<minijinja::Value> = view
.restaurants
.iter()
.filter(|r| !r.restaurant.archived)
.map(|r| {
context! {
name => r.restaurant.name,
lat => r.restaurant.lat,
lon => r.restaurant.lon,
rank => r.my_rank,
url => format!("/restaurants/{}", r.restaurant.id),
}
})
.collect();
render(
&state,
"areas/show.html",
context! {
title => view.area.name.clone(),
user => user.user,
refreshed => notice.refreshed,
error => notice.error,
markers => markers,
..minijinja::Value::from_serialize(&view)
},
)
}
#[derive(Debug, Deserialize)]
pub struct RenameForm {
pub name: String,
}
pub async fn rename_area(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
Form(form): Form<RenameForm>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let name = non_empty(Some(form.name)).ok_or(StatusCode::BAD_REQUEST)?;
let (area, _) = authorize(db, id, user.user.id, Role::Admin).await?;
if name == area.name {
return Ok(Redirect::to(&format!("/areas/{id}")));
}
// Two of your own areas with one name would be indistinguishable in your
// list; other people's areas don't matter (and aren't revealed).
let taken = my_areas(db, user.user.id).await?.iter().any(|a| a.name == name);
if taken {
return Ok(Redirect::to(&format!("/areas/{id}?error=taken")));
}
let mut active: areas::ActiveModel = area.into();
active.name = Set(name);
active.update(db).await.map_err(internal("rename area failed"))?;
Ok(Redirect::to(&format!("/areas/{id}")))
}
/// Refresh every OSM-linked restaurant in the area (hours, and any blank
/// cuisine/website/address) — how restaurants added before hours were
/// tracked pick them up.
pub async fn refresh_area(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
authorize(db, id, user.user.id, Role::Editor).await?;
let list = restaurants::Entity::find()
.filter(restaurants::Column::AreaId.eq(id))
.filter(restaurants::Column::OsmRef.is_not_null())
.all(db)
.await
.map_err(internal("list restaurants failed"))?;
let changed = super::restaurants::refresh_from_osm(db, &state.osm, list).await?;
Ok(Redirect::to(&format!("/areas/{id}?refreshed={changed}")))
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
11.1 KiB · text
History
02765fe