From d4d4c7cba335d4df00b4d2c2f3da443ef1ed99a4 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 28 Sep 2026 20:34:53 +0200 Subject: [PATCH] Portal 0.5 in the plan, mails scored as read, and a front door that shows Portal 0.5's content, from the plan: `grants` (an approval that is membership; refused without a mail sender, since the sign-in link goes by mail), `deadlines` (portal's `after:`, only along a move the machine has), and `public_states` with an `empty` line on public lists, whose words now say which stages they show. Onboarding is a mail too: a plan that invites or grants gets `mail.invite` in site.yaml in its own language, and a reword that loses {link} gets it back. `rate --repo` rates every mail, the invite included, read as it arrives: LIX, and what happened / what now / who wrote, each against a gutted twin. The mail words are guided by what that found on uhhm (name the case by a short required field, sign with the business, invite a reply rather than promise a reason). The front door shows public lists instead of pointing at them: each public bucket is a feature with its newest three records inline and a link to the whole list. Its card is named for what it shows. The plan reference gains 'What a portal site can do': the schema's options and when each pays off, as guidance rather than rules. Co-Authored-By: Claude Opus 5.5 --- plans/uhhm.yaml | 6 +- src/build.rs | 284 ++++++++++++++++++++++++++++++++++++++++++------ src/plan.rs | 40 ++++++- src/rate.rs | 115 ++++++++++++++++++-- 4 files changed, 399 insertions(+), 46 deletions(-) diff --git a/plans/uhhm.yaml b/plans/uhhm.yaml index 3e224de..d02c9c5 100644 --- a/plans/uhhm.yaml +++ b/plans/uhhm.yaml @@ -161,9 +161,13 @@ needs: success: [approved] events: { open: proposed } stages_text: { open: "received", approved: "approved", rejected: "turned down" } + # The site changes in the open: anyone can read what was approved, + # without who proposed it. + watched_by: [public] + public_states: [approved] max_fields: 3 collects: - - { name: submitted_by } + - { name: submitted_by, private: true } - { name: email, type: email, optional: true } - { name: page, optional: true } - { name: rationale, type: textarea } diff --git a/src/build.rs b/src/build.rs index 1197a70..84b176a 100644 --- a/src/build.rs +++ b/src/build.rs @@ -253,6 +253,24 @@ pub struct PlanNeed { /// case moves; the words model writes the subject and body. #[serde(default)] pub mails: BTreeMap, + /// A stage that makes the person a member of a group when their + /// case arrives there (portal's `grants:`): an approval that means + /// membership, with no second step to forget. The form must then + /// collect a required `name` and `email`, since by the time a desk + /// presses approve there is nobody left to ask. + #[serde(default)] + pub grants: BTreeMap, + /// A stage a clock moves on from (portal's `after:`): a case left + /// in it that many days, measured from its last move, goes on to + /// `to`. Only along a move the state machine already has; a clock + /// may do only what a person could have done. + #[serde(default)] + pub deadlines: BTreeMap, + /// With `watched_by: [public]`, the stages the public list shows; + /// empty shows all. A noticeboard shows what is up, not what was + /// taken down; a register shows what was decided. + #[serde(default)] + pub public_states: Vec, pub collects: Vec, /// How many required fields this person will tolerate: what the /// business said about them, not what the form happens to ask. @@ -292,6 +310,13 @@ impl Field { } } +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Deadline { + pub days: u32, + pub to: String, +} + /// The name a plan uses in `watched_by` for everyone: a read-only page /// of the bucket, no login. pub const PUBLIC: &str = "public"; @@ -449,6 +474,37 @@ impl Plan { bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from")); } } + for (state, group) in &need.grants { + if self.mail.is_none() { + bail!(at("grants a group, so the plan needs `mail: { from: ... }`: the new member's sign-in link is sent by mail, and without a sender it reaches nobody")); + } + if !need.stages[1..].contains(state) { + bail!(at(&format!("grants names {state:?}, which is not a stage a case can move into"))); + } + if !is_id(group) || group == PUBLIC || group == SUBMITTER { + bail!(at(&format!("grants {state:?} must name a group, not {group:?}"))); + } + let required = |name: &str, kind: &str| need.collects.iter().any(|f| f.name == name && f.kind == kind && !f.optional); + if !required("name", "text") || !required("email", "email") { + bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)")); + } + } + for (state, d) in &need.deadlines { + if d.days == 0 { + bail!(at(&format!("deadline on {state:?} must wait at least one day"))); + } + if !machine(need).get(state).is_some_and(|tos| tos.contains(&d.to)) { + bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to))); + } + } + if !need.public_states.is_empty() { + if !need.watched_by.iter().any(|g| g == PUBLIC) { + bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by")); + } + if let Some(bad) = need.public_states.iter().find(|s| !need.stages.contains(s)) { + bail!(at(&format!("public_states names {bad:?}, which is not one of its stages"))); + } + } for (state, text) in &need.stages_text { if !need.stages.contains(state) { bail!(at(&format!("stages_text names {state:?}, which is not one of its stages"))); @@ -518,11 +574,14 @@ impl Plan { || other.events != first.events || other.attended_by != first.attended_by || other.mails != first.mails + || other.grants != first.grants + || other.deadlines != first.deadlines + || other.public_states != first.public_states || other.stages_text != first.stages_text || other.turned_down_from != first.turned_down_from || other.reached_from != first.reached_from { - 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); + bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails, grants, deadlines, public_states and who moves and watches it", first.id, other.id); } } } @@ -755,8 +814,10 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { shopping.insert( format!("status-{bucket}"), json!({ - "heading": format!("See all {}", humanize(bucket).to_lowercase()), - "description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), + "heading": format!("Latest {}", humanize(bucket).to_lowercase()), + "description": public_blurb(&needs), + "link": format!("All {}", humanize(bucket).to_lowercase()), + "empty": "Nothing here yet.", "icon": "list", }), ); @@ -767,7 +828,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { "question": "What brings you here?", "description": plan.about, "opener": { "heading": "What we do", "description": "", "features": does }, - "shopping": { "heading": "Have a look around", "description": "", "items": shopping }, + // Named for what it shows, never an invitation to look. + "shopping": { "heading": format!("Latest from {}", plan.business), "description": "", "items": shopping }, // A door's feature says what is behind it, from the needs // that sit there: a card that leads somewhere has to say // where, or it is a button with decoration around it. @@ -880,18 +942,25 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { public_view_path(bucket), json!({ "question": format!("What has been sent in about {}?", humanize(bucket).to_lowercase()), - "description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), + "description": public_blurb(&needs), "heading": humanize(bucket), "list_description": "", // A public list says what a row is and what it leaves // out, since the reader cannot know what was pruned. "list_title": humanize(bucket), "list_note": "Each row is one case and the stage it has reached. Names and contact details are not shown here.", + "empty": "Nothing here yet.", "back": "Back", }), ); } + // The invite mail, when people are brought in through the site + // and it sends mail: the first thing a new member reads from it. + if plan.mail.is_some() && has_onboarding(plan) { + words.insert("site.yaml".to_string(), json!({ "invite": invite_fallback(&plan.language) })); + } + // 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 @@ -957,6 +1026,43 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { words } +/// Whether anyone is brought in through the site: invites from a desk, +/// or a stage that grants a group. Both send portal's invite mail. +fn has_onboarding(plan: &Plan) -> bool { + !plan.invites_by.is_empty() || plan.groups.iter().any(|g| !g.invited_by.is_empty()) || plan.needs.iter().any(|n| !n.grants.is_empty()) +} + +/// The invite mail's fallback words, in the plan's language: the +/// person, the group, the one-time link and when it runs out. Portal +/// fills `{name}`, `{username}`, `{email}`, `{group}`, `{link}`, +/// `{site}`, `{site_name}` and `{expires}`. +fn invite_fallback(lang: &str) -> Value { + if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") { + json!({ + "subject": "Din konto hos {site_name}", + "body": "Hei {name},\n\n{site_name} har laget en konto til deg og lagt deg til i {group}. Brukernavnet ditt er {username}.\n\nVelg passord her før {expires}:\n{link}\n\nEtterpå logger du inn på {site}.", + }) + } else { + json!({ + "subject": "Your account at {site_name}", + "body": "Hello {name},\n\n{site_name} has made you an account and added you to {group}. Your username is {username}.\n\nSet your password here before {expires}:\n{link}\n\nAfter that you sign in at {site}.", + }) + } +} + +/// The invite mail without its link cannot be used: a model's reword +/// that dropped `{link}` gets the fallback's link line back. +fn keep_invite_link(lang: &str, merged: &mut Value) { + let Some(body) = merged.pointer_mut("/invite/body") else { return }; + let Some(text) = body.as_str() else { return }; + if text.contains("{link}") { + return; + } + let fallback = invite_fallback(lang); + let link_line = s(&fallback["body"]).lines().filter(|l| l.contains("{link}") || l.contains("{expires}")).collect::>().join("\n"); + *body = Value::String(format!("{}\n\n{link_line}", text.trim_end())); +} + fn door_path(door: &Door) -> String { format!("questions/{}/index.yaml", door.id) } @@ -1067,6 +1173,54 @@ fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> { plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect() } +/// The stages a public list is limited to, in words: "approved", or +/// "approved or refused". Empty when it shows every stage. +fn shown_stages(need: &PlanNeed) -> String { + let words: Vec = need.public_states.iter().map(|st| stage_text(need, st)).collect(); + match words.len() { + 0 => String::new(), + 1 => words[0].clone(), + n => format!("{} or {}", words[..n - 1].join(", "), words[n - 1]), + } +} + +/// How many of a public list's newest records the front door shows. +const FRONT_DOOR_LATEST: usize = 3; + +/// The read-only resource over a public bucket: newest first, only the +/// stages the plan opens, contact details and private fields pruned, +/// and at most `limit` records when given. Portal filters by `states` +/// while listing, before `jq` runs, so the limit counts shown records. +fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option) -> Value { + let mut shown: BTreeSet<&str> = BTreeSet::new(); + for n in needs { + shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str())); + } + let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default(); + // One record per yielded value: portal collects what the filter + // yields into the list, so `map` would nest it once more + // (measured: an empty bucket rendered as `[[]]`). + let jq = format!( + "sort_by(.submitted_ms) | reverse{take} | .[] | .responses |= {{{}}}", + shown.iter().map(|f| format!("{f}: .{f}")).collect::>().join(", ") + ); + json!({ + "source": { "kind": "kv", "bucket": bucket }, + "public": true, + "states": needs[0].public_states, + "empty": empty, + "jq": jq, + }) +} + +/// What a public list holds, in one sentence, honest about its filter. +fn public_blurb(needs: &[&PlanNeed]) -> String { + match shown_stages(needs[0]).as_str() { + "" => format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), + only => format!("What {} sent in that is now {only}.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), + } +} + fn public_view_path(bucket: &str) -> String { format!("questions/status/{bucket}/index.yaml") } @@ -1200,6 +1354,8 @@ fn slots_in(value: &Value, key: &str, parent: &str) -> Value { (_, "who_title") => "a short title, two to four words, for the line that says who reads it".to_string(), (_, "list_description") => "one line over a public list of what people have sent in, or empty".to_string(), (_, "list_title") => "a short title, two to four words, naming the list itself".to_string(), + (_, "link") => "the words of a link to the whole list, two to four words, naming it".to_string(), + (_, "empty") => "one short sentence shown when the list has nothing in it yet, inviting rather than apologising".to_string(), (_, "list_note") => "one sentence saying what a single row is, and what the buttons under it do or what the list leaves out".to_string(), (_, "behind") => "one sentence naming what a reader can do behind this door, in their words".to_string(), (_, "who_description") => "one sentence: who reads what they send, and how to reach them meanwhile".to_string(), @@ -1309,6 +1465,9 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result>(), "transitions": machine(need), @@ -1365,9 +1530,14 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result s(&w["heading"]).trim(), l => l }; + f["description"] = json!(format!("{} [{link}](/status/{bucket})", s(&w["description"]).trim()).trim().to_string()); + f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST)); shopping.push(f); } if !shopping.is_empty() { @@ -1549,19 +1719,6 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result = BTreeSet::new(); - for n in &needs { - shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str())); - } - // Records keep their shape (state, timestamps) so portal - // renders them as records; only the answers are pruned. One - // record per yielded value: portal collects what the filter - // yields into the list, so `map` would nest it once more - // (measured: an empty bucket rendered as `[[]]`). - let projection = format!( - ".[] | .responses |= {{{}}}", - shown.iter().map(|f| format!("{f}: .{f}")).collect::>().join(", ") - ); files.insert( path, json!({ @@ -1572,11 +1729,7 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result String { - 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, at most three short sentences plus the link line, no greeting longer than one word, 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." + let shown_only = public_buckets(plan) + .into_iter() + .find(|(bucket, _)| public_view_path(bucket) == path) + .map(|(_, needs)| shown_stages(needs[0])) + .unwrap_or_default(); + let status_purpose = if shown_only.is_empty() { + "a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list.".to_string() + } else { + format!("a public page anyone can open, read-only, listing ONLY the cases that have reached: {shown_only}. Nothing else is shown - do not promise the reader anything waiting or turned down. `question` is what this page asks; `heading` names the list.") + }; + let purpose = if path == "site.yaml" { + "the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own." + } else 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, at most three short sentences plus the link line, no greeting longer than one word, 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. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation." } 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." + "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: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. 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." } else if path.starts_with("questions/status/") { - "a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list." + status_purpose.as_str() } else if plan.doors.iter().any(|d| door_path(d) == path) { "a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it." } else if path.ends_with("/index.yaml") { @@ -1676,7 +1841,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String { context.extend( plan.needs .iter() - .map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "))), + .map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::>().join(", "))), ); format!( "You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.", @@ -1765,6 +1930,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { keep_links(&fallback_words(&plan)["aggregates.yaml"], &mut mails); words.insert("aggregates.yaml".to_string(), mails); } + if let Some(site_words) = words.get_mut("site.yaml") { + keep_invite_link(&plan.language, site_words); + } write_repo(&out, render(&plan, &words)?)?; // The words as used, so the next rebuild can start from them. std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?; @@ -1815,6 +1983,7 @@ mod tests { 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, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), + grants: BTreeMap::new(), deadlines: BTreeMap::new(), public_states: vec![], collects: vec![], max_fields: 3, } } @@ -2120,11 +2289,17 @@ mod tests { assert!(alts[0].get("action").is_none()); assert_eq!(alts[0]["features"].as_array().unwrap().len(), 2); assert_eq!(alts[0]["features"][0]["description"], "We fix roofs."); - // The look around: the resource verbatim, and the public view linked. - assert_eq!(alts[1]["name"], "Have a look around"); + // The live content, named for what it is: the resource verbatim, + // and the public list shown inline - its newest three - with a + // link to the whole of it. + assert!(alts[1]["name"].as_str().unwrap().starts_with("Latest from")); assert!(alts[1].get("action").is_none()); assert_eq!(alts[1]["features"][0]["resource"]["source"]["kind"], "url"); - assert!(alts[1]["features"][1]["description"].as_str().unwrap().contains("](/status/leaks)")); + let listed = &alts[1]["features"][1]; + assert!(listed["description"].as_str().unwrap().contains("](/status/leaks)")); + assert_eq!(listed["resource"]["source"]["bucket"], "leaks"); + assert_eq!(listed["resource"]["public"], true); + assert!(listed["resource"]["jq"].as_str().unwrap().starts_with("sort_by(.submitted_ms) | reverse | .[:3] | .[] |")); // Only then the offers. assert_eq!(alts[2]["record_as"], "leaks"); assert_eq!(alts[3]["record_as"], "applicants"); @@ -2207,7 +2382,7 @@ mod tests { let resource = &view["alternatives"][0]["features"][0]["resource"]; assert_eq!(resource["public"], true); assert!(resource.get("requires_group").is_none()); - assert_eq!(resource["jq"], ".[] | .responses |= {what: .what, where: .where}"); + assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {what: .what, where: .where}"); assert!(view.get("qualifies").is_none()); // The followup page points people at the list. let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap(); @@ -2217,6 +2392,45 @@ mod tests { assert!(internal.validate().unwrap_err().to_string().contains("public view")); } + #[test] + fn a_stage_can_grant_a_group_a_clock_can_move_a_case_and_a_public_list_can_show_some_stages() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: komite\n who: en som vil sitte i en komité\n wants: en plass\n lands_in: soknader\n handled_by: styret\n stages: [open, plassert, avslatt]\n done_when: [plassert, avslatt]\n grants: { plassert: komiteer }\n max_fields: 3\n collects:\n - { name: name }\n - { name: email, type: email }\n - id: oppslag\n who: en nabo\n wants: et oppslag\n lands_in: oppslag\n handled_by: styret\n stages: [open, lagt_ut, tatt_ned]\n done_when: [tatt_ned]\n watched_by: [public]\n public_states: [lagt_ut]\n deadlines: { lagt_ut: { days: 30, to: tatt_ned } }\n max_fields: 2\n collects:\n - { name: hva, type: textarea }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap(); + let bucket = |name: &str| agg["aggregates"].as_array().unwrap().iter().find(|a| a["bucket"] == name).unwrap().clone(); + assert_eq!(bucket("soknader")["states"]["plassert"]["grants"], "komiteer"); + assert_eq!(bucket("oppslag")["states"]["lagt_ut"]["after"], json!({"days": 30, "to": "tatt_ned"})); + let view: Value = serde_yaml::from_str(&files["questions/status/oppslag/index.yaml"]).unwrap(); + let resource = &view["alternatives"][0]["features"][0]["resource"]; + assert_eq!(resource["states"], json!(["lagt_ut"])); + assert_eq!(resource["empty"], "Nothing here yet."); + + // A new member's sign-in link goes by mail: the site writes its + // invite mail, and a reword that loses the link gets it back. + let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap(); + assert!(s(&site["mail"]["invite"]["body"]).contains("{link}")); + let mut words = fallback_words(&plan); + words.insert("site.yaml".into(), json!({ "invite": { "subject": "Velkommen", "body": "Hei {name}, du er med i {group}." } })); + keep_invite_link("no", words.get_mut("site.yaml").unwrap()); + let body = s(&words["site.yaml"]["invite"]["body"]).to_string(); + assert!(body.starts_with("Hei {name}, du er med i {group}.") && body.contains("{link}") && body.contains("{expires}"), "{body}"); + // Without a sender, a grant is refused: the link would reach nobody. + let no_sender: Plan = serde_yaml::from_str(&yaml.replace("mail: { from: post@b.example }\n", "")).unwrap(); + assert!(no_sender.validate().unwrap_err().to_string().contains("reaches nobody")); + + // A grant needs the person's name and address on the form. + let no_name: Plan = serde_yaml::from_str(&yaml.replace(" - { name: name }\n", "")).unwrap(); + assert!(no_name.validate().unwrap_err().to_string().contains("required `name`")); + // A clock may only make a move the machine has. + let off_graph: Plan = serde_yaml::from_str(&yaml.replace("{ days: 30, to: tatt_ned }", "{ days: 30, to: open }")).unwrap(); + assert!(off_graph.validate().unwrap_err().to_string().contains("not a move")); + // public_states without a public list says nothing. + let not_public: Plan = serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap(); + assert!(not_public.validate().unwrap_err().to_string().contains("no `public`")); + } + #[test] fn a_form_that_asks_more_than_the_person_will_tolerate_is_refused() { // Two required fields under a budget of one: the plan is refused diff --git a/src/plan.rs b/src/plan.rs index 58d5bc0..4cb9c21 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -40,8 +40,8 @@ does: # the front door's first card: what the business d for: # role the business exists for and the next one it serves. text: what it does for them, in the intake's own words -shows: # things a visitor may look at before asking for anything; - - id: kebab-case # each `resource` is portal's resource block, verbatim +shows: # what the site shows as it is: a live figure, the latest + - id: kebab-case # releases, what has been built; `resource` is portal's block text: one line on what it is resource: { public: true, source: { kind: url, url: https://... } } @@ -98,6 +98,16 @@ needs: # who hears when a case enters a stage: `submitter` (the form # must then collect a required `email`) or one address. Tell the # person on arrival and on every decision that is about them. + grants: { : } + # a stage that makes the person a member of a group when their + # case arrives (an approval that IS membership); the form must + # then collect a required `name` and a required `email` + deadlines: { : { days: 30, to: } } + # a stage a clock moves on from when a case sits in it that long + # (a notice that expires, a hearing with no answer); only along + # a move the stages already allow + public_states: [] # with `public` in watched_by: the stages the public list shows + # (a noticeboard shows what is up, not what was taken down) stages_text: { open: , : } # each stage in the intake's language, where the ASCII id # would read wrong to a person: "ikke_lost" is "ikke løst" @@ -124,6 +134,32 @@ needs: - { name: address, optional: true, private: true } # never on a public page ``` +What a portal site can do - use what serves this business, leave the +rest. Each is one line in the plan, and the builder does the wiring: + +- **Show, not only ask.** A bucket with `watched_by: [public]` becomes + a public list: its newest few records sit inline on the front door, + linked to the whole list, contact details pruned. What neighbours + reported and where it stands, which changes were approved, what is + on the noticeboard: a visitor who sees the place working trusts it, + and a person who reported something sees it being dealt with. + `public_states` shows only the stages worth showing. +- **Stay in dialogue.** `mails` tells the person at the stages that + are about them, and a reply to that mail comes back to the case as + a note. `moves_by: { : [submitter] }` gives them a move of + their own through their link: confirm, withdraw, say it is solved. +- **Let a decision do the work.** `grants` makes an approval into + membership; `deadlines` lets a stage end on its own (a notice that + expires, a request nobody took up); `invites_by` lets a desk bring a + person in without anyone touching an admin tool. +- **Make the choices real.** `groups` with labels, and a select with + `options_from: groups`, so a form offers exactly the committees or + teams that exist; `multiple: true` where a person can pick several. +- **Place things.** A `location` field puts a pin on a map (where the + hazard is, where the job is), when the site has maps. +- **Open with the business itself.** `does` says what it does for the + roles it exists for; `shows` puts its live content beside that. + Rules: - One need per (kind of person, thing they want). Work inside the business counts: a role recording or posting something is a need diff --git a/src/rate.rs b/src/rate.rs index 7df5a70..2cb61c3 100644 --- a/src/rate.rs +++ b/src/rate.rs @@ -228,20 +228,78 @@ pub(crate) fn mail_texts(repo: &Path) -> Option> { } out.split_whitespace().collect::>().join(" ") }; + Some(raw_mails(repo, &doc).into_iter().map(|(name, subject, body)| (name, strip(&subject), strip(&body))).collect()) +} + +/// Every mail the site sends, as written: the state mails in +/// aggregates.yaml, then the invite mail in site.yaml - onboarding is +/// a mail too, and the first one a new member reads. +fn raw_mails(repo: &Path, aggregates: &Value) -> Vec<(String, String, String)> { let mut out = Vec::new(); - for aggregate in doc["aggregates"].as_array().into_iter().flatten() { + for aggregate in aggregates["aggregates"].as_array().into_iter().flatten() { let bucket = aggregate["bucket"].as_str().unwrap_or_default(); for (state, spec) in aggregate["states"].as_object().into_iter().flatten() { if let Some(mail) = spec.get("mail") { out.push(( format!("{bucket}:{state}"), - strip(mail["subject"].as_str().unwrap_or_default()), - strip(mail["body"].as_str().unwrap_or_default()), + mail["subject"].as_str().unwrap_or_default().to_string(), + mail["body"].as_str().unwrap_or_default().to_string(), )); } } } - Some(out) + let site: Value = std::fs::read_to_string(repo.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let invite = &site["mail"]["invite"]; + if invite.is_object() { + out.push(("invite".to_string(), invite["subject"].as_str().unwrap_or_default().to_string(), invite["body"].as_str().unwrap_or_default().to_string())); + } + out +} + +/// A mail as it arrives: the placeholders filled with plausible values, +/// so a reader meets a link where the link will be, not a blank. +fn as_received(template: &str, site_name: &str) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + out.push_str(&rest[..i]); + let after = &rest[i + 1..]; + match after.find('}') { + Some(j) if after[..j].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') && j > 0 => { + out.push_str(&match &after[..j] { + "site" => "https://example.site".to_string(), + "chain" => "8f3a1c".to_string(), + "link" => "https://id.example.site/reset/8f3a1c".to_string(), + "site_name" => site_name.to_string(), + "expires" => "2026-10-05 12:00 UTC".to_string(), + key => format!("[{}]", key.replace('_', " ")), + }); + rest = &after[j + 1..]; + } + _ => { + out.push('{'); + rest = after; + } + } + } + out.push_str(rest); + out +} + +const MAIL_QUESTIONS: [(&str, &str); 3] = [ + ("happened", "Do you know, from this mail alone, what has happened and to what?"), + ("now", "Do you know what, if anything, you can or must do now, and how?"), + ("who", "Do you know who sent it, and how you would answer them?"), +]; + +/// Three yes-or-no questions on one mail, from the person it is for. +fn mail_understanding(cmd: &str, reader: &str, subject: &str, body: &str, seed: usize) -> anyhow::Result<[bool; 3]> { + let prompt = format!( + "You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}", + MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::>().join("\n") + ); + let reply = ask(cmd, &prompt, &seed.to_string())?; + Ok([reply["happened"] == true, reply["now"] == true, reply["who"] == true]) } /// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or @@ -339,14 +397,41 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { // The mails first, when the repo carries any: a person reads a // mail alone, with no card around it, so its prose is rated on // its own - LIX and length. + // Then the three questions a person asks of any mail - what + // happened, what now, who is this - each also put to a gutted twin + // that says only that there is news. A yes the twin also gets is + // the reader agreeing with anything. if let Some(mails) = repo.as_deref().and_then(mail_texts) { if !mails.is_empty() { - println!("{:<40} {:>6} {:<14} {:>6}", "mail", "LIX", "", "words"); - for (name, subject, body) in &mails { + let dir = repo.as_deref().unwrap_or(Path::new(".")); + let site: Value = std::fs::read_to_string(dir.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let site_name = site["title"].as_str().unwrap_or("the site").to_string(); + let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let raw = raw_mails(dir, &aggregates); + println!("{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says"); + for (i, ((name, subject, body), (_, raw_subject, raw_body))) in mails.iter().zip(&raw).enumerate() { let text = format!("{subject}. {body}"); let score = lix(&text); - println!("{:<40} {:>6.0} {:<14} {:>6}", name, score, lix_band(score), text.split_whitespace().count()); - report.insert(format!("mail {name}"), json!({ "lix": score, "words": text.split_whitespace().count() })); + let reader = if name == "invite" { + format!("someone a member of {site_name} has just brought in to help run it; you did not ask for this mail") + } else { + format!("someone who sent a form to {site_name} some time ago") + }; + let (subject_in, body_in) = (as_received(raw_subject, &site_name), as_received(raw_body, &site_name)); + let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?; + let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?; + let yn = |b: bool| if b { "yes" } else { "no" }; + let failed: Vec<&str> = MAIL_QUESTIONS.iter().zip(twin).filter(|(_, t)| *t).map(|((k, _), _)| *k).collect(); + println!( + "{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", + name, score, lix_band(score), text.split_whitespace().count(), yn(got[0]), yn(got[1]), yn(got[2]), + if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) } + ); + report.insert(format!("mail {name}"), json!({ + "lix": score, "words": text.split_whitespace().count(), + "understanding": { "happened": got[0], "now": got[1], "who": got[2] }, + "control_ok": failed.is_empty(), + })); } println!(); } @@ -520,6 +605,20 @@ mod tests { assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it."); } + #[test] + fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() { + let got = as_received("Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", "Tomter Vel"); + assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}"); + let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap(); + std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap(); + let mails = mail_texts(&dir).unwrap(); + assert_eq!(mails.iter().map(|m| m.0.as_str()).collect::>(), ["b:open", "invite"]); + assert_eq!(mails[0].2, "Vi har det, ."); + std::fs::remove_dir_all(dir).ok(); + } + #[test] fn the_followup_page_is_read_from_the_repo_by_following_the_action() { let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id()));