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
+138
-16
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user