Files
portal/src/answers.rs
T
Bendik Aagaard LynghaugandClaude Fable 5 c9c5caf3ff
Deploy / deploy (push) Successful in 1m3s
Stable server fn endpoints: open tabs survive deploys
The 'previous question's resources until refresh' mystery: leptos's
auto-generated server fn routes embed a hash that changes across
builds. Every deploy therefore breaks every already-open tab - its
wasm keeps calling /api/get_question<oldhash>, the new server answers
400 'Could not find a server function at the route', and client-side
navigation quietly leaves the previous question's data on screen.
Refresh loads the new wasm with matching hashes, which is why it
always fixed it. Confirmed live: a pre-deploy client 400ed on
get_question2970801986613442004 while the freshly served wasm calls
get_question13103328088426240960, same source on both builds.

Explicit endpoint names decouple the URL from the build. This deploy
is the last breaking one; after it, old clients keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:18:15 +02:00

428 lines
16 KiB
Rust

//! Read-model storage for submitted answers: the KV projection of the
//! event log (`events::store`, `aggregates`) for every bucket with a
//! state graph in `aggregates.yaml`. A bucket with no graph still
//! works - direct KV mutate, no event log, no CAS - so content isn't
//! forced to declare one before it needs it.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
/// An answer's starting state - content decides what it may transition
/// to (`ResourceSpec.transitions`), never this file.
pub const OPEN_STATE: &str = "open";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Answer {
/// The submission's chain hash - stable, unique, and already what
/// ties this answer back into the DAG a transition later extends.
pub id: String,
pub question_id: String,
pub alternative: String,
/// The submitted form fields, verbatim - the runtime never inspects
/// these keys, only content (via its own copy/labels) gives them
/// meaning.
pub responses: serde_json::Value,
pub state: String,
pub submitted_ms: i64,
#[serde(default)]
pub decided_ms: Option<i64>,
#[serde(default)]
pub decided_by: Option<String>,
}
/// Writes a submitted answer into `bucket`, creating it on first use -
/// there's no fixed, pre-declared list of buckets, since content can
/// name any.
#[cfg(feature = "ssr")]
#[allow(clippy::too_many_arguments)]
pub async fn store_answer(
js: &async_nats::jetstream::Context,
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
bucket: &str,
id: String,
question_id: &str,
alternative: &str,
responses: &serde_json::Value,
submitted_ms: i64,
) -> anyhow::Result<()> {
let store = match js.get_key_value(bucket).await {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: bucket.to_string(),
..Default::default()
})
.await?
}
};
// A schema-backed bucket's projection must agree with its event
// log about where a record starts - organizations begin at
// "prospect", not "open". Only schemaless buckets default to open.
let initial_state = aggregates
.get(bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| OPEN_STATE.to_string());
let answer = Answer {
id: id.clone(),
question_id: question_id.to_string(),
alternative: alternative.to_string(),
responses: responses.clone(),
state: initial_state,
submitted_ms,
decided_ms: None,
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
store.put(id.clone(), data.into()).await?;
// Also seed the event log for any bucket that has a declared state
// graph - best-effort, same "a hiccup here shouldn't fail a
// submission the NATS notification event has already recorded"
// policy as the KV write above.
if let Some(schema) = aggregates.get(bucket) {
if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await {
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
}
}
Ok(())
}
/// One selected row's pending transition. `feature_name` is per-item
/// so a batch can span more than one resource feature on the same
/// 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 shared per-alternative Confirm button). Items are independent
/// - per-item CAS, no cross-item transaction - so one failure doesn't
/// roll back the others; failures are collected and reported together.
#[server(endpoint = "transition_answers")]
pub async fn transition_answers(
question_id: String,
alternative: String,
items: Vec<TransitionItem>,
) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
let state = expect_context::<AppState>();
let alt = state
.questions
.load()
.get(&question_id)
.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("; ")))
}
}
/// `aggregates::transition`, with one recovery path: an aggregate the
/// event log has never heard of but whose KV projection exists (the
/// log was purged, or predates event-sourcing) gets a minimal history
/// reseeded from the projection's own current state, then the
/// transition retries. Keeps "zap the whole event stream" a safe
/// operation instead of one that strands every pre-wipe record.
#[cfg(feature = "ssr")]
async fn transition_or_reseed(
js: &async_nats::jetstream::Context,
schema: &crate::aggregates::AggregateSchema,
item_id: &str,
to: &str,
current_state: &str,
payload: serde_json::Value,
occurred_at_ms: i64,
) -> Result<(), crate::aggregates::TransitionError> {
match crate::aggregates::transition(js, schema, item_id, to, payload.clone(), occurred_at_ms)
.await
{
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
tracing::warn!(
bucket = %schema.bucket, item_id, current_state,
"no event history for a projected record - reseeding from the projection"
);
crate::aggregates::reseed(js, schema, item_id, current_state, occurred_at_ms).await?;
crate::aggregates::transition(js, schema, item_id, to, payload, occurred_at_ms)
.await
.map(|_| ())
}
r => r.map(|_| ()),
}
}
/// 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 == item.feature_name)
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
let resource = feature
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
}
let bucket = resource
.bucket()
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
let store = state
.jetstream
.get_key_value(bucket)
.await
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
let bytes = store
.get(&item.item_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
// Matched on (from, to), not to alone: two declared transitions may
// share a target (open -> declined, in_dialogue -> declined), and
// only the one starting from the row's actual state may fire.
let transition = resource
.transitions
.iter()
.find(|t| t.to == item.to && t.from == answer.state)
.ok_or_else(|| {
ServerFnError::new(format!(
"no transition to {:?} from this item's current state {:?}",
item.to, answer.state
))
})?
.clone();
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
serde_json::json!({ "to": item.to, "item": item.item_id, "by": user.username });
// CAS on the aggregate's replayed sequence guards two concurrent
// decisions on the same item; a schemaless bucket falls back to the
// plain KV mutate below.
if let Some(schema) = state.aggregates.load().get(bucket) {
transition_or_reseed(
&state.jetstream,
schema,
&item.item_id,
&item.to,
&answer.state,
decision_payload_for_event.clone(),
decided_ms,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
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.item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
// The decision extends the DAG as a child of the answer's own
// submission hash, published like any other answer.
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.to_string(),
alternative: transition.label,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
};
emit_answer_submitted(&state.nats, &event)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(())
}
/// The self-service counterpart to `transition_answers`: no session,
/// no group check - authorized by holding `item_id` (an unguessable
/// chain hash) plus a matching `email`, both carried in the link
/// itself. Deliberately returns the same generic error for "no such
/// item" and "email doesn't match", so an unsubscribe link can't be
/// used to probe which hashes or emails exist.
#[server(endpoint = "self_transition_answer")]
pub async fn self_transition_answer(
question_id: String,
alternative: String,
item_id: String,
email: String,
) -> Result<(), ServerFnError> {
use crate::chain::hash_node;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::<AppState>();
let question = state
.questions
.load()
.get(&question_id)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let self_transition = question
.alternatives
.iter()
.find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?
.self_transition
.clone()
.ok_or_else(|| ServerFnError::new("alternative has no self transition"))?;
let store = state
.jetstream
.get_key_value(&self_transition.bucket)
.await
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
let bytes = store
.get(&item_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("not found"))?;
let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
let stored_email = answer.responses.get("email").and_then(|v| v.as_str());
if stored_email != Some(email.as_str()) {
return Err(ServerFnError::new("not found"));
}
if answer.state != OPEN_STATE {
return Err(ServerFnError::new("already processed"));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
if let Some(schema) = state.aggregates.load().get(&self_transition.bucket) {
transition_or_reseed(
&state.jetstream,
schema,
&item_id,
&self_transition.to,
&answer.state,
decision_payload_for_event.clone(),
decided_ms,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
answer.state = self_transition.to.clone();
answer.decided_ms = Some(decided_ms);
answer.decided_by = Some(email.clone());
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
store
.put(&item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let parent_hashes = vec![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,
alternative: self_transition.label,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
};
emit_answer_submitted(&state.nats, &event)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// `app.rs`'s list-rendering path only shows the nice
/// button-per-transition UI if a resource's raw JSON value
/// deserializes as `Vec<Answer>` - otherwise it silently falls
/// back to a plain read-only dump with no error. Guards that shape
/// contract directly, since nothing else would catch a drift here.
#[test]
fn answer_list_round_trips_through_the_shape_app_rs_expects() {
let answers = vec![
Answer {
id: "abc123".to_string(),
question_id: "/review".to_string(),
alternative: "Applicants".to_string(),
responses: serde_json::json!({"name": "Ada"}),
state: OPEN_STATE.to_string(),
submitted_ms: 1_700_000_000_000,
decided_ms: None,
decided_by: None,
},
Answer {
id: "def456".to_string(),
question_id: "/review".to_string(),
alternative: "Applicants".to_string(),
responses: serde_json::json!({"name": "Grace"}),
state: "invited".to_string(),
submitted_ms: 1_700_000_001_000,
decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()),
},
];
let value = serde_json::Value::Array(
answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(),
);
let round_tripped: Vec<Answer> =
serde_json::from_value(value).expect("Answer list round-trips");
assert_eq!(round_tripped.len(), 2);
assert_eq!(round_tripped[1].state, "invited");
assert_eq!(round_tripped[1].decided_by.as_deref(), Some("bl"));
}
}