build: a plan says who hears when a case moves, and the words write the mail
`mails: { <stage>: submitter | <address> }` on a need becomes `mail:`
on that state in aggregates.yaml (portal 0.3.43+), and `mail: { from,
reply_to }` on the plan becomes site.yaml's sender. The words map
gains `aggregates.yaml`, one subject and body per mailing stage, so
the model writes them like any other page; the fallback, in the
plan's language, says what happened and where the submitter's own
move is. A submitter mail needs a required email on the form; any
mail needs a sender. The uhhm and Tomter Vel plans tell the submitter
at every stage that matters to them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
f5f5132056
commit
8341089898
+137
-5
@@ -32,6 +32,10 @@ pub struct Plan {
|
||||
#[serde(default = "english")]
|
||||
pub language: String,
|
||||
pub contact: Contact,
|
||||
/// The sender of every mail the site sends (`mail:` in site.yaml).
|
||||
/// Required as soon as any need has `mails:`.
|
||||
#[serde(default)]
|
||||
pub mail: Option<PlanMail>,
|
||||
/// What the business does, in its own words - context for the
|
||||
/// model's wording, never structure.
|
||||
#[serde(default)]
|
||||
@@ -134,6 +138,16 @@ pub struct Contact {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Who the site's mail comes from: portal sends nothing without it,
|
||||
/// and gdo on the host has to be allowed to send for the domain.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlanMail {
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlanNeed {
|
||||
@@ -181,6 +195,12 @@ pub struct PlanNeed {
|
||||
/// for the reader, as portal's `attended_by` annotation.
|
||||
#[serde(default)]
|
||||
pub attended_by: Option<String>,
|
||||
/// Who hears when a case enters a stage: `submitter` (the person
|
||||
/// who sent the form, at the email it collected) or one address
|
||||
/// (the treasurer, the board). Portal sends it the moment the
|
||||
/// case moves; the words model writes the subject and body.
|
||||
#[serde(default)]
|
||||
pub mails: BTreeMap<String, String>,
|
||||
pub collects: Vec<Field>,
|
||||
/// How many required fields this person will tolerate: what the
|
||||
/// business said about them, not what the form happens to ask.
|
||||
@@ -329,6 +349,21 @@ impl Plan {
|
||||
bail!(at(&format!("{state:?} is not one of its stages")));
|
||||
}
|
||||
}
|
||||
for (state, to) in &need.mails {
|
||||
if !need.stages.contains(state) {
|
||||
bail!(at(&format!("mails names {state:?}, which is not one of its stages")));
|
||||
}
|
||||
if to == SUBMITTER {
|
||||
if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) {
|
||||
bail!(at("a mail to the submitter goes to the email the form collected, so the form must collect a required `email` of type email"));
|
||||
}
|
||||
} else if !to.contains('@') || to.contains(char::is_whitespace) {
|
||||
bail!(at(&format!("mails {state:?} must go to `submitter` or one address, not {to:?}")));
|
||||
}
|
||||
if self.mail.is_none() {
|
||||
bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from"));
|
||||
}
|
||||
}
|
||||
for (state, event) in &need.events {
|
||||
if !need.stages.contains(state) {
|
||||
bail!(at(&format!("events names {state:?}, which is not one of its stages")));
|
||||
@@ -389,8 +424,9 @@ impl Plan {
|
||||
|| other.handled_by != first.handled_by
|
||||
|| other.events != first.events
|
||||
|| other.attended_by != first.attended_by
|
||||
|| other.mails != first.mails
|
||||
{
|
||||
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by and who moves and watches it", first.id, other.id);
|
||||
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it", first.id, other.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,6 +729,58 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
);
|
||||
}
|
||||
|
||||
// The mails: one subject and body per stage that sends one, keyed
|
||||
// by bucket. Templates in portal's terms: {field} for a field of
|
||||
// the record, {site}, {chain}, {email}. The fallback says what
|
||||
// happened and, where the submitter has a move from that stage,
|
||||
// where to make it.
|
||||
let mut mails: Map<String, Value> = Map::new();
|
||||
for (bucket, needs) in plan.by_bucket() {
|
||||
let need = needs[0];
|
||||
if need.mails.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The fallback is true in the plan's language before a model
|
||||
// has written a word: Norwegian or English, the two the plans
|
||||
// come in.
|
||||
let no = plan.language.starts_with("no") || plan.language.starts_with("nb") || plan.language.starts_with("nn");
|
||||
let name_field = need.collects.iter().find(|f| f.name == "name" || f.name == "navn").map(|f| f.name.clone());
|
||||
let greeting = match (&name_field, no) {
|
||||
(Some(f), true) => format!("Hei {{{f}}},\n\n"),
|
||||
(None, true) => "Hei,\n\n".to_string(),
|
||||
(Some(f), false) => format!("Hello {{{f}}},\n\n"),
|
||||
(None, false) => "Hello,\n\n".to_string(),
|
||||
};
|
||||
let mut per_stage: Map<String, Value> = Map::new();
|
||||
for (stage, to) in &need.mails {
|
||||
let stage_word = humanize(stage).to_lowercase();
|
||||
let what = match (stage == &need.stages[0], no) {
|
||||
(true, true) => format!("Vi har mottatt det du sendte om {}.", need.wants),
|
||||
(false, true) => format!("Det du sendte om {} er nå {stage_word}.", need.wants),
|
||||
(true, false) => format!("We have received what you sent about {}.", need.wants),
|
||||
(false, false) => format!("What you sent about {} is now {stage_word}.", need.wants),
|
||||
};
|
||||
let mut body = if to == SUBMITTER {
|
||||
format!("{greeting}{what}")
|
||||
} else if no {
|
||||
format!("En sak i {} er nå {stage_word}: {{chain}}.", humanize(bucket).to_lowercase())
|
||||
} else {
|
||||
format!("A case in {} is now {stage_word}: {{chain}}.", humanize(bucket).to_lowercase())
|
||||
};
|
||||
if to == SUBMITTER && moves_of(need, SUBMITTER).iter().any(|(from, _)| from == stage) {
|
||||
let lead = if no { "Det du kan gjøre nå" } else { "What you can do next" };
|
||||
body.push_str(&format!("\n\n{lead}: {{site}}/decide/{}?chain={{chain}}&email={{email}}", need.id));
|
||||
}
|
||||
let at = if no { "på" } else { "at" };
|
||||
body.push_str(&format!("\n\n{} {at} {}", plan.contact.name, plan.contact.email));
|
||||
per_stage.insert(stage.clone(), json!({ "subject": format!("{}: {}", plan.business, humanize(stage)), "body": body }));
|
||||
}
|
||||
mails.insert(bucket.to_string(), Value::Object(per_stage));
|
||||
}
|
||||
if !mails.is_empty() {
|
||||
words.insert("aggregates.yaml".to_string(), Value::Object(mails));
|
||||
}
|
||||
|
||||
// The submitter's own page: reached by their private link, it
|
||||
// offers the moves that are theirs to make.
|
||||
for need in &plan.needs {
|
||||
@@ -938,8 +1026,16 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
let mut files: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email });
|
||||
|
||||
files.insert("site.yaml".into(), json!({ "title": plan.business, "lang": plan.language }));
|
||||
let mut site = json!({ "title": plan.business, "lang": plan.language });
|
||||
if let Some(mail) = &plan.mail {
|
||||
site["mail"] = json!({ "from": mail.from });
|
||||
if let Some(reply_to) = &mail.reply_to {
|
||||
site["mail"]["reply_to"] = json!(reply_to);
|
||||
}
|
||||
}
|
||||
files.insert("site.yaml".into(), site);
|
||||
|
||||
let mail_words = words.get("aggregates.yaml").cloned().unwrap_or(Value::Null);
|
||||
files.insert(
|
||||
"aggregates.yaml".into(),
|
||||
json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| {
|
||||
@@ -947,7 +1043,14 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
let mut aggregate = json!({
|
||||
"bucket": bucket,
|
||||
"initial": &need.stages[0],
|
||||
"states": need.stages.iter().map(|st| (st.clone(), json!({ "event": event_for(need, st) }))).collect::<Map<_, _>>(),
|
||||
"states": need.stages.iter().map(|st| {
|
||||
let mut state = json!({ "event": event_for(need, st) });
|
||||
if let Some(to) = need.mails.get(st) {
|
||||
let w = &mail_words[bucket][st];
|
||||
state["mail"] = json!({ "to": to, "subject": s(&w["subject"]), "body": s(&w["body"]) });
|
||||
}
|
||||
(st.clone(), state)
|
||||
}).collect::<Map<_, _>>(),
|
||||
"transitions": machine(need),
|
||||
});
|
||||
if let Some(who) = &need.attended_by {
|
||||
@@ -1260,7 +1363,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
}
|
||||
|
||||
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
|
||||
let purpose = if path.contains("/decide/") {
|
||||
let purpose = if path == "aggregates.yaml" {
|
||||
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, short, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it."
|
||||
} else if path.contains("/decide/") {
|
||||
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
|
||||
} else if path == "questions/index.yaml" {
|
||||
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card, things a visitor may look at before asking for anything. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
|
||||
@@ -1390,11 +1495,37 @@ mod tests {
|
||||
lands_in: "b".into(), handled_by: "g".into(),
|
||||
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
|
||||
moves_by: BTreeMap::new(), watched_by: vec![],
|
||||
events: BTreeMap::new(), attended_by: None,
|
||||
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(),
|
||||
collects: vec![], max_fields: 3,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stage_may_mail_the_submitter_or_an_address_and_the_words_fill_it() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: \"B <post@b.example>\", reply_to: i@b.example }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n stages: [open, fixed, dropped]\n done_when: [fixed, dropped]\n success: [fixed]\n moves_by: { dropped: [submitter, roofers] }\n mails: { open: submitter, fixed: submitter, dropped: board@b.example }\n max_fields: 2\n collects:\n - { name: name }\n - { name: email, type: email }\n";
|
||||
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let words = fallback_words(&plan);
|
||||
let open_body = words["aggregates.yaml"]["leaks"]["open"]["body"].as_str().unwrap();
|
||||
// The submitter has a move from open, so the fallback says where.
|
||||
assert!(open_body.starts_with("Hello {name},"));
|
||||
assert!(open_body.contains("{site}/decide/leak?chain={chain}&email={email}"));
|
||||
assert!(!words["aggregates.yaml"]["leaks"]["fixed"]["body"].as_str().unwrap().contains("/decide/"));
|
||||
let files = render(&plan, &words).unwrap();
|
||||
let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap();
|
||||
assert_eq!(site["mail"]["from"], "B <post@b.example>");
|
||||
let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap();
|
||||
let states = &agg["aggregates"][0]["states"];
|
||||
assert_eq!(states["open"]["mail"]["to"], "submitter");
|
||||
assert_eq!(states["dropped"]["mail"]["to"], "board@b.example");
|
||||
assert!(states["fixed"]["mail"]["subject"].as_str().unwrap().contains("Fixed"));
|
||||
// A submitter mail needs the email; mails need a sender.
|
||||
let no_email = yaml.replace(" - { name: email, type: email }\n", "").replace(" moves_by: { dropped: [submitter, roofers] }\n", "");
|
||||
assert!(serde_yaml::from_str::<Plan>(&no_email).unwrap().validate().unwrap_err().to_string().contains("required `email`"));
|
||||
let no_from = yaml.replace("mail: { from: \"B <post@b.example>\", reply_to: i@b.example }\n", "");
|
||||
assert!(serde_yaml::from_str::<Plan>(&no_from).unwrap().validate().unwrap_err().to_string().contains("mail: { from"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_sites_event_names_survive_a_rebuild() {
|
||||
let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]);
|
||||
@@ -1698,6 +1829,7 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, V
|
||||
/// the rendered side) are never an identity.
|
||||
fn identity(v: &serde_yaml::Value) -> Vec<(&'static str, String)> {
|
||||
const KEYS: &[&[&str]] = &[
|
||||
&["bucket"],
|
||||
&["record_as"],
|
||||
&["action"],
|
||||
&["name"],
|
||||
|
||||
Reference in New Issue
Block a user