//! The request handlers behind the six 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, ColumnPresentation, DeleteTableRequest, SetColumnPresentationRequest, }, {i18n::Locale, tr}, schema::{ColumnForm, proto_columns}, services::{authenticated_request, reject_cross_site}, }; use super::{ loader::{self, load_page}, state::{ AliasForm, CopyForm, DeleteForm, DetailColumn, GeneratedTableView, InvoiceTemplateForm, LoadError, OrderForm, PageInputs, 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 { AddColumns, Presentation, Delete, Copy, Template, History, } impl Page { fn name(self) -> &'static str { match self { Self::AddColumns => "add-columns", Self::Presentation => "presentation", Self::Delete => "delete", Self::Copy => "copy", Self::Template => "template", Self::History => "history", } } fn render_page(self, page: &TableDefinitionPageState) -> String { match self { Self::AddColumns => ui::render_add_columns_page(page), Self::Presentation => ui::render_presentation_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::AddColumns => ui::render_add_columns_fragment(page), Self::Presentation => ui::render_presentation_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/add pub(crate) async fn add_columns_page( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> Response { show(state, headers, PageInputs::for_selection(selection), Page::AddColumns).await } /// GET /admin/tables/presentation pub(crate) async fn presentation_page( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> Response { show(state, headers, PageInputs::for_selection(selection), Page::Presentation).await } /// GET /admin/tables/delete pub(crate) async fn delete_page( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> Response { show(state, headers, PageInputs::for_selection(selection), Page::Delete).await } /// GET /admin/profiles/copy pub(crate) async fn copy_page( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> Response { show(state, headers, profile_inputs(selection), Page::Copy).await } /// GET /admin/tables/from-template pub(crate) async fn template_page( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> Response { show(state, headers, profile_inputs(selection), Page::Template).await } /// GET /admin/profiles/history pub(crate) async fn history_page( State(state): State, headers: HeaderMap, Query(selection): Query, ) -> 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(&headers, error), } } // ── The writes ────────────────────────────────────────────────────────────── /// POST /admin/tables/columns/add/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(&headers, error), }; let mut inputs = PageInputs::for_selection(selection); inputs.columns = form.to_draft(catalog, false); let index = form.index.unwrap_or(0); let locale = Locale::from_headers(&headers); match form.action.as_str() { "add-column" => match inputs.columns.add_from_inputs(locale) { Ok(status) => inputs.status = Some(status), Err(message) => inputs.error = Some(message), }, "remove-column" => { if let Err(message) = inputs.columns.remove(locale, 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::AddColumns.name(); Html(ui::render_column_panel(&loaded)).into_response() } Err(error) => load_error_response(&headers, error), } } /// POST /admin/tables/columns/add — 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(&headers, error), }; let mut inputs = PageInputs::for_selection(selection); inputs.columns = form.to_draft(catalog.clone(), false); if !inputs.selection.has_table() { let message = tr!( Locale::from_headers(&headers), "td-err-select-table-first" ); return refuse( state, headers, inputs, Page::AddColumns, message, ) .await; } if inputs.columns.is_empty() { let message = tr!( Locale::from_headers(&headers), "td-err-describe-column" ); return refuse( state, headers, inputs, Page::AddColumns, message, ) .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(Locale::from_headers(&headers)) { return refuse(state, headers, inputs, Page::AddColumns, 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(tr!( Locale::from_headers(&headers), "td-columns-added", "count" => added as i64, "table" => inputs.selection.table.clone(), )); // 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::AddColumns, StatusCode::OK).await } Ok(response) => { let message = response.into_inner().sql; let message = if message.is_empty() { tr!( Locale::from_headers(&headers), "td-err-backend-no-columns" ) } else { message }; refuse(state, headers, inputs, Page::AddColumns, message).await } Err(error) => { refuse(state, headers, inputs, Page::AddColumns, error.message().to_string()).await } } } /// POST /admin/tables/presentation/alias — SetColumnPresentation, renaming one /// column. /// /// The request the backend wants is the whole table, so the columns this write /// is not about are filled in from a fresh read rather than from the browser. /// That is the point of the split: the only thing the form contributes is the /// alias and the id of the column it was typed into. pub(crate) async fn set_column_alias( State(state): State, headers: HeaderMap, Form(form): Form, ) -> 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(), }); let alias = form.alias.trim().to_string(); if alias.is_empty() { let message = tr!(Locale::from_headers(&headers), "td-err-choose-rename"); return refuse(state, headers, inputs, Page::Presentation, message).await; } let columns = match current_columns(&state, &headers, &inputs).await { Ok(columns) => columns, Err(response) => return response, }; if !columns.iter().any(|column| column.column_id == form.column_id) { let message = tr!(Locale::from_headers(&headers), "td-err-unknown-column"); return refuse(state, headers, inputs, Page::Presentation, message).await; } let presentation = columns .iter() .map(|column| ColumnPresentation { column_id: column.column_id, alias: if column.column_id == form.column_id { alias.clone() } else { column.name.clone() }, }) .collect(); apply_presentation( state, headers, inputs, form.profile, form.table, form.expected_row_version, presentation, ) .await } /// POST /admin/tables/presentation/order — SetColumnPresentation, saving the /// order staged by the browser. /// /// Every alias in the request is the name the backend just reported, so this /// write cannot rename a column. The ids from the browser must be an exact /// permutation of the current columns before their order is accepted. pub(crate) async fn set_column_order( State(state): State, headers: HeaderMap, Form(form): Form, ) -> 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(), }); let columns = match current_columns(&state, &headers, &inputs).await { Ok(columns) => columns, Err(response) => return response, }; let current_ids = columns .iter() .map(|column| column.column_id) .collect::>(); let submitted_ids = form .column_ids .iter() .copied() .collect::>(); if form.column_ids.len() != columns.len() || submitted_ids.len() != form.column_ids.len() || submitted_ids != current_ids { let message = tr!(Locale::from_headers(&headers), "td-err-invalid-order"); return refuse(state, headers, inputs, Page::Presentation, message).await; } let columns_by_id = columns .iter() .map(|column| (column.column_id, column)) .collect::>(); let presentation = form .column_ids .iter() .map(|column_id| { let column = columns_by_id[column_id]; ColumnPresentation { column_id: *column_id, alias: column.name.clone(), } }) .collect(); apply_presentation( state, headers, inputs, form.profile, form.table, form.expected_row_version, presentation, ) .await } /// The table's columns as the backend has them now, in their current order. /// /// Both presentation writes have to send every column, and the ones they are /// not about must carry the name the server holds this moment -- not the name /// the browser was showing when the page was drawn. async fn current_columns( state: &AppState, headers: &HeaderMap, inputs: &PageInputs, ) -> Result, Response> { match load_page(state.clone(), headers, inputs.clone()).await { Ok(page) => Ok(page.detail.map(|detail| detail.columns).unwrap_or_default()), Err(error) => Err(load_error_response(headers, error)), } } /// The half both writes share: send the presentation, answer with the page. /// /// `expected_row_version` is the browser's, not the one the read above saw, so /// a definition that changed under the user is still refused by the backend /// rather than silently written over. async fn apply_presentation( state: AppState, headers: HeaderMap, inputs: PageInputs, profile: String, table: String, expected_row_version: i64, columns: Vec, ) -> Response { let request = SetColumnPresentationRequest { profile_name: profile, table_name: table, columns, expected_row_version, }; let Ok(request) = authenticated_request(&headers, request) else { return Redirect::to("/login").into_response(); }; let mut definitions = state.definitions.clone(); match definitions.set_column_presentation(request).await { Ok(response) if response.get_ref().success => { let mut inputs = inputs; inputs.status = Some(response.into_inner().message); respond(state, headers, inputs, Page::Presentation, StatusCode::OK).await } Ok(response) => { let message = response.into_inner().message; let message = if message.is_empty() { tr!(Locale::from_headers(&headers), "td-err-backend-no-rename") } else { message }; refuse(state, headers, inputs, Page::Presentation, message).await } Err(error) => { refuse( state, headers, inputs, Page::Presentation, 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, headers: HeaderMap, Form(form): Form, ) -> 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() { let message = tr!( Locale::from_headers(&headers), "td-err-type-name" ); return refuse( state, headers, inputs, Page::Delete, message, ) .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, Html(tr!( Locale::from_headers(&headers), "ui-err-invalid-redirect" )), ) .into_response(), } } Ok(response) => { let message = response.into_inner().message; let message = if message.is_empty() { tr!( Locale::from_headers(&headers), "td-err-backend-no-delete" ) } 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, 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() { let message = tr!( Locale::from_headers(&headers), "td-err-name-profile" ); return refuse( state, headers, inputs, Page::Copy, message, ) .await; } let request = CopyProfileRequest { source_profile_name: form.profile.clone(), target_profile_name: form.target_profile_name.trim().to_string(), // No table selected means the whole profile, which is what the hint // promises and what the server does with an empty list. A browser // sends no `table_names` key at all when nothing is ticked; anything // posting the field empty would otherwise send one blank name, and // the server rejects a blank name rather than reading it as "all". table_names: form .table_names .iter() .map(|name| name.trim()) .filter(|name| !name.is_empty()) .map(str::to_string) .collect(), }; 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(tr!( Locale::from_headers(&headers), "td-profile-copied", "message" => response.message.clone(), "tables" => response.tables_copied as i64, "scripts" => response.scripts_copied as i64, )); 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() { tr!( Locale::from_headers(&headers), "td-err-backend-no-copy" ) } 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, 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() { let message = tr!( Locale::from_headers(&headers), "td-err-template-required" ); return refuse( state, headers, inputs, Page::Template, message, ) .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(tr!( Locale::from_headers(&headers), "td-template-tables-created", "count" => response.tables.len() as i64, )); 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(_) => { let message = tr!( Locale::from_headers(&headers), "td-err-backend-no-template" ); refuse( state, headers, inputs, Page::Template, message, ) .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(&headers, 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(headers: &HeaderMap, error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_load_error( Locale::from_headers(headers), &tr!( Locale::from_headers(headers), "td-err-permission" ), )), ) .into_response(), LoadError::Backend(message) => ( StatusCode::BAD_GATEWAY, Html(ui::render_load_error(Locale::from_headers(headers), &message)), ) .into_response(), } }