//! The workspace's request handlers — one per `TableDefinition` write. //! //! Every write answers with the whole workspace, 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. //! //! The column panel is the exception: staging a column changes nothing on the //! server, so those interactions swap only the panel. use axum::{ extract::{Query, State}, http::{HeaderMap, 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, }, ui, }; /// GET /admin/table-definition pub(crate) async fn page( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> Response { match load_page(state, &headers, PageInputs::for_selection(selection)).await { Ok(page) => Html(ui::render_page(&page)).into_response(), Err(error) => load_error_response(error), } } /// GET /admin/table-definition/workspace — the swap when the selection changes. pub(crate) async fn workspace( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> Response { match load_page(state, &headers, PageInputs::for_selection(selection)).await { Ok(page) => Html(ui::render_workspace(&page)).into_response(), Err(error) => load_error_response(error), } } /// POST /admin/table-definition/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, headers: HeaderMap, Query(selection): Query, Form(form): Form, ) -> 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(page) => Html(ui::render_column_panel(&page)).into_response(), Err(error) => load_error_response(error), } } /// POST /admin/table-definition/columns — AddTableColumns. pub(crate) async fn add_columns( State(state): State, headers: HeaderMap, Query(selection): Query, Form(form): Form, ) -> 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, "Select a table first.".to_string()).await; } if inputs.columns.is_empty() { return refuse( state, headers, inputs, "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, 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(), }; 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, 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, message).await } Err(error) => refuse(state, headers, inputs, error.message().to_string()).await, } } /// POST /admin/table-definition/rename — RenameColumnAlias. pub(crate) async fn rename_column( State(state): State, headers: HeaderMap, Form(form): Form, ) -> 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, "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, 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, message).await } Err(error) => refuse(state, headers, inputs, error.message().to_string()).await, } } /// POST /admin/table-definition/delete — DeleteTable. pub(crate) async fn delete_table( State(state): State, headers: HeaderMap, Form(form): Form, ) -> 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(), }); // 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, "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 => { inputs.status = Some(response.into_inner().message); // Whatever was selected is gone; the loader drops it, and this // keeps the workspace from asking for it again. inputs.selection.table.clear(); respond(state, headers, inputs, StatusCode::OK).await } 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, message).await } Err(error) => refuse(state, headers, inputs, error.message().to_string()).await, } } /// POST /admin/table-definition/copy — CopyProfile. pub(crate) async fn copy_profile( State(state): State, headers: HeaderMap, Form(form): Form, ) -> 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, "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, 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, message).await } Err(error) => refuse(state, headers, inputs, error.message().to_string()).await, } } /// POST /admin/table-definition/invoice-template — CreateInvoiceTemplateTable. pub(crate) async fn create_from_invoice_template( State(state): State, headers: HeaderMap, Form(form): Form, ) -> 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, "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, StatusCode::OK).await } Ok(_) => { refuse( state, headers, inputs, "The backend did not create the template's tables.".to_string(), ) .await } Err(error) => refuse(state, headers, inputs, error.message().to_string()).await, } } /// Re-reads the workspace and answers with it. async fn respond( state: AppState, headers: HeaderMap, inputs: PageInputs, status: StatusCode, ) -> Response { match load_page(state, &headers, inputs).await { Ok(page) => (status, Html(ui::render_workspace(&page))).into_response(), Err(error) => load_error_response(error), } } /// Answers a refused write: the workspace as it still is, plus the reason. async fn refuse( state: AppState, headers: HeaderMap, mut inputs: PageInputs, message: String, ) -> Response { inputs.error = Some(message); respond(state, headers, inputs, 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(), } }