Transitions now declare which state they fire from (default "open", today's implicit behavior - existing content needs no edits). AnswerRow renders only the buttons legal from a row's current state; two declared transitions may share a target (open -> declined, in_dialogue -> declined), so apply_transition matches on (from, to) against the row's actual state instead of the old blanket "already decided" check. validate_questions/question_lint reject a from naming an undeclared state or a from -> to pair the bucket's graph never declared. store_answer's KV projection now starts a record at its schema's declared initial state, not a hardcoded "open" - organizations begin at "prospect", and the read model has to agree with the event log about that. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
35663a28c1
commit
40694a8f3f
+25
-10
@@ -63,12 +63,19 @@ pub async fn store_answer(
|
|||||||
.await?
|
.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 {
|
let answer = Answer {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
question_id: question_id.to_string(),
|
question_id: question_id.to_string(),
|
||||||
alternative: alternative.to_string(),
|
alternative: alternative.to_string(),
|
||||||
responses: responses.clone(),
|
responses: responses.clone(),
|
||||||
state: OPEN_STATE.to_string(),
|
state: initial_state,
|
||||||
submitted_ms,
|
submitted_ms,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
@@ -170,12 +177,6 @@ async fn apply_transition(
|
|||||||
.resource
|
.resource
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
||||||
let transition = resource
|
|
||||||
.transitions
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.to == item.to)
|
|
||||||
.ok_or_else(|| ServerFnError::new("no such transition"))?
|
|
||||||
.clone();
|
|
||||||
let group = resource
|
let group = resource
|
||||||
.requires_group
|
.requires_group
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -199,9 +200,23 @@ async fn apply_transition(
|
|||||||
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
|
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
|
||||||
let mut answer: Answer =
|
let mut answer: Answer =
|
||||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
if answer.state != OPEN_STATE {
|
// Matched on (from, to), not to alone - two declared transitions
|
||||||
return Err(ServerFnError::new("already decided"));
|
// may share a target (open -> declined and in_dialogue -> declined),
|
||||||
}
|
// and only the one whose `from` is the row's actual current state
|
||||||
|
// is valid to fire. This also subsumes the old blanket
|
||||||
|
// "already decided" check: a row in a state no declared transition
|
||||||
|
// starts from simply has no legal move here.
|
||||||
|
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 decided_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let decision_payload_for_event =
|
let decision_payload_for_event =
|
||||||
|
|||||||
+24
-7
@@ -1183,7 +1183,24 @@ fn AnswerRow(
|
|||||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
transition_batch: ServerAction<TransitionAnswers>,
|
transition_batch: ServerAction<TransitionAnswers>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let is_open = answer.state == crate::answers::OPEN_STATE;
|
// Only the transitions legal from this row's current state - two
|
||||||
|
// declared transitions may share a target (open -> declined and
|
||||||
|
// in_dialogue -> declined), each rendered only on rows actually in
|
||||||
|
// its `from` state.
|
||||||
|
let available: Vec<Transition> = transitions
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.from == answer.state)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
// "Decided" (dimmed) means this row has no legal move here. A
|
||||||
|
// read-only listing (no transitions declared at all) keeps the old
|
||||||
|
// rule - dim anything past `open` - since "no legal move" would
|
||||||
|
// dim every row indiscriminately.
|
||||||
|
let decided = if transitions.is_empty() {
|
||||||
|
answer.state != crate::answers::OPEN_STATE
|
||||||
|
} else {
|
||||||
|
available.is_empty()
|
||||||
|
};
|
||||||
let responses = answer
|
let responses = answer
|
||||||
.responses
|
.responses
|
||||||
.as_object()
|
.as_object()
|
||||||
@@ -1195,7 +1212,7 @@ fn AnswerRow(
|
|||||||
let key = (feature_name.clone(), item_id.clone());
|
let key = (feature_name.clone(), item_id.clone());
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<article class="answer-row" class:decided=move || !is_open>
|
<article class="answer-row" class:decided=move || decided>
|
||||||
<div class="answer-fields">
|
<div class="answer-fields">
|
||||||
<For
|
<For
|
||||||
each={
|
each={
|
||||||
@@ -1220,19 +1237,19 @@ fn AnswerRow(
|
|||||||
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={
|
<Show when={
|
||||||
let transitions = transitions.clone();
|
let available = available.clone();
|
||||||
move || is_open && !transitions.is_empty()
|
move || !available.is_empty()
|
||||||
}>
|
}>
|
||||||
{
|
{
|
||||||
let key = key.clone();
|
let key = key.clone();
|
||||||
let transitions = transitions.clone();
|
let available = available.clone();
|
||||||
view! {
|
view! {
|
||||||
<div class="answer-actions">
|
<div class="answer-actions">
|
||||||
<div class="select-options">
|
<div class="select-options">
|
||||||
<For
|
<For
|
||||||
each={
|
each={
|
||||||
let transitions = transitions.clone();
|
let available = available.clone();
|
||||||
move || transitions.clone()
|
move || available.clone()
|
||||||
}
|
}
|
||||||
key=|t| t.to.clone()
|
key=|t| t.to.clone()
|
||||||
children=move |t: Transition| {
|
children=move |t: Transition| {
|
||||||
|
|||||||
@@ -216,10 +216,20 @@ impl ResourceSpec {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Transition {
|
pub struct Transition {
|
||||||
|
/// The state a row must currently be in for this transition's
|
||||||
|
/// button to render and its server call to be accepted - defaults
|
||||||
|
/// to `"open"`, which is what every transition meant before graphs
|
||||||
|
/// grew deeper than one decision (invited -> active, etc.).
|
||||||
|
#[serde(default = "default_transition_from")]
|
||||||
|
pub from: String,
|
||||||
pub to: String,
|
pub to: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_transition_from() -> String {
|
||||||
|
crate::answers::OPEN_STATE.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Requirement {
|
pub struct Requirement {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -473,12 +483,24 @@ pub fn validate_questions(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
for transition in &resource.transitions {
|
for transition in &resource.transitions {
|
||||||
|
if !schema.has_state(&transition.from) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
|
||||||
|
question.id, alternative.name, feature.name, transition.from, bucket
|
||||||
|
);
|
||||||
|
}
|
||||||
if !schema.has_state(&transition.to) {
|
if !schema.has_state(&transition.to) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
|
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
|
||||||
question.id, alternative.name, feature.name, transition.to, bucket
|
question.id, alternative.name, feature.name, transition.to, bucket
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||||
|
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,3 +702,76 @@ pub async fn automation_kv_handler(
|
|||||||
}
|
}
|
||||||
Ok(axum::Json(serde_json::Value::Array(items)))
|
Ok(axum::Json(serde_json::Value::Array(items)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "ssr"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn schema_fixture() -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
|
||||||
|
crate::aggregates::parse_aggregates_yaml(
|
||||||
|
r#"
|
||||||
|
aggregates:
|
||||||
|
- bucket: things
|
||||||
|
initial: open
|
||||||
|
states:
|
||||||
|
open: { event: opened }
|
||||||
|
middle: { event: advanced }
|
||||||
|
done: { event: finished }
|
||||||
|
transitions:
|
||||||
|
open: [middle]
|
||||||
|
middle: [done]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||||
|
let question: Question = serde_yaml::from_str(&format!(
|
||||||
|
r#"
|
||||||
|
id: /t
|
||||||
|
name: T
|
||||||
|
alternatives:
|
||||||
|
- name: A
|
||||||
|
features:
|
||||||
|
- name: ""
|
||||||
|
resource:
|
||||||
|
source: {{ kind: kv, bucket: things }}
|
||||||
|
requires_group: owners
|
||||||
|
transitions:
|
||||||
|
{transitions_yaml}
|
||||||
|
"#
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declared_edge_passes() {
|
||||||
|
let questions = question_with_transitions(
|
||||||
|
" - { from: open, to: middle, label: Advance }\n - { from: middle, to: done, label: Finish }",
|
||||||
|
);
|
||||||
|
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_defaults_to_open() {
|
||||||
|
let questions = question_with_transitions(" - { to: middle, label: Advance }");
|
||||||
|
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undeclared_from_state_is_rejected() {
|
||||||
|
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
||||||
|
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("transition.from"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undeclared_edge_is_rejected() {
|
||||||
|
// Both states exist, but open -> done skips a step the graph
|
||||||
|
// never declared.
|
||||||
|
let questions = question_with_transitions(" - { from: open, to: done, label: Skip }");
|
||||||
|
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("not a declared edge"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user