ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! Events pages (HTML). Generated by `ocre g scaffold Event name:string public_id:token user:references`,
//! then changed: an event belongs to the host who created it, only they can
//! edit or delete it, and its page is the room everyone with the link joins.
//! Queries and rules live in the model, `crate::models::event`.

use askama::Template;
use axum::{
    Form, Router,
    extract::{Path, State},
    http::{StatusCode, Uri},
    response::{Html, IntoResponse, Redirect, Response},
    routing::{get, post},
};
use ocre::{Ctx, Error, FieldError, Flash, OptionExt, Result, Session, render};
use serde::Deserialize;

use crate::auth::{CurrentUser, OptionalUser};
use crate::models::event::{self, Event, EventChanges, NewEvent};
use crate::models::question::{self, Question};
use crate::models::user::User;
use crate::questions::{self, QuestionForm};

pub fn routes() -> Router<Ctx> {
    Router::new()
        .route("/events", get(index).post(create))
        .route("/events/new", get(new))
        .route("/events/{id}", get(show).post(update))
        .route("/events/{id}/edit", get(edit))
        .route("/events/{id}/delete", post(delete))
}

/// Paths of the events pages (Rails' `events_path`, `event_path(record)`...).
/// Handlers redirect to them and templates link with them:
/// `<a href="{{ paths::show(event.public_id) }}">`; other modules call
/// `crate::events::paths::show(id)`.
pub mod paths {
    use std::fmt::Display;

    pub fn index() -> &'static str {
        "/events"
    }

    pub fn new() -> &'static str {
        "/events/new"
    }

    pub fn show(id: impl Display) -> String {
        format!("/events/{id}")
    }

    pub fn edit(id: impl Display) -> String {
        format!("/events/{id}/edit")
    }

    pub fn delete(id: impl Display) -> String {
        format!("/events/{id}/delete")
    }
}

/// The event of the `{id}` path segment, its public id: `Id(id, key)` is
/// the integer id the model functions take and the public id for links.
/// A 404 when no event has it.
struct Id(i64, String);

impl axum::extract::FromRequestParts<Ctx> for Id {
    type Rejection = ocre::Error;

    async fn from_request_parts(parts: &mut axum::http::request::Parts, ctx: &Ctx) -> Result<Self, Self::Rejection> {
        let Path(params) =
            Path::<Vec<(String, String)>>::from_request_parts(parts, ctx).await.map_err(|_| ocre::Error::NotFound)?;
        let key = params.into_iter().find(|(name, _)| name == "id").map(|(_, value)| value).or_404()?;
        let record = event::find_by_public_id(ctx, &key).await?.or_404()?;
        Ok(Self(record.id, key))
    }
}

/// The event `id`, when `user` hosts it: other users get a 403.
async fn hosted(ctx: &Ctx, user: &User, id: i64) -> Result<Event> {
    let record = event::find(ctx, id).await?.or_404()?;
    if record.user_id != user.id {
        return Err(Error::Forbidden);
    }
    Ok(record)
}

/// What the new and edit forms submit. The host is the signed-in user, never
/// a form field.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct EventForm {
    pub name: String,
}

impl EventForm {
    fn from_record(record: &Event) -> Self {
        Self { name: record.name.clone() }
    }

    /// Runs the model's checks, so the form shows every error at once
    /// (database checks run in `create`).
    fn to_new(&self, user_id: i64) -> Result<NewEvent> {
        let new = NewEvent { name: self.name.clone(), user_id };
        new.validate().finish()?;
        Ok(new)
    }

    fn to_changes(&self) -> Result<EventChanges> {
        let changes = EventChanges { name: Some(self.name.clone()), user_id: None };
        changes.validate().finish()?;
        Ok(changes)
    }
}

#[derive(Template)]
#[template(path = "events/index.html")]
struct IndexView {
    flash: Flash,
    events: Vec<Event>,
}

/// The room: the event, the ask form and the live question list.
#[derive(Template)]
#[template(path = "events/show.html")]
struct ShowView {
    flash: Flash,
    event: Event,
    /// The signed-in user hosts this event: moderation buttons and the
    /// host's channel.
    host: bool,
    /// The full link to share with the audience.
    link: String,
    questions: Vec<Question>,
    form: QuestionForm,
    errors: Vec<FieldError>,
}

