`mail: { to, subject, body }` on a state in aggregates.yaml sends when a
record enters that state, `to: submitter` or one address, templates
over the record's fields plus {site}, {chain}, {email}. An invite mails
its one-time link built in, reworded by `mail.invite` in site.yaml,
which also carries the sender (`mail.from`, `mail.reply_to`); without
it a site sends nothing and says so in the log. Portal renders the
text and an HTML alternative and publishes one message per state
change on portal.mail.send in the WORMHOLE stream, id <bucket>:<record>
:<state> so a retried transition sends once; uhhm/gdo holds the
durable consumer and talks SMTP. The lint requires a required email
field on every form recording into a bucket that mails the submitter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
444 lines
17 KiB
Rust
444 lines
17 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 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();
|
|
// The policy holds exactly the buttons this desk offers to this
|
|
// group, keyed on (from, to); it is the one check.
|
|
let decision = crate::access::decide_and_publish(
|
|
&state.nats,
|
|
&state.access.load(),
|
|
&crate::access::Principal::from_user(Some(user)),
|
|
crate::access::Action::Transition,
|
|
&crate::access::Resource::Bucket { name: bucket.to_string() },
|
|
serde_json::json!({ "from": answer.state, "to": item.to }),
|
|
);
|
|
if !decision.allow {
|
|
return Err(ServerFnError::new("not authorized"));
|
|
}
|
|
|
|
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()))?;
|
|
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
|
|
|
// 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()))?;
|
|
|
|
// The link is the credential and the email its second factor; the
|
|
// policy compares them and knows the one move a submitter may make.
|
|
// Refusal reads "not found" so the endpoint cannot be used to probe
|
|
// which records or emails exist.
|
|
let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default();
|
|
let decision = crate::access::decide_and_publish(
|
|
&state.nats,
|
|
&state.access.load(),
|
|
&crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() },
|
|
crate::access::Action::SelfTransition,
|
|
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
|
|
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
|
|
);
|
|
if !decision.allow {
|
|
return Err(ServerFnError::new(if answer.state == OPEN_STATE { "not found" } else { "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()))?;
|
|
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
|
|
|
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"));
|
|
}
|
|
}
|