5.2 KiB · text History 7a00c02
//! Map lookups, proxied to the osm-tiles APIs and returned as an htmx
//! fragment of candidates. The page's map plots them and a click on one fills
//! the page's `#place-form`.
use std::collections::HashMap;

use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Html;
use minijinja::context;
use sea_orm::*;
use serde::Deserialize;
use uuid::Uuid;
use tracing::warn;

use super::{internal, render};
use crate::access;
use crate::entities::area_members::Role;
use crate::entities::restaurants;
use crate::middleware::UserContext;
use crate::osm::Place;
use crate::AppState;

/// How far from an area's centre a search result may be. Generous enough
/// for a metro area, tight enough to drop other continents.
const SEARCH_RADIUS_M: f64 = 50_000.0;

#[derive(Debug, Deserialize)]
pub struct SearchQuery {
    pub q: String,
    pub lat: Option<f64>,
    pub lon: Option<f64>,
    /// "1" limits to eateries (adding a restaurant); anything else searches
    /// everything (picking an area's centre).
    pub food: Option<String>,
    /// The area being added to: marks candidates it already has.
    pub area: Option<Uuid>,
}

pub async fn search(
    State(state): State<AppState>,
    user: UserContext,
    Query(qs): Query<SearchQuery>,
) -> Result<Html<String>, StatusCode> {
    if let Some(area) = qs.area {
        access::require(state.db.as_ref(), area, user.user.id, Role::Voter).await?;
    }
    let q = qs.q.trim();
    let near = qs.lat.zip(qs.lon);
    let food = qs.food.as_deref() == Some("1");
    // An OSM id is an exact request: no eatery filter, no distance cut-off,
    // just that element (distance still shown when there's an area).
    if let Some((kind, id)) = crate::osm::parse_osm_id(q) {
        let (mut places, failed) = match state.osm.lookup(kind, id).await {
            Ok(p) => (p, false),
            Err(e) => {
                warn!(error = %e, "osm id lookup failed");
                (vec![], true)
            }
        };
        if let Some((lat, lon)) = near {
            crate::osm::by_distance(&mut places, lat, lon, None);
        }
        return candidates(&state, places, failed, true, qs.area).await;
    }

    let (mut places, failed) = if q.is_empty() {
        (vec![], false)
    } else {
        match state.osm.search(q, near, food).await {
            Ok(p) => (p, false),
            Err(e) => {
                warn!(error = %e, "photon search failed");
                (vec![], true)
            }
        }
    };
    // Searching from an area: nearest first, and nothing absurdly far —
    // Photon will happily offer a namesake on another continent. An area's
    // own centre search ("Austin") has no point to measure from.
    if let Some((lat, lon)) = near {
        crate::osm::by_distance(&mut places, lat, lon, Some(SEARCH_RADIUS_M));
    }
    // Area searches have no cuisine to find.
    if food {
        state.osm.enrich(&mut places).await;
    }
    candidates(&state, places, failed, true, qs.area).await
}

#[derive(Debug, Deserialize)]
pub struct NearbyQuery {
    pub lat: f64,
    pub lon: f64,
    pub area: Option<Uuid>,
}

pub async fn nearby(
    State(state): State<AppState>,
    user: UserContext,
    Query(qs): Query<NearbyQuery>,
) -> Result<Html<String>, StatusCode> {
    if let Some(area) = qs.area {
        access::require(state.db.as_ref(), area, user.user.id, Role::Voter).await?;
    }
    let (mut places, failed) = match state.osm.nearby(qs.lat, qs.lon, 150).await {
        Ok(p) => (p, false),
        Err(e) => {
            warn!(error = %e, "nearby lookup failed");
            (vec![], true)
        }
    };
    state.osm.enrich(&mut places).await;
    candidates(&state, places, failed, false, qs.area).await
}

/// Render the candidate list, marking places that are already restaurants
/// in `area` so nobody adds the same one twice. Only that area is checked:
/// other areas may be private, and the same place can be in several. `fit` zooms the map to the results:
/// wanted for a search, which can land anywhere, not for a click's nearby.
async fn candidates(
    state: &AppState,
    places: Vec<Place>,
    failed: bool,
    fit: bool,
    area: Option<Uuid>,
) -> Result<Html<String>, StatusCode> {
    let refs: Vec<String> = places.iter().filter_map(|p| p.osm_ref.clone()).collect();
    let existing: HashMap<String, uuid::Uuid> = match area {
        None => HashMap::new(),
        Some(_) if refs.is_empty() => HashMap::new(),
        Some(area) => restaurants::Entity::find()
            .filter(restaurants::Column::AreaId.eq(area))
            .filter(restaurants::Column::OsmRef.is_in(refs))
            .all(state.db.as_ref())
            .await
            .map_err(internal("load existing restaurants failed"))?
            .into_iter()
            .filter_map(|r| r.osm_ref.map(|o| (o, r.id)))
            .collect(),
    };
    let rows: Vec<minijinja::Value> = places
        .into_iter()
        .map(|p| {
            let existing_id = p.osm_ref.as_ref().and_then(|r| existing.get(r)).copied();
            context! { place => p.clone(), json => p, existing_id => existing_id }
        })
        .collect();
    render(state, "places/_candidates.html", context! { candidates => rows, failed => failed, fit => fit })
}