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:
co-authored by
Claude Fable 5.1
parent
470338b969
commit
fc998c8592
@@ -55,11 +55,15 @@ pub struct AggregateSchema {
|
||||
|
||||
/// A mail to send when a record enters a state. `to` is `submitter`
|
||||
/// (the record's `email` field, which the lint then requires the
|
||||
/// forms to collect) or one fixed address. Subject and body are
|
||||
/// templates: `{field}` for any field of the record, plus `{site}`
|
||||
/// (the instance's public URL), `{chain}` (the record id), `{email}`,
|
||||
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it
|
||||
/// as plain text with an HTML alternative.
|
||||
/// forms to collect), one fixed address, or a field of the record
|
||||
/// this one belongs to: `{notice.email}` when the form has a
|
||||
/// `type: record` field called `notice`. That is how an answer to a
|
||||
/// notice reaches whoever posted it. Subject and body are templates:
|
||||
/// `{field}` for any field of the record, `{notice.field}` for one of
|
||||
/// the record it belongs to, plus `{site}` (the instance's public
|
||||
/// URL), `{chain}` (the record id), `{email}`, `{bucket}` and
|
||||
/// `{state}`. The body is markdown; the mailer sends it as plain text
|
||||
/// with an HTML alternative.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MailSpec {
|
||||
|
||||
+19
-3
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
|
||||
use crate::auth::{current_user, User};
|
||||
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
|
||||
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, Transition};
|
||||
use crate::i18n::t;
|
||||
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
|
||||
|
||||
@@ -1301,6 +1301,7 @@ fn AlternativeCard(
|
||||
alternative=alt_name.clone()
|
||||
feature_name=feature_name.clone()
|
||||
transitions=spec.transitions.clone()
|
||||
link=spec.link.clone()
|
||||
empty=spec.empty.clone()
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
@@ -1753,6 +1754,7 @@ fn ResourceFeature(
|
||||
alternative: String,
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
link: Option<RowLink>,
|
||||
empty: Option<String>,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
@@ -1787,6 +1789,7 @@ fn ResourceFeature(
|
||||
{move || {
|
||||
let feature_name = feature_name.clone();
|
||||
let transitions = transitions.clone();
|
||||
let link = link.clone();
|
||||
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
|
||||
data.get()
|
||||
.map(|res| match res {
|
||||
@@ -1795,6 +1798,7 @@ fn ResourceFeature(
|
||||
<ResourceValue
|
||||
feature_name=feature_name
|
||||
transitions=transitions
|
||||
link=link
|
||||
empty=empty
|
||||
value=value
|
||||
pending_transitions=pending_transitions
|
||||
@@ -1822,6 +1826,7 @@ fn ResourceFeature(
|
||||
fn ResourceValue(
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
link: Option<RowLink>,
|
||||
empty: String,
|
||||
value: serde_json::Value,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
@@ -1853,11 +1858,13 @@ fn ResourceValue(
|
||||
children={
|
||||
let feature_name = feature_name.clone();
|
||||
let transitions = transitions.clone();
|
||||
let link = link.clone();
|
||||
move |answer: Answer| {
|
||||
view! {
|
||||
<AnswerRow
|
||||
feature_name=feature_name.clone()
|
||||
transitions=transitions.clone()
|
||||
link=link.clone()
|
||||
answer=answer
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
@@ -2116,6 +2123,7 @@ fn SelectField(
|
||||
fn AnswerRow(
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
link: Option<RowLink>,
|
||||
answer: Answer,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
@@ -2148,6 +2156,7 @@ fn AnswerRow(
|
||||
let state = answer.state.clone();
|
||||
let item_id = answer.id.clone();
|
||||
let key = (feature_name.clone(), item_id.clone());
|
||||
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
|
||||
|
||||
view! {
|
||||
<article class="answer-row" class:decided=move || decided>
|
||||
@@ -2195,6 +2204,9 @@ fn AnswerRow(
|
||||
<span class="answer-state">{state}</span>
|
||||
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
||||
</div>
|
||||
// The page about this record, when the list offers one:
|
||||
// where a reader answers a notice.
|
||||
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
|
||||
// What the person has written back since, oldest first: the
|
||||
// conversation beside the case, which a desk could not see
|
||||
// before. A note moves nothing, so there is no button here.
|
||||
@@ -2338,13 +2350,17 @@ pub async fn get_question(
|
||||
path: String,
|
||||
chain: Option<String>,
|
||||
) -> Result<Option<Page>, ServerFnError> {
|
||||
use crate::content::{path_matches, resolve_question};
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
use crate::content::{fill_from_user, path_matches, resolve_question};
|
||||
use crate::server::AppState;
|
||||
let state = expect_context::<AppState>();
|
||||
let questions = state.questions.load();
|
||||
let Some(question) = resolve_question(&questions, &path) else {
|
||||
let Some(mut question) = resolve_question(&questions, &path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
fill_from_user(&mut question, user.as_ref());
|
||||
let mut chain_gate = None;
|
||||
if let Some(target) = &question.requires_chain {
|
||||
// The claimed lineage must verifiably end at the required
|
||||
|
||||
+85
-1
@@ -207,6 +207,35 @@ impl Question {
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed-in person's own details into the fields that ask for
|
||||
/// them: `value: "{user.email}"` becomes their address and the field
|
||||
/// hidden, since asking someone who is known to say who they are is
|
||||
/// the form doubting them. Without a session the placeholder is
|
||||
/// emptied, and the field asks. Only the whole of a value is filled
|
||||
/// this way; a value that mixes a user placeholder with other text is
|
||||
/// left alone.
|
||||
pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) {
|
||||
for alt in &mut question.alternatives {
|
||||
for feature in &mut alt.features {
|
||||
for req in &mut feature.requirements {
|
||||
let Some(value) = req.value.as_deref() else { continue };
|
||||
let filled = match value {
|
||||
"{user.email}" => user.and_then(|u| u.email.clone()),
|
||||
"{user.name}" => user.map(|u| u.display_name.clone()),
|
||||
_ => continue,
|
||||
};
|
||||
match filled.filter(|v| !v.trim().is_empty()) {
|
||||
Some(v) => {
|
||||
req.value = Some(v);
|
||||
req.kind = "hidden".to_string();
|
||||
}
|
||||
None => req.value = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An alternative's `action` may carry `{field}` / `{field.sub}`
|
||||
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
|
||||
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
|
||||
@@ -439,6 +468,14 @@ pub struct ResourceSpec {
|
||||
/// didn't declare. `Kv` sources only.
|
||||
#[serde(default)]
|
||||
pub transitions: Vec<Transition>,
|
||||
/// A way from each listed record to a page about it: `to` is the
|
||||
/// page, with `{id}` for the record's id and `{field}` for any of
|
||||
/// its fields, and `label` the words on the link. The other half
|
||||
/// of `type: record`, seen from the list: a notice on a board links
|
||||
/// to the page where a neighbour answers it, and that page's form
|
||||
/// carries the notice's id back in.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<RowLink>,
|
||||
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
|
||||
/// fetched value before it reaches the frontend - e.g.
|
||||
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
|
||||
@@ -542,7 +579,10 @@ pub struct Requirement {
|
||||
/// A preset value the field starts with. On a dynamic page the
|
||||
/// URL segment substitutes into `{name}` placeholders here, like
|
||||
/// resource keys - `value: "{key}"` on a hidden field carries the
|
||||
/// page's key into the answer.
|
||||
/// page's key into the answer. `{user.email}` and `{user.name}`
|
||||
/// are the signed-in person's, and a field they fill is carried
|
||||
/// rather than asked (see `fill_from_user`); for a visitor they
|
||||
/// are empty, and the field asks as usual.
|
||||
#[serde(default)]
|
||||
pub value: Option<String>,
|
||||
/// `type: record` only - the bucket the referenced record lives in.
|
||||
@@ -574,6 +614,30 @@ pub struct Requirement {
|
||||
/// listing may ask about a record's own contents: one field, one
|
||||
/// value, compared as strings. Anything more is a query language, and
|
||||
/// a jq filter is already there for shaping what comes back.
|
||||
/// A link from every listed record to a page about it (`ResourceSpec.link`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RowLink {
|
||||
pub to: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl RowLink {
|
||||
/// The link for one record: `{id}` is the record's id, `{name}`
|
||||
/// any string field of its responses. A placeholder nothing fills
|
||||
/// stays as it is, which is visible, rather than a wrong page.
|
||||
pub fn href(&self, id: &str, responses: &serde_json::Value) -> String {
|
||||
let mut out = self.to.replace("{id}", id);
|
||||
if let Some(fields) = responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
if let Some(s) = v.as_str() {
|
||||
out = out.replace(&format!("{{{k}}}"), s);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FieldIs {
|
||||
pub field: String,
|
||||
@@ -1790,6 +1854,26 @@ alternatives:
|
||||
assert_eq!(render_inline_markdown("a\nb"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_in_person_is_not_asked_who_they_are() {
|
||||
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
|
||||
let q: Question = serde_yaml::from_str(yaml).unwrap();
|
||||
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], email: Some("kari@vel.no".into()) };
|
||||
let mut signed_in = q.clone();
|
||||
fill_from_user(&mut signed_in, Some(&user));
|
||||
let reqs = &signed_in.alternatives[0].features[0].requirements;
|
||||
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
|
||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
|
||||
// Mixed text is not a user field, and is left as written.
|
||||
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
|
||||
// A visitor is asked: the placeholder goes, the field stays.
|
||||
let mut visitor = q.clone();
|
||||
fill_from_user(&mut visitor, None);
|
||||
let reqs = &visitor.alternatives[0].features[0].requirements;
|
||||
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
|
||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_drops_html_and_unsafe_links() {
|
||||
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
|
||||
|
||||
@@ -135,6 +135,27 @@ pub fn validate_questions(
|
||||
}
|
||||
}
|
||||
|
||||
// A mail to the record this one belongs to goes through a field of
|
||||
// the form: `{notice.email}` needs a `type: record` field called
|
||||
// `notice` on every form that records into the bucket, or the mail
|
||||
// has nowhere to go.
|
||||
for (bucket, schema) in aggregates {
|
||||
for (state, spec) in &schema.mail_for_state {
|
||||
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
|
||||
if !has {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
|
||||
question.id, alternative.name, spec.to
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A state that grants a group makes an account for the person the
|
||||
// record is about, so the record has to know who they are. Same
|
||||
// shape as an invite's own rule, one step further back: the form
|
||||
|
||||
+63
-2
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user