From 28ef23590618d02fcb82c62464020a28081f0834 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 08:38:25 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- plans/tomtervel.yaml | 11 +++++++-- src/build.rs | 58 +++++++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/plans/tomtervel.yaml b/plans/tomtervel.yaml index d1db17d..199edb6 100644 --- a/plans/tomtervel.yaml +++ b/plans/tomtervel.yaml @@ -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" } diff --git a/src/build.rs b/src/build.rs index fc1f1b1..5a68506 100644 --- a/src/build.rs +++ b/src/build.rs @@ -182,7 +182,9 @@ pub struct PlanNeed { #[serde(default)] pub enters_as: Option, /// 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, /// 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 { /// must have a desk to carry it on. fn inviters(plan: &Plan, group: &str) -> BTreeSet { 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) -> anyhow::Result>(), + "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::>(), }), ); } @@ -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")); }