Invites and suggestions are cases; the responsible follow the content
Test / test (pull_request) Successful in 56s

Five things the first cut of accounts-from-content left open.

The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.

The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.

Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.

An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.

A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.

Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.

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:00:10 +02:00
co-authored by Claude Fable 5.1
parent 7a50ce77b2
commit ef4789f9b2
14 changed files with 1025 additions and 88 deletions
+134 -6
View File
@@ -265,6 +265,11 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
/// failed request.
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
// A suggestion has its own words for this unless the content
// gave the state a mail, which then is the one sent.
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
on_suggestion_state(state, id, to_state, responses).await;
}
return;
};
let site = state.site.load();
@@ -301,6 +306,33 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
/// The questions a person answers for, as a mail names them: each in
/// quotes, and past the first three only how many more - someone who
/// answers for a whole site is not read its table of contents.
pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") };
let and = if norwegian { "og" } else { "and" };
match questions {
[] => String::new(),
[one] => quote(one),
few if few.len() <= 3 => {
let quoted: Vec<String> = few.iter().map(quote).collect();
format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
}
many => {
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
let more = many.len() - 3;
let others = match (norwegian, more) {
(true, 1) => "ett spørsmål til".to_string(),
(true, n) => format!("{n} spørsmål til"),
(false, 1) => "one more question".to_string(),
(false, n) => format!("{n} more questions"),
};
format!("{} {and} {others}", quoted.join(", "))
}
}
}
/// The mail a person gets when the content names them responsible for
/// a question and they had no account: who they are to the site (the
/// person listed as asking that question), and the link that lets them
@@ -312,12 +344,7 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
return;
};
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let quoted: Vec<String> = questions.iter().map(|q| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") }).collect();
let list = match quoted.len() {
0 => String::new(),
1 => quoted[0].clone(),
n => format!("{} {} {}", quoted[..n - 1].join(", "), if norwegian { "og" } else { "and" }, quoted[n - 1]),
};
let list = questions_listed(questions, norwegian);
let (subject, body) = if norwegian {
(
"Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
@@ -360,6 +387,81 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
}
}
/// What the person who suggested a change hears when it moves, as
/// subject and body; `None` for a state that says nothing new to them
/// (it arriving, or one the content added). `{name}`, `{question}`,
/// `{page}`, `{quoted}`.
pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str, &'static str)> {
Some(match (to_state, norwegian) {
("discussed", true) => (
"Forslaget ditt om «{question}» blir sett på",
"Hei {name},\n\nForslaget ditt til endring på {page} er tatt opp, og blir sett på nå. Du får beskjed når det er avgjort.\n\nDu skrev:\n\n{quoted}",
),
("done", true) => (
"Endringen du foreslo i «{question}» er gjort",
"Hei {name},\n\nEndringen du foreslo på {page} er gjort. Takk for at du sa fra.\n\nDu skrev:\n\n{quoted}",
),
("declined", true) => (
"Forslaget ditt om «{question}» blir ikke gjort",
"Hei {name},\n\nForslaget ditt til endring på {page} blir ikke gjort slik det står. Svar på denne e-posten om du vil vite hvorfor, eller vil foreslå det på en annen måte.\n\nDu skrev:\n\n{quoted}",
),
("discussed", false) => (
"Your suggestion about \u{201c}{question}\u{201d} is being looked at",
"Hello {name},\n\nYour suggested change to {page} has been taken up and is being looked at. You will hear when it is settled.\n\nYou wrote:\n\n{quoted}",
),
("done", false) => (
"The change you suggested to \u{201c}{question}\u{201d} is made",
"Hello {name},\n\nThe change you suggested to {page} is made. Thank you for saying so.\n\nYou wrote:\n\n{quoted}",
),
("declined", false) => (
"Your suggestion about \u{201c}{question}\u{201d} will not be made",
"Hello {name},\n\nYour suggested change to {page} will not be made as it stands. Answer this mail if you want to know why, or to suggest it another way.\n\nYou wrote:\n\n{quoted}",
),
_ => return None,
})
}
/// A suggestion moved: tell whoever made it. Replies go to the site's
/// inbox, which is who decided.
async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
let site = state.site.load();
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
return;
};
let Some(cfg) = &site.mail else {
tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told");
return;
};
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
let to = field("email");
if !to.contains('@') {
tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
return;
}
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
let mut vars = serde_json::Map::new();
vars.insert("name".into(), field("name").into());
vars.insert("question".into(), field("question").into());
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
vars.insert("quoted".into(), quoted.into());
let text = render(body, &vars);
let mail = Outgoing {
v: CONTRACT,
id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: cfg.reply_to.clone(),
to,
subject: render(subject, &vars),
html: to_html(&text),
text,
};
if let Err(e) = publish(&state.jetstream, &mail).await {
tracing::error!(id, to_state, "publishing the suggestion mail failed: {e}");
}
}
/// A change suggested by the person responsible for a page, to the
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
/// suggester as reply-to so answering the mail answers them.
@@ -460,6 +562,32 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
mod tests {
use super::*;
#[test]
fn a_mail_names_three_questions_and_counts_the_rest() {
let q = |n: usize| (1..=n).map(|i| format!("Q{i}?")).collect::<Vec<_>>();
assert_eq!(questions_listed(&q(0), true), "");
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til");
assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions");
}
#[test]
fn a_suggestion_says_something_each_time_it_moves_and_nothing_on_arrival() {
for norwegian in [true, false] {
for state in ["discussed", "done", "declined"] {
let (subject, body) = suggestion_words(state, norwegian).expect(state);
assert!(subject.contains("{question}"), "{state}: the subject names the question");
assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
}
// Arriving is told to the inbox, not back to who sent it.
assert!(suggestion_words("open", norwegian).is_none());
assert!(suggestion_words("a_state_the_content_added", norwegian).is_none());
}
}
#[test]
fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });