Invites and suggestions are cases; the responsible follow the content
Test / test (pull_request) Successful in 56s
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:
co-authored by
Claude Fable 5.1
parent
7a50ce77b2
commit
ef4789f9b2
+134
-6
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user