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
+76
View File
@@ -0,0 +1,76 @@
//! Headless schema-check binary - loads content (from a Gitea repo URL
//! or a local directory) and validates it exactly the way
//! `content::watch_for_reload`/`main.rs`'s boot path do, with no NATS,
//! OIDC, web server, or JetStream connection involved. Built once by
//! portal's own deploy workflow and downloaded as a release artifact by
//! `questions`' own CI, rather than compiled there - keeps that repo's
//! CI coupling to "run a static binary," not "build a Rust workspace."
#![cfg(feature = "ssr")]
use portal::content;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let mut repo: Option<String> = None;
let mut branch = "main".to_string();
let mut subdir = "questions".to_string();
let mut path: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--repo" => repo = args.next(),
"--branch" => branch = args.next().unwrap_or(branch),
"--subdir" => subdir = args.next().unwrap_or(subdir),
"--path" => path = args.next(),
other => {
eprintln!("unknown argument: {other}");
std::process::exit(2);
}
}
}
let questions = if let Some(dir) = path {
load_from_dir(&dir)?
} else if let Some(repo_url) = repo {
content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?
} else {
eprintln!(
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
);
std::process::exit(2);
};
match content::validate_questions(&questions) {
Ok(()) => {
println!("OK: {} question(s) valid", questions.len());
Ok(())
}
Err(e) => {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
}
}
/// The offline counterpart to `content::load_questions_from_gitea` -
/// same "every `*.yaml` file becomes a `Question` keyed by its own
/// `id`" shape, just reading a local checkout instead of Gitea's API,
/// for linting a branch that hasn't been pushed yet.
fn load_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let mut out = std::collections::HashMap::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
let raw = std::fs::read_to_string(&path)?;
let question: content::Question = serde_yaml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
out.insert(question.id.clone(), question);
}
Ok(out)
}