use axum::{ debug_handler, extract::{Path, State}, response::Response, }; // [`axum_extra`]'s form extractor is preferred: // https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform use axum_extra::extract::Form; use interim_models::{ field::Field, portal::Portal, presentation::{Presentation, RFC_3339_S, TextInputMode}, workspace::Workspace, workspace_user_perm::{self, WorkspaceUserPerm}, }; use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use serde::Deserialize; use sqlx::query; use uuid::Uuid; use crate::{ app::{App, AppDbConn}, errors::{AppError, forbidden}, navigator::Navigator, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, }; #[derive(Debug, Deserialize)] pub(super) struct PathParams { portal_id: Uuid, rel_oid: u32, workspace_id: Uuid, } #[derive(Debug, Deserialize)] pub(super) struct FormBody { name: String, label: String, presentation_tag: String, dropdown_allow_custom: Option, text_input_mode: Option, timestamp_format: Option, } /// HTTP POST handler for adding a [`Field`] to a [`Portal`]. If the field name /// does not match a column in the backing database, a new column is created /// with a compatible type. /// /// This handler expects 3 path parameters with the structure described by /// [`PathParams`]. #[debug_handler(state = App)] pub(super) async fn post( State(mut workspace_pooler): State, AppDbConn(mut app_db): AppDbConn, CurrentUser(user): CurrentUser, navigator: Navigator, Path(PathParams { portal_id, workspace_id, .. }): Path, Form(form): Form, ) -> Result { // Check workspace authorization. let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) .fetch_all(&mut app_db) .await?; if workspace_perms.iter().all(|p| { p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect }) { return Err(forbidden!("access denied to workspace")); } // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?; let workspace = Workspace::with_id(portal.workspace_id) .fetch_one(&mut app_db) .await?; let mut workspace_client = workspace_pooler .acquire_for(workspace.id, RoleAssignment::User(user.id)) .await?; let class = PgClass::with_oid(portal.class_oid) .fetch_one(&mut workspace_client) .await?; let presentation = try_presentation_from_form(&form)?; query(&format!( "alter table {ident} add column if not exists {col} {typ}", ident = class.get_identifier(), col = escape_identifier(&form.name), typ = presentation.attr_data_type_fragment(), )) .execute(workspace_client.get_conn()) .await?; Field::insert() .portal_id(portal.id) .name(form.name) .table_label(if form.label.is_empty() { None } else { Some(form.label) }) .presentation(presentation) .build()? .insert(&mut app_db) .await?; Ok(navigator.portal_page(&portal).redirect_to()) } fn try_presentation_from_form(form: &FormBody) -> Result { // Parses the presentation tag into the correct enum variant, but without // meaningful inner value(s). Match arms should all use the // `MyVariant { .. }` pattern to pay attention to only the tag. let presentation_default = Presentation::try_from(form.presentation_tag.as_str())?; Ok(match presentation_default { Presentation::Array { .. } => todo!(), Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true }, Presentation::Text { .. } => Presentation::Text { input_mode: form .text_input_mode .clone() .map(|value| TextInputMode::try_from(value.as_str())) .transpose()? .unwrap_or_default(), }, Presentation::Timestamp { .. } => Presentation::Timestamp { format: form .timestamp_format .clone() .unwrap_or(RFC_3339_S.to_owned()), }, Presentation::Uuid { .. } => Presentation::Uuid {}, }) }