diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d377e..3dfa717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.6.15 (2026-10-01) + +- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good. + ## 0.6.14 (2026-10-01) - Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space. diff --git a/docs/content-reference.md b/docs/content-reference.md index 27aeee6..f592fe8 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -125,6 +125,10 @@ answer: from: [open, incomplete] # defaults to the initial state alone label: Send them invite: { group: case_officers } # onboard someone into Kanidm + # or with words of its own, using any field of the form: + # invite: + # group: guests + # mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." } ``` `self_transition.from` is what keeps a person in dialogue with their diff --git a/src/app.rs b/src/app.rs index b973b6a..b1d3304 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3082,10 +3082,16 @@ pub async fn submit_answer( let Some((name, who)) = crate::people::who_for(&responses) else { return Err(ServerFnError::new("an invite needs a name, and a phone number or an email")); }; - let invited = people + let mut invited = people .invite(&state, &name, &who, &invite.group, &principal_name(&principal)) .await .map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?; + // This invite's own words, and the form they draw on: kept with + // the invite, so sending it again says the same thing. + invited.mail = invite.mail.clone(); + if invited.mail.is_some() { + invited.fields = responses.as_object().cloned().unwrap_or_default(); + } if let Some(map) = responses.as_object_mut() { map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default()); } diff --git a/src/content/mod.rs b/src/content/mod.rs index 000622a..de12e54 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -391,6 +391,13 @@ pub struct Alternative { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Invite { pub group: String, + /// This invite's own mail, in place of the site's (`mail.invite`): + /// an invitation to a conversation at an hour, say. Besides + /// `{name}`, `{link}`, `{expires}`, `{username}`, `{group}`, + /// `{site}` and `{site_name}`, every field of the form is a + /// placeholder; a date and time is written out in words. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mail: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/mail.rs b/src/mail.rs index 38a8872..44ad8a1 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -416,6 +416,14 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()) .unwrap_or_default(); let mut vars = serde_json::Map::new(); + // The form's own fields first, so the invite's fixed placeholders + // win a clash; a date and time reads as one. + for (key, value) in &invited.fields { + let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string())); + if let Some(text) = text { + vars.insert(key.clone(), text.into()); + } + } vars.insert("name".into(), invited.display_name.clone().into()); vars.insert("username".into(), invited.username.clone().into()); vars.insert("questions".into(), list.into()); @@ -553,6 +561,25 @@ pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &s /// Built in, since portal knows everything about it; `mail.invite` in /// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`, /// `{site}`, `{expires}`. +/// A form's date and time (`2026-10-06T15:00`, from a `datetime-local` +/// field) as a person says it: "tirsdag 6. oktober kl. 15:00", "Tuesday +/// 6 October at 15:00". Anything else is left as it is. +pub fn spoken_datetime(value: &str, norwegian: bool) -> Option { + use chrono::{Datelike, NaiveDateTime, Timelike}; + let t = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").ok()?; + let day = t.weekday().num_days_from_monday() as usize; + let month = t.month0() as usize; + Some(if norwegian { + const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"]; + const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"]; + format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute()) + } else { + const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute()) + }) +} + pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) { let site = state.site.load(); let Some(cfg) = &site.mail else { @@ -560,7 +587,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people: return; }; let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); - let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) { + let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) { (Some(t), _, _) => (t.subject.clone(), t.body.clone()), (None, true, true) => ( "Du er invitert inn i {group} på {site_name}".to_string(), @@ -662,6 +689,13 @@ mod tests { assert_eq!(vars["state"], "fixed"); } + #[test] + fn a_date_and_time_from_a_form_reads_as_a_person_says_it() { + assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00")); + assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00")); + assert_eq!(spoken_datetime("Anders Kluge", true), None); + } + #[test] fn the_html_alternative_keeps_markdown_and_drops_raw_html() { let html = to_html("Hello **there**\n\n\n\n[link](https://x.no)"); diff --git a/src/memberships.rs b/src/memberships.rs index f0b73e9..c0f412c 100644 --- a/src/memberships.rs +++ b/src/memberships.rs @@ -254,6 +254,8 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who reset_link: None, link_expires_ms: None, created, + mail: None, + fields: Default::default(), already_member: !created, }; match existing { diff --git a/src/people.rs b/src/people.rs index db8db9c..1c45136 100644 --- a/src/people.rs +++ b/src/people.rs @@ -127,6 +127,12 @@ pub struct Invited { /// the membership is not portal's to take back. #[serde(default)] pub already_member: bool, + /// The invite's own mail and the form it was sent from, kept with + /// the invite so that sending it again sends the same words. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mail: Option, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub fields: serde_json::Map, } /// When a link runs out, from Kanidm's `expiry_time`: seconds since @@ -367,6 +373,8 @@ impl Kanidm { reset_link, link_expires_ms, created, + mail: None, + fields: Default::default(), already_member, }) } diff --git a/src/seed.rs b/src/seed.rs index 275a437..3740773 100644 --- a/src/seed.rs +++ b/src/seed.rs @@ -410,6 +410,8 @@ mod tests { reset_link: None, link_expires_ms: None, created: true, + mail: None, + fields: Default::default(), already_member, }, )