One record answers another, from a list
Test / test (pull_request) Successful in 56s

Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 06:47:36 +02:00
co-authored by Claude Fable 5.1
parent 470338b969
commit fc998c8592
9 changed files with 239 additions and 11 deletions
+63 -2
View File
@@ -251,6 +251,49 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
vars
}
/// The record field a `to` reaches through, when it is neither
/// `submitter` nor an address: `{notice.email}` gives `notice`.
pub fn parent_of(to: &str) -> Option<&str> {
let inner = to.trim().strip_prefix('{')?.strip_suffix('}')?;
let (field, _) = inner.split_once('.')?;
(!field.is_empty() && !inner.contains(char::is_whitespace)).then_some(field)
}
/// The fields of every record this one belongs to, as `{field.name}`:
/// for each `type: record` field of the forms that record into
/// `bucket`, the record it names, read from its own bucket. A
/// reference that does not resolve adds nothing, and a template that
/// needed it renders empty there.
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
let questions = state.questions.load();
let refs: Vec<(String, String)> = questions
.values()
.flat_map(|q| q.alternatives.iter())
.filter(|a| a.record_as.as_deref() == Some(bucket))
.flat_map(|a| a.features.iter().flat_map(|f| f.requirements.iter()))
.filter(|r| r.kind == "record")
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
.collect();
for (field, of) in refs {
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
let Ok(Some(bytes)) = store.get(id).await else { continue };
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
if let Some(fields) = parent.responses.as_object() {
for (k, v) in fields {
let text = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
};
vars.insert(format!("{field}.{k}"), text.into());
}
}
vars.insert(format!("{field}.id"), parent.id.into());
vars.insert(format!("{field}.state"), parent.state.into());
}
}
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
let mut headers = async_nats::HeaderMap::new();
headers.insert("Nats-Msg-Id", mail.id.as_str());
@@ -277,7 +320,8 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
return;
};
let vars = vars_for(bucket, id, to_state, responses);
let mut vars = vars_for(bucket, id, to_state, responses);
parent_vars(state, bucket, responses, &mut vars).await;
let to = if spec.to_submitter() {
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
Some(e) => e.to_string(),
@@ -287,7 +331,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
} else {
spec.to.clone()
// A field of the record this one belongs to, or an address.
let to = render(&spec.to, &vars);
if !to.contains('@') {
tracing::warn!(bucket, id, to_state, to = %spec.to, "the mail's address did not resolve to one - not sent");
return;
}
to
};
let text = render(&spec.body, &vars);
let mail = Outgoing {
@@ -588,6 +638,17 @@ mod tests {
}
}
#[test]
fn a_mail_reaches_through_a_record_field_and_nothing_else() {
assert_eq!(parent_of("{notice.email}"), Some("notice"));
assert_eq!(parent_of(" {case.contact} "), Some("case"));
assert_eq!(parent_of("submitter"), None);
assert_eq!(parent_of("post@vel.no"), None);
assert_eq!(parent_of("{email}"), None);
assert_eq!(parent_of("{.email}"), None);
assert_eq!(parent_of("{not a.field}"), None);
}
#[test]
fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });