lariv-rs 0.1.0

Compile-time plugin web application framework built on Axum, SeaORM, Maud, and HTMX
Documentation
use axum::{
    extract::{Path, Query},
    http::Uri,
    response::{IntoResponse, Redirect, Response},
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, PaginatorTrait};

use crate::{
    components::{ObjectList, SharedChromeFolder, SlotCtx, SwapKey},
    html_form::HtmlFormBody,
    http::Cap,
    picker::respond_picker_select,
    plugins::users::{middleware::RequireAuth, state::AuthContext},
    template::RenderAppPane,
    web::{
        Htmx, QueryPage, QueryPageSize, html_built_page_or_app_layout, html_built_page_with_slots,
        respond_create_modal_done_fk, respond_edit_modal_done,
    },
};

use crate::plugins::crm::{
    entities::company::{self, Entity as CompanyEntity},
    forms::CompanyForm,
    handlers::ModalNameQuery,
    keys::{
        CompanyCreateModalKey, CompanyDeleteModalKey, CompanyEditModalKey, CompanySelectModalKey,
        CompanySelectTableKey, CompanyTableKey,
    },
    routes::CompanyDetailRouteTag,
    scope::{apply_company_filters, apply_company_sort, find_company_scoped, scope_superuser},
    state::CrmState,
    templates::{
        CompanyCreateModalPage, CompanyDetailPage, CompanyEditModalPage, CompanyListPage,
        CompanyRow, CompanySelectPage, ConfirmDeletePage,
    },
};

#[derive(Debug, serde::Deserialize, Default)]
pub struct CompanyListQuery {
    #[serde(default, rename = "Name", alias = "name")]
    pub name: Option<String>,
    #[serde(default)]
    pub sort: Option<String>,
    #[serde(default)]
    pub page: QueryPage,
    #[serde(default)]
    pub page_size: QueryPageSize,
}

#[derive(Debug, serde::Deserialize, Default)]
pub struct CompanySelectQuery {
    #[serde(flatten)]
    pub filter: CompanyListQuery,
    #[serde(default)]
    pub target_input: Option<String>,
}

fn path_and_query(uri: &Uri) -> String {
    uri.path_and_query()
        .map(|pq| pq.as_str().to_string())
        .unwrap_or_else(|| uri.path().to_string())
}

fn opt_string(s: String) -> Option<String> {
    if s.trim().is_empty() { None } else { Some(s) }
}

async fn query_companies(
    db: &sea_orm::DatabaseConnection,
    q: &CompanyListQuery,
    auth: &AuthContext,
    page_size: u32,
) -> (Vec<company::Model>, u32, u64) {
    let mut query = CompanyEntity::find();
    query = apply_company_filters(query, q.name.as_deref());
    query = scope_superuser(query, auth);
    query = apply_company_sort(query, q.sort.as_deref());
    let page = q.page.get();
    let paginator = query.paginate(db, page_size as u64);
    let total = paginator.num_items().await.unwrap_or(0);
    let models = paginator
        .fetch_page((page as u64).saturating_sub(1))
        .await
        .unwrap_or_default();
    (models, page, total)
}

fn model_to_row(c: company::Model) -> CompanyRow {
    CompanyRow {
        id: c.id,
        name: c.name,
        website: c.website.unwrap_or_default(),
    }
}

async fn load_company_rows(
    db: &sea_orm::DatabaseConnection,
    q: &CompanyListQuery,
    auth: &AuthContext,
    page_size: u32,
) -> ObjectList<CompanyRow> {
    let (models, page, total) = query_companies(db, q, auth, page_size).await;
    let rows = models.into_iter().map(model_to_row).collect();
    ObjectList::from_page(rows, page, page_size, total)
}

pub async fn list(
    Cap(state): Cap<CrmState>,
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    htmx: Htmx,
    uri: Uri,
    Query(q): Query<CompanyListQuery>,
) -> maud::Markup {
    let companies = load_company_rows(&state.db, &q, &ctx, q.page_size.get()).await;
    let page = CompanyListPage {
        companies,
        filter_name: q.name.clone().unwrap_or_default(),
        sort: q.sort.clone().unwrap_or_default(),
        path_and_query: path_and_query(&uri),
        can_edit: ctx.user.is_superuser,
        page_size: q.page_size.get(),
    };
    let slot_ctx = SlotCtx::from_auth(&ctx);
    if htmx.targets::<CompanyTableKey>() {
        return page.render_table();
    }
    if htmx.wants_main_content() {
        return page.render_main().into();
    }
    if htmx.wants_app_layout() {
        return page.render_pane().into();
    }
    html_built_page_with_slots(&page, &chrome, &slot_ctx)
}

