Event-sourced applicant/subscriber/project aggregates, generalized resources
Deploy / deploy (push) Failing after 3s
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:
co-authored by
Claude Sonnet 5
parent
213b1130bb
commit
ebf4bf91b3
+91
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user