From 9bee5fdaefd9b45016e63bb2d05faab5e6a66e37 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Mon, 19 Jan 2026 17:24:27 +0000 Subject: [PATCH] remove disused "forms" feature --- .../20260119172658_remove_forms.down.sql | 34 ++++ .../20260119172658_remove_forms.up.sql | 3 + phono-models/src/field_form_prompt.rs | 131 ------------- phono-models/src/form_transition.rs | 162 ---------------- phono-models/src/lib.rs | 2 - phono-server/src/routes/forms/form_handler.rs | 121 ------------ phono-server/src/routes/forms/mod.rs | 10 - phono-server/src/routes/mod.rs | 2 - .../routes/relations_single/form_handler.rs | 176 ------------------ .../src/routes/relations_single/mod.rs | 12 -- .../update_form_transitions_handler.rs | 80 -------- .../update_prompts_handler.rs | 115 ------------ phono-server/templates/forms/form_index.html | 43 ----- phono-server/templates/rbac.html | 23 --- phono-server/templates/rbac_invite.html | 13 -- phono-server/templates/rel_rbac.html | 72 ------- .../relations_single/form_index.html | 57 ------ svelte/src/combobox.svelte | 127 ------------- .../src/form-transitions-editor.webc.svelte | 87 --------- svelte/src/i18n-textarea.webc.svelte | 55 ------ 20 files changed, 37 insertions(+), 1288 deletions(-) create mode 100644 phono-models/migrations/20260119172658_remove_forms.down.sql create mode 100644 phono-models/migrations/20260119172658_remove_forms.up.sql delete mode 100644 phono-models/src/field_form_prompt.rs delete mode 100644 phono-models/src/form_transition.rs delete mode 100644 phono-server/src/routes/forms/form_handler.rs delete mode 100644 phono-server/src/routes/forms/mod.rs delete mode 100644 phono-server/src/routes/relations_single/form_handler.rs delete mode 100644 phono-server/src/routes/relations_single/update_form_transitions_handler.rs delete mode 100644 phono-server/src/routes/relations_single/update_prompts_handler.rs delete mode 100644 phono-server/templates/forms/form_index.html delete mode 100644 phono-server/templates/rbac.html delete mode 100644 phono-server/templates/rbac_invite.html delete mode 100644 phono-server/templates/rel_rbac.html delete mode 100644 phono-server/templates/relations_single/form_index.html delete mode 100644 svelte/src/combobox.svelte delete mode 100644 svelte/src/form-transitions-editor.webc.svelte delete mode 100644 svelte/src/i18n-textarea.webc.svelte diff --git a/phono-models/migrations/20260119172658_remove_forms.down.sql b/phono-models/migrations/20260119172658_remove_forms.down.sql new file mode 100644 index 0000000..b8a1ffe --- /dev/null +++ b/phono-models/migrations/20260119172658_remove_forms.down.sql @@ -0,0 +1,34 @@ +create table if not exists form_transitions ( + id uuid not null primary key default uuidv7(), + source_id uuid not null references portals(id) on delete cascade, + dest_id uuid not null references portals(id) on delete restrict, + condition jsonb not null default 'null' +); +create index on form_transitions (source_id); + +create table if not exists field_form_prompts ( + id uuid not null primary key default uuidv7(), + field_id uuid not null references fields(id) on delete cascade, + language text not null, + content text not null default '', + unique (field_id, language) +); +create index on field_form_prompts (field_id); + +create table if not exists form_sessions ( + id uuid not null primary key default uuidv7(), + user_id uuid references users(id) on delete cascade +); + +create table if not exists form_touch_points ( + id uuid not null primary key default uuidv7(), + -- `on delete restrict` errs on the side of conservatism, but is not known + -- to be crucial. + form_session_id uuid not null references form_sessions(id) on delete restrict, + -- `on delete restrict` errs on the side of conservatism, but is not known + -- to be crucial. + portal_id uuid not null references portals(id) on delete restrict, + -- Points to a row in the portal's backing table, so foreign key constraints + -- do not apply here. + row_id uuid not null +); diff --git a/phono-models/migrations/20260119172658_remove_forms.up.sql b/phono-models/migrations/20260119172658_remove_forms.up.sql new file mode 100644 index 0000000..47f6c57 --- /dev/null +++ b/phono-models/migrations/20260119172658_remove_forms.up.sql @@ -0,0 +1,3 @@ +drop table if exists form_touch_points; +drop table if exists field_form_prompts; +drop table if exists form_transitions; diff --git a/phono-models/src/field_form_prompt.rs b/phono-models/src/field_form_prompt.rs deleted file mode 100644 index bab92da..0000000 --- a/phono-models/src/field_form_prompt.rs +++ /dev/null @@ -1,131 +0,0 @@ -use derive_builder::Builder; -use serde::{Deserialize, Serialize}; -use sqlx::query_as; -use uuid::Uuid; - -use crate::{client::AppDbClient, language::Language}; - -/// A localized prompt to display above or alongside the form input for the -/// given field. -/// -/// There may be zero or one `field_form_prompt` entries for each -/// `(field_id, language)` pair. (This uniqueness should be enforced by the -/// database.) -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct FieldFormPrompt { - /// Primary key (defaults to UUIDv7). - pub id: Uuid, - - /// ID of the field to which this prompt belongs. - pub field_id: Uuid, - - /// [ISO 639-3](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes) - /// language code. - pub language: Language, - - /// Prompt content for this field, in this language. - pub content: String, -} - -impl FieldFormPrompt { - /// Build an insert statement to create a new prompt. - pub fn upsert() -> UpsertBuilder { - UpsertBuilder::default() - } - - /// Build an update statement to alter the content of an existing prompt. - pub fn update() -> UpdateBuilder { - UpdateBuilder::default() - } - - /// Build a single-field query by field ID. - pub fn belonging_to_field(id: Uuid) -> BelongingToFieldQuery { - BelongingToFieldQuery { id } - } -} - -#[derive(Builder, Clone, Debug)] -pub struct Upsert { - field_id: Uuid, - language: Language, - content: String, -} - -impl Upsert { - pub async fn execute(self, app_db: &mut AppDbClient) -> Result { - query_as!( - FieldFormPrompt, - r#" -insert into field_form_prompts (field_id, language, content) values ($1, $2, $3) -on conflict (field_id, language) do update set - content = excluded.content -returning - id, - field_id, - language as "language: Language", - content -"#, - self.field_id, - self.language.to_string(), - self.content, - ) - .fetch_one(app_db.get_conn()) - .await - } -} - -#[derive(Builder, Clone, Debug, Default)] -pub struct Update { - id: Uuid, - content: String, -} - -impl Update { - pub async fn execute(self, app_db: &mut AppDbClient) -> Result { - query_as!( - FieldFormPrompt, - r#" -update field_form_prompts -set content = $1 -where id = $2 -returning - id, - field_id, - language as "language: Language", - content -"#, - self.content, - self.id, - ) - .fetch_one(app_db.get_conn()) - .await - } -} - -#[derive(Clone, Debug)] -pub struct BelongingToFieldQuery { - id: Uuid, -} - -impl BelongingToFieldQuery { - pub async fn fetch_all( - self, - app_db: &mut AppDbClient, - ) -> Result, sqlx::Error> { - query_as!( - FieldFormPrompt, - r#" -select - id, - field_id, - language as "language: Language", - content -from field_form_prompts -where field_id = $1 -"#, - self.id, - ) - .fetch_all(app_db.get_conn()) - .await - } -} diff --git a/phono-models/src/form_transition.rs b/phono-models/src/form_transition.rs deleted file mode 100644 index cf4cb46..0000000 --- a/phono-models/src/form_transition.rs +++ /dev/null @@ -1,162 +0,0 @@ -use derive_builder::Builder; -use serde::Serialize; -use sqlx::{Row as _, postgres::PgRow, query, query_as, types::Json}; -use uuid::Uuid; - -use crate::{client::AppDbClient, expression::PgExpressionAny}; - -/// A form transition directionally connects two portals within the same -/// workspace, representing a potential navigation of a user between two forms. -/// If the user submits a form, form transitions with `source_id` corresponding -/// to that portal will be evaluated one by one (in order by ID---that is, by -/// creation time), and the first with a condition evaluating to true will be -/// used to direct the user to the form corresponding to portal `dest_id`. -#[derive(Clone, Debug, Serialize)] -pub struct FormTransition { - /// Primary key (defaults to UUIDv7). - pub id: Uuid, - - /// When a user is filling out a sequence of forms, this is the ID of the - /// portal for which they have just submitted a form for. - /// - /// **Source portal is expected to belong to the same workspace as the - /// destination portal.** - pub source_id: Uuid, - - /// When a user is filling out a sequence of forms, this is the ID of the - /// portal for which they will be directed to if the condition evaluates to - /// true. - /// - /// **Destination portal is expected to belong to the same workspace as the - /// source portal.** - pub dest_id: Uuid, - - /// Represents a semi-arbitrary Postgres expression which will permit this - /// transition to be followed, only if the expression evaluates to true at - /// the time of the source form's submission. - pub condition: Json>, -} - -impl FormTransition { - /// Build a multi-row update statement to replace all transtitions for a - /// given source portal. - pub fn replace_for_portal(portal_id: Uuid) -> ReplaceBuilder { - ReplaceBuilder { - portal_id: Some(portal_id), - ..ReplaceBuilder::default() - } - } - - /// Build a single-field query by source portal ID. - pub fn with_source(id: Uuid) -> WithSourceQuery { - WithSourceQuery { id } - } -} - -#[derive(Clone, Copy, Debug)] -pub struct WithSourceQuery { - id: Uuid, -} - -impl WithSourceQuery { - pub async fn fetch_all( - self, - app_db: &mut AppDbClient, - ) -> Result, sqlx::Error> { - query_as!( - FormTransition, - r#" -select - id, - source_id, - dest_id, - condition as "condition: Json>" -from form_transitions -where source_id = $1 -"#, - self.id, - ) - .fetch_all(app_db.get_conn()) - .await - } -} - -#[derive(Builder, Clone, Debug)] -pub struct Replacement { - pub dest_id: Uuid, - pub condition: Option, -} - -#[derive(Builder, Clone, Debug)] -pub struct Replace { - #[builder(setter(custom))] - portal_id: Uuid, - - replacements: Vec, -} - -impl Replace { - /// Insert zero or more form transitions, and then remove all others - /// associated with the same portal. When they are being used, form - /// transitions are evaluated from first to last by ID (that is, by - /// creation timestamp, because IDs are UUIDv7s), so none of the newly added - /// transitions will supersede their predecessors until the latter are - /// deleted in one fell swoop. However, there will be a (hopefully brief) - /// period during which both the old and the new transitions will be - /// evaluated together in order, so this is not quite equivalent to an - /// atomic update. - /// - /// FIXME: There is a race condition in which executing [`Replace::execute`] - /// for the same portal two or more times simultaneously may remove *all* - /// form transitions for that portal, new and old. This would require - /// impeccable timing, but it should absolutely be fixed... at some point. - pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> { - let ids: Vec = if self.replacements.is_empty() { - vec![] - } else { - // Nice to do this in one query to avoid generating even more - // intermediate database state purgatory. Credit to [@greglearns]( - // https://github.com/launchbadge/sqlx/issues/294#issuecomment-716149160 - // ) for the clever syntax. Too bad it doesn't seem plausible to - // dovetail this with the [`query!`] macro, but that makes sense - // given the circuitous query structure. - query( - r#" -insert into form_transitions (source_id, dest_id, condition) - select * from unnest($1, $2, $3) -returning id -"#, - ) - .bind( - self.replacements - .iter() - .map(|_| self.portal_id) - .collect::>(), - ) - .bind( - self.replacements - .iter() - .map(|value| value.dest_id) - .collect::>(), - ) - .bind( - self.replacements - .iter() - .map(|value| Json(value.condition.clone())) - .collect::>() as Vec>>, - ) - .map(|row: PgRow| -> Uuid { row.get(0) }) - .fetch_all(app_db.get_conn()) - .await? - }; - - query!( - "delete from form_transitions where id <> any($1)", - ids.as_slice(), - ) - .execute(app_db.get_conn()) - .await?; - - Ok(()) - } -} diff --git a/phono-models/src/lib.rs b/phono-models/src/lib.rs index 64bda8b..8e347e5 100644 --- a/phono-models/src/lib.rs +++ b/phono-models/src/lib.rs @@ -23,8 +23,6 @@ pub mod datum; pub mod errors; pub mod expression; pub mod field; -pub mod field_form_prompt; -pub mod form_transition; pub mod language; mod macros; pub mod portal; diff --git a/phono-server/src/routes/forms/form_handler.rs b/phono-server/src/routes/forms/form_handler.rs deleted file mode 100644 index cd80e90..0000000 --- a/phono-server/src/routes/forms/form_handler.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::collections::HashMap; - -use askama::Template; -use axum::{ - extract::{Path, State}, - response::{Html, IntoResponse as _, Response}, -}; -use phono_backends::pg_attribute::PgAttribute; -use phono_models::{ - field::Field, - field_form_prompt::FieldFormPrompt, - language::Language, - portal::Portal, - presentation::{Presentation, TextInputMode}, -}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::{ - Settings, - app::AppDbConn, - errors::{AppError, not_found}, - field_info::FormFieldInfo, - workspace_pooler::{RoleAssignment, WorkspacePooler}, -}; - -#[derive(Debug, Deserialize)] -pub(super) struct PathParams { - portal_id: Uuid, -} - -/// HTTP GET handler for public-facing survey interface. This allows form -/// responses to be collected as rows directly into a Phonograph table. -pub(super) async fn get( - State(settings): State, - State(mut pooler): State, - AppDbConn(mut app_db): AppDbConn, - Path(PathParams { portal_id }): Path, -) -> Result { - // FIXME: Disallow access unless form has been explicitly marked as public. - - // WARNING: Form handler bypasses standard auth checks. - let portal = Portal::with_id(portal_id) - .fetch_optional(&mut app_db) - .await? - .ok_or(not_found!("form not found"))?; - - // WARNING: This client is connected with full workspace privileges. Even - // more so than usual, the Phonograph server is responsible for ensuring all - // auth checks are performed properly. - // - // TODO: Can this be delegated to a dedicated and less privileged role - // instead? - let mut workspace_client = pooler - .acquire_for(portal.workspace_id, RoleAssignment::Root) - .await?; - - let attrs: HashMap = PgAttribute::all_for_rel(portal.class_oid) - .fetch_all(&mut workspace_client) - .await? - .into_iter() - .map(|value| (value.attname.clone(), value)) - .collect(); - - // TODO: implement with sql join - let mut fields: Vec = vec![]; - for field in Field::belonging_to_portal(portal_id) - .fetch_all(&mut app_db) - .await? - { - let attr = attrs.get(&field.name); - let prompts: HashMap = FieldFormPrompt::belonging_to_field(field.id) - .fetch_all(&mut app_db) - .await? - .into_iter() - .map(|prompt| (prompt.language, prompt.content)) - .collect(); - fields.push(FormFieldInfo { - field, - column_present: attr.is_some(), - has_default: attr.is_some_and(|value| value.atthasdef), - not_null: attr.is_some_and(|value| value.attnotnull.is_some_and(|notnull| notnull)), - prompts, - }) - } - - let mut prompts_html: HashMap = HashMap::new(); - for field in fields.iter() { - // TODO: i18n - let prompt = field - .prompts - .get(&Language::Eng) - .cloned() - .unwrap_or_default(); - let prompt_md = markdown::to_html(&prompt); - // TODO: a11y (input labels) - prompts_html.insert(field.field.name.clone(), prompt_md); - } - - #[derive(Debug, Template)] - #[template(path = "forms/form_index.html")] - struct ResponseTemplate { - fields: Vec, - language: Language, - portal: Portal, - prompts_html: HashMap, - settings: Settings, - } - - Ok(Html( - ResponseTemplate { - fields, - language: Language::Eng, - portal, - prompts_html, - settings, - } - .render()?, - ) - .into_response()) -} diff --git a/phono-server/src/routes/forms/mod.rs b/phono-server/src/routes/forms/mod.rs deleted file mode 100644 index ffd8c8d..0000000 --- a/phono-server/src/routes/forms/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -use axum::{Router, routing::get}; -use axum_extra::routing::RouterExt as _; - -use crate::app::App; - -mod form_handler; - -pub(super) fn new_router() -> Router { - Router::new().route_with_tsr("/{portal_id}/", get(form_handler::get)) -} diff --git a/phono-server/src/routes/mod.rs b/phono-server/src/routes/mod.rs index d558d7f..1c34cad 100644 --- a/phono-server/src/routes/mod.rs +++ b/phono-server/src/routes/mod.rs @@ -21,7 +21,6 @@ use tower_http::{ use crate::{app::App, auth, settings::Settings}; -mod forms; mod relations_single; mod workspaces_multi; mod workspaces_single; @@ -42,7 +41,6 @@ pub(crate) fn new_router(app: App) -> Router<()> { ) .nest("/workspaces", workspaces_multi::new_router()) .nest("/w", workspaces_single::new_router()) - .nest("/f", forms::new_router()) .nest("/auth", auth::new_router()) .route("/__dev-healthz", get(|| async move { "ok" })) .layer(SetResponseHeaderLayer::if_not_present( diff --git a/phono-server/src/routes/relations_single/form_handler.rs b/phono-server/src/routes/relations_single/form_handler.rs deleted file mode 100644 index 61f065f..0000000 --- a/phono-server/src/routes/relations_single/form_handler.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::collections::HashMap; - -use askama::Template; -use axum::{ - debug_handler, - extract::{Path, State}, - response::{Html, IntoResponse}, -}; -use phono_backends::pg_attribute::PgAttribute; -use phono_models::{ - accessors::{Accessor as _, Actor, portal::PortalAccessor}, - field::Field, - field_form_prompt::FieldFormPrompt, - form_transition::FormTransition, - language::Language, - portal::Portal, - workspace::Workspace, -}; -use serde::{Deserialize, Serialize}; -use sqlx::postgres::types::Oid; -use strum::IntoEnumIterator as _; -use uuid::Uuid; - -use crate::{ - app::AppDbConn, - errors::AppError, - field_info::FormFieldInfo, - navigator::{Navigator, NavigatorPage as _}, - settings::Settings, - user::CurrentUser, - workspace_nav::{NavLocation, RelLocation, WorkspaceNav}, - workspace_pooler::{RoleAssignment, WorkspacePooler}, - workspaces::{RelationPortalSet, fetch_all_accessible_portals}, -}; - -#[derive(Debug, Deserialize)] -pub(super) struct PathParams { - portal_id: Uuid, - rel_oid: u32, - workspace_id: Uuid, -} - -/// HTTP GET handler for the top-level portal form builder page. -#[debug_handler(state = crate::app::App)] -pub(super) async fn get( - State(settings): State, - CurrentUser(user): CurrentUser, - AppDbConn(mut app_db): AppDbConn, - Path(PathParams { - portal_id, - rel_oid, - workspace_id, - }): Path, - navigator: Navigator, - State(mut pooler): State, -) -> Result { - let mut workspace_client = pooler - .acquire_for(workspace_id, RoleAssignment::User(user.id)) - .await?; - - let portal = PortalAccessor::new() - .id(portal_id) - .as_actor(Actor::User(user.id)) - .verify_workspace_id(workspace_id) - .verify_rel_oid(Oid(rel_oid)) - .verify_rel_ownership() - .using_app_db(&mut app_db) - .using_workspace_client(&mut workspace_client) - .fetch_one() - .await?; - - let workspace = Workspace::with_id(portal.workspace_id) - .fetch_one(&mut app_db) - .await?; - - let attrs: HashMap = PgAttribute::all_for_rel(portal.class_oid) - .fetch_all(&mut workspace_client) - .await? - .into_iter() - .map(|attr| (attr.attname.clone(), attr)) - .collect(); - let fields: Vec = { - let fields: Vec = Field::belonging_to_portal(portal.id) - .fetch_all(&mut app_db) - .await?; - let mut field_info: Vec = Vec::with_capacity(fields.len()); - for field in fields { - let attr = attrs.get(&field.name); - let prompts: HashMap = FieldFormPrompt::belonging_to_field(field.id) - .fetch_all(&mut app_db) - .await? - .into_iter() - .map(|value| (value.language, value.content)) - .collect(); - field_info.push(FormFieldInfo { - field, - column_present: attr.is_some(), - has_default: attr.map(|value| value.atthasdef).unwrap_or(false), - not_null: attr.and_then(|value| value.attnotnull).unwrap_or_default(), - prompts, - }); - } - field_info - }; - - // FIXME: exclude portals user does not have access to, as well as - // unnecessary fields - let portal_sets = - fetch_all_accessible_portals(workspace_id, &mut app_db, &mut workspace_client).await?; - - #[derive(Template)] - #[template(path = "relations_single/form_index.html")] - struct ResponseTemplate { - fields: Vec, - identifier_hints: Vec, - languages: Vec, - navigator: Navigator, - portal: Portal, - portals: Vec, - settings: Settings, - transitions: Vec, - workspace_nav: WorkspaceNav, - } - #[derive(Debug, Serialize)] - struct LanguageInfo { - code: String, - locale_str: String, - } - #[derive(Debug, Serialize)] - struct PortalDisplay { - id: Uuid, - display_name: String, - } - Ok(Html( - ResponseTemplate { - fields, - identifier_hints: attrs.keys().cloned().collect(), - languages: Language::iter() - .map(|value| LanguageInfo { - code: value.to_string(), - locale_str: value.as_locale_str().to_owned(), - }) - .collect(), - portal, - portals: portal_sets - .iter() - .flat_map(|RelationPortalSet { rel, portals }| { - portals.iter().map(|portal| PortalDisplay { - id: portal.id, - display_name: format!( - "{rel_name}: {portal_name}", - rel_name = rel.relname, - portal_name = portal.name - ), - }) - }) - .collect(), - transitions: FormTransition::with_source(portal_id) - .fetch_all(&mut app_db) - .await?, - workspace_nav: WorkspaceNav::builder() - .navigator(navigator.clone()) - .workspace(workspace) - .populate_rels(&mut app_db, &mut workspace_client) - .await? - .current(NavLocation::Rel( - Oid(rel_oid), - Some(RelLocation::Portal(portal_id)), - )) - .build()?, - navigator, - settings, - } - .render()?, - )) -} diff --git a/phono-server/src/routes/relations_single/mod.rs b/phono-server/src/routes/relations_single/mod.rs index d743ac1..d88178f 100644 --- a/phono-server/src/routes/relations_single/mod.rs +++ b/phono-server/src/routes/relations_single/mod.rs @@ -8,7 +8,6 @@ use crate::app::App; mod add_field_handler; mod add_portal_handler; -mod form_handler; mod get_data_handler; mod insert_handler; mod portal_handler; @@ -18,9 +17,7 @@ mod set_filter_handler; mod settings_handler; mod update_field_handler; mod update_field_ordinality_handler; -mod update_form_transitions_handler; mod update_portal_name_handler; -mod update_prompts_handler; mod update_rel_name_handler; mod update_values_handler; @@ -58,13 +55,4 @@ pub(super) fn new_router() -> Router { post(update_values_handler::post), ) .route("/p/{portal_id}/set-filter", post(set_filter_handler::post)) - .route_with_tsr("/p/{portal_id}/form/", get(form_handler::get)) - .route( - "/p/{portal_id}/form/update-prompts", - post(update_prompts_handler::post), - ) - .route( - "/p/{portal_id}/form/update-form-transitions", - post(update_form_transitions_handler::post), - ) } diff --git a/phono-server/src/routes/relations_single/update_form_transitions_handler.rs b/phono-server/src/routes/relations_single/update_form_transitions_handler.rs deleted file mode 100644 index ac52953..0000000 --- a/phono-server/src/routes/relations_single/update_form_transitions_handler.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::iter::zip; - -use axum::{debug_handler, extract::Path, response::Response}; -// [`axum_extra`]'s form extractor is required to support repeated keys: -// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform -use axum_extra::extract::Form; -use phono_models::form_transition::{self, FormTransition}; -use serde::Deserialize; -use sqlx::postgres::types::Oid; -use uuid::Uuid; - -use crate::{ - app::AppDbConn, - errors::{AppError, bad_request}, - navigator::{Navigator, NavigatorPage as _}, - user::CurrentUser, -}; - -#[derive(Debug, Deserialize)] -pub(super) struct PathParams { - portal_id: Uuid, - rel_oid: u32, - workspace_id: Uuid, -} - -#[derive(Clone, Debug, Deserialize)] -pub(super) struct FormBody { - dest: Vec, - condition: Vec, -} - -/// HTTP POST handler for setting form transitions for a [`Portal`]. The form -/// body is expected to be an HTTP form encoded with a list of inputs named -/// `"dest"` and `"condition"`, where `"dest"` encodes a portal ID and -/// `"condition"` is JSON deserializing to [`PgExpressionAny`]. -/// -/// Upon success, the client is redirected back to the portal's form editor -/// page. -#[debug_handler(state = crate::app::App)] -pub(super) async fn post( - AppDbConn(mut app_db): AppDbConn, - CurrentUser(_user): CurrentUser, - navigator: Navigator, - Path(PathParams { - portal_id, - rel_oid, - workspace_id, - }): Path, - Form(form): Form, -) -> Result { - // FIXME: Check workspace authorization. - // FIXME ensure workspace corresponds to rel/portal, and that user has - // permission to access/alter both as needed. - // FIXME CSRF - - let replacements = zip(form.dest, form.condition) - .map(|(dest_id, condition)| { - Ok(form_transition::Replacement { - dest_id, - condition: serde_json::from_str(&condition)?, - }) - }) - .collect::, serde_json::Error>>() - .map_err(|err| bad_request!("unable to deserialize condition: {err}"))?; - - FormTransition::replace_for_portal(portal_id) - .replacements(replacements) - .build()? - .execute(&mut app_db) - .await?; - - Ok(navigator - .portal_page() - .workspace_id(workspace_id) - .rel_oid(Oid(rel_oid)) - .portal_id(portal_id) - .suffix("form/") - .build()? - .redirect_to()) -} diff --git a/phono-server/src/routes/relations_single/update_prompts_handler.rs b/phono-server/src/routes/relations_single/update_prompts_handler.rs deleted file mode 100644 index 2143bb7..0000000 --- a/phono-server/src/routes/relations_single/update_prompts_handler.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::{collections::HashMap, str::FromStr}; - -use axum::{ - debug_handler, - extract::{Path, State}, - response::Response, -}; -// [`axum_extra`]'s form extractor is required to support repeated keys: -// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform -use axum_extra::extract::Form; -use phono_models::{ - accessors::{Accessor, Actor, portal::PortalAccessor}, - field::Field, - field_form_prompt::FieldFormPrompt, - language::Language, -}; -use serde::Deserialize; -use sqlx::postgres::types::Oid; -use uuid::Uuid; - -use crate::{ - app::AppDbConn, - errors::{AppError, bad_request}, - navigator::{Navigator, NavigatorPage as _}, - user::CurrentUser, - workspace_pooler::{RoleAssignment, WorkspacePooler}, -}; - -#[derive(Debug, Deserialize)] -pub(super) struct PathParams { - portal_id: Uuid, - rel_oid: u32, - workspace_id: Uuid, -} - -/// HTTP POST handler for setting form prompt content on all fields within a -/// single [`Portal`]. The form body is expected to be an HTTP form encoded -/// mapping of `.` to content. -/// -/// Upon success, the client is redirected back to the portal's form editor -/// page. -#[debug_handler(state = crate::app::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, - rel_oid, - workspace_id, - }): Path, - Form(form): Form>, -) -> Result { - // FIXME CSRF - - let mut workspace_client = workspace_pooler - .acquire_for(workspace_id, RoleAssignment::User(user.id)) - .await?; - - let portal = PortalAccessor::new() - .id(portal_id) - .as_actor(Actor::User(user.id)) - .verify_workspace_id(workspace_id) - .verify_rel_oid(Oid(rel_oid)) - .verify_rel_ownership() - .using_app_db(&mut app_db) - .using_workspace_client(&mut workspace_client) - .fetch_one() - .await?; - - // TODO: This can be sped up somewhat with streams, because queries using - // `app_db` can run at the same time as others using `app_db` or - // `workspace_client`. - for (name, content) in form { - let mut name_split = name.split('.'); - let field_id = name_split - .next() - .and_then(|value| Uuid::parse_str(value).ok()) - .ok_or(bad_request!("expected input name to start with "))?; - - // For authorization. - let _field = Field::belonging_to_portal(portal.id) - .with_id(field_id) - .fetch_one(&mut app_db) - .await?; - - let language = name_split - .next() - .and_then(|value| Language::from_str(value).ok()) - .ok_or(bad_request!( - "expected input name to be ." - ))?; - if name_split.next().is_some() { - return Err(bad_request!("input name longer than expected")); - } - FieldFormPrompt::upsert() - .field_id(field_id) - .language(language) - .content(content) - .build()? - .execute(&mut app_db) - .await?; - } - - // FIXME redirect to the correct page - Ok(navigator - .portal_page() - .workspace_id(workspace_id) - .rel_oid(Oid(rel_oid)) - .portal_id(portal_id) - .suffix("form/") - .build()? - .redirect_to()) -} diff --git a/phono-server/templates/forms/form_index.html b/phono-server/templates/forms/form_index.html deleted file mode 100644 index 29f1fdb..0000000 --- a/phono-server/templates/forms/form_index.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ portal.name }}{% endblock %} - -{% block head_extras %} - -{% endblock %} - -{% block main %} -
-
-
- {% for field in fields %} -
- {% if field.column_present %} - {{ prompts_html.get(field.field.name).cloned().unwrap_or_default() | safe }} - {% match field.field.presentation.0 %} - {% when Presentation::Text { input_mode } %} - {% match input_mode %} - {% when TextInputMode::SingleLine %} - - {% when TextInputMode::MultiLine %} - - {% else %} - {% endmatch %} - {% else %} - {% endmatch %} - {% endif %} -
- {% endfor %} -
-
- -
-
-
-{% endblock %} diff --git a/phono-server/templates/rbac.html b/phono-server/templates/rbac.html deleted file mode 100644 index cb48de8..0000000 --- a/phono-server/templates/rbac.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} - -{% block main %} - - - - - - - - - - - {% for user in users %} - - - - - - {% endfor %} - -
EmailUser IDRolesActions
{{ user.user.email }}{{ role_prefix }}{{ user.user.id.simple() }}...
-{% endblock %} diff --git a/phono-server/templates/rbac_invite.html b/phono-server/templates/rbac_invite.html deleted file mode 100644 index 7e72af6..0000000 --- a/phono-server/templates/rbac_invite.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
-
- - - -
-
-{% endblock %} diff --git a/phono-server/templates/rel_rbac.html b/phono-server/templates/rel_rbac.html deleted file mode 100644 index 542c432..0000000 --- a/phono-server/templates/rel_rbac.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
-
-
- {{ navbar | safe }} -
-
-
-