pub async fn detail(
    Cap(state): Cap<CrmState>,
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    htmx: Htmx,
    Path(id): Path<i64>,
) -> Response {
    let Some(company) = find_company_scoped(&state.db, id, &ctx).await else {
        return Redirect::to("/crm/companies").into_response();
    };
    let page = CompanyDetailPage {
        id: company.id,
        name: company.name,
        address_line_1: company.address_line_1.unwrap_or_default(),
        address_line_2: company.address_line_2.unwrap_or_default(),
        city: company.city.unwrap_or_default(),
        pincode: company.pincode.unwrap_or_default(),
        state: company.state.unwrap_or_default(),
        website: company.website.unwrap_or_default(),
        can_edit: ctx.user.is_superuser,
    };
    html_built_page_or_app_layout(&page, &htmx, &chrome, &SlotCtx::from_auth(&ctx)).into_response()
}

pub async fn create_get(
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    Query(q): Query<ModalNameQuery>,
) -> maud::Markup {
    if !ctx.user.is_superuser {
        return maud::html! { div class="alert alert-error" { "Forbidden" } };
    }
    let page = CompanyCreateModalPage {
        form_name: q.form_name(),
        refresh_table: q.refresh_table(),
        target_input: q.target_input(),
        name: String::new(),
        address_line_1: String::new(),
        address_line_2: String::new(),
        city: String::new(),
        pincode: String::new(),
        state: String::new(),
        website: String::new(),
        error: String::new(),
    };
    html_built_page_with_slots(&page, &chrome, &SlotCtx::from_auth(&ctx))
}

pub async fn create_post(
    Cap(state): Cap<CrmState>,
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    htmx: Htmx,
    Query(q): Query<ModalNameQuery>,
    HtmlFormBody(form): HtmlFormBody<CompanyForm>,
) -> Response {
    if !ctx.user.is_superuser {
        return Redirect::to("/crm/companies").into_response();
    }
    let now = Utc::now();
    let model = company::ActiveModel {
        id: Default::default(),
        created_at: Set(Some(now)),
        updated_at: Set(Some(now)),
        name: Set(form.name.clone()),
        address_line_1: Set(opt_string(form.address_line_1.clone())),
        address_line_2: Set(opt_string(form.address_line_2.clone())),
        city: Set(opt_string(form.city.clone())),
        pincode: Set(opt_string(form.pincode.clone())),
        state: Set(opt_string(form.state.clone())),
        website: Set(opt_string(form.website.clone())),
    };
    match model.insert(&state.db).await {
        Ok(saved) => respond_create_modal_done_fk::<CompanyCreateModalKey>(
            &htmx,
            &q.refresh_table(),
            &CompanyDetailRouteTag::new(saved.id).url(),
            saved.id,
            &saved.name,
            &q.target_input(),
        ),
        Err(e) => {
            let page = CompanyCreateModalPage {
                form_name: q.form_name(),
                refresh_table: q.refresh_table(),
                target_input: q.target_input(),
                name: form.name,
                address_line_1: form.address_line_1,
                address_line_2: form.address_line_2,
                city: form.city,
                pincode: form.pincode,
                state: form.state,
                website: form.website,
                error: e.to_string(),
            };
            html_built_page_with_slots(&page, &chrome, &SlotCtx::from_auth(&ctx)).into_response()
        }
    }
}

pub async fn edit_get(
    Cap(state): Cap<CrmState>,
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    Path(id): Path<i64>,
    Query(q): Query<ModalNameQuery>,
) -> Response {
    if !ctx.user.is_superuser {
        return Redirect::to("/crm/companies").into_response();
    }
    let Some(company) = find_company_scoped(&state.db, id, &ctx).await else {
        return Redirect::to("/crm/companies").into_response();
    };
    let page = CompanyEditModalPage {
        id: company.id,
        form_name: q.form_name(),
        name: company.name,
        address_line_1: company.address_line_1.unwrap_or_default(),
        address_line_2: company.address_line_2.unwrap_or_default(),
        city: company.city.unwrap_or_default(),
        pincode: company.pincode.unwrap_or_default(),
        state: company.state.unwrap_or_default(),
        website: company.website.unwrap_or_default(),
        error: String::new(),
    };
    html_built_page_with_slots(&page, &chrome, &SlotCtx::from_auth(&ctx)).into_response()
}