impl ShowView {
    /// The channel this page listens to (src/realtime.rs).
    fn channel(&self) -> String {
        if self.host { questions::host_channel(&self.event) } else { questions::channel(&self.event) }
    }
}

#[derive(Template)]
#[template(path = "events/new.html")]
struct NewView {
    form: EventForm,
    errors: Vec<FieldError>,
}

#[derive(Template)]
#[template(path = "events/edit.html")]
struct EditView {
    /// The public id, for the form's action.
    id: String,
    form: EventForm,
    errors: Vec<FieldError>,
}

/// The events the signed-in user hosts, newest first.
async fn index(State(ctx): State<Ctx>, CurrentUser(user): CurrentUser, flash: Flash) -> Result<Html<String>> {
    render(&IndexView { flash, events: event::for_users(&ctx, &[user.id]).await? })
}

async fn show(
    State(ctx): State<Ctx>,
    flash: Flash,
    OptionalUser(user): OptionalUser,
    uri: Uri,
    Id(id, ..): Id,
) -> Result<Html<String>> {
    let record = event::find(&ctx, id).await?.or_404()?;
    room(&ctx, flash, &uri, record, user, QuestionForm::default(), vec![]).await
}

/// Renders the room of `event`; the ask form shows `form` and `errors`.
pub async fn room(
    ctx: &Ctx,
    flash: Flash,
    uri: &Uri,
    event: Event,
    user: Option<User>,
    form: QuestionForm,
    errors: Vec<FieldError>,
) -> Result<Html<String>> {
    let host = user.is_some_and(|user| user.id == event.user_id);
    let link = format!("{}{}", crate::auth::origin(uri), paths::show(&event.public_id));
    let questions = question::for_event(ctx, event.id).await?;
    render(&ShowView { flash, event, host, link, questions, form, errors })
}

async fn new(CurrentUser(_): CurrentUser) -> Result<Html<String>> {
    render(&NewView { form: EventForm::default(), errors: vec![] })
}

async fn create(
    State(ctx): State<Ctx>,
    CurrentUser(user): CurrentUser,
    session: Session,
    Form(form): Form<EventForm>,
) -> Result<Response> {
    let created = match form.to_new(user.id) {
        Ok(new) => event::create(&ctx, new).await,
        Err(err) => Err(err),
    };
    match created {
        Ok(record) => {
            session.flash("notice", "Your event is ready: share its link.")?;
            Ok(Redirect::to(&paths::show(&record.public_id)).into_response())
        }
        Err(Error::Invalid(errors)) => {
            Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&NewView { form, errors })?).into_response())
        }
        Err(err) => Err(err),
    }
}

async fn edit(State(ctx): State<Ctx>, CurrentUser(user): CurrentUser, Id(id, key): Id) -> Result<Html<String>> {
    let record = hosted(&ctx, &user, id).await?;
    render(&EditView { id: key, form: EventForm::from_record(&record), errors: vec![] })
}

async fn update(
    State(ctx): State<Ctx>,
    CurrentUser(user): CurrentUser,
    session: Session,
    Id(id, key): Id,
    Form(form): Form<EventForm>,
) -> Result<Response> {
    hosted(&ctx, &user, id).await?;
    let updated = match form.to_changes() {
        Ok(changes) => event::update(&ctx, id, changes).await,
        Err(err) => Err(err),
    };
    match updated {
        Ok(record) => {
            let record = record.or_404()?;
            session.flash("notice", "Event was successfully updated.")?;
            Ok(Redirect::to(&paths::show(&record.public_id)).into_response())
        }
        Err(Error::Invalid(errors)) => {
            Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&EditView { id: key, form, errors })?).into_response())
        }
        Err(err) => Err(err),
    }
}

async fn delete(
    State(ctx): State<Ctx>,
    CurrentUser(user): CurrentUser,
    session: Session,
    Id(id, ..): Id,
) -> Result<Redirect> {
    hosted(&ctx, &user, id).await?;
    if !event::delete(&ctx, id).await? {
        return Err(Error::NotFound);
    }
    session.flash("notice", "Event was successfully destroyed.")?;
    Ok(Redirect::to(paths::index()))
}