build: a plan keeps a live site's event names, and names its automations

`events:` on a need overrides the event a stage is entered by. Portal
replays records by event name, so rebuilding a site that already holds
records has to keep what its aggregates.yaml says, or the desks empty.
`attended_by:` writes portal's annotation for a bucket an automation
moves. plans/uhhm.yaml: uhhm.no from a plan - the five live buckets
with their names, plus trials (the trainer tried on a client's
business, approved by hand) and custody_requests (UHHM as redoal's
custodian, a desk of its own).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 14:49:25 +02:00
co-authored by Claude Fable 5.1
parent 93a6fc0e10
commit 04a6eeea85
3 changed files with 248 additions and 7 deletions
+57 -7
View File
@@ -126,6 +126,19 @@ pub struct PlanNeed {
/// Groups that see these cases on their desk and move nothing.
#[serde(default)]
pub watched_by: Vec<String>,
/// The event name a stage is entered by, where it must not be the
/// builder's default (the stage's own name; `received` for the
/// first). A live site replays its records by event name, so a
/// rebuild of a site that already holds records has to keep the
/// names its aggregates.yaml has, or every record vanishes from
/// its desk.
#[serde(default)]
pub events: BTreeMap<String, String>,
/// Who moves cases that no desk button moves - an automation
/// listening on the bucket's events. Written into aggregates.yaml
/// for the reader, as portal's `attended_by` annotation.
#[serde(default)]
pub attended_by: Option<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.
@@ -253,6 +266,14 @@ impl Plan {
bail!(at(&format!("{state:?} is not one of its stages")));
}
}
for (state, event) in &need.events {
if !need.stages.contains(state) {
bail!(at(&format!("events names {state:?}, which is not one of its stages")));
}
if !is_id(event) {
bail!(at(&format!("event {event:?} for {state:?} must be ASCII lowercase with - or _")));
}
}
for state in &need.success {
if !need.done_when.contains(state) {
bail!(at(&format!("success {state:?} is not one of done_when")));
@@ -303,8 +324,10 @@ impl Plan {
|| other.moves_by != first.moves_by
|| other.watched_by != first.watched_by
|| other.handled_by != first.handled_by
|| other.events != first.events
|| other.attended_by != first.attended_by
{
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable 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 and who moves and watches it", first.id, other.id);
}
}
}
@@ -364,6 +387,19 @@ pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
moves
}
/// The event that enters a stage: what the plan says, else the stage's
/// own name, with the first stage entered by `received` (or `submitted`
/// when a stage is itself called received).
pub fn event_for(need: &PlanNeed, stage: &str) -> String {
if let Some(event) = need.events.get(stage) {
return event.clone();
}
if stage == need.stages[0] {
return if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" }.to_string();
}
stage.to_string()
}
/// The stages a case goes through, as one sentence: "It is received,
/// then surveyed, then quoted, and ends won or lost."
fn stages_sentence(need: &PlanNeed) -> String {
@@ -780,14 +816,16 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"aggregates.yaml".into(),
json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| {
let need = needs[0];
let initial = &need.stages[0];
let first_event = if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" };
json!({
let mut aggregate = json!({
"bucket": bucket,
"initial": initial,
"states": need.stages.iter().map(|st| (st.clone(), json!({ "event": if st == initial { first_event } else { st.as_str() } }))).collect::<Map<_, _>>(),
"initial": &need.stages[0],
"states": need.stages.iter().map(|st| (st.clone(), json!({ "event": event_for(need, st) }))).collect::<Map<_, _>>(),
"transitions": machine(need),
})
});
if let Some(who) = &need.attended_by {
aggregate["attended_by"] = json!(who);
}
aggregate
}).collect::<Vec<_>>() }),
);
@@ -1180,10 +1218,22 @@ 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,
collects: vec![], max_fields: 3,
}
}
#[test]
fn a_live_sites_event_names_survive_a_rebuild() {
let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]);
assert_eq!(event_for(&n, "open"), "received");
assert_eq!(event_for(&n, "active"), "active");
n.events = [("open", "applied"), ("active", "onboarded")].iter().map(|(s, e)| (s.to_string(), e.to_string())).collect();
assert_eq!(event_for(&n, "open"), "applied");
assert_eq!(event_for(&n, "active"), "onboarded");
assert_eq!(event_for(&n, "invited"), "invited");
}
#[test]
fn the_machine_walks_the_line_and_can_be_turned_down_anywhere() {
let m = machine(&need(&["open", "surveyed", "quoted", "won", "lost"], &["won", "lost"], &["won"], &[]));