pub async fn edit_post(
    Cap(state): Cap<CrmState>,
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    htmx: Htmx,
    Path(id): Path<i64>,
    Query(q): Query<ModalNameQuery>,
    HtmlFormBody(form): HtmlFormBody<CompanyForm>,
) -> Response {
    if !ctx.user.is_superuser {
        return Redirect::to("/crm/companies").into_response();
    }
    let Some(existing) = find_company_scoped(&state.db, id, &ctx).await else {
        return Redirect::to("/crm/companies").into_response();
    };
    let now = Utc::now();
    let mut am: company::ActiveModel = existing.into();
    am.updated_at = Set(Some(now));
    am.name = Set(form.name.clone());
    am.address_line_1 = Set(opt_string(form.address_line_1.clone()));
    am.address_line_2 = Set(opt_string(form.address_line_2.clone()));
    am.city = Set(opt_string(form.city.clone()));
    am.pincode = Set(opt_string(form.pincode.clone()));
    am.state = Set(opt_string(form.state.clone()));
    am.website = Set(opt_string(form.website.clone()));
    match am.update(&state.db).await {
        Ok(_) => respond_edit_modal_done::<CompanyEditModalKey>(
            &htmx,
            &CompanyDetailRouteTag::new(id).url(),
        ),
        Err(e) => {
            let page = CompanyEditModalPage {
                id,
                form_name: q.form_name(),
                name: form.name,
                address_line_1: form.address_line_1,
                address_line_2: form.address_line_2,
                city: form.city,
                pincode: form.pincode,
                state: form.state,
                website: form.website,
                error: e.to_string(),
            };
            html_built_page_with_slots(&page, &chrome, &SlotCtx::from_auth(&ctx)).into_response()
        }
    }
}

pub async fn delete_get(
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    Query(q): Query<ModalNameQuery>,
    Path(id): Path<i64>,
) -> maud::Markup {
    let page = ConfirmDeletePage {
        modal_uid: CompanyDeleteModalKey::ID.to_string(),
        message: "Are you sure you want to delete this company?".into(),
        form_name: q
            .name
            .clone()
            .unwrap_or_else(|| "p_crm.CompanyDeleteForm".into()),
        id,
        error: String::new(),
    };
    html_built_page_with_slots(&page, &chrome, &SlotCtx::from_auth(&ctx))
}

pub async fn delete_post(
    Cap(state): Cap<CrmState>,
    Cap(chrome): Cap<SharedChromeFolder>,
    RequireAuth(ctx): RequireAuth,
    htmx: Htmx,
    Path(id): Path<i64>,
) -> Response {
    if !ctx.user.is_superuser {
        return Redirect::to("/crm/companies").into_response();
    }
    match CompanyEntity::delete_by_id(id).exec(&state.db).await {
        Ok(_) => htmx.redirect("/crm/companies"),
        Err(e) => {
            tracing::error!(error = %e, id, "failed to delete company");
            let page = ConfirmDeletePage {
                modal_uid: CompanyDeleteModalKey::ID.to_string(),
                message: "Are you sure you want to delete this company?".into(),
                form_name: "p_crm.CompanyDeleteForm".into(),
                id,
                error: e.to_string(),
            };
            html_built_page_with_slots(&page, &chrome, &SlotCtx::from_auth(&ctx)).into_response()
        }
    }
}

pub async fn select(
    Cap(state): Cap<CrmState>,
    RequireAuth(ctx): RequireAuth,
    htmx: Htmx,
    uri: Uri,
    Query(q): Query<CompanySelectQuery>,
) -> maud::Markup {
    let companies = load_company_rows(&state.db, &q.filter, &ctx, q.filter.page_size.get()).await;
    let page = CompanySelectPage {
        companies,
        filter_name: q.filter.name.clone().unwrap_or_default(),
        sort: q.filter.sort.clone().unwrap_or_default(),
        path_and_query: path_and_query(&uri),
        target_input: q.target_input.clone().unwrap_or_else(|| "CompanyID".into()),
        can_edit: ctx.user.is_superuser,
        page_size: q.filter.page_size.get(),
    };
    respond_picker_select::<CompanySelectTableKey, CompanySelectModalKey, _>(&htmx, &page)
}