build: invites can be sent again, and suggested changes have a desk

Portal 0.5.4 moves an invite whose link ran out to expired and files
what responsible people suggest as cases in portal_edit_suggestions.
Every desk that lists invites gets the move back to open (a fresh link,
mailed), and the desk that invites into the most groups - where portal
seeds the first person - gets the list of suggestions with its moves.

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-29 21:01:48 +02:00
co-authored by Claude Fable 5.1
parent 6a746543df
commit 5e53485de5
+71 -5
View File
@@ -932,15 +932,34 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
} else { } else {
json!({ json!({
"heading": "Invitations sent", "heading": "Invitations sent",
"description": "Each invite with the link it made. Mark it done when the person is in.", "description": loc(lang, "Each invite and where it stands. A link that runs out unused can be sent again.", "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt."),
"done": "Is in", "done": "Is in",
// Portal moves an invite whose link ran out to
// `expired`; from there the desk may send it again.
"again": loc(lang, "Send again", "Send på nytt"),
"list_title": loc(lang, "Who has been invited", "Hvem som er invitert"), "list_title": loc(lang, "Who has been invited", "Hvem som er invitert"),
"list_note": loc(lang, "Each row is one invitation, with the one-time link while it is unused.", "Hver rad er én invitasjon, med engangslenken så lenge den ikke er brukt."), "list_note": loc(lang, "Each row is one invitation. It is done by itself when the person signs in, and expired when the link ran out unused.", "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."),
}) })
}; };
// What the people named as responsible for a page suggest
// changing: portal files each suggestion as a case, and the
// desk that brings people in is the one that settles them.
let suggestions = if suggestions_desk(plan).as_deref() == Some(group.as_str()) {
json!({
"heading": loc(lang, "Suggested changes to the site", "Forslag til endringer på siden"),
"description": loc(lang, "What the people listed as responsible for a question have suggested changing. They are told each time a suggestion moves.", "Det de som står som ansvarlige for et spørsmål, har foreslått å endre. De får beskjed når forslaget flyttes."),
"list_title": loc(lang, "Suggestions", "Forslag"),
"list_note": loc(lang, "Each row is one suggestion, with the page it is about. The buttons record what you have just done with it.", "Hver rad er ett forslag, med siden det gjelder. Knappene fører opp det du nettopp har gjort med det."),
"looking": loc(lang, "We are looking at it", "Vi ser på det"),
"changed": loc(lang, "The change is made", "Endringen er gjort"),
"declined": loc(lang, "Will not be done", "Gjøres ikke"),
})
} else {
Value::Null
};
words.insert( words.insert(
format!("questions/{group}/index.yaml"), format!("questions/{group}/index.yaml"),
json!({ "question": "What needs doing?", "description": format!("Everything {} looks after.", label_of(plan, &group).to_lowercase()), "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited }), json!({ "question": "What needs doing?", "description": format!("Everything {} looks after.", label_of(plan, &group).to_lowercase()), "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited, "suggestions": suggestions }),
); );
} }
// A public view per bucket the plan opens: a read-only list. // A public view per bucket the plan opens: a read-only list.
@@ -1170,6 +1189,20 @@ fn inviters(plan: &Plan, group: &str) -> BTreeSet<String> {
.collect() .collect()
} }
/// The desk that settles suggested changes: the one that may invite
/// into the most groups, which is the group portal seeds the first
/// person into. The first by name on a tie; none on a site with no
/// desk.
fn suggestions_desk(plan: &Plan) -> Option<String> {
let desks = groups(plan);
desks
.iter()
.map(|desk| (desks.iter().filter(|g| inviters(plan, g).contains(desk)).count(), desk))
.filter(|(reach, _)| *reach > 0)
.max_by(|(a, x), (b, y)| a.cmp(b).then_with(|| y.cmp(x)))
.map(|(_, desk)| desk.clone())
}
fn label_of(plan: &Plan, group: &str) -> String { fn label_of(plan: &Plan, group: &str) -> String {
plan.groups.iter().find(|g| g.id == group).map(|g| g.label.trim().to_string()).filter(|l| !l.is_empty()).unwrap_or_else(|| humanize(group)) plan.groups.iter().find(|g| g.id == group).map(|g| g.label.trim().to_string()).filter(|l| !l.is_empty()).unwrap_or_else(|| humanize(group))
} }
@@ -1711,7 +1744,32 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"features": [{ "name": s(&lw["list_title"]), "description": s(&lw["list_note"]), "resource": { "features": [{ "name": s(&lw["list_title"]), "description": s(&lw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": "portal_invites" }, "source": { "kind": "kv", "bucket": "portal_invites" },
"requires_group": group, "requires_group": group,
"transitions": [{ "from": "open", "to": "done", "label": s(&lw["done"]) }], "transitions": [
{ "from": "open", "to": "done", "label": s(&lw["done"]) },
{ "from": "expired", "to": "open", "label": s(&lw["again"]) },
{ "from": "expired", "to": "done", "label": s(&lw["done"]) },
],
}}],
}));
}
// Suggested changes, from portal's own bucket: open, discussed,
// done or declined.
if let Some(sw) = w["suggestions"].as_object() {
alternatives.push(json!({
"name": s(&sw["heading"]),
"description": s(&sw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": s(&sw["list_title"]), "description": s(&sw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": "portal_edit_suggestions" },
"requires_group": group,
"transitions": [
{ "from": "open", "to": "discussed", "label": s(&sw["looking"]) },
{ "from": "open", "to": "done", "label": s(&sw["changed"]) },
{ "from": "open", "to": "declined", "label": s(&sw["declined"]) },
{ "from": "discussed", "to": "done", "label": s(&sw["changed"]) },
{ "from": "discussed", "to": "declined", "label": s(&sw["declined"]) },
],
}}], }}],
})); }));
} }
@@ -1835,7 +1893,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
} else if plan.doors.iter().any(|d| door_path(d) == path) { } 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." "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") { } else if path.ends_with("/index.yaml") {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent." "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") { } else if path.contains("/received/") || path.ends_with("-sent.yaml") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next." "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
} else { } else {
@@ -2711,8 +2769,16 @@ mod invite_tests {
let board = &files["questions/board/index.yaml"]; let board = &files["questions/board/index.yaml"];
assert_eq!(board.matches("invite:").count(), 2, "{board}"); assert_eq!(board.matches("invite:").count(), 2, "{board}");
assert!(board.contains("bucket: portal_invites")); assert!(board.contains("bucket: portal_invites"));
// An invite whose link ran out can be sent again, from any desk
// that lists them.
assert!(board.contains("from: expired") && board.contains("label: Send again"), "{board}");
let traffic = &files["questions/traffic/index.yaml"]; let traffic = &files["questions/traffic/index.yaml"];
assert_eq!(traffic.matches("invite:").count(), 1); assert_eq!(traffic.matches("invite:").count(), 1);
assert!(traffic.contains("label: Send again"));
// Suggested changes are settled at one desk: the one that
// invites into the most groups.
assert!(board.contains("bucket: portal_edit_suggestions") && board.contains("to: discussed"), "{board}");
assert!(!traffic.contains("portal_edit_suggestions"));
assert!(traffic.contains("group: traffic") && !traffic.contains("group: board")); assert!(traffic.contains("group: traffic") && !traffic.contains("group: board"));
// The plan is refused when an inviter has no desk. // The plan is refused when an inviter has no desk.
let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [nobody]")).unwrap(); let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [nobody]")).unwrap();