Files
komp_ac/web/src/pages/admin/table_definition/logic.rs

604 lines
21 KiB
Rust

//! The request handlers behind the five table-definition pages.
//!
//! Each page has a GET that renders it and, except for the read-only history,
//! a POST per write it offers. A write answers with its own page's body,
//! re-read from the backend, so what the user sees after a change is the
//! definition as it now is rather than the form they submitted. A refused
//! write answers the same way but with 422 and the backend's own message,
//! which `ui/base.html` swaps in because a 4xx still carries the explanation.
//!
//! Two exceptions. Staging a column changes nothing on the server, so those
//! interactions swap only the column panel. And a successful delete leaves no
//! page to return to — the table it was about is gone — so it redirects to the
//! admin panel instead.
use axum::{
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
use axum_extra::extract::Form;
use crate::{
AppState,
definitions::table_definition::{
AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest,
DeleteTableRequest, RenameColumnAliasRequest,
},
schema::{ColumnForm, proto_columns},
services::{authenticated_request, reject_cross_site},
};
use super::{
loader::{self, load_page},
state::{
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
RenameForm, Selection, TableDefinitionPageState,
},
ui,
};
/// Which page a response belongs to: what the switcher marks as current, and
/// which fragment a write on it swaps back.
#[derive(Clone, Copy)]
enum Page {
Columns,
Delete,
Copy,
Template,
History,
}
impl Page {
fn name(self) -> &'static str {
match self {
Self::Columns => "columns",
Self::Delete => "delete",
Self::Copy => "copy",
Self::Template => "template",
Self::History => "history",
}
}
fn render_page(self, page: &TableDefinitionPageState) -> String {
match self {
Self::Columns => ui::render_columns_page(page),
Self::Delete => ui::render_delete_page(page),
Self::Copy => ui::render_copy_page(page),
Self::Template => ui::render_template_page(page),
Self::History => ui::render_history_page(page),
}
}
/// The `#table-panel` swap. History has no writes, so it never asks.
fn render_fragment(self, page: &TableDefinitionPageState) -> String {
match self {
Self::Columns => ui::render_columns_fragment(page),
Self::Delete => ui::render_delete_fragment(page),
Self::Copy => ui::render_copy_fragment(page),
Self::Template => ui::render_template_fragment(page),
Self::History => ui::render_history_page(page),
}
}
}
// ── The pages ───────────────────────────────────────────────────────────────
/// GET /admin/tables/columns
pub(crate) async fn columns_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, PageInputs::for_selection(selection), Page::Columns).await
}
/// GET /admin/tables/delete
pub(crate) async fn delete_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, PageInputs::for_selection(selection), Page::Delete).await
}
/// GET /admin/profiles/copy
pub(crate) async fn copy_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, profile_inputs(selection), Page::Copy).await
}
/// GET /admin/tables/from-template
pub(crate) async fn template_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, profile_inputs(selection), Page::Template).await
}
/// GET /admin/profiles/history
pub(crate) async fn history_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, PageInputs::for_selection(selection), Page::History).await
}
/// The profile-wide pages act on a profile, so a table in the query is
/// dropped rather than carried into a form that has no use for it.
fn profile_inputs(selection: Selection) -> PageInputs {
PageInputs::for_selection(Selection {
profile: selection.profile,
table: String::new(),
})
}
async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Page) -> Response {
match load_page(state, &headers, inputs).await {
Ok(mut loaded) => {
loaded.active = page.name();
Html(page.render_page(&loaded)).into_response()
}
Err(error) => load_error_response(error),
}
}
// ── The writes ──────────────────────────────────────────────────────────────
/// POST /admin/tables/columns/builder — staging a column to append.
///
/// Nothing is written here; the panel is swapped back with the column added,
/// removed, or its index toggled, exactly as the Add-table builder works.
pub(crate) async fn update_columns(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
Form(form): Form<ColumnForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
// Staging a column applies the vocabulary's rules, so it is read before
// the panel is rebuilt; `load_page` reuses what is read here.
let mut definitions = state.definitions.clone();
let catalog = match loader::load_column_catalog(&mut definitions, &headers).await {
Ok(catalog) => catalog,
Err(error) => return load_error_response(error),
};
let mut inputs = PageInputs::for_selection(selection);
inputs.columns = form.to_draft(catalog, false);
let index = form.index.unwrap_or(0);
match form.action.as_str() {
"add-column" => match inputs.columns.add_from_inputs() {
Ok(status) => inputs.status = Some(status),
Err(message) => inputs.error = Some(message),
},
"remove-column" => {
if let Err(message) = inputs.columns.remove(index) {
inputs.error = Some(message);
}
}
"toggle-index" => inputs.columns.toggle_indexed(index),
_ => {}
}
match load_page(state, &headers, inputs).await {
Ok(mut loaded) => {
loaded.active = Page::Columns.name();
Html(ui::render_column_panel(&loaded)).into_response()
}
Err(error) => load_error_response(error),
}
}
/// POST /admin/tables/columns — AddTableColumns.
pub(crate) async fn add_columns(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
Form(form): Form<ColumnForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
// The draft is validated before the write, so the vocabulary it is held to
// is read first; `load_page` reuses what is read here.
let mut definitions = state.definitions.clone();
let catalog = match loader::load_column_catalog(&mut definitions, &headers).await {
Ok(catalog) => catalog,
Err(error) => return load_error_response(error),
};
let mut inputs = PageInputs::for_selection(selection);
inputs.columns = form.to_draft(catalog.clone(), false);
if !inputs.selection.has_table() {
return refuse(state, headers, inputs, Page::Columns, "Select a table first.".to_string())
.await;
}
if inputs.columns.is_empty() {
return refuse(
state,
headers,
inputs,
Page::Columns,
"Describe at least one column before adding.".to_string(),
)
.await;
}
// The same checks the server runs, applied to a draft that may have been
// rebuilt from a posted form rather than through the panel.
if let Err(message) = inputs.columns.validate() {
return refuse(state, headers, inputs, Page::Columns, message).await;
}
let request = AddTableColumnsRequest {
profile_name: inputs.selection.profile.clone(),
table_name: inputs.selection.table.clone(),
columns: proto_columns(&inputs.columns.added),
indexes: inputs.columns.selected_index_names(),
// The append panel names its columns itself; the only generated ones it
// can produce are the phone and IBAN companions, which it does not
// offer to rename. The table's own rename form is where that is done.
generated_aliases: Vec::new(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
match definitions.add_table_columns(request).await {
Ok(response) if response.get_ref().success => {
let added = inputs.columns.added.len();
inputs.sql = Some(response.into_inner().sql);
inputs.status = Some(format!(
"{added} column{} added to `{}`.",
if added == 1 { "" } else { "s" },
inputs.selection.table
));
// The columns are the table's now, so the panel starts empty.
inputs.columns = crate::schema::ColumnDraft::for_append(catalog);
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().sql;
let message = if message.is_empty() {
"The backend did not add the columns.".to_string()
} else {
message
};
refuse(state, headers, inputs, Page::Columns, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
}
}
}
/// POST /admin/tables/rename — RenameColumnAlias.
pub(crate) async fn rename_column(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<RenameForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: form.table.clone(),
});
inputs.rename = form.clone();
if form.old_column_name.is_empty() || form.new_column_name.trim().is_empty() {
return refuse(
state,
headers,
inputs,
Page::Columns,
"Choose a column and type its new name.".to_string(),
)
.await;
}
let request = RenameColumnAliasRequest {
profile_name: form.profile.clone(),
table_name: form.table.clone(),
old_column_name: form.old_column_name.clone(),
new_column_name: form.new_column_name.trim().to_string(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.rename_column_alias(request).await {
Ok(response) if response.get_ref().success => {
inputs.status = Some(response.into_inner().message);
inputs.rename = RenameForm {
profile: form.profile,
table: form.table,
..Default::default()
};
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().message;
let message = if message.is_empty() {
"The backend did not rename the column.".to_string()
} else {
message
};
refuse(state, headers, inputs, Page::Columns, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
}
}
}
/// POST /admin/tables/delete — DeleteTable.
///
/// On success there is nothing left for this page to show, so it hands the
/// browser to the admin panel with the profile still selected.
pub(crate) async fn delete_table(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<DeleteForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: form.table.clone(),
});
// The typed name is the whole guard: the drop is CASCADE, and it takes the
// profile with it when this was its last table.
if form.confirm_table_name.trim() != form.table || form.table.is_empty() {
return refuse(
state,
headers,
inputs,
Page::Delete,
"Type the table's name exactly to confirm the deletion.".to_string(),
)
.await;
}
let request = DeleteTableRequest {
profile_name: form.profile.clone(),
table_name: form.table.clone(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.delete_table(request).await {
Ok(response) if response.get_ref().success => {
// The profile may have gone with it, in which case the admin panel
// refuses the selection; landing on the bare panel is what the
// caller wants then anyway.
let location = format!("/admin?profile={}", form.profile);
match HeaderValue::try_from(location) {
Ok(location) => {
let mut response = Html(String::new()).into_response();
response.headers_mut().insert("hx-redirect", location);
response
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(),
}
}
Ok(response) => {
let message = response.into_inner().message;
let message = if message.is_empty() {
"The backend did not delete the table.".to_string()
} else {
message
};
refuse(state, headers, inputs, Page::Delete, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Delete, error.message().to_string()).await
}
}
}
/// POST /admin/profiles/copy — CopyProfile.
pub(crate) async fn copy_profile(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<CopyForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: String::new(),
});
inputs.copy = form.clone();
if form.target_profile_name.trim().is_empty() {
return refuse(
state,
headers,
inputs,
Page::Copy,
"Name the profile to copy into.".to_string(),
)
.await;
}
let request = CopyProfileRequest {
source_profile_name: form.profile.clone(),
target_profile_name: form.target_profile_name.trim().to_string(),
table_names: form.table_names.clone(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.copy_profile(request).await {
Ok(response) if response.get_ref().success => {
let response = response.into_inner();
inputs.status = Some(format!(
"{}{} table(s) and {} script(s) copied.",
response.message, response.tables_copied, response.scripts_copied
));
inputs.copy = CopyForm {
profile: form.profile,
..Default::default()
};
respond(state, headers, inputs, Page::Copy, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().message;
let message = if message.is_empty() {
"The backend did not copy the profile.".to_string()
} else {
message
};
refuse(state, headers, inputs, Page::Copy, message).await
}
Err(error) => refuse(state, headers, inputs, Page::Copy, error.message().to_string()).await,
}
}
/// POST /admin/tables/from-template — CreateInvoiceTemplateTable.
pub(crate) async fn create_from_invoice_template(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<InvoiceTemplateForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: String::new(),
});
inputs.invoice = form.clone();
if form.table_name.trim().is_empty() || form.typst_source.trim().is_empty() {
return refuse(
state,
headers,
inputs,
Page::Template,
"A table name and the template's source are both required.".to_string(),
)
.await;
}
let request = CreateInvoiceTemplateTableRequest {
profile_name: form.profile.clone(),
table_name: form.table_name.trim().to_string(),
typst_source: form.typst_source.clone(),
row_display_columns: form.display_columns(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.create_invoice_template_table(request).await {
Ok(response) if response.get_ref().success => {
let response = response.into_inner();
inputs.status = Some(format!(
"{} table(s) created from the template.",
response.tables.len()
));
inputs.generated = response
.tables
.into_iter()
.map(|table| GeneratedTableView {
table_name: table.table_name,
collection_path: table.collection_path,
parent_table_name: table.parent_table_name,
})
.collect();
inputs.invoice = InvoiceTemplateForm {
profile: form.profile,
..Default::default()
};
respond(state, headers, inputs, Page::Template, StatusCode::OK).await
}
Ok(_) => {
refuse(
state,
headers,
inputs,
Page::Template,
"The backend did not create the template's tables.".to_string(),
)
.await
}
Err(error) => {
refuse(state, headers, inputs, Page::Template, error.message().to_string()).await
}
}
}
/// Re-reads the page's body and answers with it.
async fn respond(
state: AppState,
headers: HeaderMap,
inputs: PageInputs,
page: Page,
status: StatusCode,
) -> Response {
match load_page(state, &headers, inputs).await {
Ok(mut loaded) => {
loaded.active = page.name();
(status, Html(page.render_fragment(&loaded))).into_response()
}
Err(error) => load_error_response(error),
}
}
/// Answers a refused write: the page as it still is, plus the reason.
async fn refuse(
state: AppState,
headers: HeaderMap,
mut inputs: PageInputs,
page: Page,
message: String,
) -> Response {
inputs.error = Some(message);
respond(state, headers, inputs, page, StatusCode::UNPROCESSABLE_ENTITY).await
}
fn load_error_response(error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
LoadError::Forbidden => (
StatusCode::FORBIDDEN,
Html(ui::render_load_error(
"Table-management permission is required.",
)),
)
.into_response(),
LoadError::Backend(message) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_load_error(&message)),
)
.into_response(),
}
}