Compare commits
@@ -38,6 +38,25 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --features ssr
|
||||
|
||||
# Every plan under plans/ built deterministically and held to the
|
||||
# lint, both by this very portal: a builder change that renders a
|
||||
# site the lint rejects fails here, not at the next training run.
|
||||
# (portal-trainer's CI did this until the trainer moved in.)
|
||||
- name: Build and lint every plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --features ssr --bin portal
|
||||
bin="$CARGO_TARGET_DIR/debug/portal"
|
||||
tmp=$(mktemp -d)
|
||||
lint="$tmp/portal-lint"
|
||||
printf '#!/bin/sh\nexec %s lint "$@"\n' "$bin" > "$lint" && chmod +x "$lint"
|
||||
for plan in plans/*.yaml; do
|
||||
name=$(basename "$plan" .yaml)
|
||||
echo "== $plan"
|
||||
rm -rf "$tmp/plan-$name"
|
||||
"$bin" build --plan "$plan" --out "$tmp/plan-$name" --lint "$lint"
|
||||
done
|
||||
|
||||
- name: Stop the throwaway JetStream
|
||||
if: always()
|
||||
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
||||
|
||||
@@ -4,6 +4,21 @@ 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.6.17 (2026-10-02)
|
||||
|
||||
- A desk shows every record still moving and only the three most recently finished (a final state: no move out of it), with "Show older" for three more at a time. A desk that has run for a year is still about the work in hand.
|
||||
- Mail variables are one set for every kind of mail (docs: Mail). Every mail has `{site}` and `{site_name}`; a state mail also has `{link}`, `{self_link}` and `{desk_link}`: the reader's own page for the record (a self-service page with `?chain=&email=`, when the record's state allows a move there), the desk that moves it, and `{link}` choosing between them by who the mail is to. `{link}` in a state mail used to render as nothing.
|
||||
- The lint follows every state of every bucket a need lands in: a state no desk, link or clock reaches fails; a state a record can reach and never leave fails, after "done" too; a declared move nobody offers warns; a desk gated to one group on the page and another on its list warns; working stages must follow each other in the business's order; a mail from an unreachable state warns, and one naming a field no form collects fails. Clocks (`after:`) count as moves, and a visitor's own link only from the states it names. Each finding ends in the fix: the `{ from, to, label }` and the desk page it goes on, or the group that already has the button. A schema error no longer hides the needs findings.
|
||||
|
||||
## 0.6.16 (2026-10-01)
|
||||
|
||||
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
|
||||
- `portal iris` is the lint's name, after the iris that keeps bad content out of the portal. `portal lint` is the same command and keeps working.
|
||||
|
||||
## 0.6.15 (2026-10-01)
|
||||
|
||||
- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good.
|
||||
|
||||
## 0.6.14 (2026-10-01)
|
||||
|
||||
- Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space.
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.14"
|
||||
version = "0.6.17"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.14"
|
||||
version = "0.6.17"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- [Getting started](getting-started.md)
|
||||
- [Content reference](content-reference.md)
|
||||
- [State machines](state-machines.md)
|
||||
- [Mail](mail.md)
|
||||
- [Access](access.md)
|
||||
- [Storylines](storylines.md)
|
||||
- [Memberships](memberships.md)
|
||||
|
||||
@@ -125,6 +125,10 @@ answer:
|
||||
from: [open, incomplete] # defaults to the initial state alone
|
||||
label: Send them
|
||||
invite: { group: case_officers } # onboard someone into Kanidm
|
||||
# or with words of its own, using any field of the form:
|
||||
# invite:
|
||||
# group: guests
|
||||
# mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }
|
||||
```
|
||||
|
||||
`self_transition.from` is what keeps a person in dialogue with their
|
||||
@@ -281,6 +285,14 @@ markdown on stricter rules than the site's own: paragraphs, line
|
||||
breaks as typed, lists, emphasis and links to https, mailto or tel
|
||||
(marked `nofollow ugc`); no images, no raw HTML.
|
||||
|
||||
A desk (a list with `transitions`) shows every record still moving,
|
||||
and of those in a final state - one the state machine has no move out
|
||||
of - only the three most recently decided, newest first. "Show older"
|
||||
under the list brings three more each time it is pressed. The desk
|
||||
stays about the work in hand however long the site has run; nothing
|
||||
is hidden from a read-only list, and `leaves_after` still takes a
|
||||
record out of every list altogether.
|
||||
|
||||
`states` and `where` narrow a listing. They matter most for a register
|
||||
that is public by law — public about what was decided, not about what
|
||||
was withdrawn — while the desk working the same bucket is a different
|
||||
|
||||
+20
-2
@@ -4,11 +4,15 @@ The iris over a site. A content push is an incoming wormhole; nothing
|
||||
comes through until it checks out.
|
||||
|
||||
```
|
||||
portal lint --path questions # a local checkout
|
||||
portal iris --path questions # a local checkout
|
||||
portal lint --repo https://project.uhhm.no/uhhm/questions
|
||||
portal lint replay --path questions --nats nats://127.0.0.1:14222
|
||||
```
|
||||
|
||||
`portal iris` is the lint's name, after the iris that keeps bad content
|
||||
out of the portal; `portal lint` runs the same thing, so workflows that
|
||||
already call it keep working.
|
||||
|
||||
It was its own binary, iris, until portal 0.6: the same code, released
|
||||
as portal's twin. Now it is the `portal` binary's `lint` command, so a
|
||||
content repo's CI downloads the release and runs it:
|
||||
@@ -30,7 +34,21 @@ in `needs.yaml`, when it has one:
|
||||
- the handling group holds a desk over that bucket, and every
|
||||
finished state is reachable through the buttons it (and any
|
||||
`also_moved_by` group) offers; no record strands on the way;
|
||||
- every stage the business named is a state of the machine.
|
||||
- every stage the business named is a state of the machine, and each
|
||||
working stage can follow the one before it;
|
||||
- across each bucket, whichever need brought a record in: every state
|
||||
is somewhere a record can get to, no state a record gets to holds it
|
||||
with no way on (before done or after), every declared move is a
|
||||
button, a visitor's own link or a clock (a move nobody offers warns),
|
||||
no desk is gated to one group on the page and another on its list,
|
||||
and every mail goes out from a state records reach and names only
|
||||
fields the forms collect.
|
||||
|
||||
Every finding says what to change and where: the missing
|
||||
`{ from, to, label }` and the desk page it goes on, the group that
|
||||
already has the button, or the edge aggregates.yaml lacks. A schema
|
||||
error no longer hides the needs findings - both are printed, so one
|
||||
round can fix them together.
|
||||
|
||||
A need marked `planned` warns instead of failing. Exit status is the
|
||||
verdict, so a content repo's CI is one line.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Mail
|
||||
|
||||
Portal writes every mail itself and hands it to gdo to deliver
|
||||
(`portal.mail.send`); a reply comes back as a note beside the record
|
||||
(`portal.mail.received`). A mail is a subject and a markdown body with
|
||||
`{placeholders}`, sent as text with an HTML alternative. The kinds of
|
||||
mail are the ones content declares on a state (see
|
||||
[State machines](state-machines.md#mail)), the invite and its
|
||||
rewording (`invite: { group, mail }`, `mail.invite` in site.yaml), the
|
||||
note to a person named responsible for a question, and the answers to
|
||||
a suggested change.
|
||||
|
||||
## Variables
|
||||
|
||||
A placeholder is `{` letters, digits, `_`, `-` or `.` `}`. One that
|
||||
names nothing renders as nothing; a brace that opens no name stays a
|
||||
brace. The variables come in layers, so a template written for one
|
||||
kind of mail reads the same in another.
|
||||
|
||||
**On every mail**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `{site}` | the instance's public URL, no trailing slash |
|
||||
| `{site_name}` | site.yaml's `title` |
|
||||
|
||||
**On a mail about a record** (every state mail)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `{field}` | any field of the record, as the form sent it |
|
||||
| `{chain}` | the record's id |
|
||||
| `{email}` | the record's `email` field, trimmed |
|
||||
| `{bucket}`, `{state}` | where it is and the state it just entered |
|
||||
| `{notice.field}` | a field of the record this one belongs to, through its `type: record` field `notice` |
|
||||
| `{self_link}` | the record's own page for the person it belongs to: a page offering a self-service move (`self_transition`) on the bucket from the state the record is in, with the `?chain=&email=` that lets them act without signing in. Empty when there is no such page or no address |
|
||||
| `{desk_link}` | the desk that moves the record: the page whose list over the bucket has `transitions`, else one that reads it. Empty when no page reads the bucket |
|
||||
| `{link}` | where this reader acts: `{self_link}` in a mail `to: submitter`, `{desk_link}` in one to anyone else, and `{site}` when that link is empty |
|
||||
|
||||
`{link}` is the one to reach for. "Se meldingen og avtal befaring:
|
||||
{link}" sent to the caretaker opens the desk the report is on; "Feilen
|
||||
du meldte om er utbedret. {link}" sent to the tenant opens their own
|
||||
case where they can still act on it, or the site when there is
|
||||
nothing left for them to do.
|
||||
|
||||
**On an invite and the note to someone responsible**, beside every
|
||||
field of the invite's form: `{name}`, `{username}`, `{email}`,
|
||||
`{group}` (invite), `{questions}` (responsible), `{expires}`, and
|
||||
`{link}`, which here is the one-time sign-in link.
|
||||
|
||||
## What the lint holds a mail to
|
||||
|
||||
A state mail that sends from a state no record reaches warns; one that
|
||||
names a field no form recording into the bucket collects fails, since
|
||||
it would go out with a blank where the field should be. The lint
|
||||
knows every variable above, so `{link}` and the rest are never
|
||||
flagged. `to: submitter` requires a required `email` on every form
|
||||
filling the bucket, and `to: "{notice.email}"` requires the `notice`
|
||||
field.
|
||||
@@ -57,7 +57,9 @@ a record arrives in it.
|
||||
```
|
||||
|
||||
`{field}` is any field of the record, plus `{site}`, `{chain}`,
|
||||
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
|
||||
`{email}`, `{bucket}`, `{state}` and `{link}` - the page where the
|
||||
reader acts on the record, their own or the desk's (every variable is
|
||||
in [Mail](mail.md#variables)). The body is markdown; it is sent
|
||||
as text with an HTML alternative. `to: submitter` means the record's
|
||||
`email` field, and the lint then requires every form filling that
|
||||
bucket to collect a required `email`.
|
||||
|
||||
@@ -289,7 +289,7 @@ slideshow:
|
||||
- image: images/bildeserie/20.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i 10 år.
|
||||
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i minst 10 år.
|
||||
- image: images/bildeserie/21.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
@@ -309,4 +309,4 @@ slideshow:
|
||||
- image: images/bildeserie/25.webp
|
||||
width: 1487
|
||||
height: 1400
|
||||
caption: pH-test av borprøver i mur fra forskjellige dybder
|
||||
caption: pH-test av borprøver i betong fra forskjellige dybder
|
||||
|
||||
@@ -38,6 +38,11 @@ pub struct Answer {
|
||||
/// not see before.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub notes: Vec<Note>,
|
||||
/// Set by a desk listing, never stored: the record is in a final
|
||||
/// state (one with no move out). A desk shows the newest few of
|
||||
/// those and offers the rest on request (`resource::cap_finished`).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
/// One reply on a record. It lives here rather than with the mail
|
||||
@@ -97,6 +102,7 @@ pub async fn store_answer(
|
||||
storyline,
|
||||
decided_ms: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
decided_by: None,
|
||||
};
|
||||
let data = serde_json::to_vec(&answer)?;
|
||||
@@ -537,6 +543,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
},
|
||||
Answer {
|
||||
id: "def456".to_string(),
|
||||
@@ -549,6 +556,7 @@ mod tests {
|
||||
decided_ms: Some(1_700_000_002_000),
|
||||
decided_by: Some("bl".to_string()),
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
},
|
||||
];
|
||||
let value = serde_json::Value::Array(
|
||||
|
||||
+50
-7
@@ -1936,18 +1936,26 @@ fn ResourceFeature(
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
) -> impl IntoView {
|
||||
// A desk shows the newest few finished records; "show older" asks
|
||||
// for more (see `resource::cap_finished`).
|
||||
let finished_shown = RwSignal::new(crate::resource::FINISHED_SHOWN);
|
||||
let data = Resource::new(
|
||||
{
|
||||
let question_id = question_id.clone();
|
||||
let alternative = alternative.clone();
|
||||
let feature_name = feature_name.clone();
|
||||
move || (question_id.clone(), alternative.clone(), feature_name.clone())
|
||||
move || (question_id.clone(), alternative.clone(), feature_name.clone(), finished_shown.get())
|
||||
},
|
||||
// Forwarding sibling requirement values as query params (for a
|
||||
// resource parameterized by other form fields) is deferred, see
|
||||
// the resource-backed multi-select requirement UI follow-up.
|
||||
|(q, a, f, shown)| {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
if shown != crate::resource::FINISHED_SHOWN {
|
||||
params.insert(crate::resource::FINISHED_PARAM.to_string(), shown.to_string());
|
||||
}
|
||||
get_resource(q, a, f, params)
|
||||
},
|
||||
// Empty params for now - forwarding sibling requirement values
|
||||
// as query params (for a resource parameterized by other form
|
||||
// fields) is deferred, see the resource-backed multi-select
|
||||
// requirement UI follow-up.
|
||||
|(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()),
|
||||
);
|
||||
|
||||
// The shared batch button lives on `AlternativeCard`, above every
|
||||
@@ -1982,6 +1990,7 @@ fn ResourceFeature(
|
||||
value=value
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
finished_shown=finished_shown
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -2011,6 +2020,7 @@ fn ResourceValue(
|
||||
value: serde_json::Value,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
finished_shown: RwSignal<usize>,
|
||||
) -> impl IntoView {
|
||||
// A jq like `.[] | {...}` over an empty list yields no output at
|
||||
// all - that arrives as null, and it's the same silence as [].
|
||||
@@ -2034,6 +2044,32 @@ fn ResourceValue(
|
||||
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
|
||||
open(&a.state).cmp(&open(&b.state)).then(when)
|
||||
});
|
||||
// The desk sent one finished record more than it shows, when
|
||||
// there are older ones: drop the oldest and offer the rest.
|
||||
let shown = finished_shown.get_untracked();
|
||||
let older = answers.iter().filter(|a| a.finished).count() > shown;
|
||||
if older {
|
||||
if let Some(oldest) = answers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, a)| a.finished)
|
||||
.min_by_key(|(_, a)| a.decided_ms.unwrap_or(a.submitted_ms))
|
||||
.map(|(i, _)| i)
|
||||
{
|
||||
answers.remove(oldest);
|
||||
}
|
||||
}
|
||||
let show_older = older.then(|| {
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="show-older"
|
||||
on:click=move |_| finished_shown.update(|n| *n += crate::resource::FINISHED_SHOWN)
|
||||
>
|
||||
{move || t(&lang().get(), "show_older")}
|
||||
</button>
|
||||
}
|
||||
});
|
||||
return view! {
|
||||
<div class="answer-list" class:answer-cards=cards>
|
||||
<For
|
||||
@@ -2060,6 +2096,7 @@ fn ResourceValue(
|
||||
}
|
||||
}
|
||||
/>
|
||||
{show_older}
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
@@ -3082,10 +3119,16 @@ pub async fn submit_answer(
|
||||
let Some((name, who)) = crate::people::who_for(&responses) else {
|
||||
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
|
||||
};
|
||||
let invited = people
|
||||
let mut invited = people
|
||||
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
|
||||
// This invite's own words, and the form they draw on: kept with
|
||||
// the invite, so sending it again says the same thing.
|
||||
invited.mail = invite.mail.clone();
|
||||
if invited.mail.is_some() {
|
||||
invited.fields = responses.as_object().cloned().unwrap_or_default();
|
||||
}
|
||||
if let Some(map) = responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
}
|
||||
|
||||
+13
-3
@@ -391,6 +391,13 @@ pub struct Alternative {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Invite {
|
||||
pub group: String,
|
||||
/// This invite's own mail, in place of the site's (`mail.invite`):
|
||||
/// an invitation to a conversation at an hour, say. Besides
|
||||
/// `{name}`, `{link}`, `{expires}`, `{username}`, `{group}`,
|
||||
/// `{site}` and `{site_name}`, every field of the form is a
|
||||
/// placeholder; a date and time is written out in words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mail: Option<MailTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -1316,6 +1323,7 @@ alternatives:
|
||||
public: true
|
||||
source: {{ kind: kv, bucket: things }}
|
||||
- name: Object
|
||||
action: /object
|
||||
record_as: things
|
||||
features:
|
||||
- name: Yours
|
||||
@@ -1413,6 +1421,7 @@ id: /t
|
||||
name: Where is it?
|
||||
alternatives:
|
||||
- name: A
|
||||
action: /t
|
||||
features:
|
||||
- name: Where
|
||||
requirements:
|
||||
@@ -1516,6 +1525,7 @@ id: /b
|
||||
name: B
|
||||
alternatives:
|
||||
- name: A
|
||||
action: /b
|
||||
features:
|
||||
- name: ""
|
||||
requirements:
|
||||
@@ -1557,7 +1567,7 @@ alternatives:
|
||||
|
||||
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
||||
"id: /g\nname: G\nalternatives:\n - name: A\n action: /g\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
||||
))
|
||||
.unwrap();
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
@@ -1828,7 +1838,7 @@ alternatives:
|
||||
fn recorded_buckets_must_be_attended() {
|
||||
let write_only = vec![(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n".to_string(),
|
||||
)];
|
||||
let questions = build_questions(&write_only).unwrap();
|
||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||
@@ -1839,7 +1849,7 @@ alternatives:
|
||||
let with_desk = vec![
|
||||
(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: inbox\n".to_string(),
|
||||
),
|
||||
(
|
||||
"review/index.yaml".to_string(),
|
||||
|
||||
@@ -512,6 +512,20 @@ pub fn validate_questions(
|
||||
}
|
||||
for question in questions.values() {
|
||||
for alternative in &question.alternatives {
|
||||
// A form is sent with its button, and the button is only
|
||||
// drawn for an alternative that says where to go afterwards:
|
||||
// without `action` the person fills it in and cannot send it.
|
||||
let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
|
||||
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
|
||||
&& alternative.action.is_none()
|
||||
&& alternative.self_transition.is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: it has a form to send but no `action`, so no button is drawn; give it `action:`, the page to go to once it is sent (its own page is fine)",
|
||||
question.id,
|
||||
alternative.name
|
||||
);
|
||||
}
|
||||
if alternative.one_per_person && alternative.record_as.is_none() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ aggregates:
|
||||
Answer {
|
||||
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![],
|
||||
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![], finished: false,
|
||||
storyline: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("en", "sign_in") => "Sign in",
|
||||
("en", "sign_out") => "Sign out",
|
||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||
("en", "show_older") => "Show older",
|
||||
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
||||
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
||||
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
||||
@@ -60,6 +61,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("no", "sign_in") => "Logg inn",
|
||||
("no", "sign_out") => "Logg ut",
|
||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||
("no", "show_older") => "Vis eldre",
|
||||
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
||||
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
||||
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
||||
@@ -114,6 +116,7 @@ mod tests {
|
||||
"sign_in",
|
||||
"sign_out",
|
||||
"nothing_here_yet",
|
||||
"show_older",
|
||||
"upload_failed",
|
||||
"upload_too_large",
|
||||
"upload_unavailable",
|
||||
|
||||
+23
-12
@@ -126,11 +126,33 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
// One schema error is no reason to hide the rest: the needs
|
||||
// pass reads the same content and says what else is wrong,
|
||||
// so a fix round can take them all at once.
|
||||
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
|
||||
print_findings(&needs::check(&file, &questions, &aggregates_map));
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each finding on its own line, FAIL or WARN first; the count of FAILs.
|
||||
fn print_findings(findings: &[needs::Finding]) -> usize {
|
||||
let mut failed = 0;
|
||||
for finding in findings {
|
||||
let level = match finding.level {
|
||||
needs::Level::Fail => {
|
||||
failed += 1;
|
||||
"FAIL"
|
||||
}
|
||||
needs::Level::Warn => "WARN",
|
||||
};
|
||||
eprintln!("{level}: {} {:?}: {}", finding.scope, finding.need, finding.message);
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
/// The business-needs pass (see `crate::lint::needs`): structure always,
|
||||
/// then the wording tasks written out and/or an engine's answers
|
||||
/// scored, when asked. A repo without `needs.yaml` skips all of it.
|
||||
@@ -158,18 +180,7 @@ fn check_needs(
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let findings = needs::check(&file, questions, aggregates_map);
|
||||
let mut failed = 0;
|
||||
for finding in &findings {
|
||||
let level = match finding.level {
|
||||
needs::Level::Fail => {
|
||||
failed += 1;
|
||||
"FAIL"
|
||||
}
|
||||
needs::Level::Warn => "WARN",
|
||||
};
|
||||
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
|
||||
}
|
||||
let failed = print_findings(&needs::check(&file, questions, aggregates_map));
|
||||
for need in &file.needs {
|
||||
if let Some(path) = needs::path_for(questions, need) {
|
||||
let steps: Vec<String> = path
|
||||
|
||||
+439
-27
@@ -137,9 +137,13 @@ pub enum Level {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Finding {
|
||||
/// The need's id, or the bucket's name when `scope` is "bucket".
|
||||
pub need: String,
|
||||
pub level: Level,
|
||||
pub message: String,
|
||||
/// "need" for a need's own path; "bucket" for what holds for every
|
||||
/// record in a bucket whichever need brought it in.
|
||||
pub scope: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -255,6 +259,10 @@ pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
|
||||
struct Desk {
|
||||
page: String,
|
||||
group: Option<String>,
|
||||
/// The page's own `qualifies`, kept apart from the resource's
|
||||
/// `requires_group`: when both are set and differ, only members of
|
||||
/// both ever see the buttons.
|
||||
page_group: Option<String>,
|
||||
/// (from, to, button label)
|
||||
moves: Vec<(String, String, String)>,
|
||||
}
|
||||
@@ -272,6 +280,7 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
||||
desks.push(Desk {
|
||||
page: q.id.clone(),
|
||||
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
|
||||
page_group: q.qualifies.clone(),
|
||||
moves: resource
|
||||
.transitions
|
||||
.iter()
|
||||
@@ -284,15 +293,49 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
||||
desks
|
||||
}
|
||||
|
||||
/// Self-service moves into `bucket` - a visitor holding their own
|
||||
/// record's link, no group involved (unsubscribe).
|
||||
fn self_moves(questions: &Questions, bucket: &str) -> Vec<String> {
|
||||
/// Self-service moves in `bucket` - a visitor holding their own
|
||||
/// record's link, no group involved (unsubscribe, withdraw) - as
|
||||
/// (from, to), from the states each may be made from.
|
||||
fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String, String)> {
|
||||
questions
|
||||
.values()
|
||||
.flat_map(|q| &q.alternatives)
|
||||
.filter_map(|a| a.self_transition.as_ref())
|
||||
.filter(|s| s.bucket == bucket)
|
||||
.map(|s| s.to.clone())
|
||||
.flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The moves a clock makes in `schema` (`after:` on a state), as
|
||||
/// (from, to). Nobody presses them, and they still move records.
|
||||
fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> {
|
||||
let mut moves: Vec<(String, String)> =
|
||||
schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect();
|
||||
moves.sort();
|
||||
moves
|
||||
}
|
||||
|
||||
/// States reachable from `start` along `moves`.
|
||||
fn reachable(start: &str, moves: &[(String, String)]) -> HashSet<String> {
|
||||
let mut reached = HashSet::from([start.to_string()]);
|
||||
let mut queue = VecDeque::from([start.to_string()]);
|
||||
while let Some(state) = queue.pop_front() {
|
||||
for (_, to) in moves.iter().filter(|(from, _)| *from == state) {
|
||||
if reached.insert(to.clone()) {
|
||||
queue.push_back(to.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
reached
|
||||
}
|
||||
|
||||
/// Who else offers `from -> to` in the bucket, as "page (group)", for
|
||||
/// a finding that has to say where the missing button already is.
|
||||
fn offered_elsewhere(desks: &[Desk], from: &str, to: &str) -> Vec<String> {
|
||||
desks
|
||||
.iter()
|
||||
.filter(|d| d.moves.iter().any(|(f, t, _)| f == from && t == to))
|
||||
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -305,10 +348,16 @@ pub fn check(
|
||||
) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
// (bucket, state) a need already reported - stranding on its way
|
||||
// to done, or out of its reach - so the bucket pass does not say
|
||||
// it twice.
|
||||
let mut strands_reported: HashSet<(String, String)> = HashSet::new();
|
||||
let mut unreached_reported: HashSet<(String, String)> = HashSet::new();
|
||||
for need in &file.needs {
|
||||
let mut report = |level: Level, message: String| {
|
||||
findings.push(Finding {
|
||||
need: need.id.clone(),
|
||||
scope: "need",
|
||||
level: if need.planned || need.paused { Level::Warn } else { level },
|
||||
message: match (need.paused, need.planned, level == Level::Fail) {
|
||||
(true, _, true) => format!("paused, not open now: {message}"),
|
||||
@@ -439,23 +488,44 @@ pub fn check(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let selfs = self_moves(questions, &need.lands_in);
|
||||
// Every move that carries this need's records: the buttons on
|
||||
// the desks of handled_by/also_moved_by, the visitor's own
|
||||
// links, and the clocks.
|
||||
let mut moves: Vec<(String, String)> = own
|
||||
.iter()
|
||||
.flat_map(|d| &d.moves)
|
||||
.filter(|(from, to, _)| schema.allowed(from).contains(to))
|
||||
.map(|(from, to, _)| (from.clone(), to.clone()))
|
||||
.collect();
|
||||
moves.extend(self_moves(questions, &need.lands_in, &schema.initial));
|
||||
moves.extend(clock_moves(schema));
|
||||
let exits = |state: &str| -> Vec<String> {
|
||||
let allowed = schema.allowed(state);
|
||||
let mut to: Vec<String> = own
|
||||
.iter()
|
||||
.flat_map(|d| &d.moves)
|
||||
.filter(|(from, to, _)| from == state && allowed.contains(to))
|
||||
.map(|(_, to, _)| to.clone())
|
||||
.collect();
|
||||
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned());
|
||||
to
|
||||
moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect()
|
||||
};
|
||||
let movers = std::iter::once(need.handled_by.as_str())
|
||||
.chain(need.also_moved_by.iter().map(String::as_str))
|
||||
.map(|g| format!("{g:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ");
|
||||
// The fix for a missing move: the button on handled_by's desk,
|
||||
// or the group whose desk already has it added to also_moved_by.
|
||||
let fix_for = |from: &str, to: &str| -> String {
|
||||
let elsewhere = offered_elsewhere(&desks, from, to);
|
||||
let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk");
|
||||
if elsewhere.is_empty() {
|
||||
format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}")
|
||||
} else {
|
||||
format!(
|
||||
"{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}",
|
||||
elsewhere.join(", ")
|
||||
)
|
||||
}
|
||||
};
|
||||
let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect();
|
||||
// Reachability walks the whole machine (a finished state may
|
||||
// lead on to another: subscribed, then unsubscribed). Stranding
|
||||
// only counts on the way to the first finished state - what
|
||||
// happens to a handled record afterwards is not this need.
|
||||
// only counts on the way to the first finished state here; what
|
||||
// strands a record afterwards is the bucket's finding (below).
|
||||
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
|
||||
let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
|
||||
let mut stranded = Vec::new();
|
||||
@@ -471,6 +541,26 @@ pub fn check(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The declared edges into `state` from somewhere a record does
|
||||
// get to: the moves that would close the gap.
|
||||
let ways_in = |state: &str| -> Vec<(String, String)> {
|
||||
let mut ways: Vec<(String, String)> = reached
|
||||
.iter()
|
||||
.filter(|from| schema.allowed(from).iter().any(|to| to == state))
|
||||
.map(|from| (from.clone(), state.to_string()))
|
||||
.collect();
|
||||
ways.sort();
|
||||
ways
|
||||
};
|
||||
let missing = |state: &str| -> String {
|
||||
match ways_in(state).first() {
|
||||
Some((from, to)) => format!(" - fix: {}", fix_for(from, to)),
|
||||
None => format!(
|
||||
" - and no state a record reaches has a declared edge into it: add one in aggregates.yaml under {:?}",
|
||||
need.lands_in
|
||||
),
|
||||
}
|
||||
};
|
||||
for state in &need.done_when {
|
||||
if !schema.has_state(state) {
|
||||
report(
|
||||
@@ -478,11 +568,13 @@ pub fn check(
|
||||
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
|
||||
);
|
||||
} else if !reached.contains(state) {
|
||||
unreached_reported.insert((need.lands_in.clone(), state.clone()));
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves",
|
||||
need.handled_by
|
||||
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves{}",
|
||||
need.handled_by,
|
||||
missing(state)
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -497,11 +589,36 @@ pub fn check(
|
||||
),
|
||||
);
|
||||
} else if !reached.contains(stage) {
|
||||
unreached_reported.insert((need.lands_in.clone(), stage.clone()));
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"stage {stage:?} exists but {:?} is offered no moves that reach it",
|
||||
need.handled_by
|
||||
"stage {stage:?} exists but {:?} is offered no moves that reach it{}",
|
||||
need.handled_by,
|
||||
missing(stage)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// In the business's order: each working stage reachable from
|
||||
// the one before it, so a machine where "quoted" cannot follow
|
||||
// "surveyed" does not pass for one that goes surveyed -> quoted.
|
||||
// The finished states that close the list are alternatives
|
||||
// (won, lost), not a sequence, so they are left out.
|
||||
for pair in need.stages.windows(2) {
|
||||
let (before, after) = (&pair[0], &pair[1]);
|
||||
if done.contains(before.as_str()) || done.contains(after.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) {
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"stage {after:?} comes after {before:?} for the business, but no move leads on from {before:?} to it{}",
|
||||
match schema.allowed(before).iter().find(|to| reachable(to, &moves).contains(after) || *to == after) {
|
||||
Some(to) => format!(" - fix: {}", fix_for(before, to)),
|
||||
None => format!(" - add an edge from {before:?} toward {after:?} in aggregates.yaml under {:?}", need.lands_in),
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -515,22 +632,32 @@ pub fn check(
|
||||
}
|
||||
}
|
||||
stranded.sort();
|
||||
for state in &stranded {
|
||||
strands_reported.insert((need.lands_in.clone(), state.clone()));
|
||||
}
|
||||
for state in stranded {
|
||||
let fix = match schema.allowed(&state).first() {
|
||||
Some(to) => format!(" - fix: {}", fix_for(&state, to)),
|
||||
None => format!(
|
||||
" - and {:?} declares no way out of it: add {state:?} to done_when if it is finished, or an edge out in aggregates.yaml",
|
||||
need.lands_in
|
||||
),
|
||||
};
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"records strand in {state:?}: {:?} is offered no move out of it",
|
||||
need.handled_by
|
||||
),
|
||||
format!("records strand in {state:?}: {movers} is offered no move out of it{fix}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported));
|
||||
|
||||
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
|
||||
for persona in &file.personas {
|
||||
if !need_ids.contains(persona.need.as_str()) {
|
||||
findings.push(Finding {
|
||||
need: persona.need.clone(),
|
||||
scope: "need",
|
||||
level: Level::Fail,
|
||||
message: format!("persona {:?} points at a need that is not declared", persona.id),
|
||||
});
|
||||
@@ -539,6 +666,188 @@ pub fn check(
|
||||
findings
|
||||
}
|
||||
|
||||
/// What must hold for every record in a bucket some need lands in,
|
||||
/// whichever need brought it: every state is somewhere a record can
|
||||
/// get to, no state a record gets to holds it with no way on (before
|
||||
/// done or after), every declared move is a button someone has, every
|
||||
/// desk can be seen by whoever its buttons are for, and every mail
|
||||
/// goes out from a state records reach, saying only what the record
|
||||
/// holds.
|
||||
fn operations(
|
||||
file: &NeedsFile,
|
||||
questions: &Questions,
|
||||
aggregates: &HashMap<String, AggregateSchema>,
|
||||
strands_reported: &HashSet<(String, String)>,
|
||||
unreached_reported: &HashSet<(String, String)>,
|
||||
) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
let mut buckets: Vec<&str> = file.needs.iter().map(|n| n.lands_in.as_str()).collect();
|
||||
buckets.sort();
|
||||
buckets.dedup();
|
||||
for bucket in buckets {
|
||||
let Some(schema) = aggregates.get(bucket) else { continue };
|
||||
let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect();
|
||||
// Only open needs make a gap fail; a bucket that only planned
|
||||
// or paused needs use reports it as a warning.
|
||||
let open = needs.iter().any(|n| !n.planned && !n.paused);
|
||||
let mut report = |level: Level, message: String| {
|
||||
findings.push(Finding {
|
||||
need: bucket.to_string(),
|
||||
scope: "bucket",
|
||||
level: if open { level } else { Level::Warn },
|
||||
message,
|
||||
})
|
||||
};
|
||||
let desks = desks_over(questions, bucket);
|
||||
let mut moves: Vec<(String, String)> = desks
|
||||
.iter()
|
||||
.flat_map(|d| &d.moves)
|
||||
.filter(|(from, to, _)| schema.allowed(from).contains(to))
|
||||
.map(|(from, to, _)| (from.clone(), to.clone()))
|
||||
.collect();
|
||||
moves.extend(self_moves(questions, bucket, &schema.initial));
|
||||
moves.extend(clock_moves(schema));
|
||||
let reached = reachable(&schema.initial, &moves);
|
||||
let offered = |from: &str, to: &str| moves.iter().any(|(f, t)| f == from && t == to);
|
||||
// Where a missing button goes: the desk of the group answerable
|
||||
// for the bucket's first need.
|
||||
let home = needs
|
||||
.iter()
|
||||
.find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str())))
|
||||
.map(|d| d.page.clone())
|
||||
.unwrap_or_else(|| "the handling group's desk".to_string());
|
||||
let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}");
|
||||
|
||||
let mut states: Vec<&String> = schema.event_for_state.keys().collect();
|
||||
states.sort();
|
||||
for state in &states {
|
||||
if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) {
|
||||
continue;
|
||||
}
|
||||
let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect();
|
||||
ways.sort();
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"state {state:?} is never reached: no desk, link or clock moves a record there - fix: {}",
|
||||
match ways.first() {
|
||||
Some(from) => button(from, state),
|
||||
None => "declare an edge into it from a state records reach in aggregates.yaml, or remove it".to_string(),
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
for state in states.iter().filter(|s| reached.contains(**s)) {
|
||||
let out = schema.allowed(state);
|
||||
if out.is_empty() {
|
||||
continue; // a final state: nothing should move it
|
||||
}
|
||||
let offered_out: Vec<&String> = out.iter().filter(|to| offered(state, to)).collect();
|
||||
if offered_out.is_empty() {
|
||||
if !strands_reported.contains(&(bucket.to_string(), (*state).clone())) {
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"records strand in {state:?}: {bucket:?} declares moves on to {} but no desk, link or clock offers one - fix: {}",
|
||||
out.join(", "),
|
||||
button(state, &out[0])
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for to in out.iter().filter(|to| !offered(state, to)) {
|
||||
report(
|
||||
Level::Warn,
|
||||
format!(
|
||||
"the move {state:?} -> {to:?} is declared but no desk, link or clock offers it - {}, or drop the edge from aggregates.yaml",
|
||||
button(state, to)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for desk in &desks {
|
||||
if let (Some(page_group), Some(group)) = (&desk.page_group, &desk.group) {
|
||||
if page_group != group {
|
||||
report(
|
||||
Level::Warn,
|
||||
format!(
|
||||
"the desk on {} is gated twice: the page qualifies {page_group:?}, its list requires {group:?} - only members of both see the buttons; make them the same group",
|
||||
desk.page
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The fields a record of this bucket holds: everything asked on
|
||||
// a form that records into it.
|
||||
let fields: Vec<(&str, &str)> = questions
|
||||
.values()
|
||||
.flat_map(|q| &q.alternatives)
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket))
|
||||
.flat_map(|a| &a.features)
|
||||
.flat_map(|f| &f.requirements)
|
||||
.map(|r| (r.name.as_str(), r.kind.as_str()))
|
||||
.collect();
|
||||
let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect();
|
||||
mail_states.sort_by(|a, b| a.0.cmp(b.0));
|
||||
for (state, mail) in mail_states {
|
||||
if !reached.contains(state) {
|
||||
report(Level::Warn, format!("the mail on entering {state:?} never goes out: no record reaches {state:?}"));
|
||||
}
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) {
|
||||
let known = match key.split_once('.') {
|
||||
Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"),
|
||||
None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key),
|
||||
};
|
||||
if !known && !unknown.contains(&key) {
|
||||
unknown.push(key);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"the mail on entering {state:?} uses {}, which no form recording into {bucket:?} collects - it would go out blank there; the forms collect: {}",
|
||||
unknown.iter().map(|k| format!("{{{k}}}")).collect::<Vec<_>>().join(", "),
|
||||
if names.is_empty() { "nothing".to_string() } else { names.join(", ") }
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// What the mailer fills in on every state mail, whatever the record
|
||||
/// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`).
|
||||
const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"];
|
||||
|
||||
/// The `{key}`s in a mail template, read the way `mail::render` reads
|
||||
/// them: letters, digits, `_`, `-` and `.`, closed by `}`.
|
||||
fn placeholders(template: &str) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let after = &rest[start + 1..];
|
||||
let len = after
|
||||
.char_indices()
|
||||
.take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
if len > 0 && after[len..].starts_with('}') {
|
||||
keys.push(after[..len].to_string());
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Everything a simulation of the business needs, resolved: each
|
||||
/// need's path with the choice on every page and the form that gets
|
||||
/// filled, each bucket's state machine with the buttons every desk
|
||||
@@ -611,6 +920,11 @@ pub fn sim_model(
|
||||
"steps": steps,
|
||||
}));
|
||||
if let Some(schema) = aggregates.get(&need.lands_in) {
|
||||
// The simulator's shape: the states a visitor's own link
|
||||
// moves to (it does not model where from).
|
||||
let mut self_move_targets: Vec<String> =
|
||||
self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect();
|
||||
self_move_targets.dedup();
|
||||
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
|
||||
json!({
|
||||
"initial": schema.initial,
|
||||
@@ -623,7 +937,7 @@ pub fn sim_model(
|
||||
"from": from, "to": to, "label": label,
|
||||
})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"self_moves": self_moves(questions, &need.lands_in),
|
||||
"self_moves": self_move_targets,
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -895,8 +1209,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec![
|
||||
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves",
|
||||
"records strand in \"quoted\": \"owners\" is offered no move out of it",
|
||||
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
|
||||
"records strand in \"quoted\": \"owners\" is offered no move out of it - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1037,4 +1351,102 @@ mod tests {
|
||||
|
||||
assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]);
|
||||
}
|
||||
|
||||
/// The fixture site with its aggregates replaced, for the checks
|
||||
/// that are about the state machine rather than the pages.
|
||||
fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap<String, AggregateSchema>) {
|
||||
let (questions, _) = site(desk_moves);
|
||||
let aggregates = parse_aggregates_yaml(&format!(
|
||||
"aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n"
|
||||
))
|
||||
.unwrap();
|
||||
(questions, aggregates)
|
||||
}
|
||||
|
||||
fn warns(findings: &[Finding]) -> Vec<&str> {
|
||||
findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_is_a_move() {
|
||||
// Nobody closes a stale quote; six weeks do. That is not a strand.
|
||||
let (questions, aggregates) = site_with(
|
||||
" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n",
|
||||
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted, after: { days: 42, to: lost } }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted]\n quoted: [won, lost]\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert!(fails(&findings).is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_nobody_reaches_and_a_strand_after_done_are_the_buckets() {
|
||||
// "archived" follows "won" but no desk offers it; "parked" has
|
||||
// no way in at all.
|
||||
let (questions, aggregates) = site_with(
|
||||
FULL_DESK,
|
||||
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n archived: { event: archived }\n done: { event: done }\n parked: { event: parked }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n won: [archived]\n archived: [done]\n parked: [open]\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
let bucket: Vec<&Finding> = findings.iter().filter(|f| f.scope == "bucket").collect();
|
||||
assert!(bucket.iter().all(|f| f.need == "projects"), "{bucket:?}");
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec![
|
||||
"state \"archived\" is never reached: no desk, link or clock moves a record there - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
|
||||
"state \"done\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
|
||||
"state \"parked\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
|
||||
"records strand in \"won\": \"projects\" declares moves on to archived but no desk, link or clock offers one - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_nobody_offers_is_a_warning_when_there_is_another_way_on() {
|
||||
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n");
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert!(fails(&findings).is_empty(), "{findings:?}");
|
||||
assert_eq!(
|
||||
warns(&findings),
|
||||
vec!["the move \"open\" -> \"lost\" is declared but no desk, link or clock offers it - add `{ from: open, to: lost, label: ... }` to the transitions on /desk, or drop the edge from aggregates.yaml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_stages_must_follow_each_other() {
|
||||
// The business surveys, then quotes; this machine can only
|
||||
// quote straight from open, and surveying leads nowhere.
|
||||
let (questions, aggregates) = site_with(
|
||||
" - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n",
|
||||
" initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n",
|
||||
);
|
||||
let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates);
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_may_only_say_what_the_record_holds() {
|
||||
let (questions, aggregates) = site_with(
|
||||
FULL_DESK,
|
||||
" initial: open\n states:\n open: { event: submitted, mail: { to: submitter, subject: 'Got it, {email}', body: 'We read {brief} and will reply at {site}. {refnr} {link}' } }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec!["the mail on entering \"open\" uses {refnr}, which no form recording into \"projects\" collects - it would go out blank there; the forms collect: brief, budget, email"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_desk_gated_twice_is_seen_only_by_members_of_both() {
|
||||
let (mut questions, aggregates) = site(FULL_DESK);
|
||||
questions.get_mut("/desk").unwrap().qualifies = Some("staff".into());
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert!(
|
||||
warns(&findings).contains(&"the desk on /desk is gated twice: the page qualifies \"staff\", its list requires \"owners\" - only members of both see the buttons; make them the same group"),
|
||||
"{findings:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+144
-6
@@ -251,6 +251,80 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
|
||||
vars
|
||||
}
|
||||
|
||||
/// What every mail portal sends may say, whatever made it - a state
|
||||
/// mail, an invite, a suggestion's answer: `{site}` (the instance's
|
||||
/// public URL) and `{site_name}` (site.yaml's title). One place, so a
|
||||
/// template written for one kind of mail reads the same in another
|
||||
/// (docs/mail.md, Variables).
|
||||
pub fn site_vars(site: &crate::content::SiteConfig) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(site).into());
|
||||
vars
|
||||
}
|
||||
|
||||
/// Where a mail about a record can send its reader (docs/mail.md):
|
||||
///
|
||||
/// - `{self_link}`: the record's own page for the person it belongs
|
||||
/// to - a page offering a self-service move on this bucket from the
|
||||
/// state the record is in, with the `?chain=&email=` that
|
||||
/// lets them act without signing in;
|
||||
/// - `{desk_link}`: the desk that moves the record - the page whose
|
||||
/// list over the bucket carries transitions, else one that reads it;
|
||||
/// - `{link}`: `{self_link}` in a mail to the submitter, `{desk_link}`
|
||||
/// in one to anyone else, and the site itself when there is neither.
|
||||
///
|
||||
/// Empty strings, never missing keys, for a link the content has no
|
||||
/// page for: a template naming one renders blank there rather than
|
||||
/// with a broken URL.
|
||||
pub fn record_links(
|
||||
questions: &std::collections::HashMap<String, crate::content::Question>,
|
||||
initial: &str,
|
||||
bucket: &str,
|
||||
id: &str,
|
||||
state: &str,
|
||||
email: &str,
|
||||
to_submitter: bool,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut pages: Vec<&crate::content::Question> = questions.values().collect();
|
||||
pages.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
// Only a move the engine would accept from where the record is:
|
||||
// a link to a button that refuses is worse than none.
|
||||
let self_page = pages.iter().find(|q| {
|
||||
q.alternatives
|
||||
.iter()
|
||||
.filter_map(|a| a.self_transition.as_ref())
|
||||
.any(|st| st.bucket == bucket && st.from_states(initial).iter().any(|f| f == state))
|
||||
});
|
||||
let reads = |q: &&crate::content::Question, moving: bool| {
|
||||
q.alternatives
|
||||
.iter()
|
||||
.flat_map(|a| &a.features)
|
||||
.filter_map(|f| f.resource.as_ref())
|
||||
.any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty()))
|
||||
};
|
||||
let desk_page = pages.iter().find(|q| reads(q, true)).or_else(|| pages.iter().find(|q| reads(q, false)));
|
||||
let site = public_url();
|
||||
let self_link = match self_page {
|
||||
Some(q) if email.contains('@') => {
|
||||
let email: String = url::form_urlencoded::byte_serialize(email.as_bytes()).collect();
|
||||
format!("{site}{}?chain={id}&email={email}", q.id)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let desk_link = desk_page.map(|q| format!("{site}{}", q.id)).unwrap_or_default();
|
||||
let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) {
|
||||
(true, false, _) => self_link.clone(),
|
||||
(false, _, false) => desk_link.clone(),
|
||||
_ => site,
|
||||
};
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("self_link".into(), self_link.into());
|
||||
vars.insert("desk_link".into(), desk_link.into());
|
||||
vars.insert("link".into(), link.into());
|
||||
vars
|
||||
}
|
||||
|
||||
/// The record field a `to` reaches through, when it is neither
|
||||
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
||||
pub fn parent_of(to: &str) -> Option<&str> {
|
||||
@@ -325,6 +399,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
||||
};
|
||||
let mut vars = vars_for(bucket, id, to_state, responses);
|
||||
parent_vars(state, bucket, responses, &mut vars).await;
|
||||
vars.extend(site_vars(site));
|
||||
{
|
||||
let email = vars.get("email").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let questions = state.questions.load();
|
||||
let initial = state.aggregates.load().at(storyline).get(bucket).map(|s| s.initial.clone()).unwrap_or_default();
|
||||
vars.extend(record_links(questions.at(storyline), &initial, bucket, id, to_state, &email, spec.to_submitter()));
|
||||
}
|
||||
let to = if spec.to_submitter() {
|
||||
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
||||
Some(e) => e.to_string(),
|
||||
@@ -416,12 +497,19 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_default();
|
||||
let mut vars = serde_json::Map::new();
|
||||
// The form's own fields first, so the invite's fixed placeholders
|
||||
// win a clash; a date and time reads as one.
|
||||
for (key, value) in &invited.fields {
|
||||
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
|
||||
if let Some(text) = text {
|
||||
vars.insert(key.clone(), text.into());
|
||||
}
|
||||
}
|
||||
vars.insert("name".into(), invited.display_name.clone().into());
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("questions".into(), list.into());
|
||||
vars.extend(site_vars(&site));
|
||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(&site).into());
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
@@ -493,7 +581,7 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state
|
||||
return;
|
||||
}
|
||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
||||
let mut vars = serde_json::Map::new();
|
||||
let mut vars = site_vars(&site);
|
||||
vars.insert("name".into(), field("name").into());
|
||||
vars.insert("question".into(), field("question").into());
|
||||
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
||||
@@ -553,6 +641,25 @@ pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &s
|
||||
/// Built in, since portal knows everything about it; `mail.invite` in
|
||||
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
|
||||
/// `{site}`, `{expires}`.
|
||||
/// A form's date and time (`2026-10-06T15:00`, from a `datetime-local`
|
||||
/// field) as a person says it: "tirsdag 6. oktober kl. 15:00", "Tuesday
|
||||
/// 6 October at 15:00". Anything else is left as it is.
|
||||
pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
|
||||
use chrono::{Datelike, NaiveDateTime, Timelike};
|
||||
let t = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").ok()?;
|
||||
let day = t.weekday().num_days_from_monday() as usize;
|
||||
let month = t.month0() as usize;
|
||||
Some(if norwegian {
|
||||
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
|
||||
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
|
||||
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
||||
} else {
|
||||
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
@@ -560,7 +667,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
|
||||
let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
|
||||
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
||||
(None, true, true) => (
|
||||
"Du er invitert inn i {group} på {site_name}".to_string(),
|
||||
@@ -590,9 +697,8 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("email".into(), invited.email.clone().into());
|
||||
vars.insert("group".into(), invited.group.clone().into());
|
||||
vars.extend(site_vars(&site));
|
||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(&site).into());
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
@@ -613,6 +719,30 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_record_mail_links_its_reader_to_where_they_act() {
|
||||
let files = vec![
|
||||
("index.yaml".to_string(), "name: Meld fra\nalternatives:\n - name: Feil\n action: /takk\n record_as: feil\n features:\n - name: Feil\n requirements:\n - { name: email, type: email }\n".to_string()),
|
||||
("takk.yaml".to_string(), "name: Takk\nfollowup: true\n".to_string()),
|
||||
("trekk.yaml".to_string(), "name: Trekk saken\nalternatives:\n - name: Trekk\n self_transition: { bucket: feil, to: trukket, label: Trekk, from: [open, befaring] }\n".to_string()),
|
||||
("styret/index.yaml".to_string(), "name: Styret\nqualifies: styret\nalternatives:\n - name: Feil\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: feil }\n requires_group: styret\n transitions:\n - { to: befaring, label: Befaring }\n".to_string()),
|
||||
];
|
||||
let questions = crate::content::build_questions(&files).unwrap();
|
||||
let site = public_url();
|
||||
let to_tenant = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", true);
|
||||
assert_eq!(to_tenant["self_link"], format!("{site}/trekk?chain=abc&email=kari%40x.no"));
|
||||
assert_eq!(to_tenant["desk_link"], format!("{site}/styret"));
|
||||
assert_eq!(to_tenant["link"], to_tenant["self_link"]);
|
||||
let to_board = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", false);
|
||||
assert_eq!(to_board["link"], to_board["desk_link"]);
|
||||
// Past where the self-service move can be made: the person has
|
||||
// no page of their own, so `{link}` is the site.
|
||||
let after = record_links(&questions, "open", "feil", "abc", "utbedret", "kari@x.no", true);
|
||||
assert_eq!(after["self_link"], "");
|
||||
assert_eq!(after["link"], site);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -662,6 +792,13 @@ mod tests {
|
||||
assert_eq!(vars["state"], "fixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
|
||||
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
|
||||
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
|
||||
assert_eq!(spoken_datetime("Anders Kluge", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
|
||||
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
|
||||
@@ -709,6 +846,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
|
||||
/// `portal lint` checks a content repo the way the site reads it;
|
||||
/// `portal iris` checks a content repo the way the site reads it (the
|
||||
/// iris keeps bad content out of the portal; `portal lint` is the same);
|
||||
/// `portal plan`, `build`, `report` and the rest train one. The lint
|
||||
/// and the trainer are the same code as the server, at the same
|
||||
/// version, so they never disagree about what content means.
|
||||
@@ -8,7 +9,7 @@
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match argv.first().map(String::as_str) {
|
||||
Some("lint") => return portal::lint::run(argv[1..].to_vec()).await,
|
||||
Some("iris" | "lint") => return portal::lint::run(argv[1..].to_vec()).await,
|
||||
Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => {
|
||||
// The trainer runs models through shell commands and waits
|
||||
// on them; off the runtime's threads, as a CLI should.
|
||||
@@ -19,7 +20,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
Some("serve") | None => {}
|
||||
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
|
||||
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, iris (or lint), plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
|
||||
}
|
||||
serve().await
|
||||
}
|
||||
|
||||
@@ -254,6 +254,8 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member: !created,
|
||||
};
|
||||
match existing {
|
||||
@@ -351,6 +353,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,12 @@ pub struct Invited {
|
||||
/// the membership is not portal's to take back.
|
||||
#[serde(default)]
|
||||
pub already_member: bool,
|
||||
/// The invite's own mail and the form it was sent from, kept with
|
||||
/// the invite so that sending it again sends the same words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mail: Option<crate::content::MailTemplate>,
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub fields: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
|
||||
@@ -367,6 +373,8 @@ impl Kanidm {
|
||||
reset_link,
|
||||
link_expires_ms,
|
||||
created,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
})
|
||||
}
|
||||
@@ -714,6 +722,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -328,6 +328,11 @@ async fn fetch_resource_value(
|
||||
}
|
||||
}
|
||||
}
|
||||
if !resource.transitions.is_empty() {
|
||||
if let Some(schema) = state.aggregates.load().at(0).get(bucket) {
|
||||
items = cap_finished(items, schema, finished_wanted(params));
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items)
|
||||
}
|
||||
}
|
||||
@@ -348,6 +353,41 @@ async fn fetch_resource_value(
|
||||
}
|
||||
}
|
||||
|
||||
/// How many finished records a desk listing should show: three, or
|
||||
/// what the page asked for with `__finished` after "show older".
|
||||
pub const FINISHED_PARAM: &str = "__finished";
|
||||
pub const FINISHED_SHOWN: usize = 3;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn finished_wanted(params: &std::collections::HashMap<String, String>) -> usize {
|
||||
params.get(FINISHED_PARAM).and_then(|n| n.parse().ok()).unwrap_or(FINISHED_SHOWN)
|
||||
}
|
||||
|
||||
/// A desk is for the cases still moving. Every record still open stays;
|
||||
/// of those in a final state (no move out), only the `shown` most
|
||||
/// recently decided, plus one more so the page knows there are older
|
||||
/// ones to offer. Each finished record is marked, so the page can tell
|
||||
/// them apart without knowing the state machine.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(crate) fn cap_finished(items: Vec<serde_json::Value>, schema: &crate::aggregates::AggregateSchema, shown: usize) -> Vec<serde_json::Value> {
|
||||
let is_final = |v: &serde_json::Value| {
|
||||
v.get("state").and_then(|s| s.as_str()).is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty())
|
||||
};
|
||||
let when = |v: &serde_json::Value| {
|
||||
v.get("decided_ms").and_then(|d| d.as_i64()).or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64())).unwrap_or(0)
|
||||
};
|
||||
let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final);
|
||||
finished.sort_by_key(|v| std::cmp::Reverse(when(v)));
|
||||
finished.truncate(shown + 1);
|
||||
for v in &mut finished {
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
obj.insert("finished".into(), true.into());
|
||||
}
|
||||
}
|
||||
open.extend(finished);
|
||||
open
|
||||
}
|
||||
|
||||
/// Fetches JSON from a path on the same Gitea instance content is
|
||||
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
|
||||
/// set - confirmed live against `project.uhhm.no` that at least the
|
||||
@@ -758,3 +798,32 @@ mod tests {
|
||||
assert_eq!(out, serde_json::json!(["id: /x\n"]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "ssr"))]
|
||||
mod finished_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_desk_keeps_every_open_case_and_the_newest_finished_ones() {
|
||||
let schema = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: leads\n initial: open\n states:\n open: { event: in }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [won, lost]\n",
|
||||
)
|
||||
.unwrap()
|
||||
.remove("leads")
|
||||
.unwrap();
|
||||
let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided });
|
||||
let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)];
|
||||
items.extend((1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)));
|
||||
let ids = |v: &[serde_json::Value]| v.iter().map(|x| x["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
|
||||
let shown = cap_finished(items.clone(), &schema, 3);
|
||||
// Both open cases, then the three newest finished and one more
|
||||
// to tell the page there are older ones.
|
||||
assert_eq!(ids(&shown), ["o1", "o2", "f6", "f5", "f4", "f3"]);
|
||||
assert!(shown[2..].iter().all(|v| v["finished"] == true));
|
||||
assert!(shown[..2].iter().all(|v| v.get("finished").is_none()));
|
||||
|
||||
// Asking for all of them: no extra, so no "show older".
|
||||
assert_eq!(cap_finished(items, &schema, 6).len(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,8 @@ mod tests {
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created: true,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -190,7 +190,16 @@ not done. Its messages are exact:
|
||||
- `no desk gated on "g" reads "b"`: add the desk.
|
||||
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
|
||||
is missing a transition the state machine allows, or the state
|
||||
machine is missing the edge.
|
||||
machine is missing the edge. The message ends in `fix:` with the
|
||||
exact `{ from, to, label }` and the desk page to put it on - do that.
|
||||
- `state "s" is never reached`: nothing moves a record into it. Offer
|
||||
the move it names, or remove the state.
|
||||
- `stage "b" comes after "a" ... but no move leads on`: the machine
|
||||
cannot go the way the business works; add the edge it names.
|
||||
- `the mail on entering "s" uses {x}`: the mail names a field no form
|
||||
collects. Use one of the fields it lists, or collect `x`.
|
||||
- WARN `the move "a" -> "b" is declared but no desk ... offers it`:
|
||||
offer it, or drop the edge - every edge should be a button.
|
||||
- `action ... does not match any declared question id`: the page does
|
||||
not exist.
|
||||
- `entry page ... names no one responsible`: add `responsible`.
|
||||
|
||||
@@ -1280,6 +1280,25 @@ textarea:focus {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
/* After the newest finished cases: the older ones, on request. Quiet,
|
||||
since the desk is for what is still moving. */
|
||||
.show-older {
|
||||
justify-self: start;
|
||||
background: none;
|
||||
border: 0.06rem solid var(--line);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--ink-dim);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.show-older:hover {
|
||||
color: inherit;
|
||||
border-color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.answer-row {
|
||||
border: 0.06rem solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
|
||||
Reference in New Issue
Block a user