build: a door may point to a members-only need; signed-in people are not asked who they are; a granted group does not self-invite

The vel: paid membership grants the medlemmer group, and a committee
seat is offered on the bidra door and taken on the members' page.

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-30 08:38:25 +02:00
co-authored by Claude Fable 5.1
parent 67250af445
commit 28ef235906
2 changed files with 53 additions and 16 deletions
+9 -2
View File
@@ -82,9 +82,13 @@ needs:
# Medlemmet melder seg ut selv, gjennom sin egen lenke.
moves_by: { utmeldt: [submitter, kasserer] }
mails: { open: submitter, betalt: submitter }
# Betalt kontingent er medlemskapet: husstanden får konto og plass
# blant medlemmene i samme trekk, og det som er medlemmenes eget
# (komitéplass) ligger bak innloggingen.
grants: { betalt: medlemmer }
max_fields: 3
collects:
- { name: navn }
- { name: name }
- { name: adresse }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
@@ -172,11 +176,14 @@ needs:
- { name: telefon, type: tel }
- { name: hva, type: select, multiple: true, options: [17. mai, Juletregranstenning, Skøytedag, Dugnad på elvestien, Det som trengs] }
# Komitéplass er for medlemmer: døra viser veien, skjemaet ligger på
# medlemmenes side bak innloggingen.
- id: komite
who: en som vil sitte i en komité
who: et medlem som vil sitte i en komité
wants: en plass i den komiteen som driver med det de bryr seg om
lands_in: komitesoknader
door: bidra
enters_as: medlemmer
handled_by: styret
stages: [open, plassert, avslatt]
stages_text: { open: "mottatt", plassert: "plassert i en komité", avslatt: "avslått" }
+44 -14
View File
@@ -182,7 +182,9 @@ pub struct PlanNeed {
#[serde(default)]
pub enters_as: Option<String>,
/// The door this public need sits behind; none means straight on
/// the front door.
/// the front door. With `enters_as` too, the door carries a card
/// that leads to the group's page, where the form is: a seat on a
/// committee is offered to everyone and open to members.
#[serde(default)]
pub door: Option<String>,
/// One answer per record of another need: a neighbour answering a
@@ -420,9 +422,8 @@ impl Plan {
if !doors.contains(door) {
bail!(at(&format!("door {door:?} is not one of the plan's doors")));
}
if need.enters_as.is_some() {
bail!(at("a door is a public way in; a need with enters_as has its group's page instead"));
}
// With enters_as as well, the door only points the way:
// the form sits on the group's page, behind sign-in.
}
if let Some(about) = &need.about {
let Some(parent) = self.needs.iter().find(|n| &n.id == about) else {
@@ -1273,7 +1274,11 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
/// must have a desk to carry it on.
fn inviters(plan: &Plan, group: &str) -> BTreeSet<String> {
let desks = groups(plan);
std::iter::once(group.to_string())
// A group a case grants is entered by that case, not by a member
// waving somebody in: members do not make members.
let granted = plan.needs.iter().any(|n| n.grants.values().any(|g| g == group));
(if granted { None } else { Some(group.to_string()) })
.into_iter()
.chain(plan.invites_by.iter().cloned())
.chain(plan.groups.iter().filter(|g| g.id == group).flat_map(|g| g.invited_by.iter().cloned()))
.filter(|g| desks.contains(g))
@@ -1540,9 +1545,13 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
if f.multiple { r["multiple"] = json!(true); }
let hint = s(&w["hints"][&f.name]).trim();
if !hint.is_empty() && f.kind != "select" { r["placeholder"] = json!(hint); }
// Answering a record is done by neighbours as well as members:
// someone signed in is not asked for what the site knows.
if parent.is_some() && f.name == "email" && f.kind == "email" { r["value"] = json!("{user.email}"); }
// Someone signed in is not asked for what the site knows: on a
// group's page they always are, and answering a record they
// may be.
if parent.is_some() || need.enters_as.is_some() {
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
}
r
}).collect();
// The record this answer is about, carried in from the page's own
@@ -1754,7 +1763,17 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"name": s(&w["question"]),
"description": s(&w["description"]),
"responsible": responsible,
"alternatives": plan.needs_behind(&door.id).iter().map(|n| form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
"alternatives": plan.needs_behind(&door.id).iter().map(|n| match &n.enters_as {
None => form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n)),
// The way to the group's page: portal shows the
// sign-in gate there to whoever is not in the group.
Some(group) => json!({
"name": s(&w["needs"][&n.id]["heading"]),
"description": s(&w["needs"][&n.id]["description"]),
"action": internal_page(n, group),
"consequence": [s(&w["needs"][&n.id]["button"])],
}),
}).collect::<Vec<_>>(),
}),
);
}
@@ -2029,7 +2048,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
} else if path.starts_with("questions/status/") {
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."
"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 - or, for a need that is members' only, the card that leads to the members' page, where the form is behind sign-in: say so in its description."
} 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, 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.ends_with("/[key].yaml") {
@@ -2547,12 +2566,23 @@ mod tests {
assert_eq!(needs["needs"][2]["max_steps"], 1);
assert_eq!(plan.front_door_count(), 2);
// A door nobody is behind, a door on an internal need, and a
// door that is also a group are refused.
// A door nobody is behind, and a door that is also a group, are
// refused. A door on a need that enters as a group is the way
// to that group's page: a card on the door, the form behind
// sign-in, and the spec entering on the group's page.
let lonely: Plan = serde_yaml::from_str(&yaml.replace(" door: your-building\n", "")).unwrap();
assert!(lonely.validate().unwrap_err().to_string().contains("leads nowhere"));
let internal: Plan = serde_yaml::from_str(&yaml.replacen(" door: your-building\n", " door: your-building\n enters_as: pms\n", 1)).unwrap();
assert!(internal.validate().unwrap_err().to_string().contains("public way in"));
let members: Plan = serde_yaml::from_str(&yaml.replacen(" door: your-building\n", " door: your-building\n enters_as: pms\n", 1)).unwrap();
members.validate().unwrap();
let files = render(&members, &fallback_words(&members)).unwrap();
let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap();
assert_eq!(door["alternatives"][0]["action"], "/pms/survey");
assert!(door["alternatives"][0].get("record_as").is_none());
let page: Value = serde_yaml::from_str(&files["questions/pms/survey.yaml"]).unwrap();
assert_eq!(page["alternatives"][0]["record_as"], "enquiries");
let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap();
assert_eq!(needs["needs"][0]["enters"], "/pms/survey");
assert_eq!(needs["needs"][0]["enters_as"], "pms");
let clash: Plan = serde_yaml::from_str(&yaml.replace("your-building", "pms")).unwrap();
assert!(clash.validate().unwrap_err().to_string().contains("also a group"));
}