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
+138 -16
View File
@@ -633,13 +633,41 @@ pub fn is_responsible(user: Option<&User>, contact: &str) -> bool {
user.and_then(|u| u.email.as_deref()).is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
}
/// One suggestion as the person who made it sees it: what they wrote
/// and where it stands.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MySuggestion {
pub id: String,
pub text: String,
pub state: String,
pub submitted_ms: i64,
}
/// The translation key for where a suggestion stands. A state the
/// content added to the bucket's graph has no words here, and shows as
/// itself.
fn suggestion_state_key(state: &str) -> Option<&'static str> {
match state {
"open" => Some("suggestion_open"),
"discussed" => Some("suggestion_discussed"),
"done" => Some("suggestion_done"),
"declined" => Some("suggestion_declined"),
_ => None,
}
}
/// A change suggested by the person responsible for the page: one
/// field, sent to the site's inbox. Shown only to them.
/// field, which becomes a case for whoever keeps the site, and below
/// it what became of the ones sent before. Shown only to them.
#[component]
fn SuggestEdit(question_id: String) -> impl IntoView {
let send = ServerAction::<SuggestPageEdit>::new();
let text = NodeRef::<leptos::html::Textarea>::new();
let sent = move || send.value().get().is_some_and(|r| r.is_ok());
let mine = {
let question_id = question_id.clone();
Resource::new(move || send.version().get(), move |_| my_suggestions(question_id.clone()))
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let value = text.get().map(|t| t.value()).unwrap_or_default();
@@ -662,6 +690,30 @@ fn SuggestEdit(question_id: String) -> impl IntoView {
>
<p>{move || t(&lang().get(), "suggest_edit_sent")}</p>
</Show>
<Transition fallback=|| ()>
{move || {
mine.get().and_then(|res| res.ok()).filter(|items| !items.is_empty()).map(|items| view! {
<h3>{move || t(&lang().get(), "suggestions_yours")}</h3>
<ul class="suggest-edit-mine">
<For
each=move || items.clone()
key=|s| (s.id.clone(), s.state.clone())
children=|s: MySuggestion| {
let state = s.state.clone();
let label = move || suggestion_state_key(&state).map(|key| t(&lang().get(), key).to_string()).unwrap_or_else(|| state.clone());
view! {
<li>
<span class="suggest-edit-state" data-state=s.state.clone()>{label}</span>
<span class="suggest-edit-text">{s.text}</span>
</li>
}
.into_any()
}
/>
</ul>
})
}}
</Transition>
</section>
}
}
@@ -2483,11 +2535,15 @@ pub struct SubmitResult {
/// A change to a page, suggested by the person it names as responsible.
/// Checked here, not trusted from the page: the session's address must
/// be the page's responsible contact. Kept in `portal_edit_suggestions`,
/// announced on `portal.edits.suggested`, and mailed to the site's inbox.
/// be the page's responsible contact. It becomes a case in
/// `portal_edit_suggestions` (open, discussed, done or declined, see
/// the built-in aggregates), is announced like any answer and on
/// `portal.edits.suggested`, and is mailed to the site's inbox.
#[server(endpoint = "suggest_page_edit")]
pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::<AppState>();
@@ -2505,25 +2561,91 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
let user = user.expect("is_responsible needs a user");
let email = user.email.clone().unwrap_or_default();
let now_ms = chrono::Utc::now().timestamp_millis();
let record = serde_json::json!({
"question_id": question_id, "question": question.name,
"by": user.username, "email": email, "text": text, "submitted_ms": now_ms,
// The fields a desk shows and a mail may name: who, what, and
// which page. `email` is where word of what became of it goes.
let responses = serde_json::json!({
"name": user.display_name, "email": email, "by": user.username,
"page": question_id, "question": question.name, "text": text,
});
let store = match state.jetstream.get_key_value("portal_edit_suggestions").await {
Ok(store) => store,
Err(_) => state
.jetstream
.create_key_value(async_nats::jetstream::kv::Config { bucket: "portal_edit_suggestions".to_string(), ..Default::default() })
.await
.map_err(|e| ServerFnError::new(e.to_string()))?,
let id = hash_node(&question_id, &[], &responses, now_ms);
crate::answers::store_answer(
&state.jetstream,
&state.aggregates.load(),
crate::content::EDIT_SUGGESTIONS_BUCKET,
id.clone(),
&question_id,
SUGGESTION_ALTERNATIVE,
&responses,
now_ms,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let event = AnswerSubmitted {
chain_hash: id,
parent_hashes: Vec::new(),
question_id: question_id.clone(),
alternative: SUGGESTION_ALTERNATIVE.to_string(),
responses: responses.clone(),
timestamp_ms: now_ms,
};
let bytes = serde_json::to_vec(&record).map_err(|e| ServerFnError::new(e.to_string()))?;
store.put(format!("{now_ms}-{}", user.username.replace(|c: char| !c.is_ascii_alphanumeric(), "_")), bytes.clone().into()).await.map_err(|e| ServerFnError::new(e.to_string()))?;
let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}");
}
if let Ok(bytes) = serde_json::to_vec(&responses) {
let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
}
crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await;
Ok(())
}
/// What a suggestion is recorded under, where an answer has the name
/// of the alternative that was chosen.
#[cfg(feature = "ssr")]
pub const SUGGESTION_ALTERNATIVE: &str = "Suggested change";
/// The signed-in person's own suggestions for a page, newest first,
/// with where each one stands. Theirs only: matched on the session's
/// address, which is what a suggestion was filed under.
#[server(endpoint = "my_suggestions")]
pub async fn my_suggestions(question_id: String) -> Result<Vec<MySuggestion>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
let state = expect_context::<AppState>();
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
let Some(email) = user.and_then(|u| u.email).map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
return Ok(Vec::new());
};
// Made on first write: nobody has suggested anything yet.
let Ok(store) = state.jetstream.get_key_value(crate::content::EDIT_SUGGESTIONS_BUCKET).await else {
return Ok(Vec::new());
};
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => Vec::new(),
}
};
let mut mine = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
let theirs = answer.responses.get("email").and_then(|v| v.as_str()).is_some_and(|e| e.trim().to_lowercase() == email);
if theirs && answer.question_id == question_id {
mine.push(MySuggestion {
id: answer.id,
text: answer.responses.get("text").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
state: answer.state,
submitted_ms: answer.submitted_ms,
});
}
}
mine.sort_by_key(|s| std::cmp::Reverse(s.submitted_ms));
Ok(mine)
}
#[server(endpoint = "submit_answer")]
pub async fn submit_answer(
question_id: String,