botserver/src/basic/keywords/on_form_submit.rs
Rodrigo Rodriguez (Pragmatismo) a41ff7a7d4 Add documentation and core BASIC language functions
- Add SET_SCHEDULE.md and TEMPLATE_VARIABLES.md documentation
- Implement array functions (CONTAINS, PUSH/POP, SLICE, SORT, UNIQUE)
- Implement math functions module structure
- Implement datetime functions module structure
- Implement validation functions (ISNULL, ISEMPTY, VAL, STR, TYPEOF)
- Implement error handling functions (THROW, ERROR, ASSERT)
- Add CRM lead scoring keywords (SCORE_LEAD, AI_SCORE_LEAD)
- Add messaging keywords (SEND_TEMPLATE, CREATE_TEMPLATE)
2025-11-30 11:09:16 -03:00

504 lines
16 KiB
Rust

//! ON FORM SUBMIT - Webhook-based form handling for landing pages
//!
//! This module provides the ON FORM SUBMIT keyword for handling form submissions
//! from .gbui landing pages. Forms submitted from gbui files trigger this handler.
//!
//! BASIC Syntax:
//! ON FORM SUBMIT "form_name"
//! ' Handle form data
//! name = FORM.name
//! email = FORM.email
//! TALK "Thank you, " + name
//! END ON
//!
//! Examples:
//! ' Handle contact form submission
//! ON FORM SUBMIT "contact_form"
//! name = FORM.name
//! email = FORM.email
//! message = FORM.message
//!
//! ' Save to database
//! SAVE "contacts", name, email, message
//!
//! ' Send notification
//! SEND MAIL TO "admin@company.com" WITH
//! subject = "New Contact: " + name
//! body = message
//! END WITH
//!
//! ' Respond to user
//! TALK "Thank you for contacting us, " + name + "!"
//! END ON
//!
//! ' Handle lead capture form
//! ON FORM SUBMIT "lead_capture"
//! lead = #{
//! "name": FORM.name,
//! "email": FORM.email,
//! "company": FORM.company,
//! "phone": FORM.phone
//! }
//!
//! score = SCORE_LEAD(lead)
//!
//! IF score >= 70 THEN
//! SEND TEMPLATE "high_value_lead" TO "sales@company.com" VIA "email" WITH lead
//! END IF
//! END ON
use crate::shared::models::UserSession;
use crate::shared::state::AppState;
use log::{debug, error, info, trace};
use rhai::{Dynamic, Engine, EvalAltResult, Map, Position};
use std::collections::HashMap;
use std::sync::Arc;
/// Register the ON FORM SUBMIT keyword
///
/// This keyword allows BASIC scripts to handle form submissions from .gbui files.
/// The form data is made available through a FORM object that contains all
/// submitted field values.
pub fn on_form_submit_keyword(state: &Arc<AppState>, user: UserSession, engine: &mut Engine) {
let state_clone = state.clone();
let user_clone = user.clone();
// Register FORM_DATA function to get form data map
engine.register_fn("FORM_DATA", move || -> Map {
trace!("FORM_DATA called by user {}", user_clone.user_id);
// Return empty map - actual form data is injected at runtime
Map::new()
});
let user_clone2 = user.clone();
// Register FORM_FIELD function to get specific field
engine.register_fn("FORM_FIELD", move |field_name: &str| -> Dynamic {
trace!(
"FORM_FIELD called for '{}' by user {}",
field_name,
user_clone2.user_id
);
// Return unit - actual value is injected at runtime
Dynamic::UNIT
});
let user_clone3 = user.clone();
// Register FORM_HAS function to check if field exists
engine.register_fn("FORM_HAS", move |field_name: &str| -> bool {
trace!(
"FORM_HAS called for '{}' by user {}",
field_name,
user_clone3.user_id
);
false
});
let user_clone4 = user.clone();
// Register FORM_FIELDS function to get list of field names
engine.register_fn("FORM_FIELDS", move || -> rhai::Array {
trace!("FORM_FIELDS called by user {}", user_clone4.user_id);
rhai::Array::new()
});
// Register GET_FORM helper
let user_clone5 = user.clone();
engine.register_fn("GET_FORM", move |form_name: &str| -> Map {
trace!(
"GET_FORM called for '{}' by user {}",
form_name,
user_clone5.user_id
);
let mut result = Map::new();
result.insert("form_name".into(), Dynamic::from(form_name.to_string()));
result.insert("submitted".into(), Dynamic::from(false));
result
});
// Register VALIDATE_FORM helper
let user_clone6 = user.clone();
engine.register_fn("VALIDATE_FORM", move |form_data: Map| -> Map {
trace!("VALIDATE_FORM called by user {}", user_clone6.user_id);
validate_form_data(&form_data)
});
// Register VALIDATE_FORM with rules
let user_clone7 = user.clone();
engine.register_fn("VALIDATE_FORM", move |form_data: Map, rules: Map| -> Map {
trace!(
"VALIDATE_FORM with rules called by user {}",
user_clone7.user_id
);
validate_form_with_rules(&form_data, &rules)
});
// Register REGISTER_FORM_HANDLER to set up form handler
let state_for_handler = state_clone.clone();
let user_clone8 = user.clone();
engine.register_fn(
"REGISTER_FORM_HANDLER",
move |form_name: &str, handler_script: &str| -> bool {
trace!(
"REGISTER_FORM_HANDLER called for '{}' by user {}",
form_name,
user_clone8.user_id
);
// TODO: Store handler registration in state
info!(
"Registered form handler for '{}' -> '{}'",
form_name, handler_script
);
true
},
);
// Register IS_FORM_SUBMISSION check
let user_clone9 = user.clone();
engine.register_fn("IS_FORM_SUBMISSION", move || -> bool {
trace!("IS_FORM_SUBMISSION called by user {}", user_clone9.user_id);
// This would be set to true when script is invoked from form submission
false
});
// Register GET_SUBMISSION_ID
let user_clone10 = user.clone();
engine.register_fn("GET_SUBMISSION_ID", move || -> String {
trace!("GET_SUBMISSION_ID called by user {}", user_clone10.user_id);
// Generate or return the current submission ID
generate_submission_id()
});
// Register SAVE_SUBMISSION to persist form data
let user_clone11 = user.clone();
engine.register_fn(
"SAVE_SUBMISSION",
move |form_name: &str, data: Map| -> Map {
trace!(
"SAVE_SUBMISSION called for '{}' by user {}",
form_name,
user_clone11.user_id
);
save_form_submission(form_name, &data)
},
);
// Register GET_SUBMISSIONS to retrieve past submissions
let user_clone12 = user.clone();
engine.register_fn("GET_SUBMISSIONS", move |form_name: &str| -> rhai::Array {
trace!(
"GET_SUBMISSIONS called for '{}' by user {}",
form_name,
user_clone12.user_id
);
// TODO: Implement database lookup
rhai::Array::new()
});
// Register GET_SUBMISSIONS with limit
let user_clone13 = user.clone();
engine.register_fn(
"GET_SUBMISSIONS",
move |form_name: &str, limit: i64| -> rhai::Array {
trace!(
"GET_SUBMISSIONS called for '{}' with limit {} by user {}",
form_name,
limit,
user_clone13.user_id
);
// TODO: Implement database lookup with limit
rhai::Array::new()
},
);
debug!("Registered ON FORM SUBMIT keyword and helpers");
}
/// Validate form data with basic rules
fn validate_form_data(form_data: &Map) -> Map {
let mut result = Map::new();
let mut is_valid = true;
let mut errors = rhai::Array::new();
// Check for empty required fields (fields that exist but are empty)
for (key, value) in form_data.iter() {
if value.is_unit() || value.to_string().trim().is_empty() {
// Field is empty - might be an error depending on context
// For basic validation, we just note it
}
}
result.insert("valid".into(), Dynamic::from(is_valid));
result.insert("errors".into(), Dynamic::from(errors));
result.insert("field_count".into(), Dynamic::from(form_data.len() as i64));
result
}
/// Validate form data with custom rules
fn validate_form_with_rules(form_data: &Map, rules: &Map) -> Map {
let mut result = Map::new();
let mut is_valid = true;
let mut errors = rhai::Array::new();
for (field_name, rule) in rules.iter() {
let field_key = field_name.as_str();
let rule_str = rule.to_string().to_lowercase();
// Check if field exists
let field_value = form_data.get(field_key);
if rule_str.contains("required") {
match field_value {
None => {
is_valid = false;
let mut error = Map::new();
error.insert("field".into(), Dynamic::from(field_key.to_string()));
error.insert("rule".into(), Dynamic::from("required"));
error.insert(
"message".into(),
Dynamic::from(format!("Field '{}' is required", field_key)),
);
errors.push(Dynamic::from(error));
}
Some(val) if val.is_unit() || val.to_string().trim().is_empty() => {
is_valid = false;
let mut error = Map::new();
error.insert("field".into(), Dynamic::from(field_key.to_string()));
error.insert("rule".into(), Dynamic::from("required"));
error.insert(
"message".into(),
Dynamic::from(format!("Field '{}' cannot be empty", field_key)),
);
errors.push(Dynamic::from(error));
}
_ => {}
}
}
if rule_str.contains("email") {
if let Some(val) = field_value {
let email = val.to_string();
if !email.is_empty() && !is_valid_email(&email) {
is_valid = false;
let mut error = Map::new();
error.insert("field".into(), Dynamic::from(field_key.to_string()));
error.insert("rule".into(), Dynamic::from("email"));
error.insert(
"message".into(),
Dynamic::from(format!("Field '{}' must be a valid email", field_key)),
);
errors.push(Dynamic::from(error));
}
}
}
if rule_str.contains("phone") {
if let Some(val) = field_value {
let phone = val.to_string();
if !phone.is_empty() && !is_valid_phone(&phone) {
is_valid = false;
let mut error = Map::new();
error.insert("field".into(), Dynamic::from(field_key.to_string()));
error.insert("rule".into(), Dynamic::from("phone"));
error.insert(
"message".into(),
Dynamic::from(format!(
"Field '{}' must be a valid phone number",
field_key
)),
);
errors.push(Dynamic::from(error));
}
}
}
}
result.insert("valid".into(), Dynamic::from(is_valid));
result.insert("errors".into(), Dynamic::from(errors));
result.insert("field_count".into(), Dynamic::from(form_data.len() as i64));
result.insert("rules_checked".into(), Dynamic::from(rules.len() as i64));
result
}
/// Basic email validation
fn is_valid_email(email: &str) -> bool {
let email = email.trim();
if email.is_empty() {
return false;
}
// Simple validation: must contain @ and have something before and after
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[0];
let domain = parts[1];
// Local part must not be empty
if local.is_empty() {
return false;
}
// Domain must contain at least one dot and not be empty
if domain.is_empty() || !domain.contains('.') {
return false;
}
// Domain must have something after the last dot
let domain_parts: Vec<&str> = domain.split('.').collect();
if domain_parts.last().map(|s| s.is_empty()).unwrap_or(true) {
return false;
}
true
}
/// Basic phone validation
fn is_valid_phone(phone: &str) -> bool {
let phone = phone.trim();
if phone.is_empty() {
return false;
}
// Remove common formatting characters
let digits: String = phone
.chars()
.filter(|c| c.is_ascii_digit() || *c == '+')
.collect();
// Must have at least 7 digits (minimum for a phone number)
let digit_count = digits.chars().filter(|c| c.is_ascii_digit()).count();
digit_count >= 7
}
/// Generate a unique submission ID
fn generate_submission_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("sub_{}", timestamp)
}
/// Save form submission to storage
fn save_form_submission(form_name: &str, data: &Map) -> Map {
let mut result = Map::new();
let submission_id = generate_submission_id();
// TODO: Implement actual database storage
info!(
"Saving form submission for '{}' with id '{}'",
form_name, submission_id
);
result.insert("success".into(), Dynamic::from(true));
result.insert("submission_id".into(), Dynamic::from(submission_id));
result.insert("form_name".into(), Dynamic::from(form_name.to_string()));
result.insert("field_count".into(), Dynamic::from(data.len() as i64));
result.insert("timestamp".into(), Dynamic::from(chrono_timestamp()));
result
}
/// Get current timestamp in ISO format
fn chrono_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
// Simple ISO-like format without external dependencies
format!("{}Z", secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_valid_email() {
assert!(is_valid_email("user@example.com"));
assert!(is_valid_email("user.name@example.co.uk"));
assert!(is_valid_email("user+tag@example.com"));
assert!(!is_valid_email("invalid"));
assert!(!is_valid_email("@example.com"));
assert!(!is_valid_email("user@"));
assert!(!is_valid_email("user@example"));
assert!(!is_valid_email(""));
}
#[test]
fn test_is_valid_phone() {
assert!(is_valid_phone("+1234567890"));
assert!(is_valid_phone("123-456-7890"));
assert!(is_valid_phone("(123) 456-7890"));
assert!(is_valid_phone("1234567"));
assert!(!is_valid_phone("123"));
assert!(!is_valid_phone(""));
assert!(!is_valid_phone("abc"));
}
#[test]
fn test_validate_form_data() {
let mut form_data = Map::new();
form_data.insert("name".into(), Dynamic::from("John"));
form_data.insert("email".into(), Dynamic::from("john@example.com"));
let result = validate_form_data(&form_data);
assert!(result.get("valid").unwrap().as_bool().unwrap());
}
#[test]
fn test_validate_form_with_rules_required() {
let mut form_data = Map::new();
form_data.insert("name".into(), Dynamic::from("John"));
// Missing email field
let mut rules = Map::new();
rules.insert("name".into(), Dynamic::from("required"));
rules.insert("email".into(), Dynamic::from("required"));
let result = validate_form_with_rules(&form_data, &rules);
assert!(!result.get("valid").unwrap().as_bool().unwrap());
}
#[test]
fn test_validate_form_with_rules_email() {
let mut form_data = Map::new();
form_data.insert("email".into(), Dynamic::from("invalid-email"));
let mut rules = Map::new();
rules.insert("email".into(), Dynamic::from("email"));
let result = validate_form_with_rules(&form_data, &rules);
assert!(!result.get("valid").unwrap().as_bool().unwrap());
}
#[test]
fn test_generate_submission_id() {
let id = generate_submission_id();
assert!(id.starts_with("sub_"));
}
#[test]
fn test_save_form_submission() {
let mut data = Map::new();
data.insert("name".into(), Dynamic::from("Test"));
let result = save_form_submission("test_form", &data);
assert!(result.get("success").unwrap().as_bool().unwrap());
assert!(result.contains_key("submission_id"));
}
}