Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c2956e15 | ||
|
|
267f7f0787 | ||
|
|
f395ace556 | ||
|
|
cc3107e502 |
+5
-1
@@ -4,6 +4,10 @@ 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
|
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).
|
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)
|
## 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.
|
- The lint lets an invite form collect a required `phone` in place of a required `email`: memberships know a person by number first.
|
||||||
@@ -400,4 +404,4 @@ hosts now, since kasse's runner deploys its own instances.
|
|||||||
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
|
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
|
||||||
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
|
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
|
||||||
| portal.klingenbergbygg.no | kasse | `app@klingenberg-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
@@ -3357,7 +3357,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.5.10"
|
version = "0.5.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.5.10"
|
version = "0.5.11"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ engine does with it.
|
|||||||
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
|
||||||
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
|
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- [Content reference](content-reference.md)
|
- [Content reference](content-reference.md)
|
||||||
- [State machines](state-machines.md)
|
- [State machines](state-machines.md)
|
||||||
- [Access](access.md)
|
- [Access](access.md)
|
||||||
|
- [Storylines](storylines.md)
|
||||||
- [Memberships](memberships.md)
|
- [Memberships](memberships.md)
|
||||||
- [Operations](operations.md)
|
- [Operations](operations.md)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ mail:
|
|||||||
|
|
||||||
`mail.from` is required as soon as any state declares a 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
|
## A question
|
||||||
|
|
||||||
One YAML file is one page.
|
One YAML file is one page.
|
||||||
|
|||||||
@@ -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
|
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
|
||||||
fails closed rather than appearing to work and storing nowhere.
|
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
|
## The docs site
|
||||||
|
|
||||||
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
|
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -84,6 +84,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|||||||
EVENT_ALTERNATIVE,
|
EVENT_ALTERNATIVE,
|
||||||
&responses,
|
&responses,
|
||||||
now_ms,
|
now_ms,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
tracing::info!(question = %q.id, "event record created");
|
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(),
|
alternative: SUMMARY_DUE_LABEL.to_string(),
|
||||||
responses: payload,
|
responses: payload,
|
||||||
timestamp_ms: now_ms,
|
timestamp_ms: now_ms,
|
||||||
|
storyline: 0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
+14
-2
@@ -24,6 +24,10 @@ pub struct Answer {
|
|||||||
pub responses: serde_json::Value,
|
pub responses: serde_json::Value,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub submitted_ms: i64,
|
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)]
|
#[serde(default)]
|
||||||
pub decided_ms: Option<i64>,
|
pub decided_ms: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -64,6 +68,7 @@ pub async fn store_answer(
|
|||||||
alternative: &str,
|
alternative: &str,
|
||||||
responses: &serde_json::Value,
|
responses: &serde_json::Value,
|
||||||
submitted_ms: i64,
|
submitted_ms: i64,
|
||||||
|
storyline: u32,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let store = match js.get_key_value(bucket).await {
|
let store = match js.get_key_value(bucket).await {
|
||||||
Ok(store) => store,
|
Ok(store) => store,
|
||||||
@@ -89,6 +94,7 @@ pub async fn store_answer(
|
|||||||
responses: responses.clone(),
|
responses: responses.clone(),
|
||||||
state: initial_state,
|
state: initial_state,
|
||||||
submitted_ms,
|
submitted_ms,
|
||||||
|
storyline,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
@@ -132,9 +138,11 @@ pub async fn transition_answers(
|
|||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
|
let storyline = crate::server::storyline::current().await;
|
||||||
let alt = state
|
let alt = state
|
||||||
.questions
|
.questions
|
||||||
.load()
|
.load()
|
||||||
|
.at(storyline)
|
||||||
.get(&question_id)
|
.get(&question_id)
|
||||||
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
||||||
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
||||||
@@ -289,7 +297,7 @@ async fn apply_transition(
|
|||||||
.put(&item.item_id, data.into())
|
.put(&item.item_id, data.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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;
|
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.
|
// An expired invite sent again: the move back is the asking.
|
||||||
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
||||||
@@ -307,6 +315,7 @@ async fn apply_transition(
|
|||||||
alternative: transition.label,
|
alternative: transition.label,
|
||||||
responses: decision_payload_for_event,
|
responses: decision_payload_for_event,
|
||||||
timestamp_ms: decided_ms,
|
timestamp_ms: decided_ms,
|
||||||
|
storyline: answer.storyline,
|
||||||
};
|
};
|
||||||
emit_answer_submitted(&state.nats, &event)
|
emit_answer_submitted(&state.nats, &event)
|
||||||
.await
|
.await
|
||||||
@@ -417,7 +426,7 @@ pub async fn self_transition_answer(
|
|||||||
.put(&item_id, data.into())
|
.put(&item_id, data.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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;
|
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
||||||
|
|
||||||
let parent_hashes = vec![item_id.clone()];
|
let parent_hashes = vec![item_id.clone()];
|
||||||
@@ -429,6 +438,7 @@ pub async fn self_transition_answer(
|
|||||||
alternative: self_transition.label,
|
alternative: self_transition.label,
|
||||||
responses: decision_payload_for_event,
|
responses: decision_payload_for_event,
|
||||||
timestamp_ms: decided_ms,
|
timestamp_ms: decided_ms,
|
||||||
|
storyline: answer.storyline,
|
||||||
};
|
};
|
||||||
emit_answer_submitted(&state.nats, &event)
|
emit_answer_submitted(&state.nats, &event)
|
||||||
.await
|
.await
|
||||||
@@ -456,6 +466,7 @@ mod tests {
|
|||||||
responses: serde_json::json!({"name": "Ada"}),
|
responses: serde_json::json!({"name": "Ada"}),
|
||||||
state: OPEN_STATE.to_string(),
|
state: OPEN_STATE.to_string(),
|
||||||
submitted_ms: 1_700_000_000_000,
|
submitted_ms: 1_700_000_000_000,
|
||||||
|
storyline: 0,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
@@ -467,6 +478,7 @@ mod tests {
|
|||||||
responses: serde_json::json!({"name": "Grace"}),
|
responses: serde_json::json!({"name": "Grace"}),
|
||||||
state: "invited".to_string(),
|
state: "invited".to_string(),
|
||||||
submitted_ms: 1_700_000_001_000,
|
submitted_ms: 1_700_000_001_000,
|
||||||
|
storyline: 0,
|
||||||
decided_ms: Some(1_700_000_002_000),
|
decided_ms: Some(1_700_000_002_000),
|
||||||
decided_by: Some("bl".to_string()),
|
decided_by: Some("bl".to_string()),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
|||||||
+13
-6
@@ -2358,8 +2358,9 @@ pub async fn get_question(
|
|||||||
use crate::content::{fill_from_user, path_matches, resolve_question};
|
use crate::content::{fill_from_user, path_matches, resolve_question};
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
let questions = state.questions.load();
|
let storylines = state.questions.load();
|
||||||
let Some(mut question) = resolve_question(&questions, &path) else {
|
let questions = storylines.at(crate::server::storyline::current().await);
|
||||||
|
let Some(mut question) = resolve_question(questions, &path) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
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> {
|
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
let state = expect_context::<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
|
// Not content: whether this instance can serve tiles at all is its
|
||||||
// own configuration, and the browser has to know before it decides
|
// own configuration, and the browser has to know before it decides
|
||||||
// whether to draw a map or ask for two numbers.
|
// 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()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
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
|
// What the chain verifiably answered - the same lookup a
|
||||||
// `requires_chain` gate does, so a forged or foreign hash resolves
|
// `requires_chain` gate does, so a forged or foreign hash resolves
|
||||||
// to nothing - and therefore where it sent the visitor.
|
// 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,
|
SUGGESTION_ALTERNATIVE,
|
||||||
&responses,
|
&responses,
|
||||||
now_ms,
|
now_ms,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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(),
|
alternative: SUGGESTION_ALTERNATIVE.to_string(),
|
||||||
responses: responses.clone(),
|
responses: responses.clone(),
|
||||||
timestamp_ms: now_ms,
|
timestamp_ms: now_ms,
|
||||||
|
storyline: 0,
|
||||||
};
|
};
|
||||||
if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
|
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}");
|
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
|
// resolve_question, not a plain map get: a dynamic page's concrete
|
||||||
// path (what the client holds as its question id) resolves to the
|
// path (what the client holds as its question id) resolves to the
|
||||||
// pattern page it came from.
|
// 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"))?;
|
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||||
|
|
||||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||||
@@ -2796,6 +2801,7 @@ pub async fn submit_answer(
|
|||||||
alternative: alternative.clone(),
|
alternative: alternative.clone(),
|
||||||
responses: responses.clone(),
|
responses: responses.clone(),
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
|
storyline: storyline as u32,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2821,6 +2827,7 @@ pub async fn submit_answer(
|
|||||||
&alternative,
|
&alternative,
|
||||||
&responses,
|
&responses,
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
|
storyline as u32,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -2829,7 +2836,7 @@ pub async fn submit_answer(
|
|||||||
// Arrival is a state too: what the schema says the first
|
// Arrival is a state too: what the schema says the first
|
||||||
// state sends, it sends now.
|
// state sends, it sends now.
|
||||||
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
|
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;
|
crate::people::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-15
@@ -9,20 +9,27 @@ use super::*;
|
|||||||
/// config; a file that exists but doesn't parse or validate is a real
|
/// config; a file that exists but doesn't parse or validate is a real
|
||||||
/// error, surfaced at boot rather than papered over.
|
/// error, surfaced at boot rather than papered over.
|
||||||
#[cfg(feature = "ssr")]
|
#[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 (owner, repo) = parse_owner_repo(repo_url)?;
|
||||||
let api_base = gitea_api_base(repo_url)?;
|
let api_base = gitea_api_base(repo_url)?;
|
||||||
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
||||||
Ok(raw) => raw,
|
Ok(raw) => raw,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::info!("no site.yaml in content repo ({e}), using default branding");
|
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 =
|
// The invite mail's words may be several (see `variants.rs`).
|
||||||
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
let files = vec![("site.yaml".to_string(), raw)];
|
||||||
site.validate()?;
|
let arity = super::variants::arity(&files)?;
|
||||||
Ok(site)
|
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
|
/// 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(
|
pub async fn load_aggregates_from_gitea(
|
||||||
repo_url: &str,
|
repo_url: &str,
|
||||||
branch: &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 (owner, repo) = parse_owner_repo(repo_url)?;
|
||||||
let api_base = gitea_api_base(repo_url)?;
|
let api_base = gitea_api_base(repo_url)?;
|
||||||
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
|
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")]
|
#[cfg(feature = "ssr")]
|
||||||
@@ -119,7 +133,7 @@ pub async fn load_questions_from_gitea(
|
|||||||
repo_url: &str,
|
repo_url: &str,
|
||||||
branch: &str,
|
branch: &str,
|
||||||
subdir: &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 (owner, repo) = parse_owner_repo(repo_url)?;
|
||||||
let api_base = gitea_api_base(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}"))?;
|
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
|
||||||
files.push((path[prefix.len()..].to_string(), raw));
|
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
|
/// 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,
|
repo_url: String,
|
||||||
branch: String,
|
branch: String,
|
||||||
subdir: 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<
|
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>>,
|
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
|
||||||
) {
|
) {
|
||||||
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
|
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
|
||||||
@@ -209,7 +236,7 @@ pub async fn watch_for_reload(
|
|||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
while sub.next().await.is_some() {
|
while sub.next().await.is_some() {
|
||||||
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
|
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
|
||||||
Ok(loaded) => with_builtin_aggregates(loaded),
|
Ok(loaded) => loaded,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
|
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
|
||||||
continue;
|
continue;
|
||||||
@@ -228,7 +255,7 @@ pub async fn watch_for_reload(
|
|||||||
};
|
};
|
||||||
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
||||||
Ok(loaded) => {
|
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");
|
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ use serde::{Deserialize, Serialize};
|
|||||||
// Loading is server-only; the rest is shared with the browser build.
|
// Loading is server-only; the rest is shared with the browser build.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
mod gitea;
|
mod gitea;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
mod variants;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod validate;
|
mod validate;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub use gitea::*;
|
pub use gitea::*;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use variants::*;
|
||||||
pub use handlers::*;
|
pub use handlers::*;
|
||||||
pub use markdown::*;
|
pub use markdown::*;
|
||||||
pub use validate::*;
|
pub use validate::*;
|
||||||
@@ -1073,6 +1077,25 @@ pub fn resolve_question(
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub fn build_questions(
|
pub fn build_questions(
|
||||||
files: &[(String, String)],
|
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>> {
|
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
|
||||||
// Sections first: (directory prefix, config), shallowest first so a
|
// Sections first: (directory prefix, config), shallowest first so a
|
||||||
// later (deeper) section overrides an outer one field-by-field.
|
// later (deeper) section overrides an outer one field-by-field.
|
||||||
|
|||||||
@@ -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
@@ -94,7 +94,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|||||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||||
let _ = store.put(&id, data.into()).await;
|
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;
|
crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
|
||||||
|
|
||||||
let parent_hashes = vec![id.clone()];
|
let parent_hashes = vec![id.clone()];
|
||||||
@@ -108,6 +108,7 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|||||||
alternative: target.clone(),
|
alternative: target.clone(),
|
||||||
responses: payload,
|
responses: payload,
|
||||||
timestamp_ms: now_ms,
|
timestamp_ms: now_ms,
|
||||||
|
storyline: answer.storyline,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -145,6 +146,7 @@ aggregates:
|
|||||||
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
|
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
|
||||||
responses: serde_json::json!({}), state: state.into(),
|
responses: serde_json::json!({}), state: state.into(),
|
||||||
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
|
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
|
||||||
|
storyline: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ pub struct AnswerSubmitted {
|
|||||||
pub alternative: String,
|
pub alternative: String,
|
||||||
pub responses: serde_json::Value,
|
pub responses: serde_json::Value,
|
||||||
pub timestamp_ms: i64,
|
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";
|
pub const ANSWERS_SUBJECT: &str = "portal.answers.submitted";
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub mod mail;
|
|||||||
pub mod seed;
|
pub mod seed;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod memberships;
|
pub mod memberships;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod report;
|
||||||
|
|
||||||
pub mod maps;
|
pub mod maps;
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -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
|
/// if anything. Best-effort by design - the transition is already
|
||||||
/// committed, and a mail that cannot be rendered is logged, never a
|
/// committed, and a mail that cannot be rendered is logged, never a
|
||||||
/// failed request.
|
/// failed request.
|
||||||
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
|
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, storyline: u32) {
|
||||||
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
|
// 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
|
// A suggestion has its own words for this unless the content
|
||||||
// gave the state a mail, which then is the one sent.
|
// gave the state a mail, which then is the one sent.
|
||||||
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let site = state.site.load();
|
let site_storylines = state.site.load();
|
||||||
|
let site = site_storylines.at(storyline);
|
||||||
let Some(cfg) = &site.mail else {
|
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");
|
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
|
||||||
return;
|
return;
|
||||||
@@ -702,6 +705,7 @@ mod tests {
|
|||||||
responses: serde_json::json!({ "email": email, "where": "The bend" }),
|
responses: serde_json::json!({ "email": email, "where": "The bend" }),
|
||||||
state: "open".into(),
|
state: "open".into(),
|
||||||
submitted_ms: 1,
|
submitted_ms: 1,
|
||||||
|
storyline: 0,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
|||||||
+8
-5
@@ -30,13 +30,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
|
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
|
||||||
let gitea_base = content::gitea_api_base(&content_repo)?;
|
let gitea_base = content::gitea_api_base(&content_repo)?;
|
||||||
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
||||||
let aggregates = content::with_builtin_aggregates(
|
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
|
||||||
content::load_aggregates_from_gitea(&content_repo, &content_branch).await?,
|
|
||||||
);
|
|
||||||
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||||
content::validate_questions(&questions, &aggregates)?;
|
content::validate_questions(&questions, &aggregates)?;
|
||||||
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
|
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 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 questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
|
||||||
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
||||||
@@ -225,6 +224,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
get(upload::attachment),
|
get(upload::attachment),
|
||||||
)
|
)
|
||||||
.route("/gitea-repo", get(content::gitea_repo_handler))
|
.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.
|
// Content-shipped assets (hero module etc.), same-origin.
|
||||||
.route("/site/{*path}", get(content::site_asset_handler))
|
.route("/site/{*path}", get(content::site_asset_handler))
|
||||||
.route("/automation/kv/{bucket}", get(content::automation_kv_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))
|
.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
|
// Outermost, so a caller who is over the limit is turned away
|
||||||
// before a session is loaded or a handler is entered.
|
// before a session is loaded or a handler is entered.
|
||||||
let app = match governor {
|
let app = match governor {
|
||||||
|
|||||||
+3
-1
@@ -226,6 +226,7 @@ async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream:
|
|||||||
alternative: target.to_string(),
|
alternative: target.to_string(),
|
||||||
responses: payload,
|
responses: payload,
|
||||||
timestamp_ms: now_ms,
|
timestamp_ms: now_ms,
|
||||||
|
storyline: answer.storyline,
|
||||||
};
|
};
|
||||||
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
|
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
|
||||||
Ok(answer)
|
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,
|
"name": name, "group": group, "by": by,
|
||||||
"phone": who.phone, "email": who.email, "sub": who.sub,
|
"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))
|
Ok(invited(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,6 +347,7 @@ mod tests {
|
|||||||
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
|
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
|
||||||
state: state.into(),
|
state: state.into(),
|
||||||
submitted_ms: 0,
|
submitted_ms: 0,
|
||||||
|
storyline: 0,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
|||||||
+3
-1
@@ -414,7 +414,7 @@ pub async fn record(state: &crate::server::AppState, invited: &Invited, responsi
|
|||||||
responses["responsible_for"] = serde_json::json!(responsible_for);
|
responses["responsible_for"] = serde_json::json!(responsible_for);
|
||||||
}
|
}
|
||||||
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
|
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}");
|
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(),
|
alternative: target.to_string(),
|
||||||
responses: payload,
|
responses: payload,
|
||||||
timestamp_ms: now_ms,
|
timestamp_ms: now_ms,
|
||||||
|
storyline: answer.storyline,
|
||||||
};
|
};
|
||||||
if let Err(e) = crate::events::emit_answer_submitted(&state.nats, &event).await {
|
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}");
|
tracing::warn!(id = %answer.id, target, "the invite was moved but not published: {e}");
|
||||||
@@ -702,6 +703,7 @@ mod tests {
|
|||||||
}}),
|
}}),
|
||||||
state: state.into(),
|
state: state.into(),
|
||||||
submitted_ms: 1_000,
|
submitted_ms: 1_000,
|
||||||
|
storyline: 0,
|
||||||
decided_ms: None,
|
decided_ms: None,
|
||||||
decided_by: None,
|
decided_by: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
|
|||||||
@@ -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
@@ -33,7 +33,7 @@ pub async fn get_resource(
|
|||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<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
|
let resource = feature
|
||||||
.resource
|
.resource
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -62,7 +62,7 @@ pub async fn get_requirement_options(
|
|||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<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
|
let requirement = feature
|
||||||
.requirements
|
.requirements
|
||||||
.iter()
|
.iter()
|
||||||
@@ -103,7 +103,7 @@ pub async fn get_requirement_binding(
|
|||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<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
|
let requirement = feature
|
||||||
.requirements
|
.requirements
|
||||||
.iter()
|
.iter()
|
||||||
@@ -128,6 +128,7 @@ pub async fn get_requirement_binding(
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
fn find_feature(
|
fn find_feature(
|
||||||
state: &crate::server::AppState,
|
state: &crate::server::AppState,
|
||||||
|
storyline: usize,
|
||||||
question_id: &str,
|
question_id: &str,
|
||||||
alternative: &str,
|
alternative: &str,
|
||||||
feature_name: &str,
|
feature_name: &str,
|
||||||
@@ -137,7 +138,7 @@ fn find_feature(
|
|||||||
// also what substitutes the URL segment into the page's resource
|
// also what substitutes the URL segment into the page's resource
|
||||||
// keys - so this lookup is where a `/review/<record>` page's
|
// keys - so this lookup is where a `/review/<record>` page's
|
||||||
// feature acquires its record-specific key.
|
// 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"))?;
|
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||||
question
|
question
|
||||||
.alternatives
|
.alternatives
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
#![cfg(feature = "ssr")]
|
#![cfg(feature = "ssr")]
|
||||||
|
|
||||||
pub mod oidc;
|
pub mod oidc;
|
||||||
|
pub mod storyline;
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use axum::extract::FromRef;
|
use axum::extract::FromRef;
|
||||||
@@ -21,18 +22,18 @@ pub struct AppState {
|
|||||||
/// message (see `content::watch_for_reload`) - readers never hold
|
/// message (see `content::watch_for_reload`) - readers never hold
|
||||||
/// a lock, just an atomic pointer load, so a reload never blocks or
|
/// a lock, just an atomic pointer load, so a reload never blocks or
|
||||||
/// is blocked by an in-flight request.
|
/// 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
|
/// Every bucket's event-sourced state graph, loaded from
|
||||||
/// `aggregates.yaml` (see `content::load_aggregates_from_gitea`) and
|
/// `aggregates.yaml` (see `content::load_aggregates_from_gitea`) and
|
||||||
/// hot-swapped alongside `questions` on the same reload. A bucket
|
/// hot-swapped alongside `questions` on the same reload. A bucket
|
||||||
/// with no entry here isn't event-sourced - plain KV mutate-in-place
|
/// with no entry here isn't event-sourced - plain KV mutate-in-place
|
||||||
/// still works (see `answers.rs`).
|
/// 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`
|
/// Site branding from the content repo's optional `site.yaml`
|
||||||
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
|
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
|
||||||
/// `aggregates` on the same reload. Default = the historical uhhm
|
/// `aggregates` on the same reload. Default = the historical uhhm
|
||||||
/// look.
|
/// 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
|
/// The access policy compiled from the content above (see
|
||||||
/// `access.rs`), swapped with it on every reload. Every server entry
|
/// `access.rs`), swapped with it on every reload. Every server entry
|
||||||
/// point decides through this and nothing else.
|
/// point decides through this and nothing else.
|
||||||
|
|||||||
@@ -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
@@ -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();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
// The submission: a KV record in the initial state, plus the first event.
|
// 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
|
.await
|
||||||
.expect("store");
|
.expect("store");
|
||||||
let store = js.get_key_value(&bucket).await.expect("bucket exists");
|
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(),
|
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
|
||||||
nats,
|
nats,
|
||||||
jetstream: js,
|
jetstream: js,
|
||||||
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(questions)),
|
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(aggregates)),
|
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(Default::default())),
|
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)),
|
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
|
||||||
gitea_base: String::new(),
|
gitea_base: String::new(),
|
||||||
content_raw_base: String::new(),
|
content_raw_base: String::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user