Files
portal/src/answers.rs
T

564 lines
22 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,
/// The storyline the record was sent from, so the mail it gets
/// tells the same one (see `content/variants.rs`).
#[serde(default)]
pub storyline: u32,
#[serde(default)]
pub decided_ms: Option<i64>,
#[serde(default)]
pub decided_by: Option<String>,
/// Replies that came back by mail, oldest first (see
/// `mail::note_for`). A note never moves a record; it is the
/// conversation beside the case, which is the thing a desk could
/// not see before.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>,
}
/// One reply on a record. It lives here rather than with the mail
/// code because it is part of what a record is: the desks render it,
/// and the browser has to know its shape even though it will never
/// speak to a mail server.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Note {
pub id: String,
pub from: String,
#[serde(default)]
pub subject: String,
pub text: String,
pub received_ms: i64,
}
/// 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,
storyline: u32,
) -> 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,
storyline,
decided_ms: None,
notes: Vec::new(),
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(())
}
/// Whose a record is, on an alternative that keeps one per person
/// (`one_per_person`): the signed-in person's username, written by the
/// server into the record's fields, never taken from the form.
pub const OWNER_FIELD: &str = "by";
/// Every record in `bucket` that belongs to `username`, newest first.
#[cfg(feature = "ssr")]
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
use futures::TryStreamExt;
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
let keys: Vec<String> = match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => return Vec::new(),
};
let mut own = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
own.push(answer);
}
}
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
own
}
/// A person keeps one card: of their records still in the bucket's
/// first state, only the newest stays in the list, and the rest leave
/// it (the event log keeps them). Run after every store, so two sends
/// racing each other - a double click - both settle on the same one.
#[cfg(feature = "ssr")]
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
let own = own_records(js, bucket, username).await;
let Ok(store) = js.get_key_value(bucket).await else { return };
for older in own.iter().filter(|a| a.state == initial).skip(1) {
if let Err(e) = store.delete(&older.id).await {
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
}
}
}
/// Fills the form of every `one_per_person` alternative with what the
/// signed-in person's own newest record says, so sending it again is
/// editing their card. A file field shows the file already there, at
/// the address a list would open it from, and may be left empty to
/// keep it.
#[cfg(feature = "ssr")]
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
let Some(user) = user else { return };
for alt in &mut question.alternatives {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
if req.kind == "hidden" {
continue;
}
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
if req.kind == "file" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
} else {
req.value = Some(value.to_string());
}
}
}
}
/// 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 storyline = crate::server::storyline::current().await;
let alt = state
.questions
.load()
.at(storyline)
.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")]
pub(crate) 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, answer.storyline).await;
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
// An expired invite sent again: the move back is the asking.
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
crate::people::on_reopened(state, &item.item_id).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,
storyline: answer.storyline,
};
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 {
// Told apart by which half of the rule failed. If the record is
// somewhere this button may be fired from, the email was wrong,
// and the answer must not confirm that the record exists. If it
// is not, the case has moved past this move and saying so is no
// longer a way to learn anything about it.
let initial = state
.aggregates
.load()
.get(&self_transition.bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| OPEN_STATE.to_string());
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
return Err(ServerFnError::new(if in_reach { "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, answer.storyline).await;
crate::people::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,
storyline: answer.storyline,
};
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,
storyline: 0,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
},
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,
storyline: 0,
decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()),
notes: Vec::new(),
},
];
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"));
}
}