Sharing

-
-
-

Table Owners

-

- Owners are able to edit table structure, including configuring columns, - adding, updating, and deleting record data, and dropping the table - entirely from the database. -

-

- Each table in Postgres has exactly one owner role, so it's typically - best practice to create a dedicated role for this purpose and then grant - membership of that role to one or more users. -

- {{ owners | safe }} -
-
-

Invitations

- - Invite Collaborators - - - - - - {# rolname is intentionally hidden in a submenu (todo), as it is - likely to confuse new users #} - - - - - - {# place invitations at beginning of list as they're liable to cause - unpleasant surprises if forgotten #} - {% for (email, invites) in invites_by_email %} - - - - - - {% endfor %} - -
EmailPrivilegesActions
{{ email }} - {% for invite in invites %}{{ invite.privilege }}{% endfor %} -
-
-
-

Permissions

-
    - {% for acl_tree in acl_trees %} -
  • -
    - {% for privilege in acl_tree.acl_item.privileges %}{{ privilege.privilege.to_abbrev() }}{% endfor %} -
    - {{ acl_tree.grantees | safe }} -
  • - {% endfor %} -
-
-
-
-{% endblock %} diff --git a/phono-server/templates/relations_single/form_index.html b/phono-server/templates/relations_single/form_index.html deleted file mode 100644 index 3c2a591..0000000 --- a/phono-server/templates/relations_single/form_index.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
- -
-
- {{ workspace_nav | safe }} -
-
-
-
-
-

Prompts

-
- {% for field_info in fields %} -
-
- {{ field_info.field.table_label.clone().unwrap_or(field_info.field.name.clone()) }} -
-
- - -
-
- {% endfor %} -
- -
-
-
-
-

Destinations

- - - -
-
-
-
- - -{% endblock %} diff --git a/svelte/src/combobox.svelte b/svelte/src/combobox.svelte deleted file mode 100644 index 90b69ad..0000000 --- a/svelte/src/combobox.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - - - -
- { - popover_element?.showPopover(); - }} - onkeydown={(ev) => { - if (ev.key === "Escape") { - popover_element?.hidePopover(); - } - }} - type="text" - /> -
- {#each completions as completion} - - {/each} -
-
- - diff --git a/svelte/src/form-transitions-editor.webc.svelte b/svelte/src/form-transitions-editor.webc.svelte deleted file mode 100644 index 2186770..0000000 --- a/svelte/src/form-transitions-editor.webc.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - -
-
    - {#each value as _, i} -
  • -
    - Continue to form: - -
    -
    - if: - - -
    -
  • - {/each} -
- -
- If no destinations match, the user will be redirected to a success page. -
-
diff --git a/svelte/src/i18n-textarea.webc.svelte b/svelte/src/i18n-textarea.webc.svelte deleted file mode 100644 index 3c75a50..0000000 --- a/svelte/src/i18n-textarea.webc.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - -
- - {#each languages as { code }} - - {/each} -