//! The builder's request handlers. //! //! Every interaction posts the whole draft and swaps the whole builder back, //! so the server stays the single owner of the draft's rules — the same ones //! the TUI client applies in-process between keystrokes. use axum::{ extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::Form; use crate::{ AppState, services::{authenticated_request, reject_cross_site}, }; use super::{ draft::TableDraft, loader::{LoadError, load_page}, state::{AddTablePageState, BuilderForm}, ui, }; /// The profile the table-definition workspace hands over when it sends the /// user here to create a table, so the picker opens on the right one. #[derive(Debug, Default, serde::Deserialize)] pub(crate) struct NewTableQuery { #[serde(default)] profile: String, #[serde(default)] global: bool, } /// GET /admin/tables/new pub(crate) async fn new_table_page( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Response { let mut draft = TableDraft::new(); draft.profile_name = query.profile.trim().to_string(); draft.global = query.global; match load_page(state, &headers, draft, None, None).await { Ok(page) => Html(ui::render_page(&page)).into_response(), Err(error) => load_error_response(error), } } /// POST /admin/tables/builder — every button and every `change` in the builder. pub(crate) async fn update_builder( State(state): State, headers: HeaderMap, Form(form): Form, ) -> Response { if let Some(rejection) = reject_cross_site(&headers) { return rejection; } let mut page = match load_page(state, &headers, form.to_draft(), None, None).await { Ok(page) => page, Err(error) => return load_error_response(error), }; apply_action(&mut page, &form); Html(ui::render_builder(&page)).into_response() } /// POST /admin/tables — create the table. pub(crate) async fn create_table( State(state): State, headers: HeaderMap, Form(form): Form, ) -> Response { if let Some(rejection) = reject_cross_site(&headers) { return rejection; } let mut page = match load_page(state.clone(), &headers, form.to_draft(), None, None).await { Ok(page) => page, Err(error) => return load_error_response(error), }; // The draft is validated here with exactly the checks the client runs // before it will save; the server re-validates authoritatively. let request = match page.draft.clone().into_request() { Ok(request) => request, Err(message) => { page.error = Some(message); return (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))) .into_response(); } }; let profile_name = if request.global { "__global".to_string() } else { request.profile_name.clone() }; let request = match authenticated_request(&headers, request) { Ok(request) => request, Err(_) => return Redirect::to("/login").into_response(), }; let mut definitions = state.definitions; let result = definitions.post_table_definition(request).await; match result { Ok(response) if response.get_ref().success => { let location = format!( "/admin/table-definition?profile={profile_name}&table={}", page.draft.table_name ); let Ok(location) = HeaderValue::try_from(location) else { return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(); }; // `hx-redirect` alone, with no body and no 3xx: the form is posted // over XHR, and the browser would follow a `Location` itself, // leaving htmx to swap the whole redirected page into `#builder`. let mut response = Html(String::new()).into_response(); response.headers_mut().insert("hx-redirect", location); response } Ok(response) => { page.error = Some(if response.get_ref().sql.is_empty() { "The backend did not create the table.".to_string() } else { response.get_ref().sql.clone() }); (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response() } Err(error) => { page.error = Some(error.message().to_string()); (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response() } } } /// Runs the pressed button against the draft. /// /// `refresh` covers the plain re-renders — picking a profile or a column type /// changes which fields apply, which is the client's field-visibility rule. fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { let index = form.index.unwrap_or(0); match form.action.as_str() { "add-column" => match page.draft.columns.add_from_inputs() { Ok(status) => page.status = Some(status), Err(message) => page.error = Some(message), }, "remove-column" => match page.draft.remove_column(index) { Ok(status) => page.status = Some(status), Err(message) => page.error = Some(message), }, "toggle-index" => page.draft.columns.toggle_indexed(index), "toggle-display" => page.draft.toggle_row_display_candidate(index), _ => {} } } fn load_error_response(error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_submission_error( "Table-management permission is required.", )), ) .into_response(), LoadError::Backend(message) => ( StatusCode::BAD_GATEWAY, Html(ui::render_submission_error(&message)), ) .into_response(), } }