Initial commit: content-driven onboarding portal

Leptos/Axum app that renders a Question/Alternative/Feature schema
loaded from a sibling content repo (portal-content). Kanidm OIDC login,
content-driven authorization (Question.qualifies), a generic NATS
KV-backed resource + state-transition mechanism (no bespoke "applicant"
concept baked into the runtime - it's all content), a SHA-256 DAG chain
tying submissions and decisions together, and the "YES - Rasterized
Lines" piece (ported from the live uhhm.no site) as the landing hero.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-29 19:38:40 +02:00
commit aa1a7fa572
21 changed files with 7873 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
//! Durable, queryable storage for submitted answers - the KV-backed
//! counterpart to the ephemeral `events::AnswerSubmitted` every
//! submission already publishes. The runtime here has no notion of
//! "applicant" or any other specific workflow: an `Alternative.record_as`
//! names a NATS KV bucket (nothing more); on submit, the response gets
//! wrapped in the same envelope (`Answer`) regardless of what content
//! produced it. `resource::get_resource` reads it back generically, and
//! `transition_answer` below moves an answer between states named in
//! content (`ResourceSpec.transitions`), never states hardcoded here.
//! Whatever workflow this ends up powering - applicant review, order
//! triage, comment moderation - is entirely a property of some
//! `portal-content` YAML, not of this file.
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")]
pub async fn store_answer(
js: &async_nats::jetstream::Context,
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?
}
};
let answer = Answer {
id: id.clone(),
question_id: question_id.to_string(),
alternative: alternative.to_string(),
responses: responses.clone(),
state: OPEN_STATE.to_string(),
submitted_ms,
decided_ms: None,
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
store.put(id, data.into()).await?;
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.
#[server]
pub async fn transition_answer(
question_id: String,
feature_name: String,
item_id: String,
to: String,
) -> 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
.questions
.get(&question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let feature = question
.alternatives
.iter()
.flat_map(|a| &a.features)
.find(|f| f.name == 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 transition = resource
.transitions
.iter()
.find(|t| t.to == 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"));
}
let store = state
.jetstream
.get_key_value(&resource.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("unknown answer"))?;
let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
if answer.state != OPEN_STATE {
return Err(ServerFnError::new("already decided"));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
answer.state = 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())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
// Extend the DAG: the decision is a child node of the answer's own
// 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 decision_payload = serde_json::json!({ "to": to, "item": item_id, "by": user.username });
let parent_hashes = vec![item_id.clone()];
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
question_id,
alternative: transition.label,
responses: decision_payload,
timestamp_ms: decided_ms,
};
emit_answer_submitted(&state.nats, &event)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(())
}