One shared, content-labeled Confirm button per alternative instead of one per row
Deploy / deploy (push) Successful in 1m5s
Deploy / deploy (push) Successful in 1m5s
Each AnswerRow used to carry its own ServerAction<TransitionAnswer> and its own "Confirm" button - a page with N open rows showed N buttons. Selecting a transition now just toggles an entry in a shared pending_transitions map (keyed by feature_name+item_id) owned by AlternativeCard; one button, labeled from the alternative's own consequence field (same mechanism Subscribers' "Send" already used), confirms every selection at once via the new batch transition_answers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
84c3fad339
commit
95ad459b24
+73
-40
@@ -88,43 +88,83 @@ pub async fn store_answer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves a stored answer from `open` to one of the target states its
|
||||
/// own resource spec declares (`ResourceSpec.transitions`) - `to` is
|
||||
/// checked against that content-declared allow-list, never accepted as
|
||||
/// an arbitrary string. Requires a signed-in member of the resource's
|
||||
/// `requires_group`, regardless of whether the resource is otherwise
|
||||
/// `public` - reads can be public, mutations always need identity.
|
||||
/// One selected row's pending transition - `feature_name` is carried
|
||||
/// per-item (not once per call) so a batch can span more than one
|
||||
/// resource feature on the same alternative, even though today's
|
||||
/// content never actually declares more than one transitionable
|
||||
/// resource per alternative.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TransitionItem {
|
||||
pub feature_name: String,
|
||||
pub item_id: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// Moves every selected row to its chosen target state in one call -
|
||||
/// the batched counterpart to what used to be a `transition_answer`
|
||||
/// fired separately per row, one "Confirm" button each. Now there's
|
||||
/// one shared button per alternative (see `AlternativeCard`), and a
|
||||
/// click here applies whatever was selected across every row at once.
|
||||
/// Each item is independent - there's no cross-item transaction to
|
||||
/// have, only per-item CAS (see `aggregates::transition`) - so one
|
||||
/// item's failure doesn't roll back or block the others. Failures are
|
||||
/// collected and reported together; whatever succeeded stays applied.
|
||||
#[server]
|
||||
pub async fn transition_answer(
|
||||
pub async fn transition_answers(
|
||||
question_id: String,
|
||||
alternative: String,
|
||||
feature_name: String,
|
||||
item_id: String,
|
||||
to: String,
|
||||
items: Vec<TransitionItem>,
|
||||
) -> 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>();
|
||||
let question = state
|
||||
let alt = state
|
||||
.questions
|
||||
.load()
|
||||
.get(&question_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
// Same alternative-scoped lookup as get_resource, and for the same
|
||||
// reason - a feature name isn't unique across a whole question,
|
||||
// only within its own alternative.
|
||||
let feature = question
|
||||
.alternatives
|
||||
.iter()
|
||||
.find(|a| a.name == alternative)
|
||||
.ok_or_else(|| ServerFnError::new("unknown alternative"))?
|
||||
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
||||
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
||||
|
||||
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()))?
|
||||
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for item in items {
|
||||
let item_id = item.item_id.clone();
|
||||
if let Err(e) = apply_transition(&state, &question_id, &alt, &user, item).await {
|
||||
errors.push(format!("{item_id}: {e}"));
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerFnError::new(errors.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Same alternative-scoped feature lookup as `get_resource`, and for
|
||||
/// the same reason - a feature name isn't unique across a whole
|
||||
/// question, only within its own alternative.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn apply_transition(
|
||||
state: &crate::server::AppState,
|
||||
question_id: &str,
|
||||
alt: &crate::content::Alternative,
|
||||
user: &crate::auth::User,
|
||||
item: TransitionItem,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use crate::chain::hash_node;
|
||||
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
||||
|
||||
let feature = alt
|
||||
.features
|
||||
.iter()
|
||||
.find(|f| f.name == feature_name)
|
||||
.find(|f| f.name == item.feature_name)
|
||||
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
|
||||
let resource = feature
|
||||
.resource
|
||||
@@ -133,20 +173,13 @@ pub async fn transition_answer(
|
||||
let transition = resource
|
||||
.transitions
|
||||
.iter()
|
||||
.find(|t| t.to == to)
|
||||
.find(|t| t.to == item.to)
|
||||
.ok_or_else(|| ServerFnError::new("no such transition"))?
|
||||
.clone();
|
||||
let group = resource
|
||||
.requires_group
|
||||
.as_deref()
|
||||
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
|
||||
|
||||
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()))?
|
||||
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
||||
if !user.groups.iter().any(|g| g == group) {
|
||||
return Err(ServerFnError::new("not authorized"));
|
||||
}
|
||||
@@ -160,7 +193,7 @@ pub async fn transition_answer(
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
||||
let bytes = store
|
||||
.get(&item_id)
|
||||
.get(&item.item_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
|
||||
@@ -172,7 +205,7 @@ pub async fn transition_answer(
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
let decision_payload_for_event =
|
||||
serde_json::json!({ "to": to, "item": item_id, "by": user.username });
|
||||
serde_json::json!({ "to": item.to, "item": item.item_id, "by": user.username });
|
||||
|
||||
// For any bucket with a declared state graph, the real fix for the
|
||||
// lost-update race two concurrent decisions on the same item used to
|
||||
@@ -182,18 +215,18 @@ pub async fn transition_answer(
|
||||
// the direct KV mutate this always did - not every resource has to
|
||||
// be event-sourced to keep working.
|
||||
if let Some(schema) = state.aggregates.load().get(bucket) {
|
||||
crate::aggregates::transition(&state.jetstream, schema, &item_id, &to, decision_payload_for_event.clone(), decided_ms)
|
||||
crate::aggregates::transition(&state.jetstream, schema, &item.item_id, &item.to, decision_payload_for_event.clone(), decided_ms)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
}
|
||||
|
||||
answer.state = to.clone();
|
||||
answer.state = item.to.clone();
|
||||
answer.decided_ms = Some(decided_ms);
|
||||
answer.decided_by = Some(user.username.clone());
|
||||
|
||||
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
store
|
||||
.put(&item_id, data.into())
|
||||
.put(&item.item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
@@ -201,12 +234,12 @@ pub async fn transition_answer(
|
||||
// submission hash, published the same way any other answer is -
|
||||
// question_id/alternative come from this call's own arguments and
|
||||
// the content-declared transition label, never a hardcoded value.
|
||||
let parent_hashes = vec![item_id.clone()];
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
let parent_hashes = vec![item.item_id.clone()];
|
||||
let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id,
|
||||
question_id: question_id.to_string(),
|
||||
alternative: transition.label,
|
||||
responses: decision_payload_for_event,
|
||||
timestamp_ms: decided_ms,
|
||||
|
||||
Reference in New Issue
Block a user