Compare commits

...
6 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug c7c2956e15 Release 0.5.11
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 39s
Publish release / publish (push) Failing after 2m13s
2026-09-30 15:17:32 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 267f7f0787 Changelog: 0.5.11
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:17:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f395ace556 Storylines: the same site told several ways, one per visitor, counted
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m1s
Any text a person reads may be a list, one entry per storyline, every
list the same length. A visitor is given one storyline for the whole
site and reads it on every page and in every mail about their case.
Records and events carry the storyline; GET /report gives counts per
bucket, storyline and state, and nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:11:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 cc3107e502 Docs: vel.klingenbergbygg.no runs v0.5.10 with memberships
docs / deploy (push) Successful in 3s
Test / test (push) Failing after 25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:22:11 +02:00
Bendik Aagaard Lynghaug cb31995299 Release 0.5.10
docs / deploy (push) Successful in 4s
Test / test (push) Failing after 45s
Publish release / publish (push) Successful in 3m35s
2026-09-30 14:10:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 579ad6f50d The lint lets an invite form ask for a phone number instead of an address
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:09:49 +02:00
27 changed files with 573 additions and 50 deletions
+9 -1
View File
@@ -4,6 +4,14 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## 0.5.11 (2026-09-30)
- Storylines (`src/content/variants.rs`, `docs/storylines.md`): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (the `portal_storyline` cookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field's `name`, `options`, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carry `storyline`, and `GET /report` gives counts per bucket, per storyline, per state, and nothing else.
## 0.5.10 (2026-09-30)
- The lint lets an invite form collect a required `phone` in place of a required `email`: memberships know a person by number first.
## 0.5.9 (2026-09-30)
- With memberships as the people backend and a Kanidm token still set, an invite makes the Kanidm account as well (no group, the one-time link in the mail): Kanidm makes people, portal makes members, and a site can switch to memberships before its sign-in provider is one everyone already has.
@@ -396,4 +404,4 @@ hosts now, since kasse's runner deploys its own instances.
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.8 |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.10, memberships |
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.5.9"
version = "0.5.11"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.5.9"
version = "0.5.11"
edition = "2021"
[lib]
+1
View File
@@ -11,6 +11,7 @@ engine does with it.
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
| [Storylines](storylines.md) | The same site told several ways: lists of texts, one storyline per visitor, counted per storyline. |
| [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. |
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
+1
View File
@@ -8,6 +8,7 @@
- [Content reference](content-reference.md)
- [State machines](state-machines.md)
- [Access](access.md)
- [Storylines](storylines.md)
- [Memberships](memberships.md)
- [Operations](operations.md)
+5
View File
@@ -38,6 +38,11 @@ mail:
`mail.from` is required as soon as any state declares a mail.
Any text a person reads (`name`, `description`, `label`,
`placeholder`, a mail's `subject` and `body`, `consequence`,
`encouragements`) may be a list instead of one string: one entry per
[storyline](storylines.md), all lists the same length.
## A question
One YAML file is one page.
+6
View File
@@ -130,6 +130,12 @@ then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
fails closed rather than appearing to work and storing nowhere.
## The report
`GET /report` is public and answers with counts only: per bucket, per
[storyline](storylines.md), per state. It is what the trainer reads to
compare the storylines on what people did.
## The docs site
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
+60
View File
@@ -0,0 +1,60 @@
# Storylines
The same site, told two ways, and counted.
Anywhere content says something to a person, it may say it in several
ways by giving a list instead of one string:
```yaml
name: Vil du hjelpe til i nabolaget?
description:
- Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan.
- Naboene gjør det meste selv. Her melder du deg til det du har lyst på.
alternatives:
- name: [Bli med, Meld deg]
consequence: [[Send], [Meld meg]]
```
Each index is a *storyline*. A visitor is given one at their first
request, kept in a cookie (`portal_storyline`) for a year, and reads
that storyline on every page, in every button and in every mail the
site sends them about their case. A single string is every storyline.
## The rules
- **Same length everywhere.** Every list of texts in the content has
the site's arity, so a storyline never has a hole in it. The lint
refuses a list of another length and names it.
- **What varies is what a person reads:** `name`, `description`,
`label`, `placeholder`, `title`, a mail's `subject` and `body`, a
list's `empty` line, `consequence` and `encouragements` (those two as
lists of lists).
- **What the machine reads never varies:** a field's `name` (the stored
key), a select's `options` (the stored values), states, groups,
paths, buckets.
- **One storyline per site**, not per page. That is what makes it a
storyline rather than a scatter of variants: a warm front door leads
to a warm form and a warm mail.
- A signed-in person keeps the storyline they had as a visitor.
- A site that tells one storyline sets no cookie.
## What is counted
Every record and every answer event carries the storyline it came
from (`storyline` on `Answer` and on `portal.answers.submitted`). The
mail a case gets tells the same storyline, so a person who read the
warm front door is not thanked in the plain voice.
`GET /report` gives the counts, per bucket, per storyline, per state:
```json
{ "storylines": 2,
"buckets": { "hjelpebehov": { "0": { "open": 3, "ordnet": 9 },
"1": { "open": 5, "ordnet": 14 } } } }
```
Storylines are assigned evenly, so those numbers are the comparison:
which words brought more people to send, and how their cases went. No
record, field, address or time leaves the site. The trainer reads this
(`portal-trainer report`) next to what its personas scored offline, and
the plan's `tones` are where the storylines are written.
+2
View File
@@ -84,6 +84,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
EVENT_ALTERNATIVE,
&responses,
now_ms,
0,
)
.await?;
tracing::info!(question = %q.id, "event record created");
@@ -137,6 +138,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
alternative: SUMMARY_DUE_LABEL.to_string(),
responses: payload,
timestamp_ms: now_ms,
storyline: 0,
},
)
.await?;
+14 -2
View File
@@ -24,6 +24,10 @@ pub struct Answer {
pub responses: serde_json::Value,
pub state: String,
pub submitted_ms: i64,
/// The storyline the record was sent from, so the mail it gets
/// tells the same one (see `content/variants.rs`).
#[serde(default)]
pub storyline: u32,
#[serde(default)]
pub decided_ms: Option<i64>,
#[serde(default)]
@@ -64,6 +68,7 @@ pub async fn store_answer(
alternative: &str,
responses: &serde_json::Value,
submitted_ms: i64,
storyline: u32,
) -> anyhow::Result<()> {
let store = match js.get_key_value(bucket).await {
Ok(store) => store,
@@ -89,6 +94,7 @@ pub async fn store_answer(
responses: responses.clone(),
state: initial_state,
submitted_ms,
storyline,
decided_ms: None,
notes: Vec::new(),
decided_by: None,
@@ -132,9 +138,11 @@ pub async fn transition_answers(
use crate::server::AppState;
let state = expect_context::<AppState>();
let storyline = crate::server::storyline::current().await;
let alt = state
.questions
.load()
.at(storyline)
.get(&question_id)
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
@@ -289,7 +297,7 @@ async fn apply_transition(
.put(&item.item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses, answer.storyline).await;
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
// An expired invite sent again: the move back is the asking.
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
@@ -307,6 +315,7 @@ async fn apply_transition(
alternative: transition.label,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
storyline: answer.storyline,
};
emit_answer_submitted(&state.nats, &event)
.await
@@ -417,7 +426,7 @@ pub async fn self_transition_answer(
.put(&item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses, answer.storyline).await;
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
let parent_hashes = vec![item_id.clone()];
@@ -429,6 +438,7 @@ pub async fn self_transition_answer(
alternative: self_transition.label,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
storyline: answer.storyline,
};
emit_answer_submitted(&state.nats, &event)
.await
@@ -456,6 +466,7 @@ mod tests {
responses: serde_json::json!({"name": "Ada"}),
state: OPEN_STATE.to_string(),
submitted_ms: 1_700_000_000_000,
storyline: 0,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
@@ -467,6 +478,7 @@ mod tests {
responses: serde_json::json!({"name": "Grace"}),
state: "invited".to_string(),
submitted_ms: 1_700_000_001_000,
storyline: 0,
decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()),
notes: Vec::new(),
+13 -6
View File
@@ -2358,8 +2358,9 @@ pub async fn get_question(
use crate::content::{fill_from_user, path_matches, resolve_question};
use crate::server::AppState;
let state = expect_context::<AppState>();
let questions = state.questions.load();
let Some(mut question) = resolve_question(&questions, &path) else {
let storylines = state.questions.load();
let questions = storylines.at(crate::server::storyline::current().await);
let Some(mut question) = resolve_question(questions, &path) else {
return Ok(None);
};
let session: tower_sessions::Session = leptos_axum::extract().await?;
@@ -2401,7 +2402,7 @@ pub async fn get_question(
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let mut site = state.site.load().as_ref().clone();
let mut site = state.site.load().at(crate::server::storyline::current().await).clone();
// Not content: whether this instance can serve tiles at all is its
// own configuration, and the browser has to know before it decides
// whether to draw a map or ask for two numbers.
@@ -2435,7 +2436,8 @@ pub async fn list_qualifying_questions(
.map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let questions = state.questions.load();
let storylines = state.questions.load();
let questions = storylines.at(crate::server::storyline::current().await);
// What the chain verifiably answered - the same lookup a
// `requires_chain` gate does, so a forged or foreign hash resolves
// to nothing - and therefore where it sent the visitor.
@@ -2583,6 +2585,7 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
SUGGESTION_ALTERNATIVE,
&responses,
now_ms,
0,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
@@ -2593,6 +2596,7 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
alternative: SUGGESTION_ALTERNATIVE.to_string(),
responses: responses.clone(),
timestamp_ms: now_ms,
storyline: 0,
};
if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}");
@@ -2679,7 +2683,8 @@ pub async fn submit_answer(
// resolve_question, not a plain map get: a dynamic page's concrete
// path (what the client holds as its question id) resolves to the
// pattern page it came from.
let question = crate::content::resolve_question(&state.questions.load(), &question_id)
let storyline = crate::server::storyline::current().await;
let question = crate::content::resolve_question(state.questions.load().at(storyline), &question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
@@ -2796,6 +2801,7 @@ pub async fn submit_answer(
alternative: alternative.clone(),
responses: responses.clone(),
timestamp_ms,
storyline: storyline as u32,
},
)
.await
@@ -2821,6 +2827,7 @@ pub async fn submit_answer(
&alternative,
&responses,
timestamp_ms,
storyline as u32,
)
.await
{
@@ -2829,7 +2836,7 @@ pub async fn submit_answer(
// Arrival is a state too: what the schema says the first
// state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses, storyline as u32).await;
crate::people::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
}
}
+42 -15
View File
@@ -9,20 +9,27 @@ use super::*;
/// config; a file that exists but doesn't parse or validate is a real
/// error, surfaced at boot rather than papered over.
#[cfg(feature = "ssr")]
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<SiteConfig> {
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<Storylines<SiteConfig>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
Ok(raw) => raw,
Err(e) => {
tracing::info!("no site.yaml in content repo ({e}), using default branding");
return Ok(SiteConfig::default());
return Ok(Storylines::one(SiteConfig::default()));
}
};
let site: SiteConfig =
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
site.validate()?;
Ok(site)
// The invite mail's words may be several (see `variants.rs`).
let files = vec![("site.yaml".to_string(), raw)];
let arity = super::variants::arity(&files)?;
let mut each = Vec::with_capacity(arity);
for n in 0..arity {
let site: SiteConfig =
serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
site.validate()?;
each.push(site);
}
Ok(Storylines { each })
}
/// Extracts `scheme://host` from a repo's normal browser URL - the
@@ -107,11 +114,18 @@ pub(crate) async fn fetch_gitea_file(
pub async fn load_aggregates_from_gitea(
repo_url: &str,
branch: &str,
) -> anyhow::Result<std::collections::HashMap<String, crate::aggregates::AggregateSchema>> {
) -> anyhow::Result<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
crate::aggregates::parse_aggregates_yaml(&raw)
// A mail's subject and body may be several (see `variants.rs`);
// the runtime's own buckets are added to every storyline.
let files = vec![("aggregates.yaml".to_string(), raw)];
let arity = super::variants::arity(&files)?;
let each = (0..arity)
.map(|n| crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1).map(with_builtin_aggregates))
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Storylines { each })
}
#[cfg(feature = "ssr")]
@@ -119,7 +133,7 @@ pub async fn load_questions_from_gitea(
repo_url: &str,
branch: &str,
subdir: &str,
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
) -> anyhow::Result<Storylines<std::collections::HashMap<String, Question>>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
@@ -161,7 +175,20 @@ pub async fn load_questions_from_gitea(
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
files.push((path[prefix.len()..].to_string(), raw));
}
build_questions(&files)
build_storylines(&files)
}
/// Every storyline's arity is the site's or one: a mail told in two
/// voices on a site whose pages are told in three has a hole in it.
#[cfg(feature = "ssr")]
pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result<usize> {
let arity = questions.max(aggregates).max(site);
for (what, n) in [("questions", questions), ("aggregates.yaml", aggregates), ("site.yaml", site)] {
if n != 1 && n != arity {
anyhow::bail!("{what} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
}
}
Ok(arity)
}
/// Published by the content repo's own CI (after it lints a push) to
@@ -192,11 +219,11 @@ pub async fn watch_for_reload(
repo_url: String,
branch: String,
subdir: String,
questions: std::sync::Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
questions: std::sync::Arc<arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>>,
aggregates: std::sync::Arc<
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>>,
>,
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
site: std::sync::Arc<arc_swap::ArcSwap<Storylines<SiteConfig>>>,
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
) {
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
@@ -209,7 +236,7 @@ pub async fn watch_for_reload(
use futures::StreamExt;
while sub.next().await.is_some() {
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
Ok(loaded) => with_builtin_aggregates(loaded),
Ok(loaded) => loaded,
Err(e) => {
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
continue;
@@ -228,7 +255,7 @@ pub async fn watch_for_reload(
};
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| same_arity(loaded.arity(), loaded_aggregates.arity(), loaded_site.arity())) {
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
continue;
}
+23
View File
@@ -13,12 +13,16 @@ use serde::{Deserialize, Serialize};
// Loading is server-only; the rest is shared with the browser build.
#[cfg(feature = "ssr")]
mod gitea;
#[cfg(feature = "ssr")]
mod variants;
mod handlers;
mod markdown;
mod validate;
#[cfg(feature = "ssr")]
pub use gitea::*;
#[cfg(feature = "ssr")]
pub use variants::*;
pub use handlers::*;
pub use markdown::*;
pub use validate::*;
@@ -1073,6 +1077,25 @@ pub fn resolve_question(
#[cfg(feature = "ssr")]
pub fn build_questions(
files: &[(String, String)],
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
// The first storyline, once every list of texts has been held to
// the site's arity (see `variants.rs`); the machinery reads this
// one, and `build_storylines` builds the rest for the visitors.
variants::arity(files)?;
build_one_storyline(&variants::storyline(files, 0))
}
/// Every storyline of the content, each a full map of questions.
#[cfg(feature = "ssr")]
pub fn build_storylines(files: &[(String, String)]) -> anyhow::Result<variants::Storylines<std::collections::HashMap<String, Question>>> {
let arity = variants::arity(files)?;
let each = (0..arity).map(|n| build_one_storyline(&variants::storyline(files, n))).collect::<anyhow::Result<Vec<_>>>()?;
Ok(variants::Storylines { each })
}
#[cfg(feature = "ssr")]
fn build_one_storyline(
files: &[(String, String)],
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
// Sections first: (directory prefix, config), shallowest first so a
// later (deeper) section overrides an outer one field-by-field.
+5 -2
View File
@@ -278,9 +278,12 @@ pub fn validate_questions(
.flat_map(|f| &f.requirements)
.any(|r| r.name == name && !r.optional)
};
if !has("name") || !has("email") {
// A person is known by a phone number or an address;
// either will do, since memberships key on the number
// first and Kanidm on the address.
if !has("name") || !(has("email") || has("phone")) {
anyhow::bail!(
"question {:?} alternative {:?}: an invite must collect required `name` and `email` fields",
"question {:?} alternative {:?}: an invite must collect a required `name` and a required `email` or `phone` field",
question.id, alternative.name
);
}
+224
View File
@@ -0,0 +1,224 @@
//! Storylines: a text may be several, and `[n]` runs through the site.
//!
//! Anywhere content says something to a person - a page's `name` and
//! `description`, an alternative's, a feature's, a `label`, a
//! `placeholder`, a mail's `subject` and `body`, a list's `empty` line,
//! the `consequence` buttons and `encouragements` - it may give a list
//! instead of one string:
//!
//! ```yaml
//! description:
//! - Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan.
//! - Naboene gjør det meste selv. Her melder du deg til det du har lyst på.
//! ```
//!
//! Every such list has the same length, the site's *arity*, and a
//! visitor is given one index for the whole site (a cookie, see
//! `main.rs`), so they read one storyline from the front door to the
//! mail they get. A single string is every storyline. What a visitor
//! did is recorded with the index (`AnswerSubmitted.variant`), so the
//! storylines can be compared on what people actually did, and nothing
//! else about them.
//!
//! This is done to the YAML before the structs see it: the files are
//! expanded once per storyline, and everything downstream keeps its one
//! string per field. Keys that name things to the machine - a field's
//! `name`, a select's `options`, states, groups, paths - never vary.
//!
//! The lint holds every list to the arity, so a storyline cannot have
//! a hole in it.
use serde_yaml::Value;
/// Keys whose value is one string to a person.
const TEXT: &[&str] = &["name", "description", "label", "placeholder", "subject", "body", "empty", "title"];
/// Keys whose value is a list of strings to a person; their storylines
/// are a list of lists.
const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
/// Where in a document a key is not text but an identifier: a
/// requirement's `name` is the stored field, never varied.
fn is_identifier(path: &[String], key: &str) -> bool {
// `path` ends with the key itself, then the item's index, then the
// list it is in.
key == "name" && path.iter().rev().nth(2).is_some_and(|p| p == "requirements")
}
fn is_strings(seq: &[Value]) -> bool {
!seq.is_empty() && seq.iter().all(|v| v.is_string())
}
fn is_lists_of_strings(seq: &[Value]) -> bool {
!seq.is_empty() && seq.iter().all(|v| v.as_sequence().is_some_and(|s| s.iter().all(|x| x.is_string())))
}
/// The storyline counts found in a document, with where each was.
fn counts(value: &Value, path: &mut Vec<String>, out: &mut Vec<(String, usize)>) {
match value {
Value::Mapping(map) => {
for (k, v) in map {
let key = k.as_str().unwrap_or_default().to_string();
path.push(key.clone());
let variants = match v {
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq.len()),
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq.len()),
_ => None,
};
match variants {
Some(n) => out.push((path.join("."), n)),
None => counts(v, path, out),
}
path.pop();
}
}
Value::Sequence(seq) => {
for (i, v) in seq.iter().enumerate() {
path.push(i.to_string());
counts(v, path, out);
path.pop();
}
}
_ => {}
}
}
/// One storyline of a document: every list of texts becomes its `n`th.
fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
match value {
Value::Mapping(map) => {
for (k, v) in map.iter_mut() {
let key = k.as_str().unwrap_or_default().to_string();
path.push(key.clone());
let chosen = match v {
Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()),
_ => None,
};
match chosen {
Some(c) => *v = c,
None => pick(v, path, n),
}
path.pop();
}
}
Value::Sequence(seq) => {
for (i, v) in seq.iter_mut().enumerate() {
path.push(i.to_string());
pick(v, path, n);
path.pop();
}
}
_ => {}
}
}
/// How many storylines these files tell: the length every list of
/// texts has, or 1 when there is none. A list of another length is an
/// error that names it.
pub fn arity(files: &[(String, String)]) -> anyhow::Result<usize> {
let mut found: Vec<(String, String, usize)> = Vec::new();
for (rel, raw) in files {
let Ok(value) = serde_yaml::from_str::<Value>(raw) else { continue };
let mut out = Vec::new();
counts(&value, &mut Vec::new(), &mut out);
found.extend(out.into_iter().map(|(p, n)| (rel.clone(), p, n)));
}
let arity = found.iter().map(|(_, _, n)| *n).max().unwrap_or(1);
if let Some((rel, p, n)) = found.iter().find(|(_, _, n)| *n != arity) {
anyhow::bail!("{rel}: {p} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length");
}
Ok(arity.max(1))
}
/// The files as storyline `n` tells them.
pub fn storyline(files: &[(String, String)], n: usize) -> Vec<(String, String)> {
files
.iter()
.map(|(rel, raw)| {
let Ok(mut value) = serde_yaml::from_str::<Value>(raw) else { return (rel.clone(), raw.clone()) };
let mut out = Vec::new();
counts(&value, &mut Vec::new(), &mut out);
if out.is_empty() {
return (rel.clone(), raw.clone());
}
pick(&mut value, &mut Vec::new(), n);
(rel.clone(), serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()))
})
.collect()
}
/// One thing per storyline, and the first as what the machinery reads
/// when nobody in particular is asking: a seed, a lint, a desk sweep.
#[derive(Clone, Debug)]
pub struct Storylines<T> {
pub each: Vec<T>,
}
impl<T> Storylines<T> {
pub fn one(item: T) -> Self {
Storylines { each: vec![item] }
}
pub fn arity(&self) -> usize {
self.each.len().max(1)
}
/// Storyline `n`, or the last for an index from a site that told
/// more storylines than this one does.
pub fn at(&self, n: usize) -> &T {
&self.each[n.min(self.each.len() - 1)]
}
}
impl<T> std::ops::Deref for Storylines<T> {
type Target = T;
fn deref(&self) -> &T {
&self.each[0]
}
}
#[cfg(test)]
mod tests {
use super::*;
const PAGE: &str = "name: [Vil du hjelpe til?, Trenger naboene deg?]\ndescription: Alle trenger flere hender.\nalternatives:\n - name: [Bli med, Meld deg]\n description: [Si hva du kan., Si hva du har lyst på.]\n consequence: [[Send], [Meld meg]]\n features:\n - name: Skjema\n requirements:\n - name: [navn]\n label: [Navn, Hva heter du?]\n - name: hva\n type: select\n options: [Dugnad, Komité]\n";
#[test]
fn every_list_of_texts_is_a_storyline_and_identifiers_are_not() {
let files = vec![("index.yaml".to_string(), PAGE.to_string())];
assert_eq!(arity(&files).unwrap(), 2);
let second = storyline(&files, 1);
let v: Value = serde_yaml::from_str(&second[0].1).unwrap();
assert_eq!(v["name"], "Trenger naboene deg?");
assert_eq!(v["description"], "Alle trenger flere hender.");
assert_eq!(v["alternatives"][0]["name"], "Meld deg");
assert_eq!(v["alternatives"][0]["consequence"], serde_yaml::to_value(vec!["Meld meg"]).unwrap());
// A requirement's name is the stored field: left as written.
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["name"], serde_yaml::to_value(vec!["navn"]).unwrap());
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["label"], "Hva heter du?");
// Options are values people choose from and are stored: untouched.
assert_eq!(v["alternatives"][0]["features"][0]["requirements"][1]["options"], serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap());
}
#[test]
fn a_site_with_no_lists_tells_one_storyline_and_a_short_list_is_refused() {
let plain = vec![("index.yaml".to_string(), "name: Hva?\nalternatives:\n - name: A\n consequence: [Send]\n".to_string())];
assert_eq!(arity(&plain).unwrap(), 1);
assert_eq!(storyline(&plain, 0)[0].1, plain[0].1);
let uneven = vec![
("index.yaml".to_string(), PAGE.to_string()),
("b.yaml".to_string(), "name: [En, To, Tre]\n".to_string()),
];
let err = arity(&uneven).unwrap_err().to_string();
assert!(err.contains("b.yaml: name tells 3") && err.contains("tells 2") || err.contains("tells 3"), "{err}");
}
#[test]
fn the_first_storyline_is_what_the_machinery_reads() {
let s = Storylines { each: vec!["a", "b"] };
assert_eq!(*s, "a");
assert_eq!(*s.at(1), "b");
assert_eq!(*s.at(7), "b");
assert_eq!(s.arity(), 2);
}
}
+3 -1
View File
@@ -94,7 +94,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
if let Ok(data) = serde_json::to_vec(&answer) {
let _ = store.put(&id, data.into()).await;
}
crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses, answer.storyline).await;
crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
let parent_hashes = vec![id.clone()];
@@ -108,6 +108,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
alternative: target.clone(),
responses: payload,
timestamp_ms: now_ms,
storyline: answer.storyline,
},
)
.await
@@ -145,6 +146,7 @@ aggregates:
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
responses: serde_json::json!({}), state: state.into(),
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
storyline: 0,
}
}
+4
View File
@@ -15,6 +15,10 @@ pub struct AnswerSubmitted {
pub alternative: String,
pub responses: serde_json::Value,
pub timestamp_ms: i64,
/// The storyline the person read (see `content/variants.rs`): what
/// the storylines are compared on, and nothing else about them.
#[serde(default)]
pub storyline: u32,
}
pub const ANSWERS_SUBJECT: &str = "portal.answers.submitted";
+2
View File
@@ -35,6 +35,8 @@ pub mod mail;
pub mod seed;
#[cfg(feature = "ssr")]
pub mod memberships;
#[cfg(feature = "ssr")]
pub mod report;
pub mod maps;
+7 -3
View File
@@ -306,8 +306,10 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
/// if anything. Best-effort by design - the transition is already
/// committed, and a mail that cannot be rendered is logged, never a
/// failed request.
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, storyline: u32) {
// The mail tells the storyline the person read.
let storyline = storyline as usize;
let Some(spec) = state.aggregates.load().at(storyline).get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
// A suggestion has its own words for this unless the content
// gave the state a mail, which then is the one sent.
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
@@ -315,7 +317,8 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
return;
};
let site = state.site.load();
let site_storylines = state.site.load();
let site = site_storylines.at(storyline);
let Some(cfg) = &site.mail else {
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
return;
@@ -702,6 +705,7 @@ mod tests {
responses: serde_json::json!({ "email": email, "where": "The bend" }),
state: "open".into(),
submitted_ms: 1,
storyline: 0,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
+8 -5
View File
@@ -30,13 +30,12 @@ async fn main() -> anyhow::Result<()> {
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
let gitea_base = content::gitea_api_base(&content_repo)?;
let content_raw_base = content::gitea_raw_base(&content_repo)?;
let aggregates = content::with_builtin_aggregates(
content::load_aggregates_from_gitea(&content_repo, &content_branch).await?,
);
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
content::validate_questions(&questions, &aggregates)?;
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let storylines = content::same_arity(questions.arity(), aggregates.arity(), site.arity())?;
tracing::info!(count = questions.len(), storylines, repo = %content_repo, branch = %content_branch, "loaded content");
let access = Arc::new(arc_swap::ArcSwap::from_pointee(portal::access::Policy::from_content(&questions, &aggregates)));
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
@@ -225,6 +224,8 @@ async fn main() -> anyhow::Result<()> {
get(upload::attachment),
)
.route("/gitea-repo", get(content::gitea_repo_handler))
// What the storylines did, as counts (report.rs).
.route("/report", get(portal::report::handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
@@ -273,7 +274,9 @@ async fn main() -> anyhow::Result<()> {
},
)
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
.layer(session_layer);
.layer(session_layer)
// Which storyline this visitor reads, before anything renders.
.layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign));
// Outermost, so a caller who is over the limit is turned away
// before a session is loaded or a handler is entered.
let app = match governor {
+3 -1
View File
@@ -226,6 +226,7 @@ async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream:
alternative: target.to_string(),
responses: payload,
timestamp_ms: now_ms,
storyline: answer.storyline,
};
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
Ok(answer)
@@ -266,7 +267,7 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
"name": name, "group": group, "by": by,
"phone": who.phone, "email": who.email, "sub": who.sub,
});
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms).await?;
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms, 0).await?;
Ok(invited(true))
}
}
@@ -346,6 +347,7 @@ mod tests {
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
state: state.into(),
submitted_ms: 0,
storyline: 0,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
+3 -1
View File
@@ -414,7 +414,7 @@ pub async fn record(state: &crate::server::AppState, invited: &Invited, responsi
responses["responsible_for"] = serde_json::json!(responsible_for);
}
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms).await {
if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms, 0).await {
tracing::warn!(user = %invited.username, "the invite is sent, but could not be put on file for a desk: {e}");
}
}
@@ -454,6 +454,7 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr
alternative: target.to_string(),
responses: payload,
timestamp_ms: now_ms,
storyline: answer.storyline,
};
if let Err(e) = crate::events::emit_answer_submitted(&state.nats, &event).await {
tracing::warn!(id = %answer.id, target, "the invite was moved but not published: {e}");
@@ -702,6 +703,7 @@ mod tests {
}}),
state: state.into(),
submitted_ms: 1_000,
storyline: 0,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
+53
View File
@@ -0,0 +1,53 @@
//! What the storylines did, as numbers and nothing else.
//!
//! `GET /report` answers with a count per bucket, per storyline, per
//! state: how many cases each storyline brought in and where they got
//! to. That is the whole of what the storylines are compared on. No
//! record, no field, no address, no time leaves the site: a storyline
//! is assigned evenly, so the counts are the comparison, and the
//! trainer (`portal-trainer report`) reads them to say which words
//! worked. Public, since a count is what a public list already shows.
#![cfg(feature = "ssr")]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// The report: the site's storylines, and per bucket the count of
/// records by storyline and state.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Report {
pub storylines: usize,
/// bucket -> storyline (as a string, JSON's way) -> state -> count
pub buckets: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
}
pub async fn gather(state: &crate::server::AppState) -> Report {
let mut report = Report { storylines: state.questions.load().arity(), buckets: BTreeMap::new() };
let aggregates = state.aggregates.load();
for bucket in aggregates.keys() {
// The runtime's own buckets are not the site's offers.
if bucket.starts_with("portal_") {
continue;
}
let Ok(store) = state.jetstream.get_key_value(bucket).await else { continue };
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => continue,
}
};
let counts = report.buckets.entry(bucket.clone()).or_default();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
*counts.entry(answer.storyline.to_string()).or_default().entry(answer.state).or_default() += 1;
}
}
report
}
pub async fn handler(axum::extract::State(state): axum::extract::State<crate::server::AppState>) -> axum::Json<Report> {
axum::Json(gather(&state).await)
}
+5 -4
View File
@@ -33,7 +33,7 @@ pub async fn get_resource(
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
let resource = feature
.resource
.as_ref()
@@ -62,7 +62,7 @@ pub async fn get_requirement_options(
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
let requirement = feature
.requirements
.iter()
@@ -103,7 +103,7 @@ pub async fn get_requirement_binding(
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
let requirement = feature
.requirements
.iter()
@@ -128,6 +128,7 @@ pub async fn get_requirement_binding(
#[cfg(feature = "ssr")]
fn find_feature(
state: &crate::server::AppState,
storyline: usize,
question_id: &str,
alternative: &str,
feature_name: &str,
@@ -137,7 +138,7 @@ fn find_feature(
// also what substitutes the URL segment into the page's resource
// keys - so this lookup is where a `/review/<record>` page's
// feature acquires its record-specific key.
let question = crate::content::resolve_question(&state.questions.load(), question_id)
let question = crate::content::resolve_question(state.questions.load().at(storyline), question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
question
.alternatives
+4 -3
View File
@@ -1,6 +1,7 @@
#![cfg(feature = "ssr")]
pub mod oidc;
pub mod storyline;
use arc_swap::ArcSwap;
use axum::extract::FromRef;
@@ -21,18 +22,18 @@ pub struct AppState {
/// message (see `content::watch_for_reload`) - readers never hold
/// a lock, just an atomic pointer load, so a reload never blocks or
/// is blocked by an in-flight request.
pub questions: Arc<ArcSwap<HashMap<String, Question>>>,
pub questions: Arc<ArcSwap<crate::content::Storylines<HashMap<String, Question>>>>,
/// Every bucket's event-sourced state graph, loaded from
/// `aggregates.yaml` (see `content::load_aggregates_from_gitea`) and
/// hot-swapped alongside `questions` on the same reload. A bucket
/// with no entry here isn't event-sourced - plain KV mutate-in-place
/// still works (see `answers.rs`).
pub aggregates: Arc<ArcSwap<HashMap<String, crate::aggregates::AggregateSchema>>>,
pub aggregates: Arc<ArcSwap<crate::content::Storylines<HashMap<String, crate::aggregates::AggregateSchema>>>>,
/// Site branding from the content repo's optional `site.yaml`
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
/// `aggregates` on the same reload. Default = the historical uhhm
/// look.
pub site: Arc<ArcSwap<crate::content::SiteConfig>>,
pub site: Arc<ArcSwap<crate::content::Storylines<crate::content::SiteConfig>>>,
/// The access policy compiled from the content above (see
/// `access.rs`), swapped with it on every reload. Every server entry
/// point decides through this and nothing else.
+70
View File
@@ -0,0 +1,70 @@
//! Which storyline a visitor reads (see `content/variants.rs`): one
//! index for the whole site, chosen at their first request and kept in
//! a cookie for a year, so every page and every mail they get tells
//! the same story. A site that tells one story sets no cookie.
#![cfg(feature = "ssr")]
use axum::{
extract::{Request, State},
http::header::{COOKIE, SET_COOKIE},
middleware::Next,
response::Response,
};
pub const COOKIE_NAME: &str = "portal_storyline";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Storyline(pub usize);
/// The index in a request's cookies, if it names one this site tells.
fn from_cookies(header: Option<&str>, arity: usize) -> Option<usize> {
header?
.split(';')
.map(str::trim)
.find_map(|kv| kv.strip_prefix(COOKIE_NAME).and_then(|rest| rest.strip_prefix('=')))
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n < arity)
}
/// Evenly, from nothing about the person.
fn pick(arity: usize) -> usize {
(uuid::Uuid::new_v4().as_u128() % arity.max(1) as u128) as usize
}
pub async fn assign(State(state): State<super::AppState>, mut req: Request, next: Next) -> Response {
let arity = state.questions.load().arity();
let held = from_cookies(req.headers().get(COOKIE).and_then(|v| v.to_str().ok()), arity);
let n = held.unwrap_or_else(|| pick(arity));
req.extensions_mut().insert(Storyline(n));
let mut res = next.run(req).await;
if held.is_none() && arity > 1 {
if let Ok(value) = format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse() {
res.headers_mut().append(SET_COOKIE, value);
}
}
res
}
/// The storyline of the request a server function is answering, or
/// the first when there is none to ask (a call outside a request).
pub async fn current() -> usize {
leptos_axum::extract::<axum::Extension<Storyline>>()
.await
.map(|axum::Extension(Storyline(n))| n)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cookie_names_a_storyline_the_site_tells() {
assert_eq!(from_cookies(Some("a=b; portal_storyline=1; c=d"), 3), Some(1));
assert_eq!(from_cookies(Some("portal_storyline=7"), 3), None);
assert_eq!(from_cookies(Some("portal_storylines=1"), 3), None);
assert_eq!(from_cookies(None, 3), None);
assert!(pick(1) == 0);
assert!((0..50).all(|_| pick(3) < 3));
}
}
+4 -4
View File
@@ -34,7 +34,7 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
let now = chrono::Utc::now().timestamp_millis();
// The submission: a KV record in the initial state, plus the first event.
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now)
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now, 0)
.await
.expect("store");
let store = js.get_key_value(&bucket).await.expect("bucket exists");
@@ -178,9 +178,9 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
nats,
jetstream: js,
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(questions)),
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)),
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(Default::default())),
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(questions))),
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(aggregates))),
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(Default::default()))),
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
gitea_base: String::new(),
content_raw_base: String::new(),