Event-sourced applicant/subscriber/project aggregates, generalized resources
Deploy / deploy (push) Failing after 3s

Replaces the free-string, direct-KV-mutate state model in answers.rs
with a proper event log (events/store.rs, JetStream-backed, CAS via
expected_last_subject_sequence) and three pure state machines
(aggregates/{applicant,subscriber,project}.rs). Closes a real
lost-update race in the old transition_answer (concurrent decisions on
the same item could both win, publishing contradictory events). KV
buckets become best-effort read-model projections, not the source of
truth. Content-declared transition targets are now validated at
load/reload time against the real compiled transition tables, not
accepted as arbitrary strings.

Buckets renamed to describe their content, not their relation to the
app (portal_applicants -> applicants, etc); "inquiry" folded into a
richer "project" concept.

ResourceSpec generalized beyond a single KV bucket: Kv | GiteaStarred |
GiteaOrgRepos | Url sources, with an optional jq filter (via the jaq
crate) to shape live data for the frontend. Url source is SSRF-guarded
(https-only, rejects loopback/private/link-local, real DNS resolve).

New headless question_lint binary (validates content against compiled
transition tables with no NATS/OIDC/server involved) and a one-time
backfill_events binary (dry-run by default) for migrating existing KV
data onto the new event log.

Questions get an optional `responsible` contact plus a lightweight
"report this question" action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-06 08:52:29 +02:00
co-authored by Claude Sonnet 5
parent 213b1130bb
commit ebf4bf91b3
16 changed files with 1811 additions and 67 deletions
+91 -2
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Transition};
use crate::content::{is_qualified, Alternative, Question, Responsible, Transition};
use crate::resource::get_resource;
/// The visible site name/wordmark - "portal" is just this codebase's
@@ -157,6 +157,7 @@ fn QuestionView(
.into_any();
}
let question_id_for_note = question_id.clone();
view! {
<Hero
title=question.name.clone()
@@ -197,10 +198,64 @@ fn QuestionView(
}
/>
</div>
{question
.responsible
.clone()
.map(|r| {
view! { <ResponsibleNote question_id=question_id_for_note.clone() responsible=r/> }
})}
}
.into_any()
}
/// "Asked by X — contact them if you get stuck", plus a one-click
/// "report this question" action (see `report_question`) - mainly
/// meant for a question that reads as unhelpfully LLM-generated, so
/// whoever's responsible for it hears about it.
#[component]
fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoView {
let report = ServerAction::<ReportQuestion>::new();
let reported = RwSignal::new(false);
Effect::new(move |_| {
if report.value().get().is_some_and(|r| r.is_ok()) {
reported.set(true);
}
});
view! {
<p class="question-responsible">
"Asked by " {responsible.name.clone()} ""
<a href=format!("mailto:{}", responsible.contact)>"contact them"</a>
" if you get stuck."
{move || {
if reported.get() {
view! { <span class="question-reported">" Reported — thanks."</span> }
.into_any()
} else {
let question_id = question_id.clone();
view! {
<button
type="button"
class="question-report"
disabled=move || report.pending().get()
on:click=move |_| {
report
.dispatch(ReportQuestion {
question_id: question_id.clone(),
reason: None,
});
}
>
" Not helpful? Report this question."
</button>
}
.into_any()
}
}}
</p>
}
}
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
// (ported to `public/yes.js`, an ES module now instead of a page-owning
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
@@ -691,7 +746,11 @@ fn ResourceFeature(
let feature_name = feature_name.clone();
move || (question_id.clone(), alternative.clone(), feature_name.clone())
},
|(q, a, f)| get_resource(q, a, f),
// Empty params for now - forwarding sibling requirement values
// as query params (for a resource parameterized by other form
// fields) is deferred, see the resource-backed multi-select
// requirement UI follow-up.
|(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()),
);
view! {
@@ -996,6 +1055,36 @@ pub async fn submit_answer(
Ok(SubmitResult { next, chain_hash })
}
/// Fire-and-forget "this question wasn't helpful" report - a plain log
/// entry in the event store (`aggregate_type: "question_report"`, no
/// state machine, nothing to transition), reviewable via
/// `events::store::load_events(js, "question_report", question_id)` the
/// same way any other aggregate's history is. No auth, same as reading
/// a public question - a report is cheap, low-stakes signal, not
/// something worth gating behind a session.
#[server]
pub async fn report_question(
question_id: String,
reason: Option<String>,
) -> Result<(), ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let occurred_at_ms = chrono::Utc::now().timestamp_millis();
crate::events::store::append_event(
&state.jetstream,
"question_report",
&question_id,
"reported",
serde_json::json!({ "reason": reason }),
occurred_at_ms,
None,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}
/// Uploads one file to `POST /upload` and returns its stored object key
/// - the value a `type: file` requirement contributes to
/// `responses_json`, same as any other field. A plain `fetch`, not a