Compare commits
69
Commits
leaves-after
...
main
@@ -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
|
||||
|
||||
+73
-1
@@ -4,7 +4,79 @@ 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).
|
||||
|
||||
## Unreleased
|
||||
## 0.6.23 (2026-10-07)
|
||||
|
||||
- A configuration profile's per-payload `PayloadVersion` is an integer, as the plist key is typed; it was a string, which iOS may refuse.
|
||||
|
||||
## 0.6.22 (2026-10-07)
|
||||
|
||||
- `/profile.mobileconfig` renders an Apple configuration profile from the mail server's own PACC document (`profile.pacc` in `site.yaml`), so iOS can be set up in one install instead of probing for ports it cannot reach. Mail, CalDAV and CardDAV in one profile; every host, port and path from PACC, never written down twice; no address, username or password in the document, so one profile serves everyone and iOS asks at install; identifiers derived from the site, so reinstalling replaces the accounts rather than duplicating them (docs: content-reference, "A configuration profile").
|
||||
|
||||
## 0.6.21 (2026-10-07)
|
||||
|
||||
- A content repo's `.well-known/` is served at the site root (`/.well-known/security.txt`, `/.well-known/assetlinks.json`, `/.well-known/matrix/server`), and a miss there is a 404 instead of the app shell answering 200 with an HTML page. RFC 8615 reserves the prefix and a question's id cannot begin with a dot, so nothing a repo ships can shadow a page. An extensionless file under it is typed as JSON, by the convention those files follow; `.txt`, `.xml`, `.webmanifest` and `.mobileconfig` are typed too, the last so an Apple configuration profile offers to install rather than download.
|
||||
|
||||
## 0.6.20 (2026-10-05)
|
||||
|
||||
- A releases card lets a platform's file carry an architecture: a Mac visitor is offered the `macos-arm64` file, not sent to TestFlight past it.
|
||||
|
||||
## 0.6.19 (2026-10-05)
|
||||
|
||||
- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest").
|
||||
|
||||
## 0.6.18 (2026-10-03)
|
||||
|
||||
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
|
||||
- `portal generate` follows its findings through: each structure round is logged as fixed, still open, new and reopened; a fix round is shown the open findings with their fixes and only the files they name; a change that reopens a closed finding is undone; and the phase stops when a round closes nothing, writing what is left to run.log. A hesitant visitor no longer triggers a wording rewrite by itself, only a misroute does. A lint without `--findings` is still driven the old way.
|
||||
|
||||
## 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.
|
||||
|
||||
## 0.6.13 (2026-10-01)
|
||||
|
||||
- Who is here (the name and "sign out", or "sign in") sits on the wordmark's own line instead of being pinned to the corner of the page, where it read as a row of its own. With a picture strip it is in the wordmark's bar; without, it is level with the mark at the right edge. On a narrow screen it drops under the mark.
|
||||
- Trainer: every form, public ones included, carries a signed-in person's name and address from the login instead of asking for them; a visitor still sees the fields. Only the inside forms did before, so someone signed in filled in their own name and address again on a public form.
|
||||
|
||||
## 0.6.12 (2026-10-01)
|
||||
|
||||
- Trainer: the words model is told who opens each mail: the person who sent the case, the person who posted the record an answer is about, or someone at the business. Mails to the business about another person's case used to be written as if to that person ("you will hear from us"); on Gullberg, three of nine were.
|
||||
|
||||
## 0.6.11 (2026-10-01)
|
||||
|
||||
- `wordmark_ink: true` in `site.yaml`: an SVG wordmark is drawn in the page's own ink, as a mask over the text colour, so it follows the page's light and dark in every browser. A wordmark shown as an `<img>` only knows the system's setting, and some browsers don't pass even that on. Opacity in the file still shows, so a lighter line stays lighter. Opt-in, for one-colour marks; a mark with a colour of its own keeps its colours.
|
||||
|
||||
## 0.6.10 (2026-09-30)
|
||||
|
||||
- Trainer: who a business is gets a page of its own ("who are we?", `/hvem` in Norwegian), with nothing to do on it. It has one card per need that `presents` (the people, the work, each shown as picture cards) and one for the plan's `values`. The front door leads there instead of carrying it all.
|
||||
- Trainer: the words model is told that a form page from the inside asks a real question and says where the answer ends up ("How do you present yourself?", not "Your card on the front page"), and that a `one_per_person` form is the colleague's own.
|
||||
- Trainer: `portal build` names every need no persona tries. Klingenberg Bygg's plan had personas only for customers, so nobody ever played the office filling in its own forms.
|
||||
- Klingenberg Bygg: "Hvem består Klingenberg Bygg av?" with Menneskene, Erfaringer (projects with pictures, added from the office) and Verdier (a first draft for Fredrik to replace).
|
||||
|
||||
## 0.6.9 (2026-09-30)
|
||||
|
||||
- What a person wrote on a card (a bio) is shown as markdown: paragraphs, line breaks as typed, lists, emphasis, and links to https, mailto or tel marked `rel="nofollow ugc"`. No images and no raw HTML, which is stricter than the site's own descriptions. The desk still shows the text as written.
|
||||
|
||||
## 0.6.8 (2026-09-30)
|
||||
|
||||
- An upload that did not arrive says why: larger than 20 MB (checked in the browser before anything is sent), a site that takes no files (no Garage configured), not allowed from here, or anything else. It used to say "try again, or with a smaller file" whatever the reason, which sent people looking for a smaller file when the site had no storage at all.
|
||||
|
||||
## 0.6.7 (2026-09-30)
|
||||
|
||||
- `leaves_after: { minutes: 10 }` on a state: a record that has been there that long leaves its bucket's lists (days, hours and minutes add up; swept every minute; the event log keeps it). Trainer: `leaves_after` per stage on a need. A Klingenberg Bygg card taken off the front page leaves the office's list ten minutes later instead of standing there greyed out.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.6"
|
||||
version = "0.6.23"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.6"
|
||||
version = "0.6.23"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -60,6 +60,7 @@ web-sys = { version = "0.3", features = [
|
||||
"FileList",
|
||||
"FormData",
|
||||
"HtmlInputElement",
|
||||
"Navigator",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+111
-1
@@ -24,6 +24,7 @@ on every hot reload, and in CI through `iris check`.
|
||||
```yaml
|
||||
title: Vestmark kommune # the browser title and the header
|
||||
wordmark: /site/logo.svg # an image instead of the title
|
||||
wordmark_ink: true # a one-colour SVG mark drawn in the page's own ink
|
||||
wordmark_invert: true # flip it in dark mode
|
||||
wordmark_height: 2.4 # rem
|
||||
lang: no # the interface language: en or no
|
||||
@@ -96,6 +97,79 @@ Pictures in the content repo are served from this origin under
|
||||
`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an
|
||||
hour. Keep them web-sized: around 1400 pixels tall is plenty.
|
||||
|
||||
## Files at the site root
|
||||
|
||||
A repo's `.well-known/` directory is served at the root of the site, at
|
||||
the same paths it has in the repo:
|
||||
|
||||
```
|
||||
.well-known/security.txt -> https://<site>/.well-known/security.txt
|
||||
.well-known/assetlinks.json -> https://<site>/.well-known/assetlinks.json
|
||||
.well-known/matrix/server -> https://<site>/.well-known/matrix/server
|
||||
```
|
||||
|
||||
This is the one part of the URL space a repo may write into directly.
|
||||
RFC 8615 reserves `/.well-known/`, and a question's id comes from a file
|
||||
name under `questions/`, which cannot begin with a dot - so nothing
|
||||
shipped here can shadow a page. A path that is not in the repo answers
|
||||
**404**, not the app shell, so a program looking for one of these can
|
||||
tell a missing file from a present one.
|
||||
|
||||
A file with no extension is served as JSON, which is what the
|
||||
extensionless well-known files are. `.txt`, `.xml`, `.webmanifest` and
|
||||
`.mobileconfig` are typed as themselves; the last matters because iOS
|
||||
only offers to *install* a configuration profile that arrives as
|
||||
`application/x-apple-aspen-config`.
|
||||
|
||||
Paths a reverse proxy claims in front of portal never reach the repo -
|
||||
mail autoconfiguration (`/.well-known/autoconfig/`) and the CalDAV and
|
||||
CardDAV redirects are usually handed to a mail server that generates
|
||||
them.
|
||||
|
||||
## A configuration profile
|
||||
|
||||
iOS has no autoconfiguration for IMAP accounts - Apple Mail reads neither
|
||||
Mozilla autoconfig nor Microsoft Autodiscover, which are for ActiveSync - so
|
||||
adding an account by hand leaves the device probing for ports. A probe that
|
||||
meets a *filtered* port, a firewall that drops rather than refuses, waits out
|
||||
a TCP timeout, and the account comes back "could not be verified", which reads
|
||||
to the person as a wrong password. An Apple configuration profile ends the
|
||||
guessing by stating the hosts and the ports, and it carries mail, calendars
|
||||
and contacts in a single install.
|
||||
|
||||
Portal renders one at `/profile.mobileconfig` when `site.yaml` says where to
|
||||
get the values:
|
||||
|
||||
```yaml
|
||||
profile:
|
||||
pacc: https://mail.example.no/.well-known/user-agent-configuration.json
|
||||
name: Example # what the accounts are called on the device
|
||||
organization: Example AS # the profile's publisher, defaults to name
|
||||
```
|
||||
|
||||
`pacc` is the mail server's **PACC** document - the standard that replaces
|
||||
guessing at hostnames, defined to be fetched from the mail domain itself, and
|
||||
served by Stalwart among others. Every host, port and collection path in the
|
||||
profile comes from it, so nothing about the server is written down a second
|
||||
place to drift from the first: move a collection on the server and the profile
|
||||
moves with it. Where PACC states no port, the registered implicit-TLS default
|
||||
is used (993 for IMAP, 465 for submission, 443 for DAV).
|
||||
|
||||
What the profile contains is only what PACC already says in public. It carries
|
||||
**no address and no username** - iOS asks for those and the password at
|
||||
install - so one document serves everyone with an account and may be fetched
|
||||
by anyone. Payload identifiers are derived from the site and the payload
|
||||
rather than drawn at random, so reinstalling replaces the accounts instead of
|
||||
stacking duplicates beside them.
|
||||
|
||||
Two things it cannot do. A profile Portal renders is unsigned, so iOS shows it
|
||||
as *Unverified* while still offering to install it; signing needs a
|
||||
certificate the device already trusts. And a PACC document describing no
|
||||
protocol iOS understands - JMAP alone, or an incoming server with no outgoing
|
||||
one - is an error rather than a half-empty profile that looks like it worked.
|
||||
|
||||
No `profile:` means `/profile.mobileconfig` is a 404.
|
||||
|
||||
## An alternative
|
||||
|
||||
One choice. Never two actions behind one heading: if two things can be
|
||||
@@ -124,6 +198,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
|
||||
@@ -250,6 +328,7 @@ reads is never a client-supplied parameter.
|
||||
states: [approved, refused] # only these; empty means all
|
||||
where: { field: case, equals: "{key}" }
|
||||
jq: ".[] | {name, url: .html_url}"
|
||||
limit: 1 # at most this many, after jq
|
||||
empty: Nothing decided yet.
|
||||
link: { to: "/notices/{id}", label: Answer this } # a page per record
|
||||
transitions: # the desk's buttons
|
||||
@@ -259,6 +338,14 @@ reads is never a client-supplied parameter.
|
||||
`source.kind` is one of `kv`, `gitea_starred`,
|
||||
`gitea_releases`, `url`.
|
||||
|
||||
`limit: 1` shows only the first item — for a feed where only the newest
|
||||
matters. A releases card wants the cut someone should run, not every
|
||||
cut there has ever been, and a page that grows by one card per release
|
||||
gets longer forever. It applies *after* `jq`, so a filter can reorder
|
||||
and the limit takes the top of that ordering. A source that yields a
|
||||
single record has no length and ignores it; `limit: 0` on a list shows
|
||||
nothing, and the `empty` line speaks instead.
|
||||
|
||||
A spec with neither `public: true` nor `requires_group` is
|
||||
unreachable: reads fail closed, and mutations always need a group
|
||||
whatever `public` says.
|
||||
@@ -275,7 +362,30 @@ time. The cards appear in the order the records were put up. That is
|
||||
how a site shows the people who work there, or a gallery of what it
|
||||
has built. On a desk, the same picture is a thumbnail that opens.
|
||||
`accept: image/jpeg,image/png,image/webp` on the field makes a phone
|
||||
send a JPEG whatever it keeps its photos as.
|
||||
send a JPEG whatever it keeps its photos as. What the person wrote is shown as
|
||||
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.
|
||||
|
||||
A **releases manifest** - a `kind: url` resource whose JSON is
|
||||
`{ name, latest, versions: [{ version, date, notes, assets: [{ platform,
|
||||
url, sha256, size }] }], channels: { arch: { repo, package, note },
|
||||
ios: { testflight, note } } }`, newest version first, as a release
|
||||
pipeline writes it - renders as one card: the newest version, the file
|
||||
for the visitor's own platform first (read from the browser: `ios`,
|
||||
`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the
|
||||
other files and the channels folded under "All downloads". Linux gets
|
||||
the package repo lines beside its file; iPhones and Macs get
|
||||
TestFlight. Platforms a manifest lacks fall through to the folded
|
||||
list.
|
||||
|
||||
`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
|
||||
|
||||
+42
-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,43 @@ 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.
|
||||
|
||||
`--findings <out.jsonl>` writes the same findings one JSON object per
|
||||
line: `id`, `level` (`fail`/`warn`), `code`, `scope` (`need`,
|
||||
`bucket`, or `repo` for a schema error), `need` (the need's id, or the
|
||||
bucket's name), `subject` (the state, the move as `from>to`, or the
|
||||
page), `message` and `fix`. The file is written empty when the content
|
||||
is clean. `id` hashes scope, need, code and subject and nothing of the
|
||||
wording, so the same gap keeps its id from one run to the next: a
|
||||
trainer can tell what a round closed, what is still open and what it
|
||||
broke. The codes:
|
||||
|
||||
| code | |
|
||||
|---|---|
|
||||
| `schema` | content the site would refuse to load |
|
||||
| `need-duplicate`, `persona-unknown-need` | needs.yaml itself |
|
||||
| `entry-missing`, `entry-gated`, `entry-no-responsible` | the page a visitor arrives on |
|
||||
| `path-missing`, `path-closed`, `path-over-budget`, `path-no-action` | getting the answer recorded |
|
||||
| `machine-missing`, `desk-missing`, `desk-gated-twice` | who works the bucket |
|
||||
| `done-unknown`, `done-unreachable`, `success-not-done` | finishing a case |
|
||||
| `stage-unknown`, `stage-unreachable`, `stage-order` | the business's own stages |
|
||||
| `state-unreached`, `strand`, `edge-unoffered` | every state and move of the bucket |
|
||||
| `mail-unreachable`, `mail-unknown-field` | what a state mails |
|
||||
|
||||
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`.
|
||||
|
||||
+16
-5
@@ -243,13 +243,24 @@ against the lint. It is kept for the record of what it measured.
|
||||
visitor will tolerate.
|
||||
2. **Structure.** Pages, state machines and desks are generated, then
|
||||
revised against the lint, `iris check`, until every need is met:
|
||||
a path within budget, a desk held by the right group, every
|
||||
finished state reachable, no record stranding. The page-writing
|
||||
model sees the needs and never the personas.
|
||||
a path within budget, a desk held by the right group, every state
|
||||
of every bucket reachable and none stranding a record. The
|
||||
page-writing model sees the needs and never the personas. Each
|
||||
round the lint writes its findings with stable ids (`--findings`,
|
||||
kept in `<out>/findings/round-N.jsonl`), and the round is logged as
|
||||
what it fixed, what is still open, what is new and what it
|
||||
reopened. A fix round is shown only the open findings, each with
|
||||
the change that closes it, and the files they name (the whole repo
|
||||
when a finding is about getting around the site). A change that
|
||||
reopens a closed finding is undone and the model told what it
|
||||
broke. A round that closes nothing, or leaves as many open as it
|
||||
found, ends the phase: what is left is written to `run.log` rather
|
||||
than polished at again.
|
||||
3. **Wording.** A separate model plays each persona, blind, first with
|
||||
headings only and then with descriptions. A misroute is a wording
|
||||
defect and goes back to the generator. A rewrite that breaks
|
||||
structure is reverted.
|
||||
defect and goes back to the generator; a visitor who chose right
|
||||
but hesitated is reported and rewrites nothing by itself. A rewrite
|
||||
that breaks structure is reverted.
|
||||
|
||||
Model output is only ever written to `aggregates.yaml`, `site.yaml`
|
||||
and `questions/**.yaml` inside the output directory.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 409.87222 77.398003" role="img" aria-label="GULLBERG sanatorium">
|
||||
<title>GULLBERG sanatorium</title>
|
||||
<!-- Futura, drawn as outlines so every machine shows the same mark.
|
||||
"sanatorium" is spaced out to run the width of GULLBERG, set in
|
||||
a little from each edge. As an image it colours itself for light
|
||||
and dark; with wordmark_ink in site.yaml, portal colours it with
|
||||
the page's own ink instead, and "sanatorium" stays lighter by its
|
||||
opacity. -->
|
||||
<style>
|
||||
.mark { fill: #1d1b18; }
|
||||
.sub { fill: #1d1b18; fill-opacity: 0.6; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.mark, .sub { fill: #f1ece3; }
|
||||
}
|
||||
</style>
|
||||
<path class="mark"
|
||||
d="M 0,25.216 Q 0,19.84 1.984,15.232 3.968,10.624 7.36,7.232 10.816,3.84 15.424,1.92 20.032,0 25.216,0 q 2.88,0 5.44,0.576 2.624,0.512 4.992,1.728 2.432,1.216 4.736,3.072 2.304,1.856 4.608,4.48 l -5.184,4.928 Q 33.856,6.912 25.344,6.912 q -3.84,0 -7.168,1.408 -3.264,1.408 -5.696,3.904 -2.432,2.432 -3.84,5.76 -1.344,3.328 -1.344,7.232 0,3.968 1.472,7.296 1.536,3.328 3.968,5.76 2.496,2.368 5.632,3.712 3.136,1.344 6.4,1.344 3.008,0 5.568,-0.96 2.56,-1.024 4.416,-2.688 1.92,-1.664 3.072,-3.904 1.152,-2.304 1.408,-4.8 V 30.912 H 27.2 v -6.848 h 19.968 v 1.6 q 0,5.504 -1.6,10.048 -1.6,4.48 -4.544,7.68 -2.88,3.2 -7.104,4.992 -4.16,1.728 -9.344,1.728 -5.12,0 -9.6,-1.92 Q 10.496,46.208 7.168,42.816 3.84,39.424 1.92,34.88 0,30.336 0,25.216 Z M 69.360026,0.96 h 7.296004 v 30.208 q 0,3.328 0.832,5.696 0.832,2.304 2.24,3.712 1.472,1.408 3.392,2.048 1.92,0.64 4.096,0.64 2.176,0 4.096,-0.64 1.92,-0.64 3.328,-2.048 1.472,-1.472 2.304,-3.84 0.896,-2.368 0.896,-5.824 V 0.96 h 7.232 V 32 q 0,4.352 -1.472,7.744 -1.408,3.392 -3.84,5.696 -2.432,2.304 -5.696,3.52 -3.2,1.152 -6.848,1.152 -3.648,0 -6.912,-1.152 -3.2,-1.216 -5.632004,-3.52 -2.432,-2.304 -3.904,-5.696 -1.408,-3.392 -1.408,-7.744 z M 129.18404,49.216 V 0.96 h 7.296 v 41.408 h 14.208 v 6.848 z m 41.90405,0 V 0.96 h 7.296 v 41.408 h 14.208 v 6.848 z m 41.90405,0 V 0.96 h 8.32 q 8.064,0 12.48,3.264 4.48,3.2 4.48,9.792 0,3.2 -1.536,5.76 -1.472,2.496 -3.904,3.84 v 0.064 q 4.416,1.28 6.912,4.416 2.56,3.136 2.56,7.296 0,3.52 -1.28,6.144 -1.28,2.56 -3.584,4.288 -2.304,1.728 -5.568,2.56 -3.2,0.832 -7.168,0.832 z m 12.416,-6.848 q 2.496,0 4.288,-0.576 1.792,-0.576 2.944,-1.536 1.152,-0.96 1.664,-2.24 0.512,-1.344 0.512,-2.88 0,-1.536 -0.512,-2.816 -0.512,-1.344 -1.728,-2.368 -1.152,-1.024 -3.136,-1.6 -1.92,-0.576 -4.8,-0.576 h -4.352 v 14.592 z m -2.688,-20.672 q 4.352,0 6.336,-1.984 1.984,-1.984 1.984,-5.504 0,-3.456 -2.048,-4.992 -1.984,-1.536 -6.272,-1.536 h -2.432 V 21.696 Z M 291.31219,0.96 v 6.848 h -19.328 v 11.584 h 18.816 v 6.848 h -18.816 v 16.128 h 19.328 v 6.848 h -26.624 V 0.96 Z m 32.56003,0 q 4.16,0 7.552,0.832 3.392,0.832 5.824,2.56 2.432,1.728 3.776,4.416 1.344,2.624 1.344,6.336 0,4.736 -2.688,8.32 -2.624,3.52 -7.808,5.376 l 14.72,20.416 h -8.896 l -13.568,-19.776 h -1.536 v 19.776 h -7.296 V 0.96 Z m -1.28,22.144 h 2.304 q 5.12,0 7.744,-1.984 2.624,-1.984 2.624,-5.888 0,-7.424 -10.048,-7.424 h -2.624 z m 40.112,2.112 q 0,-5.376 1.984,-9.984 1.984,-4.608 5.376,-8 3.456,-3.392 8.064,-5.312 4.608,-1.92 9.792,-1.92 2.88,0 5.44,0.576 2.624,0.512 4.992,1.728 2.432,1.216 4.736,3.072 2.304,1.856 4.608,4.48 l -5.184,4.928 q -5.952,-7.872 -14.464,-7.872 -3.84,0 -7.168,1.408 -3.264,1.408 -5.696,3.904 -2.432,2.432 -3.84,5.76 -1.344,3.328 -1.344,7.232 0,3.968 1.472,7.296 1.536,3.328 3.968,5.76 2.496,2.368 5.632,3.712 3.136,1.344 6.4,1.344 3.008,0 5.568,-0.96 2.56,-1.024 4.416,-2.688 1.92,-1.664 3.072,-3.904 1.152,-2.304 1.408,-4.8 v -0.064 h -12.032 v -6.848 h 19.968 v 1.6 q 0,5.504 -1.6,10.048 -1.6,4.48 -4.544,7.68 -2.88,3.2 -7.104,4.992 -4.16,1.728 -9.344,1.728 -5.12,0 -9.6,-1.92 -4.48,-1.984 -7.808,-5.376 -3.328,-3.392 -5.248,-7.936 -1.92,-4.544 -1.92,-9.664 z"
|
||||
id="g"
|
||||
|
||||
aria-label="GULLBERG" />
|
||||
<path class="sub"
|
||||
d="m 11.999999,76.176 1.079,-0.819 q 0.195,0.338 0.481,0.546 0.286,0.195 0.676,0.195 0.351,0 0.546,-0.143 0.195,-0.156 0.195,-0.442 0,-0.143 -0.065,-0.26 -0.052,-0.117 -0.195,-0.234 -0.143,-0.117 -0.39,-0.247 -0.247,-0.13 -0.624,-0.299 -0.793,-0.364 -1.118,-0.793 -0.325,-0.442 -0.325,-1.066 0,-0.403 0.156,-0.728 0.156,-0.325 0.416,-0.546 0.273,-0.234 0.624,-0.351 0.364,-0.13 0.78,-0.13 0.676,0 1.17,0.247 0.507,0.247 0.793,0.663 l -1.053,0.845 q -0.143,-0.182 -0.403,-0.325 -0.247,-0.143 -0.52,-0.143 -0.273,0 -0.429,0.117 -0.156,0.117 -0.156,0.312 0,0.104 0.052,0.195 0.052,0.091 0.195,0.195 0.143,0.104 0.39,0.234 0.26,0.13 0.663,0.312 0.819,0.377 1.118,0.832 0.299,0.442 0.299,1.066 0,0.455 -0.156,0.832 -0.156,0.364 -0.442,0.624 -0.286,0.26 -0.676,0.39 -0.39,0.143 -0.845,0.143 -0.884,0 -1.443,-0.377 -0.559,-0.377 -0.793,-0.845 z m 40.993985,-2.067 q 0,-0.689 0.221,-1.274 0.234,-0.598 0.637,-1.027 0.416,-0.442 0.962,-0.689 0.559,-0.247 1.209,-0.247 0.585,0 1.079,0.195 0.494,0.195 0.962,0.65 h 0.013 v -0.689 h 1.43 v 6.188 h -1.43 v -0.689 h -0.013 q -0.468,0.455 -0.962,0.663 -0.481,0.208 -1.04,0.208 -0.663,0 -1.235,-0.247 -0.559,-0.26 -0.975,-0.702 -0.403,-0.442 -0.637,-1.04 -0.221,-0.598 -0.221,-1.3 z m 1.456,0 q 0,0.429 0.117,0.806 0.13,0.364 0.364,0.637 0.234,0.273 0.572,0.429 0.351,0.143 0.78,0.143 0.429,0 0.767,-0.143 0.351,-0.156 0.598,-0.416 0.247,-0.273 0.377,-0.637 0.143,-0.364 0.143,-0.793 0,-0.416 -0.143,-0.78 -0.13,-0.364 -0.377,-0.624 -0.247,-0.273 -0.598,-0.429 -0.338,-0.156 -0.754,-0.156 -0.416,0 -0.767,0.156 -0.338,0.156 -0.585,0.416 -0.234,0.26 -0.364,0.624 -0.13,0.351 -0.13,0.767 z M 98.512,71.028 v 0.663 h 0.026 q 0.338,-0.39 0.78,-0.611 0.455,-0.221 1.092,-0.221 1.079,0 1.599,0.676 0.533,0.663 0.533,1.885 v 3.796 h -1.43 v -3.458 q 0,-0.897 -0.286,-1.248 -0.286,-0.364 -0.884,-0.364 -0.663,0 -1.053,0.442 -0.377,0.442 -0.377,1.521 v 3.107 h -1.43 v -6.188 z m 41.05899,3.081 q 0,-0.689 0.221,-1.274 0.234,-0.598 0.637,-1.027 0.416,-0.442 0.962,-0.689 0.559,-0.247 1.209,-0.247 0.585,0 1.079,0.195 0.494,0.195 0.962,0.65 h 0.013 v -0.689 h 1.43 v 6.188 h -1.43 v -0.689 h -0.013 q -0.468,0.455 -0.962,0.663 -0.481,0.208 -1.04,0.208 -0.663,0 -1.235,-0.247 -0.559,-0.26 -0.975,-0.702 -0.403,-0.442 -0.637,-1.04 -0.221,-0.598 -0.221,-1.3 z m 1.456,0 q 0,0.429 0.117,0.806 0.13,0.364 0.364,0.637 0.234,0.273 0.572,0.429 0.351,0.143 0.78,0.143 0.429,0 0.767,-0.143 0.351,-0.156 0.598,-0.416 0.247,-0.273 0.377,-0.637 0.143,-0.364 0.143,-0.793 0,-0.416 -0.143,-0.78 -0.13,-0.364 -0.377,-0.624 -0.247,-0.273 -0.598,-0.429 -0.338,-0.156 -0.754,-0.156 -0.416,0 -0.767,0.156 -0.338,0.156 -0.585,0.416 -0.234,0.26 -0.364,0.624 -0.13,0.351 -0.13,0.767 z M 183.958,71.028 v -2.262 h 1.43 v 2.262 h 1.105 v 1.339 h -1.105 v 4.849 h -1.43 v -4.849 h -1.001 v -1.339 z m 38.849,3.055 q 0,-0.663 0.234,-1.248 0.234,-0.585 0.663,-1.014 0.442,-0.442 1.04,-0.702 0.611,-0.26 1.352,-0.26 0.741,0 1.339,0.26 0.611,0.247 1.04,0.689 0.442,0.442 0.676,1.04 0.247,0.598 0.247,1.287 0,0.689 -0.247,1.287 -0.234,0.598 -0.676,1.04 -0.429,0.429 -1.04,0.689 -0.611,0.247 -1.365,0.247 -0.728,0 -1.339,-0.247 -0.598,-0.26 -1.027,-0.702 -0.429,-0.442 -0.663,-1.053 -0.234,-0.611 -0.234,-1.313 z m 1.456,0.026 q 0,0.923 0.494,1.469 0.494,0.546 1.339,0.546 0.832,0 1.339,-0.546 0.507,-0.546 0.507,-1.443 0,-0.884 -0.507,-1.43 -0.494,-0.559 -1.339,-0.559 -0.403,0 -0.741,0.143 -0.338,0.143 -0.585,0.416 -0.247,0.26 -0.377,0.624 -0.13,0.351 -0.13,0.78 z m 42.28101,-3.081 h 1.43 v 0.559 h 0.026 q 0.351,-0.377 0.65,-0.546 0.312,-0.182 0.767,-0.182 0.559,0 1.17,0.377 l -0.65,1.3 q -0.403,-0.286 -0.793,-0.286 -1.17,0 -1.17,1.755 v 3.211 h -1.43 z m 42.15098,0 v 6.188 h -1.43 v -6.188 z m -1.651,-2.691 q 0,-0.377 0.273,-0.65 0.273,-0.273 0.65,-0.273 0.39,0 0.663,0.273 0.286,0.273 0.286,0.65 0,0.377 -0.273,0.663 -0.273,0.273 -0.663,0.273 -0.403,0 -0.676,-0.273 -0.26,-0.286 -0.26,-0.663 z m 39.122,2.691 h 1.43 v 3.549 q 0,1.547 1.209,1.547 1.222,0 1.222,-1.547 v -3.549 h 1.43 v 3.588 q 0,1.313 -0.676,2.054 -0.676,0.728 -1.976,0.728 -0.598,0 -1.092,-0.169 -0.481,-0.169 -0.832,-0.507 -0.338,-0.351 -0.533,-0.871 -0.182,-0.52 -0.182,-1.235 z m 42.74902,0 h 1.43 V 71.6 h 0.013 q 0.286,-0.338 0.65,-0.533 0.377,-0.208 0.962,-0.208 0.624,0 1.027,0.234 0.403,0.221 0.702,0.689 h 0.013 q 0.377,-0.455 0.845,-0.689 0.481,-0.234 1.118,-0.234 1.105,0 1.651,0.637 0.546,0.637 0.546,1.898 v 3.822 h -1.43 v -3.432 q 0,-0.897 -0.26,-1.261 -0.26,-0.377 -0.845,-0.377 -0.65,0 -0.949,0.429 -0.286,0.416 -0.286,1.313 v 3.328 h -1.43 v -3.523 q 0,-0.806 -0.26,-1.17 -0.26,-0.377 -0.819,-0.377 -0.637,0 -0.949,0.429 -0.299,0.416 -0.299,1.313 v 3.328 h -1.43 z"
|
||||
id="s"
|
||||
|
||||
aria-label="sanatorium" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,61 @@
|
||||
# Aksjonæravtalen for Gullberg AS: punkter å dekke
|
||||
|
||||
Ikke en avtale, og ikke på siden. En liste over det aksjonæravtalen bør
|
||||
svare på, til samtale mellom de fire og med advokat eller regnskapsfører.
|
||||
Lånet går over lang tid (rundt 100 000 kr i året mot 1,6 millioner), så
|
||||
avtalen må tåle at livene til de fire endrer seg underveis.
|
||||
|
||||
## Eierskapet
|
||||
|
||||
- Hvem de fire aksjonærene er, med 25 % hver, og hva hver har skutt inn.
|
||||
- Hvordan huset går fra dødsboet til selskapet: skjøte, takst som legges
|
||||
til grunn, og hvem som betaler dokumentavgiften.
|
||||
- Hva søsknene som løses ut får, når, og at de ikke har noe krav etterpå.
|
||||
|
||||
## Lånet
|
||||
|
||||
- Hvem som står for lånet overfor banken, og om noen har kausjonert
|
||||
personlig. Hvis bare noen kausjonerer: hvordan de holdes skadesløse av
|
||||
de andre.
|
||||
- Hva som skjer hvis husleia ikke dekker avdraget en måned, eller et
|
||||
halvår: hvem skyter inn, i hvilket forhold, og som lån eller kapital.
|
||||
- Hva som skjer hvis renta stiger mye.
|
||||
|
||||
## Driften
|
||||
|
||||
- Hva styret avgjør alene, og hva som krever alle fire (nye lån, salg av
|
||||
huset, store utbedringer over et beløp, endring av husleia, ny eier).
|
||||
- Hva som skjer ved stemmelikhet, to mot to: hvem eller hva som bryter.
|
||||
- Klingenberg Bygg som forvalter, og som den som gir overslag og gjør
|
||||
arbeidet: hvordan overslag godkjennes, og hvordan en mulig
|
||||
interessekonflikt håndteres.
|
||||
- Et vedlikeholdsfond: hvor mye av overskuddet som settes av før noe
|
||||
deles ut.
|
||||
|
||||
## Bruken av huset
|
||||
|
||||
- Hvordan sommerukene fordeles mellom de fire, hvem som velger først, og
|
||||
hva som skjer med uker ingen bruker.
|
||||
- Om uker kan leies ut til andre, og hvem som får inntekten.
|
||||
- Hva aksjonærene betaler for egne uker, om noe.
|
||||
|
||||
## Når noe endrer seg
|
||||
|
||||
- En aksjonær vil selge: forkjøpsrett for de andre, hvordan prisen
|
||||
fastsettes (takst, formel), og hvor lang tid de andre har.
|
||||
- En aksjonær dør eller skiller seg: om aksjene går til arvinger eller
|
||||
ektefelle, eller om de andre kan løse dem ut.
|
||||
- En aksjonær klarer ikke sin del: hva som skjer med andelen.
|
||||
- Huset skal selges: hvordan, og hvordan pengene fordeles etter lånet.
|
||||
- Brann, stor skade, eller at huset ikke kan leies ut: forsikring, og
|
||||
hva som skjer med lånet.
|
||||
|
||||
## Utbytte og innsyn
|
||||
|
||||
- Når det deles ut utbytte, og i hvilken rekkefølge (lån, fond, utbytte).
|
||||
- Hva hver aksjonær har rett til å se (rapportene på siden, regnskapet).
|
||||
|
||||
## Tvister og varighet
|
||||
|
||||
- Hvordan uenighet løses før noen går til retten (mekling).
|
||||
- Når avtalen tas opp igjen, for eksempel hvert femte år.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Huset
|
||||
|
||||
Utkast, skrives av eierne: hva huset er, hvor det ligger, hvor mange rom det har og i hvilken stand.
|
||||
|
||||
# Forretningen
|
||||
|
||||
Utkast, skrives av eierne: de to inntektene, romleie til studenter og timeshare-uker, og hva hver av dem gir i året.
|
||||
|
||||
# Eierne
|
||||
|
||||
Utkast, skrives av eierne: Gullberg AS eies av fire aksjonærer med 25 % hver. Hvem de er, hvordan styret er satt sammen, og hva som krever alle fire.
|
||||
|
||||
# Lånet
|
||||
|
||||
Utkast, skrives av eierne: 1,6 millioner til å løse ut de to søsknene som ikke blir med (800 000 kr hver), og at selskapet overtar huset ved skjøte tinglyst på selskapet. Dokumentavgiften (normalt 2,5 % av verdien), rente, løpetid og sikkerhet slik banken tilbyr, og hvordan avdraget betjenes av husleia.
|
||||
|
||||
# Budsjettet
|
||||
|
||||
Utkast, skrives av eierne: inntekter, drift, renter og avdrag for et normalår, og hvor rapportene står.
|
||||
|
||||
# Det som mangler
|
||||
|
||||
Utkast, skrives av eierne: de største manglene ved huset, hva de koster å rette, og planen for det. Lista på siden holdes oppdatert.
|
||||
|
||||
# Forvaltningen
|
||||
|
||||
Utkast, skrives av eierne: at Klingenberg Bygg forvalter huset, og hva det innebærer.
|
||||
@@ -0,0 +1,327 @@
|
||||
# GULLBERG sanatorium - et Klingenberg Bygg-prosjekt på
|
||||
# gullberg.klingenbergbygg.no. UTKAST 2026-09-30, til samtale; ikke
|
||||
# bygget eller satt ut.
|
||||
#
|
||||
# Oppsettet (samtale 2026-10-01): hele huset legges i Gullberg AS, som
|
||||
# eies av fire aksjonærer med 25 % hver. Selskapet låner 1,6 millioner i
|
||||
# banken for å løse ut de to søsknene som ikke blir med (800 000 kr hver),
|
||||
# og overtar huset (skjøte tinglyst på selskapet). Dokumentavgiften,
|
||||
# normalt 2,5 % av verdien, må med i lånet eller budsjettet. Rundt
|
||||
# 100 000 kr i året til banken er en lang nedbetaling: alt som kan gå
|
||||
# galt underveis hører hjemme i aksjonæravtalen (se
|
||||
# documents/gullberg-aksjonaeravtale-punkter.md, ikke på siden).
|
||||
#
|
||||
# Huset ligger bak Ås kirke, i Syverudveien (17?), et kort stykke fra
|
||||
# NMBU. Rommene er for alle, men studenter går foran.
|
||||
#
|
||||
# Siden er huset i drift: rom til leie, feil meldt til Klingenberg
|
||||
# Bygg som forvalter, styrets liste over det som er bra, mangler og er
|
||||
# planlagt, husleia måned for måned, kvartalsrapporter, nedbetalingsplanen
|
||||
# alle fire står bak, og eierskiftet steg for steg. Banken er ikke en egen
|
||||
# gruppe på siden ennå; styret sender rapportene.
|
||||
#
|
||||
# Pengene: 3 soverom à 8 000 kr er 24 000 kr i måneden, rundt 264 000 i
|
||||
# året over 11 måneder; sommeren er timeshare-uker. Husleia går som fast
|
||||
# trekk eller AvtaleGiro til selskapets konto; Vipps eller Stripe bare for
|
||||
# engangsbeløp. Depositum på egen depositumskonto (husleieloven).
|
||||
#
|
||||
# Åpne spørsmål:
|
||||
# - hvem de fire aksjonærene er, og om nye medeiere søkes ved siden av
|
||||
# lånet (medeier-tilbudet er tatt ut av forsiden til det er avgjort)
|
||||
# - bankens tilbud: rente, løpetid, sikkerhet, kausjon
|
||||
# - takst; hvem som sitter i styret
|
||||
# - antall måneder studentene leier; timeshare bare for aksjonærene?
|
||||
# - regnskapsfører/advokat: skiftet, dokumentavgift, skatt på husleie
|
||||
# - kontakt og avsender (under er plassholdere)
|
||||
business: GULLBERG sanatorium
|
||||
language: no
|
||||
# Plassholder til styret har en adresse.
|
||||
contact: { name: Styret i Gullberg, email: bl@klingenbergbygg.no, group: styret }
|
||||
people: memberships
|
||||
mail: { from: "Gullberg <gullberg@klingenbergbygg.no>", reply_to: bl@klingenbergbygg.no }
|
||||
about: >
|
||||
Et gammelt hus bak Ås kirke, i Syverudveien, med rimelige rom til leie;
|
||||
studenter ved NMBU går foran, men alle kan søke,
|
||||
og uker om sommeren for dem som eier det. Drevet av eierne, forvaltet
|
||||
av Klingenberg Bygg; det som mangler, står på lista med planen for
|
||||
det, og tallene ligger åpent.
|
||||
|
||||
groups:
|
||||
- { id: styret, label: Styret }
|
||||
- { id: aksjonaerer, label: Aksjonærer }
|
||||
- { id: leietakere, label: Leietakere }
|
||||
- { id: klingenberg, label: Klingenberg Bygg }
|
||||
invites_by: [styret]
|
||||
|
||||
does:
|
||||
- id: rom
|
||||
for: leie-rom
|
||||
text: >
|
||||
Rimelige rom bak Ås kirke, et kort stykke fra NMBU. Studenter går
|
||||
foran, men alle kan søke. Du søker her, kommer på visning og sier
|
||||
selv ja til tilbudet.
|
||||
|
||||
# Forretningsplanen, ord for ord: ett kort per del. Skal skrives av
|
||||
# eierne; filen har bare delene og hva som hører hjemme i dem.
|
||||
documents:
|
||||
- id: forretningsplan
|
||||
question: Hvordan går Gullberg rundt?
|
||||
description: Forretningsplanen for Gullberg, slik eierne har lagt den fram for banken.
|
||||
file: documents/gullberg-forretningsplan.md
|
||||
|
||||
needs:
|
||||
# Offentlig: noen søker rom. Studenter går foran; styret ser hva den
|
||||
# som søker gjør til daglig, og velger etter det.
|
||||
- id: leie-rom
|
||||
who: en som trenger et rimelig rom i Ås, oftest en student ved NMBU
|
||||
wants: et rom å bo på til en leie som går opp, med studenter først i køen
|
||||
lands_in: romsoknader
|
||||
handled_by: styret
|
||||
stages: [open, visning, tilbud, akseptert, signert, avslatt]
|
||||
stages_text: { open: "mottatt", visning: "visning avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", signert: "kontrakt signert", avslatt: "avsluttet uten leie" }
|
||||
done_when: [signert, avslatt]
|
||||
success: [signert]
|
||||
turned_down_from: [open, visning, tilbud]
|
||||
moves_by: { akseptert: [submitter], avslatt: [submitter, styret] }
|
||||
mails: { open: submitter, visning: submitter, tilbud: submitter, akseptert: bl@klingenbergbygg.no, signert: submitter }
|
||||
grants: { signert: leietakere }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: email, type: email }
|
||||
- { name: telefon, type: tel }
|
||||
- { name: situasjon, type: select, options: [Student ved NMBU, Student et annet sted, Jobber eller annet] }
|
||||
- { name: innflytting, type: date, optional: true }
|
||||
- { name: om, type: textarea, optional: true }
|
||||
|
||||
# Rommene som er ledige, som bildekort styret legger inn og tar ned.
|
||||
- id: ledig-rom
|
||||
who: styret, når et rom blir ledig
|
||||
wants: at de som søker ser rommet før de søker
|
||||
enters_as: styret
|
||||
lands_in: rom
|
||||
handled_by: styret
|
||||
watched_by: [public]
|
||||
presents: true
|
||||
public_states: [open]
|
||||
stages: [open, utleid]
|
||||
stages_text: { open: "ledig", utleid: "leid ut" }
|
||||
done_when: [utleid]
|
||||
success: [utleid]
|
||||
leaves_after: { utleid: { minutes: 10 } }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: bilde, type: picture }
|
||||
- { name: om, type: textarea }
|
||||
|
||||
# Styrets liste: det som er bra, det som mangler, det som er planlagt.
|
||||
# Aksjonærene ser alt; allmennheten bare planene styret har publisert,
|
||||
# og de står som kunngjøringer.
|
||||
- id: liste
|
||||
who: styret, om huset og driften
|
||||
wants: én liste over det som er bra, det som mangler og det som er planlagt, med pris og tid
|
||||
enters_as: styret
|
||||
lands_in: liste
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer, public]
|
||||
public_states: [publisert]
|
||||
stages: [open, vedtatt, publisert, forkastet]
|
||||
stages_text: { open: "utkast", vedtatt: "vedtatt", publisert: "publisert", forkastet: "forkastet" }
|
||||
done_when: [publisert, forkastet]
|
||||
success: [publisert]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: slag, type: select, options: [Kvalitet, Mangel, Plan] }
|
||||
- { name: beskrivelse, type: textarea }
|
||||
- { name: kostnad, optional: true }
|
||||
- { name: naar, optional: true }
|
||||
|
||||
# Budsjettrapporten: tallene i felt, ikke bare en PDF. Aksjonærene ser
|
||||
# den her; styret sender den til banken og fører inn at den er sendt.
|
||||
- id: rapport
|
||||
who: styret, hvert kvartal
|
||||
wants: at aksjonærene ser hvordan det går, i tall, og at banken får det den skal ha
|
||||
enters_as: styret
|
||||
lands_in: rapporter
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, sendt]
|
||||
stages_text: { open: "lagt fram for aksjonærene", sendt: "sendt til banken" }
|
||||
done_when: [sendt]
|
||||
success: [sendt]
|
||||
max_fields: 7
|
||||
collects:
|
||||
- { name: periode }
|
||||
- { name: leieinntekter }
|
||||
- { name: timeshare }
|
||||
- { name: driftskostnader }
|
||||
- { name: renter-og-avdrag }
|
||||
- { name: saldo }
|
||||
- { name: kommentar, type: textarea }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Nedbetalingsplanen: hvordan pengene går og hva banken får. Styret
|
||||
# legger den fram, BEGGE aksjonærene må godkjenne før den går til
|
||||
# banken, og styret fører inn bankens svar. "Alle i gruppa" finnes ikke
|
||||
# i portal ennå: i dag holder det at én trykker. Ny portal-funksjon før
|
||||
# dette brukes.
|
||||
- id: nedbetalingsplan
|
||||
who: styret, når veien videre og pengestrømmen skal avtales
|
||||
wants: en nedbetalingsplan alle fire aksjonærene står bak, som banken sier ja til
|
||||
enters_as: styret
|
||||
lands_in: nedbetalingsplaner
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, godkjent, sendt, innvilget, endringer]
|
||||
stages_text: { open: "til godkjenning hos aksjonærene", godkjent: "godkjent av aksjonærene", sendt: "sendt til banken", innvilget: "innvilget av banken", endringer: "banken ber om endringer" }
|
||||
reached_from: { godkjent: [open], sendt: [godkjent], innvilget: [sendt], endringer: [sendt] }
|
||||
done_when: [innvilget, endringer]
|
||||
success: [innvilget]
|
||||
moves_by: { godkjent: [aksjonaerer], sendt: [styret], innvilget: [styret], endringer: [styret] }
|
||||
max_fields: 6
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: laanebelop }
|
||||
- { name: maanedlig-avdrag }
|
||||
- { name: forste-trekk, type: date }
|
||||
- { name: pengestrom, type: textarea }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Husleia, måned for måned: forventet mot betalt, fra kontoutskriften.
|
||||
# Aksjonærene og långiveren ser hvordan det går mellom rapportene.
|
||||
- id: husleie
|
||||
who: styret, når husleia for en måned er kommet inn
|
||||
wants: at aksjonærene ser hvilke rom som har betalt, og hva som gikk til banken
|
||||
enters_as: styret
|
||||
lands_in: husleie
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, betalt, purret]
|
||||
stages_text: { open: "forventet", betalt: "betalt", purret: "purret" }
|
||||
reached_from: { purret: [open], betalt: [open, purret] }
|
||||
done_when: [betalt]
|
||||
success: [betalt]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: rom }
|
||||
- { name: maaned }
|
||||
- { name: belop }
|
||||
- { name: betalt-dato, type: date, optional: true }
|
||||
|
||||
# Feil i huset: en leietaker melder fra, Klingenberg Bygg ser på det og
|
||||
# gir et overslag, styret godkjenner kostnaden før arbeidet gjøres.
|
||||
- id: feil
|
||||
who: en leietaker som har funnet noe som er galt i huset
|
||||
wants: at det blir sett på og rettet
|
||||
enters_as: leietakere
|
||||
lands_in: feil
|
||||
handled_by: klingenberg
|
||||
# Leietakeren ser sine egne meldinger og hvor langt de har kommet.
|
||||
followed_by: [leietakere]
|
||||
watched_by: [styret]
|
||||
stages: [open, befaring, overslag, godkjent, utbedret, avvist]
|
||||
stages_text: { open: "meldt", befaring: "befaring avtalt", overslag: "overslag gitt", godkjent: "godkjent av styret", utbedret: "utbedret", avvist: "avvist" }
|
||||
reached_from: { avvist: [open, befaring, overslag] }
|
||||
done_when: [utbedret, avvist]
|
||||
success: [utbedret]
|
||||
moves_by: { godkjent: [styret], avvist: [styret, klingenberg] }
|
||||
mails: { open: fredrik@klingenbergbygg.no, overslag: bl@klingenbergbygg.no, utbedret: submitter }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: hva }
|
||||
- { name: hvor, optional: true }
|
||||
- { name: beskrivelse, type: textarea }
|
||||
- { name: email, type: email }
|
||||
- { name: bilde, type: picture, optional: true }
|
||||
|
||||
# Overslaget: Klingenberg Bygg gir et beløp for å rette en feil. Styret
|
||||
# og aksjonærene ser det; godkjente overslag hører med i rapporten.
|
||||
- id: overslag
|
||||
who: Klingenberg Bygg, når en feil er sett på
|
||||
wants: at styret får et beløp å ta stilling til, og at det havner i regnskapet
|
||||
enters_as: klingenberg
|
||||
lands_in: overslag
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer, klingenberg]
|
||||
stages: [open, godkjent, avvist, fakturert]
|
||||
stages_text: { open: "til styret", godkjent: "godkjent", avvist: "avvist", fakturert: "fakturert" }
|
||||
reached_from: { avvist: [open], fakturert: [godkjent] }
|
||||
done_when: [fakturert, avvist]
|
||||
success: [fakturert]
|
||||
moves_by: { fakturert: [klingenberg] }
|
||||
mails: { open: bl@klingenbergbygg.no }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: gjelder }
|
||||
- { name: belop }
|
||||
- { name: hva-det-dekker, type: textarea }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Eierskiftet, steg for steg: fra selskapet stiftes til søsknene er
|
||||
# utbetalt. Aksjonærene og banken ser hvor langt det har kommet.
|
||||
- id: eierskifte
|
||||
who: styret, mens huset går over til selskapet
|
||||
wants: at aksjonærene og banken ser hvor langt eierskiftet har kommet, med dokumentene
|
||||
enters_as: styret
|
||||
lands_in: eierskifte
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, arbeid, ferdig]
|
||||
stages_text: { open: "ikke startet", arbeid: "i gang", ferdig: "ferdig" }
|
||||
done_when: [ferdig]
|
||||
success: [ferdig]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: steg, type: select, options: [Selskapet stiftet, Takst, Lånet innvilget, Aksjonæravtale signert, Skjøte signert, Skjøte tinglyst, Dokumentavgift betalt, Søsknene utbetalt] }
|
||||
- { name: merknad, type: textarea, optional: true }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Aksjonærene: uker i huset, og saker til styret.
|
||||
- id: uke
|
||||
who: en aksjonær som vil bruke huset en periode
|
||||
wants: en uke i huset, bekreftet av styret
|
||||
enters_as: aksjonaerer
|
||||
lands_in: uker
|
||||
handled_by: styret
|
||||
stages: [open, bekreftet, avslatt]
|
||||
stages_text: { open: "ønsket", bekreftet: "bekreftet", avslatt: "ikke ledig" }
|
||||
done_when: [bekreftet, avslatt]
|
||||
success: [bekreftet]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: fra, type: date }
|
||||
- { name: til, type: date }
|
||||
- { name: merknad, type: textarea, optional: true }
|
||||
|
||||
- id: sak
|
||||
who: en aksjonær som vil ha noe avgjort
|
||||
wants: at styret tar saken opp og avgjør den
|
||||
enters_as: aksjonaerer
|
||||
lands_in: saker
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, sakslisten, vedtatt, avvist]
|
||||
stages_text: { open: "meldt", sakslisten: "på sakslisten", vedtatt: "vedtatt", avvist: "avvist" }
|
||||
done_when: [vedtatt, avvist]
|
||||
success: [vedtatt]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: sak, type: textarea }
|
||||
|
||||
personas:
|
||||
- { id: student-nmbu, need: leie-rom, text: "Jeg begynner på master på NMBU til høsten og har ikke råd til hybel i Ås sentrum. Et rimelig rom i et stort hus høres perfekt ut." }
|
||||
- { id: ung-arbeider, need: leie-rom, text: "Jeg har fått jobb i Ås og trenger et rimelig sted å bo. Jeg er ikke student; kan jeg søke likevel?" }
|
||||
- { id: student-kort, need: leie-rom, text: "Jeg skal på utveksling og trenger et rom bare for ett semester. Går det an?" }
|
||||
- { id: styret-rom, need: ledig-rom, text: "Rommet i andre etasje blir ledig til jul, og jeg har et fint bilde av det. Det vil jeg legge ut." }
|
||||
- { id: styret-mangel, need: liste, text: "Taket på uthuset lekker. Det må stå på lista, og vi må ha en plan og en pris for det." }
|
||||
- { id: styret-kvartal, need: rapport, text: "Tredje kvartal er gjort opp. Leia kom inn, men strømmen ble dyrere enn vi trodde. Rapporten skal til banken." }
|
||||
- { id: leietaker-vann, need: feil, text: "Det drypper fra taket på badet i andre etasje når noen dusjer over. Hvem sier jeg fra til?" }
|
||||
- { id: klingenberg-overslag, need: overslag, text: "Vi har sett på lekkasjen på badet. Det er en pakning og litt flis, rundt 6 500 kroner. Styret må si ja før vi setter i gang." }
|
||||
- { id: styret-skjote, need: eierskifte, text: "Skjøtet er tinglyst på selskapet i dag. Banken skal se det, og kopien skal ligge ved." }
|
||||
- { id: aksjonaer-sommer, need: uke, text: "Vi vil gjerne være i huset første uka i juli med barna." }
|
||||
- { id: styret-plan, need: nedbetalingsplan, text: "Vi har regnet på det: 24 000 i måneden fra rommene, og vi kan sette av en fast sum til långiveren hver måned. Alle fire må stå bak det før vi går til banken." }
|
||||
- { id: styret-husleie, need: husleie, text: "Oktober er inne for to av rommene, det tredje mangler. Det må stå et sted alle ser." }
|
||||
- { id: aksjonaer-sak, need: sak, text: "Jeg mener vi bør se på solceller før vi tar opp mer lån. Kan styret ta det opp?" }
|
||||
+39
-11
@@ -38,16 +38,18 @@ does:
|
||||
godkjenning innen flere fagområder. Når tilbudet er godtatt,
|
||||
følger du oppdraget og henter dokumentene innlogget.
|
||||
|
||||
# Hvem vi er: erfaringen, i våre egne ord, og menneskene. Verdiene
|
||||
# skriver Fredrik selv; de legges inn her som en `values`-linje til.
|
||||
# Hvem vi er: en egen side med menneskene, erfaringene (oppdrag med
|
||||
# bilder, lagt inn fra kontoret) og verdiene under. Verdiene er et
|
||||
# utkast i husets tone; Fredrik bytter dem ut når han vil.
|
||||
values:
|
||||
- id: erfaring
|
||||
text: >
|
||||
Skoler i drift, brannstasjoner, omsorgsboliger, kantina på Det
|
||||
Norske Teatret og en Munch-eiendom på Hvitsten. Vi har gått over,
|
||||
prosjektert og fulgt opp bygg for kommuner, borettslag, bedrifter
|
||||
og private, fra borprøver i gammel mur til kunstgress på
|
||||
Fredrikstad stasjon.
|
||||
- id: mal-to-ganger
|
||||
text: Vi måler to ganger og skriver én gang. Det gjelder betong, og det gjelder rapporter.
|
||||
- id: bygget-forst
|
||||
text: Et bygg bryr seg ikke om hvem som har rett. Vi skriver det bygget trenger, ikke det som er lettest å si.
|
||||
- id: ingen-overraskelser
|
||||
text: Det vi finner, får du vite. Også sprekkene.
|
||||
- id: vannet
|
||||
text: Vannet finner alltid en vei. Derfor leter vi etter den først.
|
||||
|
||||
needs:
|
||||
- id: tilstand
|
||||
@@ -170,6 +172,27 @@ needs:
|
||||
- { name: erfaring, type: textarea }
|
||||
- { name: portrett, type: picture }
|
||||
|
||||
# Erfaringene: oppdrag vi vil vise fram, med bilde og noen løse ord.
|
||||
- id: erfaring
|
||||
who: en av oss på kontoret, når et oppdrag er verdt å vise fram
|
||||
wants: at den som vurderer oss ser hva vi har gjort
|
||||
enters_as: kontor
|
||||
lands_in: erfaringer
|
||||
handled_by: kontor
|
||||
watched_by: [public]
|
||||
presents: true
|
||||
public_states: [open]
|
||||
stages: [open, fjernet]
|
||||
stages_text: { open: "vises", fjernet: "fjernet" }
|
||||
done_when: [fjernet]
|
||||
success: [fjernet]
|
||||
leaves_after: { fjernet: { minutes: 10 } }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: bilde, type: picture }
|
||||
- { name: om, type: textarea }
|
||||
|
||||
personas:
|
||||
- { id: kommune-skole, need: tilstand, text: "Vi i kommunen skal legge fram en sak om rehabilitering av en barneskole fra 1972 og trenger en tilstandsanalyse politikerne kan beslutte ut fra." }
|
||||
- { id: borettslag, need: tilstand, text: "Styret i borettslaget lurer på hva som haster mest av tak, fasade og rør. Vi trenger noen som kan gå over byggene og si hva det står til." }
|
||||
@@ -178,6 +201,11 @@ personas:
|
||||
- { id: skole-i-drift, need: prosjekt, text: "Skolen skal utvides mens den er i drift. Vi trenger en beskrivelse og en fremdriftsplan med byggetrinn." }
|
||||
- { id: soke-garasje, need: soknad, text: "Tegningene til garasjen er ferdige, men jeg aner ikke hvordan jeg søker kommunen. Kan dere ta søknaden?" }
|
||||
- { id: ansvarsrett, need: soknad, text: "Vi har prosjektert ombyggingen selv, men mangler noen med ansvarsrett som kan stå for søknaden." }
|
||||
# Innenfra: det kontoret selv fyller ut.
|
||||
- { id: rapport-klar, need: leveranse, text: "Tilstandsrapporten til borettslaget er ferdig. Jeg vil at styreleder skal få den og vite at den ligger der." }
|
||||
- { id: ny-hos-oss, need: ansatt, text: "Jeg har nettopp begynt hos Klingenberg Bygg og vil at kundene skal se hvem jeg er og hva jeg kan." }
|
||||
- { id: nytt-portrett, need: ansatt, text: "Portrettet mitt på forsiden er fra da jeg begynte. Jeg vil bytte det uten å skrive alt det andre på nytt." }
|
||||
- { id: brannstasjonen, need: erfaring, text: "Brannstasjonen i Ytre Enebakk står ferdig, og vi har gode bilder fra fundamenteringen. Den vil jeg vise fram." }
|
||||
|
||||
slideshow:
|
||||
max_height: 60
|
||||
@@ -261,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
|
||||
@@ -281,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
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
{
|
||||
"aggregates.yaml": {
|
||||
"feil": {
|
||||
"open": {
|
||||
"subject": "Ny feil meldt på Gullberg: {hva}",
|
||||
"body": "En leietaker på Gullberg har meldt fra om {hva}. Se meldingen og avtal befaring: {link}"
|
||||
},
|
||||
"overslag": {
|
||||
"subject": "Overslag til styret: {hva}",
|
||||
"body": "Klingenberg Bygg har sett på {hva} og gitt et overslag. Styret godkjenner eller avviser det før arbeidet settes i gang: {link}"
|
||||
},
|
||||
"utbedret": {
|
||||
"subject": "{hva} er rettet",
|
||||
"body": "Feilen du meldte om, {hva}, er nå utbedret. Si gjerne til hvis noe fortsatt ikke er som det skal. {link}"
|
||||
}
|
||||
},
|
||||
"overslag": {
|
||||
"open": {
|
||||
"subject": "Nytt overslag fra Klingenberg Bygg: {gjelder}",
|
||||
"body": "Klingenberg Bygg har sendt et overslag for {gjelder}. Det ligger hos styret til godkjenning: {link}"
|
||||
}
|
||||
},
|
||||
"romsoknader": {
|
||||
"akseptert": {
|
||||
"subject": "{name} har takket ja til rommet",
|
||||
"body": "{name} har takket ja til tilbudet om rom på Gullberg. Neste steg er kontrakten og depositumet. {link}"
|
||||
},
|
||||
"open": {
|
||||
"subject": "Søknaden din om rom på Gullberg er mottatt",
|
||||
"body": "Hei {name},\n\nsøknaden din om rom på Gullberg er mottatt. Styret går gjennom søknadene fortløpende; studenter går foran, men alle blir sett på. Du hører fra oss om visning. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}\n\nHilsen styret i Gullberg"
|
||||
},
|
||||
"signert": {
|
||||
"subject": "Kontrakten er signert",
|
||||
"body": "Kontrakten for rommet du søkte om er nå signert av begge parter. Rommet er ditt - velkommen inn. {link}"
|
||||
},
|
||||
"tilbud": {
|
||||
"subject": "Du har fått tilbud om rom",
|
||||
"body": "Styret har sett på søknaden din og kan tilby deg rommet. Svar her så snart du kan, så går vi videre med kontrakten. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}"
|
||||
},
|
||||
"visning": {
|
||||
"subject": "Visning av rommet er satt opp",
|
||||
"body": "Søknaden din har kommet til visning - styret vil vise deg rommet før det avgjøres. Du hører fra oss om tid og sted. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"questions/aksjonaerer/index.yaml": {
|
||||
"question": "Hvordan ligger huset og driften an?",
|
||||
"description": "Her ser aksjonærene hvordan huset og driften står: eierskiftet, husleia, lista, overslagene og sakene.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {
|
||||
"eierskifte": {
|
||||
"heading": "Se hvor langt eierskiftet har kommet",
|
||||
"description": "Hvert steg i overgangen til Gullberg AS, med dokumentene som hører til.",
|
||||
"moves": {},
|
||||
"list_title": "Steg i eierskiftet",
|
||||
"list_note": "Hver rad er et steg med merknad og dokument; flytt det videre når det er i arbeid eller ferdig."
|
||||
},
|
||||
"husleie": {
|
||||
"heading": "Se hvilke rom som har betalt",
|
||||
"description": "Husleia for hvert rom, måned for måned, og hva som er purret på.",
|
||||
"moves": {},
|
||||
"list_title": "Husleie",
|
||||
"list_note": "Hver rad er ett rom for én måned, med beløp og betalingsdato; merk den betalt eller purret."
|
||||
},
|
||||
"liste": {
|
||||
"heading": "Se det som er bra, mangler og planlagt",
|
||||
"description": "Én liste over tilstanden i huset, med pris og tid på det som står igjen.",
|
||||
"moves": {},
|
||||
"list_title": "Lista",
|
||||
"list_note": "Hver rad er ett punkt med kostnad og tidspunkt; vedta den, publiser den eller forkast den."
|
||||
},
|
||||
"nedbetalingsplaner": {
|
||||
"heading": "Godkjenn nedbetalingsplanen",
|
||||
"description": "Planen for lånet, lagt fram for alle fire aksjonærene og banken.",
|
||||
"moves": {
|
||||
"open->godkjent": "Godkjenn planen"
|
||||
},
|
||||
"list_title": "Nedbetalingsplaner",
|
||||
"list_note": "Hver rad er en plan med lånebeløp og pengestrøm; følg den fra godkjent til innvilget hos banken."
|
||||
},
|
||||
"overslag": {
|
||||
"heading": "Se overslagene fra Klingenberg Bygg",
|
||||
"description": "Beløpet Klingenberg Bygg har satt på en feil, klart til vedtak.",
|
||||
"moves": {},
|
||||
"list_title": "Overslag",
|
||||
"list_note": "Hver rad er ett overslag med beløp og hva det dekker; godkjenn, avvis eller fakturer det."
|
||||
},
|
||||
"rapporter": {
|
||||
"heading": "Se kvartalsrapportene",
|
||||
"description": "Tallene aksjonærene og banken skal ha, kvartal for kvartal.",
|
||||
"moves": {},
|
||||
"list_title": "Rapporter",
|
||||
"list_note": "Hver rad er én periode med inntekter, kostnader og saldo; send den når den er klar."
|
||||
},
|
||||
"saker": {
|
||||
"heading": "Se sakene som er meldt til styret",
|
||||
"description": "Det en aksjonær vil ha styrets ord på, samlet på ett sted.",
|
||||
"moves": {},
|
||||
"list_title": "Saksliste",
|
||||
"list_note": "Hver rad er én sak; sett den på sakslisten, vedta den eller avvis den."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"uke": {
|
||||
"heading": "Be om en uke i huset",
|
||||
"description": "Reserver en periode selv, bekreftet av styret.",
|
||||
"button": "Søk om uke"
|
||||
},
|
||||
"sak": {
|
||||
"heading": "Få noe avgjort av styret",
|
||||
"description": "Meld en sak du vil ha styrets ord på.",
|
||||
"button": "Meld sak"
|
||||
}
|
||||
},
|
||||
"invites": {
|
||||
"aksjonaerer": {
|
||||
"heading": "Inviter en ny aksjonær inn",
|
||||
"description": "De får en konto og en lenke som gjelder én gang.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Ny aksjonær",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invited": {
|
||||
"heading": "Se hvem som er invitert",
|
||||
"description": "Status på invitasjonene som er sendt ut.",
|
||||
"done": "Aktiv",
|
||||
"withdraw": "Trekk tilbake",
|
||||
"again": "Send på nytt",
|
||||
"list_title": "Invitasjoner",
|
||||
"list_note": "Hver rad er én invitasjon; send den på nytt om lenken har gått ut, eller trekk den tilbake."
|
||||
},
|
||||
"suggestions": null
|
||||
},
|
||||
"questions/aksjonaerer/sak-sent.yaml": {
|
||||
"question": "Hva skjer med saken jeg nettopp sendte inn?",
|
||||
"description": "Saken er sendt til styret. Den havner på sakslisten og blir tatt opp der.",
|
||||
"next_heading": "Følg saken videre",
|
||||
"next_description": "Styret vurderer saken og vedtar eller avviser den. Du finner status på sakslisten."
|
||||
},
|
||||
"questions/aksjonaerer/sak.yaml": {
|
||||
"heading": "Få en sak opp i styret",
|
||||
"description": "Noe som bør avgjøres, men ikke er ditt å avgjøre alene? Legg den fram, så tar styret den opp.",
|
||||
"button": "Send saken",
|
||||
"encouragement": "Styret ser den og setter den på sakslisten.",
|
||||
"icon": "vote",
|
||||
"paused": "Pause mottak av saker",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse av saken",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Fra forslag til vedtak",
|
||||
"road_description": "Saken kommer inn og settes på sakslisten, blir tatt opp av styret og ender med et vedtak eller en avvisning.",
|
||||
"road_icon": "list",
|
||||
"who_title": "Hvem som leser den",
|
||||
"who_description": "Styret får saken og tar stilling til den; haster det, nå dem på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Ny sak",
|
||||
"form_description": "Skriv kort hva saken går ut på og hvorfor den bør opp.",
|
||||
"labels": {
|
||||
"tittel": "Tittel på saken",
|
||||
"sak": "Hva gjelder saken"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Fast eller flytende leie for sommeren",
|
||||
"sak": "Vi bør avklare om timeshare-ukene skal prises likt hele året, eller om sommeren skal koste mer. Foreslår at styret tar stilling til dette før neste sesong."
|
||||
},
|
||||
"question": "Er det noe som bør avgjøres av styret?",
|
||||
"page_description": "Her melder du inn en sak du vil at styret skal ta opp og avgjøre."
|
||||
},
|
||||
"questions/aksjonaerer/uke-sent.yaml": {
|
||||
"question": "Hva skjer nå med ukesøknaden din?",
|
||||
"description": "Den er sendt og ligger hos styret, som avgjør om uken blir bekreftet.",
|
||||
"next_heading": "Styret ser på søknaden din",
|
||||
"next_description": "De bekrefter eller avslår uken. Du finner svaret i oversikten når det er klart."
|
||||
},
|
||||
"questions/aksjonaerer/uke.yaml": {
|
||||
"heading": "Reserver en uke i huset",
|
||||
"description": "Si fra når du vil bruke huset, så ser styret om datoene passer.",
|
||||
"button": "Send ønske",
|
||||
"encouragement": "Du hører fra styret så snart datoene er sjekket.",
|
||||
"icon": "calendar",
|
||||
"paused": "Tar vi ikke imot ukeønsker nå",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Styret ser på ønsket ditt og bekrefter eller avslår uken.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser dette",
|
||||
"who_description": "Styret ser ønsket ditt; skriv til bl@klingenbergbygg.no om det haster.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Ukeønske",
|
||||
"form_description": "Fyll inn datoene du ønsker huset.",
|
||||
"labels": {
|
||||
"fra": "Fra dato",
|
||||
"til": "Til dato",
|
||||
"merknad": "Merknad"
|
||||
},
|
||||
"hints": {
|
||||
"fra": "f.eks. 6. juli 2027",
|
||||
"til": "f.eks. 13. juli 2027",
|
||||
"merknad": "f.eks. kommer med familie, trenger ekstra seng"
|
||||
},
|
||||
"question": "Hvilken uke vil du bruke huset?",
|
||||
"page_description": "Meld fra om uken du ønsker, så bekrefter styret den."
|
||||
},
|
||||
"questions/decide/leie-rom.yaml": {
|
||||
"question": "Vil du ta imot tilbudet om rom?",
|
||||
"description": "Styret har sett på søknaden din og tilbyr deg et rom. Si ja og det er ditt, si nei og det går videre til neste på lista.",
|
||||
"moves": {
|
||||
"akseptert": {
|
||||
"heading": "Ta imot rommet",
|
||||
"description": "Du sier ja til rommet du har fått tilbud om. Styret tar kontakt om innflytting og signering.",
|
||||
"button": "Ta imot"
|
||||
},
|
||||
"avslatt": {
|
||||
"heading": "Takk nei til rommet",
|
||||
"description": "Du sier nei til tilbudet, og rommet går videre til neste søker.",
|
||||
"button": "Takk nei"
|
||||
}
|
||||
}
|
||||
},
|
||||
"questions/hvem.yaml": {
|
||||
"question": "Hva er Gullberg?",
|
||||
"description": "Et gammelt hus bak Ås kirke, eid av fire aksjonærer og drevet av styret, med Klingenberg Bygg som forvalter. Her er rommene som er ledige nå.",
|
||||
"presents": {
|
||||
"rom": {
|
||||
"heading": "Rommene som er ledige",
|
||||
"description": "Rommene som står ledige for dem som søker et sted å bo.",
|
||||
"empty": "Ingen rom er ledige akkurat nå, men nye kommer når noen flytter ut."
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"heading": "Det styret har vedtatt",
|
||||
"description": "Det som gjelder for huset og driften, slik styret har satt det.",
|
||||
"items": {}
|
||||
}
|
||||
},
|
||||
"questions/index.yaml": {
|
||||
"question": "Trenger du et rimelig rom i Ås?",
|
||||
"description": "Gullberg er et gammelt hus bak Ås kirke, et kort stykke fra NMBU. Eierne driver det selv, og Klingenberg Bygg forvalter huset og vedlikeholdet.",
|
||||
"opener": {
|
||||
"heading": "Et rimelig sted å bo, nær NMBU",
|
||||
"description": "Rommene leies ut for et år av gangen, til en pris som går opp med studielånet. Studenter går foran, men alle kan søke.",
|
||||
"features": {
|
||||
"rom": {
|
||||
"heading": "Rom til leie, studenter først",
|
||||
"description": "Du leier et rom i huset for et år av gangen. Studenter ved NMBU får rommene først; er det ledig, får andre også.",
|
||||
"icon": "house"
|
||||
}
|
||||
}
|
||||
},
|
||||
"who": {
|
||||
"heading": "Hva er Gullberg?",
|
||||
"description": "Fire aksjonærer eier huset sammen og driver det selv, med Klingenberg Bygg som forvalter.",
|
||||
"button": "Se huset og rommene"
|
||||
},
|
||||
"shopping": {
|
||||
"heading": "Hva som er gjort, og hva som mangler",
|
||||
"description": "Det styret har vedtatt for huset: planene, med pris og tid der det er kjent.",
|
||||
"items": {
|
||||
"status-liste": {
|
||||
"heading": "Siste fra lista",
|
||||
"description": "Punktene styret har tatt stilling til, ferskest øverst.",
|
||||
"link": "Se hele lista",
|
||||
"empty": "Snart kommer de første punktene på lista.",
|
||||
"icon": "clipboard-list"
|
||||
}
|
||||
}
|
||||
},
|
||||
"doors": {},
|
||||
"needs": {
|
||||
"leie-rom": {
|
||||
"heading": "Søk om et rom",
|
||||
"description": "Fortell litt om deg og hva du gjør til daglig. Studenter går foran, men alle kan søke.",
|
||||
"button": "Søk rom",
|
||||
"encouragement": "Det tar bare et par minutter, og du hører fra styret så snart et rom er ledig.",
|
||||
"icon": "key",
|
||||
"paused": "Søknader er satt på pause",
|
||||
"requires_title": "Dette trenger vi",
|
||||
"requires_description": "Navn, e-post og hvilket lærested du går på.",
|
||||
"requires_icon": "clipboard-list",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Søknaden havner hos styret, som inviterer deg på visning av et ledig rom. Sier du ja til tilbudet, signerer du leieavtalen og flytter inn.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser søknaden",
|
||||
"who_description": "Styret i Gullberg leser søknaden din, og du når dem i mellomtiden på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Søknadsskjema",
|
||||
"form_description": "Fyll inn det du vet nå - resten kan vente til visningen.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"email": "E-post",
|
||||
"telefon": "Telefon",
|
||||
"innflytting": "Ønsket innflytting",
|
||||
"om": "Litt om deg",
|
||||
"situasjon": "Hva gjør du til daglig?"
|
||||
},
|
||||
"hints": {
|
||||
"name": "Kari Nordmann",
|
||||
"email": "kari@student.no",
|
||||
"telefon": "123 45 678",
|
||||
"innflytting": "August 2026",
|
||||
"om": "Jeg studerer medisin og trenger et rolig sted å bo nær campus.",
|
||||
"situasjon": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"questions/klingenberg/index.yaml": {
|
||||
"question": "Hva trenger huset nå?",
|
||||
"description": "Her samler Klingenberg Bygg det leietakerne melder inn, og styret ser overslagene før de godkjennes.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {
|
||||
"feil": {
|
||||
"heading": "Se hva som er meldt inn av feil",
|
||||
"description": "Her ser du det leietakerne har meldt, fra ny melding til den er utbedret.",
|
||||
"moves": {
|
||||
"befaring->overslag": "Lag overslag",
|
||||
"befaring->avvist": "Avvis saken",
|
||||
"godkjent->utbedret": "Meld utbedret",
|
||||
"open->befaring": "Sett opp befaring",
|
||||
"open->avvist": "Avvis saken",
|
||||
"overslag->avvist": "Avvis overslaget"
|
||||
},
|
||||
"list_title": "Meldte feil",
|
||||
"list_note": "Hver rad er en feil som er meldt inn; knappene under flytter den videre til befaring, overslag eller utbedret."
|
||||
},
|
||||
"overslag": {
|
||||
"heading": "Følg opp overslagene dine",
|
||||
"description": "Her ser du overslagene du har levert, og om styret har godkjent dem.",
|
||||
"moves": {
|
||||
"godkjent->fakturert": "Merk fakturert"
|
||||
},
|
||||
"list_title": "Leverte overslag",
|
||||
"list_note": "Hver rad er et overslag du har levert; marker det fakturert når regningen er sendt."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"overslag": {
|
||||
"heading": "Se alle overslag i ett regnskap",
|
||||
"description": "Her finner du overslagene samlet, uansett hvem som har meldt feilen.",
|
||||
"button": "Åpne overslag"
|
||||
}
|
||||
},
|
||||
"invites": {
|
||||
"klingenberg": {
|
||||
"heading": "Gi noen hos Klingenberg Bygg tilgang",
|
||||
"description": "Personen får en konto og en lenke som gjelder én gang, så de kan logge inn og se feilene selv.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter ny person",
|
||||
"form_description": "Ha navn, telefon og e-post klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invited": {
|
||||
"heading": "Hold oversikt over inviterte",
|
||||
"description": "Her ser du hvem som er invitert, og om de har tatt lenken i bruk.",
|
||||
"done": "Tatt i bruk",
|
||||
"withdraw": "Trekk tilbake",
|
||||
"again": "Send på nytt",
|
||||
"list_title": "Sendte invitasjoner",
|
||||
"list_note": "Hver rad er en invitasjon; send den på nytt hvis lenken har gått ut, eller trekk den tilbake."
|
||||
},
|
||||
"suggestions": null
|
||||
},
|
||||
"questions/klingenberg/overslag-sent.yaml": {
|
||||
"question": "Hva skjer med overslaget du nettopp sendte inn?",
|
||||
"description": "Det ligger nå hos styret, med beløp og hva det dekker. De tar stilling til det og fører det videre i regnskapet.",
|
||||
"next_heading": "Styret vurderer overslaget",
|
||||
"next_description": "Du hører fra dem hvis de trenger mer, ellers venter du på svar om det blir godkjent, avvist eller fakturert."
|
||||
},
|
||||
"questions/klingenberg/overslag.yaml": {
|
||||
"heading": "Overslaget går til styret",
|
||||
"description": "Når du har sett på en feil og satt en pris, sender du overslaget hit, så styret kan ta stilling til det.",
|
||||
"button": "Send overslag",
|
||||
"encouragement": "Styret ser det i løpet av kort tid, og det følger saken videre til regnskapet.",
|
||||
"icon": "receipt",
|
||||
"paused": "Ta ikke mot nye overslag nå",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Styret godkjenner eller avviser overslaget. Blir det godkjent og arbeidet utført, blir det fakturert.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser det",
|
||||
"who_description": "Styret i Gullberg leser overslaget og tar stilling til det, du når dem på bl@klingenbergbygg.no i mellomtiden.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Overslag på feil",
|
||||
"form_description": "Ha prisen og hva den dekker klart før du sender.",
|
||||
"labels": {
|
||||
"gjelder": "Hva overslaget gjelder",
|
||||
"belop": "Beløp",
|
||||
"hva-det-dekker": "Hva beløpet dekker",
|
||||
"dokument": "Dokument"
|
||||
},
|
||||
"hints": {
|
||||
"gjelder": "Lekkasje på badet i rom 4",
|
||||
"belop": "12 500 kr",
|
||||
"hva-det-dekker": "Ny membran, fliser og arbeidstid, to dagers arbeid",
|
||||
"dokument": "overslag-rom4.pdf"
|
||||
},
|
||||
"question": "Hva vil det koste å rette feilen?",
|
||||
"page_description": "Overslaget går til styret, som godkjenner eller avviser det før det føres i regnskapet."
|
||||
},
|
||||
"questions/leietakere/feil-sent.yaml": {
|
||||
"question": "Hva skjer nå med feilen jeg meldte?",
|
||||
"description": "Meldingen er sendt til Klingenberg Bygg, som drar på befaring og vurderer hva den koster å rette.",
|
||||
"next_heading": "Klingenberg Bygg ser på saken",
|
||||
"next_description": "De befarer feilen og lager et overslag som styret tar stilling til."
|
||||
},
|
||||
"questions/leietakere/feil.yaml": {
|
||||
"heading": "Meld fra om noe som er galt i huset",
|
||||
"description": "Fant du noe som ikke virker eller er ødelagt? Si fra, så ser styret på det.",
|
||||
"button": "Send feilen",
|
||||
"encouragement": "Tar et par minutter, og styret får beskjed med én gang.",
|
||||
"icon": "wrench",
|
||||
"paused": "Ta imot nye feilmeldinger",
|
||||
"requires_title": "Overskrift",
|
||||
"requires_description": "Forklaring",
|
||||
"requires_icon": "Ikon",
|
||||
"road_title": "Hva skjer videre",
|
||||
"road_description": "Styret ser på feilen og avtaler en befaring. Deretter henter de inn et overslag, godkjenner det, og feilen rettes opp – eller avvises, med beskjed om hvorfor.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser dette",
|
||||
"who_description": "Styret i Gullberg får meldingen og følger den opp; haster det, send en e-post til bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Meld en feil",
|
||||
"form_description": "Fortell hva som er galt og hvor, gjerne med bilde.",
|
||||
"labels": {
|
||||
"hva": "Hva er galt",
|
||||
"hvor": "Hvor i huset",
|
||||
"beskrivelse": "Beskriv feilen",
|
||||
"email": "E-post",
|
||||
"bilde": "Bilde"
|
||||
},
|
||||
"hints": {
|
||||
"hva": "Vannlekkasje under kjøkkenvasken",
|
||||
"hvor": "Kjøkkenet i 2. etasje",
|
||||
"beskrivelse": "Det drypper fra røret under vasken, har blitt verre siste uken",
|
||||
"email": "navn@student.no",
|
||||
"bilde": "Last opp et bilde av feilen"
|
||||
},
|
||||
"question": "Har du funnet noe i huset som bør rettes opp?",
|
||||
"page_description": "Meldingen går rett til Klingenberg Bygg, som forvalter huset. De ser på det, og styret godkjenner det som koster penger."
|
||||
},
|
||||
"questions/leietakere/index.yaml": {
|
||||
"question": "Hva kan vi gjøre for deg i huset?",
|
||||
"description": "Feil leietakerne melder inn, og overslagene Klingenberg Bygg sender over, samlet på ett sted.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {},
|
||||
"links": {
|
||||
"feil": {
|
||||
"heading": "Har du funnet noe som er galt i huset?",
|
||||
"description": "Meld fra, så går det rett til Klingenberg Bygg.",
|
||||
"button": "Meld fra"
|
||||
}
|
||||
},
|
||||
"invites": {},
|
||||
"invited": null,
|
||||
"suggestions": null
|
||||
},
|
||||
"questions/received/leie-rom.yaml": {
|
||||
"question": "Hva skjer med søknaden min nå?",
|
||||
"description": "Styret går gjennom romsøknader fortløpende og svarer så snart det er en visning å tilby.",
|
||||
"next_heading": "Du hører fra styret om en visning",
|
||||
"next_description": "Styret tar kontakt på e-post eller telefon for å avtale tid. Får du tilbud om rommet, får du beskjed rett etter."
|
||||
},
|
||||
"questions/status/liste/index.yaml": {
|
||||
"question": "Hva er vedtatt og gjort for huset?",
|
||||
"description": "Her ligger det som styret har vedtatt og satt i verk, med pris og tid der det er kjent.",
|
||||
"heading": "Det som er vedtatt og gjort",
|
||||
"list_description": "Åpen liste over det styret har vedtatt for huset.",
|
||||
"list_title": "Vedtatt og gjort",
|
||||
"list_note": "Hver rad er én sak, med hva den gjelder, kostnad og tidspunkt der det er satt.",
|
||||
"empty": "Ingen saker er publisert ennå.",
|
||||
"answer": "Svar"
|
||||
},
|
||||
"questions/status/rom/index.yaml": {
|
||||
"question": "Hvilke rom er ledige nå?",
|
||||
"description": "Her finner du rommene som er ledige for innflytting, med bilde og en kort beskrivelse av hvert rom.",
|
||||
"heading": "Ledige rom i huset",
|
||||
"list_description": "Rommene som er ledige akkurat nå.",
|
||||
"list_title": "Ledige rom",
|
||||
"list_note": "Hver rad er et rom du kan søke på.",
|
||||
"empty": "Ingen rom er ledige akkurat nå, men se gjerne innom igjen senere.",
|
||||
"answer": "Svar"
|
||||
},
|
||||
"questions/styret/eierskifte-sent.yaml": {
|
||||
"question": "Hva skjer nå med eierskiftesaken du sendte inn?",
|
||||
"description": "Steget er registrert og legger seg på listen over eierskiftet. Aksjonærene ser det neste gang de ser på fremdriften.",
|
||||
"next_heading": "Følg eierskiftet videre",
|
||||
"next_description": "Du finner status og alle steg i oversikten over eierskiftet, og kan legge inn mer når noe nytt skjer."
|
||||
},
|
||||
"questions/styret/eierskifte.yaml": {
|
||||
"heading": "Se hvor eierskiftet står",
|
||||
"description": "Meld fra om neste steg i overgangen til selskapet, så ser aksjonærene og banken fremgangen.",
|
||||
"button": "Legg inn steg",
|
||||
"encouragement": "Tar et minutt, og listen oppdateres med én gang.",
|
||||
"icon": "file-text",
|
||||
"paused": "Satt på vent",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Fra melding til ferdig",
|
||||
"road_description": "Steget legges inn, så arbeides det med, og til slutt merkes det som ferdig.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser det",
|
||||
"who_description": "Styret følger eierskiftet her; ring styret i mellomtiden hvis noe ikke kan vente.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Nytt steg i eierskiftet",
|
||||
"form_description": "Skriv hva som er gjort, og legg ved dokumentet hvis det finnes et.",
|
||||
"labels": {
|
||||
"steg": "Hva er gjort",
|
||||
"merknad": "Merknad",
|
||||
"dokument": "Dokument"
|
||||
},
|
||||
"hints": {
|
||||
"steg": "Skjøtet er sendt til tinglysing",
|
||||
"merknad": "Megler venter på signatur fra den siste selgeren",
|
||||
"dokument": "skjote-gullberg.pdf"
|
||||
},
|
||||
"question": "Hvor langt har eierskiftet kommet?",
|
||||
"page_description": "Her legger styret inn stegene i overgangen til selskapet, så aksjonærene kan følge med. Banken får det den skal ha fra styret."
|
||||
},
|
||||
"questions/styret/husleie-sent.yaml": {
|
||||
"question": "Hva skjer nå med husleia jeg registrerte?",
|
||||
"description": "Betalingen er lagt inn og synlig for resten av styret, sammen med de andre rommenes status denne måneden.",
|
||||
"next_heading": "Følg med i husleielista",
|
||||
"next_description": "Der ser dere hvilke rom som har betalt og hva som er purret på."
|
||||
},
|
||||
"questions/styret/husleie.yaml": {
|
||||
"heading": "Husleia for måneden, samlet ett sted",
|
||||
"description": "Legg inn hva som er betalt per rom, så aksjonærene ser det og banken får sitt.",
|
||||
"button": "Registrer husleie",
|
||||
"encouragement": "Det tar et minutt per rom.",
|
||||
"icon": "banknote",
|
||||
"paused": "Tas ikke imot nå",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Hva som skjer videre",
|
||||
"road_description": "Du registrerer at husleia er betalt. Hvis et rom ikke har betalt, markeres det som purret til det kommer inn.",
|
||||
"road_icon": "refresh-cw",
|
||||
"who_title": "Hvem som ser dette",
|
||||
"who_description": "Styret ser oversikten; har du spørsmål underveis, ta kontakt med styret på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Registrer husleie",
|
||||
"form_description": "Ha rom, måned og beløp klart.",
|
||||
"labels": {
|
||||
"rom": "Hvilket rom",
|
||||
"maaned": "Hvilken måned",
|
||||
"belop": "Beløp",
|
||||
"betalt-dato": "Betalt dato"
|
||||
},
|
||||
"hints": {
|
||||
"rom": "Rom 2",
|
||||
"maaned": "Oktober 2026",
|
||||
"belop": "4500",
|
||||
"betalt-dato": "2026-10-05"
|
||||
},
|
||||
"question": "Hvilke rom har betalt husleie denne måneden?",
|
||||
"page_description": "Her ser styret hvilke rom som har betalt husleia og hva som har gått til banken."
|
||||
},
|
||||
"questions/styret/index.yaml": {
|
||||
"question": "Hva trenger styret å gjøre nå?",
|
||||
"description": "Her samler styret alt som skjer i huset: søknader, feil, økonomi og eierskiftet, med knappene som flytter hver sak videre.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {
|
||||
"eierskifte": {
|
||||
"heading": "Se hvor langt eierskiftet har kommet",
|
||||
"description": "Følg stegene når huset går over til selskapet, med dokumentene på plass for aksjonærer og bank.",
|
||||
"moves": {
|
||||
"arbeid->ferdig": "Merk steget ferdig",
|
||||
"open->arbeid": "Start arbeidet"
|
||||
},
|
||||
"list_title": "Steg i eierskiftet",
|
||||
"list_note": "Hver rad er ett steg i overgangen, med merknad og dokument; knappen under flytter steget videre."
|
||||
},
|
||||
"feil": {
|
||||
"heading": "Følg opp feil som er meldt inn",
|
||||
"description": "Se hva leietakerne har funnet galt i huset, og send det videre til Klingenberg Bygg for befaring og overslag.",
|
||||
"moves": {
|
||||
"befaring->avvist": "Avvis feilen",
|
||||
"open->avvist": "Avvis feilen",
|
||||
"overslag->godkjent": "Godkjenn overslaget",
|
||||
"overslag->avvist": "Avvis overslaget"
|
||||
},
|
||||
"list_title": "Meldte feil",
|
||||
"list_note": "Hver rad er en feil meldt av en leietaker, med bilde og beskrivelse; knappene under sender den videre eller avviser den."
|
||||
},
|
||||
"husleie": {
|
||||
"heading": "Hold oversikt over husleia hver måned",
|
||||
"description": "Se hvilke rom som har betalt, og purr dem som ikke har gjort det.",
|
||||
"moves": {
|
||||
"open->betalt": "Merk som betalt",
|
||||
"open->purret": "Send purring",
|
||||
"purret->betalt": "Merk som betalt"
|
||||
},
|
||||
"list_title": "Husleie per rom",
|
||||
"list_note": "Hver rad er husleia for ett rom én måned, med beløp og betalingsdato; knappene under merker betalt eller sender purring."
|
||||
},
|
||||
"liste": {
|
||||
"heading": "Styr lista over det som er bra, mangler og er planlagt",
|
||||
"description": "Vedta punktene, sett pris og tid, og publiser dem som leietakerne skal se.",
|
||||
"moves": {
|
||||
"open->vedtatt": "Vedta punktet",
|
||||
"open->forkastet": "Forkast punktet",
|
||||
"vedtatt->publisert": "Publiser punktet",
|
||||
"vedtatt->forkastet": "Forkast punktet"
|
||||
},
|
||||
"list_title": "Punkter på lista",
|
||||
"list_note": "Hver rad er ett punkt med slag, beskrivelse, kostnad og tid; knappene under vedtar, publiserer eller forkaster det."
|
||||
},
|
||||
"nedbetalingsplaner": {
|
||||
"heading": "Følg nedbetalingsplanen fra aksjonærene til banken",
|
||||
"description": "Følg planen fra forslag til bankens svar, med pengestrømmen alle fire aksjonærene står bak.",
|
||||
"moves": {
|
||||
"godkjent->sendt": "Send til banken",
|
||||
"sendt->endringer": "Be om endringer",
|
||||
"sendt->innvilget": "Merk som innvilget"
|
||||
},
|
||||
"list_title": "Nedbetalingsplaner",
|
||||
"list_note": "Hver rad er en plan med lånebeløp, avdrag og pengestrøm; knappene under sender den til banken eller merker svaret."
|
||||
},
|
||||
"overslag": {
|
||||
"heading": "Ta stilling til overslag fra Klingenberg Bygg",
|
||||
"description": "Se beløpet for hver feil som er sett på, og godkjenn det som skal inn i regnskapet.",
|
||||
"moves": {
|
||||
"open->godkjent": "Godkjenn overslaget",
|
||||
"open->avvist": "Avvis overslaget"
|
||||
},
|
||||
"list_title": "Overslag",
|
||||
"list_note": "Hver rad er et overslag med beløp og hva det dekker; knappene under godkjenner eller avviser det."
|
||||
},
|
||||
"rapporter": {
|
||||
"heading": "Send kvartalsrapporten til aksjonærene og banken",
|
||||
"description": "Samle tallene for kvartalet, og send rapporten videre når den er klar.",
|
||||
"moves": {
|
||||
"open->sendt": "Send rapporten"
|
||||
},
|
||||
"list_title": "Kvartalsrapporter",
|
||||
"list_note": "Hver rad er rapporten for ett kvartal, med leieinntekter, kostnader og saldo; knappen under sender den."
|
||||
},
|
||||
"rom": {
|
||||
"heading": "Vis ledige rom frem til studentene",
|
||||
"description": "Legg ut rommet som er blitt ledig, og merk det utleid når noen har flyttet inn.",
|
||||
"moves": {
|
||||
"open->utleid": "Merk som utleid"
|
||||
},
|
||||
"list_title": "Rom",
|
||||
"list_note": "Hver rad er et rom med bilde og beskrivelse; knappen under merker det utleid."
|
||||
},
|
||||
"romsoknader": {
|
||||
"heading": "Følg opp søknader om rom",
|
||||
"description": "Se hvem som har søkt og hva de gjør til daglig (studenter går foran), sett opp visning, og før søknaden frem til signert leieavtale.",
|
||||
"moves": {
|
||||
"akseptert->signert": "Merk som signert",
|
||||
"open->visning": "Sett opp visning",
|
||||
"open->avslatt": "Avslå søknaden",
|
||||
"tilbud->avslatt": "Avslå søknaden",
|
||||
"visning->tilbud": "Gi tilbud om rom",
|
||||
"visning->avslatt": "Avslå søknaden"
|
||||
},
|
||||
"list_title": "Søknader om rom",
|
||||
"list_note": "Hver rad er en søknad, med hva søkeren gjør til daglig og ønsket innflytting. Studenter går foran. Knappene fører søknaden videre eller avslår den."
|
||||
},
|
||||
"saker": {
|
||||
"heading": "Saker aksjonærene har meldt",
|
||||
"description": "Ta saker aksjonærene melder inn, sett dem på sakslisten, og vedta eller avvis dem.",
|
||||
"moves": {
|
||||
"open->sakslisten": "Sett på sakslisten",
|
||||
"open->avvist": "Avvis saken",
|
||||
"sakslisten->vedtatt": "Vedta saken",
|
||||
"sakslisten->avvist": "Avvis saken"
|
||||
},
|
||||
"list_title": "Saker",
|
||||
"list_note": "Hver rad er en sak meldt inn av en aksjonær, med tittel og beskrivelse; knappene under setter den på sakslisten, vedtar eller avviser den."
|
||||
},
|
||||
"uker": {
|
||||
"heading": "Bekreft ukene aksjonærene vil bruke huset",
|
||||
"description": "Se hvem som har meldt seg på en uke, og bekreft eller avslå den.",
|
||||
"moves": {
|
||||
"open->bekreftet": "Bekreft uken",
|
||||
"open->avslatt": "Avslå uken"
|
||||
},
|
||||
"list_title": "Ønskede uker",
|
||||
"list_note": "Hver rad er en uke en aksjonær har meldt seg på, med datoer og merknad; knappene under bekrefter eller avslår den."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"ledig-rom": {
|
||||
"heading": "Legg ut et rom som er blitt ledig",
|
||||
"description": "Skriv tittel, legg ved bilde og beskriv rommet, så studentene kan søke.",
|
||||
"button": "Legg ut rom"
|
||||
},
|
||||
"liste": {
|
||||
"heading": "Legg til et nytt punkt på lista",
|
||||
"description": "Skriv hva som er bra, mangler eller planlagt, med pris og tid om du har det.",
|
||||
"button": "Legg til punkt"
|
||||
},
|
||||
"rapport": {
|
||||
"heading": "Lag kvartalets rapport",
|
||||
"description": "Fyll inn tallene for kvartalet, så aksjonærene og banken får det de skal ha.",
|
||||
"button": "Lag rapport"
|
||||
},
|
||||
"nedbetalingsplan": {
|
||||
"heading": "Lag en ny nedbetalingsplan",
|
||||
"description": "Skriv lånebeløp, avdrag og pengestrøm, så banken har noe å ta stilling til.",
|
||||
"button": "Lag plan"
|
||||
},
|
||||
"husleie": {
|
||||
"heading": "Registrer husleie for et rom",
|
||||
"description": "Før inn beløp og måned når husleia for et rom er kommet inn.",
|
||||
"button": "Registrer husleie"
|
||||
},
|
||||
"eierskifte": {
|
||||
"heading": "Legg til et steg i eierskiftet",
|
||||
"description": "Beskriv steget og legg ved dokument, så aksjonærene ser fremgangen.",
|
||||
"button": "Legg til steg"
|
||||
}
|
||||
},
|
||||
"invites": {
|
||||
"aksjonaerer": {
|
||||
"heading": "Gi en aksjonær tilgang til sin side",
|
||||
"description": "Send en invitasjon så aksjonæren får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter aksjonær",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
},
|
||||
"klingenberg": {
|
||||
"heading": "Gi Klingenberg Bygg tilgang til feilmeldinger",
|
||||
"description": "Send en invitasjon så de får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter Klingenberg Bygg",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
},
|
||||
"leietakere": {
|
||||
"heading": "Gi en leietaker tilgang til sin side",
|
||||
"description": "Send en invitasjon så leietakeren får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter leietaker",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
},
|
||||
"styret": {
|
||||
"heading": "Gi et styremedlem tilgang til desken",
|
||||
"description": "Send en invitasjon så styremedlemmet får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter styremedlem",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invited": {
|
||||
"heading": "Se hvem som er invitert og hvor langt de har kommet",
|
||||
"description": "Følg med på invitasjoner som er sendt, og send en ny lenke om den gamle har løpt ut.",
|
||||
"done": "Tatt i bruk",
|
||||
"withdraw": "Trekk tilbake",
|
||||
"again": "Send på nytt",
|
||||
"list_title": "Sendte invitasjoner",
|
||||
"list_note": "Hver rad er en invitasjon sendt til en person; knappene under trekker den tilbake eller sender den på nytt."
|
||||
},
|
||||
"suggestions": {
|
||||
"heading": "Ta stilling til foreslåtte endringer",
|
||||
"description": "Se hva andre har foreslått å endre på sidene de har ansvar for.",
|
||||
"list_title": "Foreslåtte endringer",
|
||||
"list_note": "Hver rad er en foreslått endring; knappene under tar den til vurdering, godtar eller avslår den.",
|
||||
"looking": "Tar en titt",
|
||||
"changed": "Endret",
|
||||
"declined": "Avslått"
|
||||
}
|
||||
},
|
||||
"questions/styret/ledig-rom-sent.yaml": {
|
||||
"question": "Hva skjer med rommet du nettopp la ut?",
|
||||
"description": "Rommet er lagt inn og synlig for dem som søker. Dere ser det under ledige rom til det er leid ut.",
|
||||
"next_heading": "Studenter kan søke på rommet nå",
|
||||
"next_description": "Søknader kommer inn etter hvert, og dere i styret ser dem sammen med de andre søknadene.",
|
||||
"see_all": {
|
||||
"heading": "Se alle rommene og status",
|
||||
"description": "Full oversikt over hvilke rom som er ledige og hvilke som er leid ut.",
|
||||
"button": "Se rommene"
|
||||
}
|
||||
},
|
||||
"questions/styret/ledig-rom.yaml": {
|
||||
"heading": "Meld inn et rom som er ledig",
|
||||
"description": "Når et rom står tomt, legger du det ut her, så ser de som søker det først.",
|
||||
"button": "Legg ut rommet",
|
||||
"encouragement": "Så snart det er lagt ut, kan folk søke på det.",
|
||||
"icon": "key",
|
||||
"paused": "Ta rommet ned fra listen for nå",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever bilde",
|
||||
"road_title": "Fra ledig til utleid",
|
||||
"road_description": "Rommet ligger ute til det er utleid.",
|
||||
"road_icon": "key",
|
||||
"who_title": "Hvem ser dette",
|
||||
"who_description": "Studenter som søker rom ser dette, og styret kan nås på bl@klingenbergbygg.no i mellomtiden.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Om rommet",
|
||||
"form_description": "Et bilde og en kort beskrivelse er nok til at de som søker kan se seg om.",
|
||||
"labels": {
|
||||
"tittel": "Tittel på rommet",
|
||||
"bilde": "Bilde av rommet",
|
||||
"om": "Om rommet"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Rom i 2. etasje, mot gata",
|
||||
"bilde": "",
|
||||
"om": "Lyst rom med eget bad, nær kjøkken og felles stue."
|
||||
},
|
||||
"question": "Hvilket rom er ledig nå?",
|
||||
"page_description": "Legg ut et rom som har blitt ledig, så de som søker kan se det."
|
||||
},
|
||||
"questions/styret/liste-sent.yaml": {
|
||||
"question": "Hva skjer med punktet nå?",
|
||||
"description": "Forslaget ditt er lagret. Styret tar det opp til behandling.",
|
||||
"next_heading": "Styret vedtar eller forkaster punktet",
|
||||
"next_description": "Det blir stående til styret har tatt stilling, med pris og tid synlig for alle.",
|
||||
"see_all": {
|
||||
"heading": "Se hele lista",
|
||||
"description": "Alt som er meldt inn, vedtatt eller publisert ligger samlet.",
|
||||
"button": "Til lista"
|
||||
}
|
||||
},
|
||||
"questions/styret/liste.yaml": {
|
||||
"heading": "Listen over huset og planene",
|
||||
"description": "Legg til det som er bra, det som mangler og det som er planlagt, med pris og tid der dere vet det.",
|
||||
"button": "Legg til punkt",
|
||||
"encouragement": "Det havner på listen med en gang, og dere kan vedta det senere.",
|
||||
"icon": "list",
|
||||
"paused": "Listen er stengt for nye punkter",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Fra forslag til vedtak",
|
||||
"road_description": "Punktet legges inn, styret tar stilling til det og vedtar eller forkaster, og det som vedtas blir publisert på listen alle ser.",
|
||||
"road_icon": "check-circle",
|
||||
"who_title": "Hvem ser listen",
|
||||
"who_description": "Styret fører og vedtar listen; ta kontakt på bl@klingenbergbygg.no om noe haster.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Nytt punkt på listen",
|
||||
"form_description": "Skriv kort hva det gjelder, og ta med pris og tid hvis dere har tall.",
|
||||
"labels": {
|
||||
"tittel": "Tittel",
|
||||
"slag": "Hva slags punkt",
|
||||
"beskrivelse": "Beskrivelse",
|
||||
"kostnad": "Kostnad",
|
||||
"naar": "Når"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Nytt kjøkken i andre etasje",
|
||||
"slag": "Mangler",
|
||||
"beskrivelse": "Kjøkkenet er slitt og trenger nye hvitevarer og benkeplate.",
|
||||
"kostnad": "ca. 45 000 kr",
|
||||
"naar": "Våren 2027"
|
||||
},
|
||||
"question": "Hva bør med på listen over huset og driften?",
|
||||
"page_description": "Punktene går inn på listen styret fører over det som er bra, det som mangler og det som er planlagt."
|
||||
},
|
||||
"questions/styret/nedbetalingsplan-sent.yaml": {
|
||||
"question": "Hvor er nedbetalingsplanen nå?",
|
||||
"description": "Den ligger nå hos aksjonærene. Når alle fire har godkjent den, sender styret den til banken og fører inn svaret.",
|
||||
"next_heading": "Følg svaret fra banken",
|
||||
"next_description": "Når banken svarer, oppdaterer dere planen med godkjent, innvilget eller endringer – så de andre aksjonærene ser hvor den står."
|
||||
},
|
||||
"questions/styret/nedbetalingsplan.yaml": {
|
||||
"heading": "Nedbetalingsplanen alle fire står bak",
|
||||
"description": "Legg fram planen for lånet, så banken og aksjonærene vet hvordan det skal betales ned.",
|
||||
"button": "Send plan",
|
||||
"encouragement": "Styret tar den videre til banken så snart alle fire har sagt ja.",
|
||||
"icon": "banknote",
|
||||
"paused": "Satt på pause",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Planen går til godkjenning hos aksjonærene, så sendes den til banken. Sier banken ja, er den innvilget; ber den om endringer, kommer det en ny runde.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem ser den",
|
||||
"who_description": "Styret og aksjonærene ser planen; spørsmål før den er sendt går til styret på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Nedbetalingsplan",
|
||||
"form_description": "Ha lånebeløp, avdrag og når det første trekket kommer, klart.",
|
||||
"labels": {
|
||||
"tittel": "Tittel",
|
||||
"laanebelop": "Lånebeløp",
|
||||
"maanedlig-avdrag": "Månedlig avdrag",
|
||||
"forste-trekk": "Første trekk",
|
||||
"pengestrom": "Hvordan pengene flyter",
|
||||
"dokument": "Dokument"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Nedbetalingsplan 2026",
|
||||
"laanebelop": "2 400 000 kr",
|
||||
"maanedlig-avdrag": "18 000 kr",
|
||||
"forste-trekk": "1. desember 2026",
|
||||
"pengestrom": "Husleie dekker avdraget, timeshare går til vedlikehold, overskudd settes av til buffer",
|
||||
"dokument": "Last opp plan eller avtaleutkast"
|
||||
},
|
||||
"question": "Hvordan skal lånet betales ned, og står alle fire bak det?",
|
||||
"page_description": "Planen går til aksjonærene og banken, og viser hvordan lånet skal betales ned."
|
||||
},
|
||||
"questions/styret/rapport-sent.yaml": {
|
||||
"question": "Hvor går rapporten nå?",
|
||||
"description": "Den er sendt. Aksjonærene får tallene, og det som skal til banken går dit.",
|
||||
"next_heading": "Neste rapport venter til kvartalet er omme",
|
||||
"next_description": "Neste rapport legger styret inn når kvartalet er omme."
|
||||
},
|
||||
"questions/styret/rapport.yaml": {
|
||||
"heading": "Rapporten som viser hvordan det går",
|
||||
"description": "Hvert kvartal legger styret inn tallene, så aksjonærene ser status og banken får det den skal ha.",
|
||||
"button": "Send rapport",
|
||||
"encouragement": "Så snart den er sendt, ligger den klar for aksjonærene og banken.",
|
||||
"icon": "file-text",
|
||||
"paused": "Sett rapportering på pause",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Hva skjer videre",
|
||||
"road_description": "Rapporten sendes til aksjonærene og banken. Når den er ute, merkes den som sendt.",
|
||||
"road_icon": "send",
|
||||
"who_title": "Hvem leser den",
|
||||
"who_description": "Styret står for rapportene; har du spørsmål underveis, ta kontakt på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Kvartalets tall",
|
||||
"form_description": "Ha regnskapstallene for kvartalet klare før du fyller ut.",
|
||||
"labels": {
|
||||
"periode": "Hvilket kvartal gjelder dette",
|
||||
"leieinntekter": "Leieinntekter",
|
||||
"timeshare": "Inntekter fra ukene",
|
||||
"driftskostnader": "Driftskostnader",
|
||||
"renter-og-avdrag": "Renter og avdrag til banken",
|
||||
"saldo": "Saldo ved kvartalets slutt",
|
||||
"kommentar": "Kommentar til tallene",
|
||||
"dokument": "Regnskapsdokument"
|
||||
},
|
||||
"hints": {
|
||||
"periode": "3. kvartal 2026",
|
||||
"leieinntekter": "54 000",
|
||||
"timeshare": "12 000",
|
||||
"driftskostnader": "18 500",
|
||||
"renter-og-avdrag": "22 000",
|
||||
"saldo": "145 000",
|
||||
"kommentar": "Ett rom stod tomt i juli, ellers som normalt",
|
||||
"dokument": "kvartalsregnskap-q3-2026.pdf"
|
||||
},
|
||||
"question": "Hvordan gikk kvartalet, i tall?",
|
||||
"page_description": "Rapporten går til aksjonærene og banken, så de ser inntekter, kostnader og saldo for kvartalet."
|
||||
},
|
||||
"site.yaml": {
|
||||
"invite": {
|
||||
"subject": "Du er invitert til Gullberg",
|
||||
"body": "Hei {name},\n\nDu er invitert inn på Gullberg sin side, som {group}.\n\nVelg passord her før {expires}:\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.\n\nHilsen styret i Gullberg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,20 +115,8 @@
|
||||
},
|
||||
"who": {
|
||||
"heading": "Hvem består Klingenberg Bygg av?",
|
||||
"description": "Mennesker, verdier og erfaringer.",
|
||||
"items": {
|
||||
"erfaring": {
|
||||
"heading": "Erfaringene",
|
||||
"description": "Skoler i drift, brannstasjoner, omsorgsboliger, kantina på Det Norske Teatret og en Munch-eiendom på Hvitsten. Vi har gått over, prosjektert og fulgt opp bygg for kommuner, borettslag, bedrifter og private, fra borprøver i gammel mur til kunstgress på Fredrikstad stasjon.",
|
||||
"icon": "award"
|
||||
},
|
||||
"people-ansatte": {
|
||||
"heading": "Menneskene",
|
||||
"description": "De som går befaringene, skriver rapportene og står for søknadene.",
|
||||
"empty": "Snart presenterer vi oss her.",
|
||||
"icon": "users"
|
||||
}
|
||||
}
|
||||
"description": "Mennesker, erfaringer og verdier.",
|
||||
"button": "Bli kjent med oss"
|
||||
}
|
||||
},
|
||||
"questions/received/tilstand.yaml": {
|
||||
@@ -264,6 +252,15 @@
|
||||
},
|
||||
"list_title": "Ansatte",
|
||||
"list_note": "Hver rad er én person på forsiden."
|
||||
},
|
||||
"erfaringer": {
|
||||
"heading": "Erfaringer",
|
||||
"description": "Oppdragene slik besøkende ser dem.",
|
||||
"moves": {
|
||||
"open->fjernet": "Fjern"
|
||||
},
|
||||
"list_title": "Erfaringer",
|
||||
"list_note": "Hver rad er ett oppdrag som vises nå."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
@@ -273,8 +270,13 @@
|
||||
"button": "Ny leveranse"
|
||||
},
|
||||
"ansatt": {
|
||||
"heading": "Ditt kort på forsiden",
|
||||
"description": "Rolle, erfaring og portrett, slik besøkende ser deg.",
|
||||
"heading": "Hvordan presenterer du deg?",
|
||||
"description": "Kortet ditt på forsiden: rolle, erfaring og portrett.",
|
||||
"button": "Åpne"
|
||||
},
|
||||
"erfaring": {
|
||||
"heading": "Hvilket oppdrag vil du vise fram?",
|
||||
"description": "Et bilde og noen løse ord, blant erfaringene.",
|
||||
"button": "Åpne"
|
||||
}
|
||||
},
|
||||
@@ -474,7 +476,7 @@
|
||||
"question": "Kortet ditt er lagret",
|
||||
"description": "Slik står du nå på forsiden.",
|
||||
"next_heading": "Vil du endre noe?",
|
||||
"next_description": "Åpne «Ditt kort på forsiden» igjen: det er fylt ut med det du har, og det du lagrer erstatter det.",
|
||||
"next_description": "Åpne «Hvordan presenterer du deg?» igjen: kortet ditt er fylt ut med det du har, og det du lagrer erstatter det.",
|
||||
"see_all": {
|
||||
"heading": "Se alle ansatte",
|
||||
"description": "",
|
||||
@@ -482,8 +484,8 @@
|
||||
}
|
||||
},
|
||||
"questions/kontor/ansatt.yaml": {
|
||||
"heading": "Ditt kort på forsiden",
|
||||
"description": "Slik står du blant menneskene på forsiden. Rett det du vil og lagre; du har ett kort, og det nye erstatter det gamle.",
|
||||
"heading": "Kortet ditt",
|
||||
"description": "Rett det du vil og lagre. Du har ett kort, og det du lagrer erstatter det som står der nå.",
|
||||
"button": "Lagre kortet",
|
||||
"encouragement": "",
|
||||
"icon": "id-card",
|
||||
@@ -507,11 +509,11 @@
|
||||
"hints": {
|
||||
"name": "",
|
||||
"rolle": "For eksempel daglig leder, rådgivende ingeniør",
|
||||
"erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av",
|
||||
"erfaring": "Utdanning, fagområder, bygg du er stolt av. Du kan skrive **fet**, *kursiv*, lister med - og [lenker](https://…).",
|
||||
"portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
|
||||
},
|
||||
"question": "Ditt kort på forsiden",
|
||||
"page_description": "Det du skriver her, står under portrettet ditt på forsiden."
|
||||
"question": "Hvordan presenterer du deg?",
|
||||
"page_description": "Det du skriver her, står på kortet ditt blant menneskene på forsiden."
|
||||
},
|
||||
"questions/status/ansatte/index.yaml": {
|
||||
"question": "Hvem består Klingenberg Bygg av?",
|
||||
@@ -522,5 +524,99 @@
|
||||
"list_note": "",
|
||||
"empty": "Snart presenterer vi oss her.",
|
||||
"answer": ""
|
||||
},
|
||||
"questions/hvem.yaml": {
|
||||
"question": "Hvem består Klingenberg Bygg av?",
|
||||
"description": "Menneskene som gjør jobben, oppdragene vi har gjort, og det vi står for.",
|
||||
"presents": {
|
||||
"ansatte": {
|
||||
"heading": "Menneskene",
|
||||
"description": "De som går befaringene, skriver rapportene og står for søknadene.",
|
||||
"empty": "Snart presenterer vi oss her."
|
||||
},
|
||||
"erfaringer": {
|
||||
"heading": "Erfaringer",
|
||||
"description": "Et utvalg oppdrag, med bilder.",
|
||||
"empty": "Snart viser vi fram oppdrag her."
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"heading": "Verdier",
|
||||
"description": "Det vi står for.",
|
||||
"items": {
|
||||
"mal-to-ganger": {
|
||||
"heading": "Mål to ganger",
|
||||
"description": "Vi måler to ganger og skriver én gang. Det gjelder betong, og det gjelder rapporter.",
|
||||
"icon": "ruler"
|
||||
},
|
||||
"bygget-forst": {
|
||||
"heading": "Bygget først",
|
||||
"description": "Et bygg bryr seg ikke om hvem som har rett. Vi skriver det bygget trenger, ikke det som er lettest å si.",
|
||||
"icon": "building"
|
||||
},
|
||||
"ingen-overraskelser": {
|
||||
"heading": "Ingen overraskelser i veggen",
|
||||
"description": "Det vi finner, får du vite. Også sprekkene.",
|
||||
"icon": "search"
|
||||
},
|
||||
"vannet": {
|
||||
"heading": "Vannet finner en vei",
|
||||
"description": "Vannet finner alltid en vei. Derfor leter vi etter den først.",
|
||||
"icon": "droplet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"questions/kontor/erfaring.yaml": {
|
||||
"heading": "Et oppdrag med bilde",
|
||||
"description": "Et bilde og noen løse ord om hva vi gjorde. Det vises med en gang.",
|
||||
"button": "Vis fram",
|
||||
"encouragement": "",
|
||||
"icon": "image-plus",
|
||||
"paused": "",
|
||||
"requires_title": "",
|
||||
"requires_description": "",
|
||||
"requires_icon": "",
|
||||
"road_title": "Når du sender",
|
||||
"road_description": "Oppdraget vises blant erfaringene med en gang, til noen på kontoret fjerner det.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem ser det",
|
||||
"who_description": "Alle som besøker siden. Skriv ikke noe om kunden de ikke ville lest selv.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Oppdraget",
|
||||
"form_description": "",
|
||||
"labels": {
|
||||
"tittel": "Hva var det?",
|
||||
"bilde": "Bilde",
|
||||
"om": "Noen ord om det"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "For eksempel: Brannstasjonen i Ytre Enebakk",
|
||||
"bilde": "Et bilde fra oppdraget, gjerne liggende",
|
||||
"om": "Hva vi gjorde, og hva som var verdt å gjøre skikkelig. **Fet**, *kursiv*, lister og lenker går an."
|
||||
},
|
||||
"question": "Hvilket oppdrag vil du vise fram?",
|
||||
"page_description": "Det du legger inn her, står blant erfaringene på «Hvem består Klingenberg Bygg av?»."
|
||||
},
|
||||
"questions/kontor/erfaring-sent.yaml": {
|
||||
"question": "Oppdraget vises nå",
|
||||
"description": "Det står blant erfaringene på «Hvem består Klingenberg Bygg av?».",
|
||||
"next_heading": "Skal det ned igjen?",
|
||||
"next_description": "Trykk «Fjern» på raden i listen over erfaringer her på kontoret. Det forsvinner fra listen ti minutter etter.",
|
||||
"see_all": {
|
||||
"heading": "Se alle erfaringer",
|
||||
"description": "",
|
||||
"button": "Åpne"
|
||||
}
|
||||
},
|
||||
"questions/status/erfaringer/index.yaml": {
|
||||
"question": "Hva har Klingenberg Bygg gjort?",
|
||||
"description": "Et utvalg oppdrag, med bilder.",
|
||||
"heading": "Erfaringer",
|
||||
"list_description": "",
|
||||
"list_title": "Erfaringer",
|
||||
"list_note": "",
|
||||
"empty": "Snart viser vi fram oppdrag her.",
|
||||
"answer": ""
|
||||
}
|
||||
}
|
||||
|
||||
+351
-68
@@ -70,12 +70,22 @@ pub enum Principal {
|
||||
|
||||
impl Principal {
|
||||
pub fn anonymous() -> Self {
|
||||
Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None }
|
||||
Principal::User {
|
||||
sub: "anonymous".into(),
|
||||
groups: Vec::new(),
|
||||
phone: None,
|
||||
email: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
||||
match user {
|
||||
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
|
||||
Some(u) => Principal::User {
|
||||
sub: u.sub.clone(),
|
||||
groups: u.groups.clone(),
|
||||
phone: u.phone.clone(),
|
||||
email: u.email.clone(),
|
||||
},
|
||||
None => Principal::anonymous(),
|
||||
}
|
||||
}
|
||||
@@ -110,13 +120,23 @@ impl Action {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Resource {
|
||||
Page { id: String },
|
||||
Bucket { name: String },
|
||||
Page {
|
||||
id: String,
|
||||
},
|
||||
Bucket {
|
||||
name: String,
|
||||
},
|
||||
/// One resource block on one alternative of one page. `feature` is
|
||||
/// the feature name, or `feature|requirement` (`|bind` appended)
|
||||
/// for a requirement's own resource.
|
||||
Feature { page: String, alternative: String, feature: String },
|
||||
Group { name: String },
|
||||
Feature {
|
||||
page: String,
|
||||
alternative: String,
|
||||
feature: String,
|
||||
},
|
||||
Group {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -164,12 +184,29 @@ impl Policy {
|
||||
// Each rule is parsed on its own under its own id, so a
|
||||
// decision's reasons name rules, not `policy17`.
|
||||
let mut sources: Vec<(String, String)> = Vec::new();
|
||||
let mut emit = |id_base: String, who: String, action: &str, resource: String, when: String, policy: String, text: &mut String, rules: &mut Vec<Rule>| {
|
||||
let mut emit = |id_base: String,
|
||||
who: String,
|
||||
action: &str,
|
||||
resource: String,
|
||||
when: String,
|
||||
policy: String,
|
||||
text: &mut String,
|
||||
rules: &mut Vec<Rule>| {
|
||||
let n = ids.entry(id_base.clone()).or_insert(0);
|
||||
*n += 1;
|
||||
let id = if *n == 1 { id_base } else { format!("{id_base}#{n}") };
|
||||
let id = if *n == 1 {
|
||||
id_base
|
||||
} else {
|
||||
format!("{id_base}#{n}")
|
||||
};
|
||||
text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id)));
|
||||
rules.push(Rule { id: id.clone(), who, action: action.to_string(), resource, when });
|
||||
rules.push(Rule {
|
||||
id: id.clone(),
|
||||
who,
|
||||
action: action.to_string(),
|
||||
resource,
|
||||
when,
|
||||
});
|
||||
sources.push((id, policy));
|
||||
};
|
||||
|
||||
@@ -179,7 +216,10 @@ impl Policy {
|
||||
let page = quote(&q.id);
|
||||
let (who, principal) = match &q.qualifies {
|
||||
None => ("anyone".to_string(), "principal".to_string()),
|
||||
Some(g) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
||||
Some(g) => (
|
||||
format!("group {g}"),
|
||||
format!("principal in Group::{}", quote(g)),
|
||||
),
|
||||
};
|
||||
emit(
|
||||
format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(),
|
||||
@@ -197,16 +237,25 @@ impl Policy {
|
||||
}
|
||||
for req in &feature.requirements {
|
||||
if let Some(spec) = &req.resource {
|
||||
specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec));
|
||||
specs.push((
|
||||
format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name),
|
||||
spec,
|
||||
));
|
||||
}
|
||||
if let Some(bind) = &req.bind {
|
||||
specs.push((format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), &bind.resource));
|
||||
specs.push((
|
||||
format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name),
|
||||
&bind.resource,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (fid, spec) in specs {
|
||||
let (who, principal) = match (&spec.requires_group, spec.public) {
|
||||
(Some(g), _) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
|
||||
(Some(g), _) => (
|
||||
format!("group {g}"),
|
||||
format!("principal in Group::{}", quote(g)),
|
||||
),
|
||||
(None, true) => ("anyone".to_string(), "principal".to_string()),
|
||||
// Neither: unreachable by design (fail closed).
|
||||
(None, false) => continue,
|
||||
@@ -279,20 +328,37 @@ impl Policy {
|
||||
.expect("generated policy text always parses");
|
||||
policies.add(policy).expect("rule ids are unique");
|
||||
}
|
||||
let schema = Schema::from_cedarschema_str(SCHEMA).map(|(s, _)| s).expect("the schema is a constant");
|
||||
let patterns = questions.values().filter(|q| q.is_dynamic()).map(|q| q.id.clone()).collect();
|
||||
Policy { policies, schema, rules, text, patterns }
|
||||
let schema = Schema::from_cedarschema_str(SCHEMA)
|
||||
.map(|(s, _)| s)
|
||||
.expect("the schema is a constant");
|
||||
let patterns = questions
|
||||
.values()
|
||||
.filter(|q| q.is_dynamic())
|
||||
.map(|q| q.id.clone())
|
||||
.collect();
|
||||
Policy {
|
||||
policies,
|
||||
schema,
|
||||
rules,
|
||||
text,
|
||||
patterns,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every generated rule type-checks against the schema. A rule the
|
||||
/// schema cannot express is a compiler bug, and this is where it
|
||||
/// shows.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let result = Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
|
||||
let result =
|
||||
Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
|
||||
if result.validation_passed() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(result.validation_errors().map(|e| e.to_string()).collect::<Vec<_>>().join("; "))
|
||||
Err(result
|
||||
.validation_errors()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +383,10 @@ impl Policy {
|
||||
context: serde_json::Value,
|
||||
) -> Decision {
|
||||
let uid = |ty: &str, id: &str| {
|
||||
EntityUid::from_type_name_and_id(EntityTypeName::from_str(ty).expect("static type"), EntityId::new(id))
|
||||
EntityUid::from_type_name_and_id(
|
||||
EntityTypeName::from_str(ty).expect("static type"),
|
||||
EntityId::new(id),
|
||||
)
|
||||
};
|
||||
let mut entities: Vec<Entity> = Vec::new();
|
||||
let principal_uid = match principal {
|
||||
@@ -332,8 +401,14 @@ impl Policy {
|
||||
}
|
||||
Principal::Submitter { record, email } => {
|
||||
let me = uid("Submitter", record);
|
||||
let attrs = HashMap::from([("email".to_string(), RestrictedExpression::new_string(email.clone()))]);
|
||||
entities.push(Entity::new(me.clone(), attrs, HashSet::new()).expect("a string attribute always evaluates"));
|
||||
let attrs = HashMap::from([(
|
||||
"email".to_string(),
|
||||
RestrictedExpression::new_string(email.clone()),
|
||||
)]);
|
||||
entities.push(
|
||||
Entity::new(me.clone(), attrs, HashSet::new())
|
||||
.expect("a string attribute always evaluates"),
|
||||
);
|
||||
me
|
||||
}
|
||||
Principal::Service { name } => {
|
||||
@@ -345,9 +420,14 @@ impl Policy {
|
||||
let resource_uid = match resource {
|
||||
Resource::Page { id } => uid("Page", &self.page_entity(id)),
|
||||
Resource::Bucket { name } => uid("Bucket", name),
|
||||
Resource::Feature { page, alternative, feature } => {
|
||||
uid("Feature", &format!("{}|{alternative}|{feature}", self.page_entity(page)))
|
||||
}
|
||||
Resource::Feature {
|
||||
page,
|
||||
alternative,
|
||||
feature,
|
||||
} => uid(
|
||||
"Feature",
|
||||
&format!("{}|{alternative}|{feature}", self.page_entity(page)),
|
||||
),
|
||||
Resource::Group { name } => uid("Group", name),
|
||||
};
|
||||
entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new()));
|
||||
@@ -357,22 +437,32 @@ impl Policy {
|
||||
return Decision::default();
|
||||
};
|
||||
// An absent context is an empty one, not a malformed one.
|
||||
let context = if context.is_object() { context } else { serde_json::json!({}) };
|
||||
let context = if context.is_object() {
|
||||
context
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let Ok(context) = Context::from_json_value(context, None) else {
|
||||
return Decision::default();
|
||||
};
|
||||
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) else {
|
||||
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None)
|
||||
else {
|
||||
return Decision::default();
|
||||
};
|
||||
let response = Authorizer::new().is_authorized(&request, &self.policies, &entities);
|
||||
Decision {
|
||||
allow: response.decision() == cedar_policy::Decision::Allow,
|
||||
reasons: response.diagnostics().reason().map(|id| id.to_string()).collect(),
|
||||
reasons: response
|
||||
.diagnostics()
|
||||
.reason()
|
||||
.map(|id| id.to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool {
|
||||
self.decide(principal, action, resource, serde_json::json!({})).allow
|
||||
self.decide(principal, action, resource, serde_json::json!({}))
|
||||
.allow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,21 +563,50 @@ pub async fn serve(
|
||||
// A question that names a person and lists no groups is
|
||||
// asking portal what they belong to: their memberships,
|
||||
// matched on subject, phone number or address.
|
||||
if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) {
|
||||
if let (
|
||||
Principal::User {
|
||||
sub,
|
||||
groups,
|
||||
phone,
|
||||
email,
|
||||
},
|
||||
Some(state),
|
||||
) = (&mut q.principal, &state)
|
||||
{
|
||||
if groups.is_empty() && sub != "anonymous" {
|
||||
let who = crate::memberships::Identity {
|
||||
sub: Some(sub.clone()),
|
||||
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||
phone: phone.as_deref().and_then(|p| {
|
||||
crate::memberships::normalize_phone(
|
||||
p,
|
||||
&std::env::var("PHONE_COUNTRY")
|
||||
.unwrap_or_else(|_| "+47".into()),
|
||||
)
|
||||
}),
|
||||
email: email.as_deref().map(|e| e.trim().to_lowercase()),
|
||||
};
|
||||
*groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who);
|
||||
*groups = crate::memberships::groups_of(
|
||||
&crate::memberships::all(state).await,
|
||||
&who,
|
||||
);
|
||||
}
|
||||
}
|
||||
decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context)
|
||||
decide_and_publish(
|
||||
&nats,
|
||||
&access.load(),
|
||||
&q.principal,
|
||||
q.action,
|
||||
&q.resource,
|
||||
q.context,
|
||||
)
|
||||
}
|
||||
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
|
||||
Err(e) => Decision {
|
||||
allow: false,
|
||||
reasons: vec![format!("malformed question: {e}")],
|
||||
},
|
||||
};
|
||||
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
||||
let bytes = serde_json::to_vec(&decision)
|
||||
.unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
||||
let _ = nats.publish(reply, bytes.into()).await;
|
||||
}
|
||||
}
|
||||
@@ -513,14 +632,22 @@ mod tests {
|
||||
}
|
||||
|
||||
fn user(groups: &[&str]) -> Principal {
|
||||
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
|
||||
Principal::User {
|
||||
sub: "u".into(),
|
||||
groups: groups.iter().map(|g| g.to_string()).collect(),
|
||||
phone: None,
|
||||
email: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generated_policy_validates_against_the_schema() {
|
||||
let p = site();
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
assert!(p.rules.iter().any(|r| r.id == "move:applicants:open>invited"));
|
||||
assert!(p
|
||||
.rules
|
||||
.iter()
|
||||
.any(|r| r.id == "move:applicants:open>invited"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -530,7 +657,12 @@ mod tests {
|
||||
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/")));
|
||||
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review")));
|
||||
assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review")));
|
||||
let d = p.decide(&user(&["owners"]), Action::Submit, &page("/review"), json!({}));
|
||||
let d = p.decide(
|
||||
&user(&["owners"]),
|
||||
Action::Submit,
|
||||
&page("/review"),
|
||||
json!({}),
|
||||
);
|
||||
assert!(d.allow);
|
||||
assert_eq!(d.reasons, vec!["open:/review"]);
|
||||
// A concrete dynamic path resolves to its pattern's rule.
|
||||
@@ -541,8 +673,16 @@ mod tests {
|
||||
#[test]
|
||||
fn reads_follow_requires_group_or_public() {
|
||||
let p = site();
|
||||
let desk = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "".into() };
|
||||
let releases = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "Releases".into() };
|
||||
let desk = Resource::Feature {
|
||||
page: "/review".into(),
|
||||
alternative: "Applicants".into(),
|
||||
feature: "".into(),
|
||||
};
|
||||
let releases = Resource::Feature {
|
||||
page: "/review".into(),
|
||||
alternative: "Applicants".into(),
|
||||
feature: "Releases".into(),
|
||||
};
|
||||
assert!(p.allows(&user(&["owners"]), Action::Read, &desk));
|
||||
assert!(!p.allows(&user(&["staff"]), Action::Read, &desk));
|
||||
assert!(p.allows(&Principal::anonymous(), Action::Read, &releases));
|
||||
@@ -551,26 +691,88 @@ mod tests {
|
||||
#[test]
|
||||
fn transitions_are_exactly_the_desks_buttons() {
|
||||
let p = site();
|
||||
let bucket = Resource::Bucket { name: "applicants".into() };
|
||||
let bucket = Resource::Bucket {
|
||||
name: "applicants".into(),
|
||||
};
|
||||
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to });
|
||||
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
||||
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("invited", "active")).allow);
|
||||
assert!(
|
||||
p.decide(
|
||||
&user(&["owners"]),
|
||||
Action::Transition,
|
||||
&bucket,
|
||||
ctx("open", "invited")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
assert!(
|
||||
p.decide(
|
||||
&user(&["owners"]),
|
||||
Action::Transition,
|
||||
&bucket,
|
||||
ctx("invited", "active")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
// Right target, wrong starting state: no rule.
|
||||
assert!(!p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "active")).allow);
|
||||
assert!(!p.decide(&user(&["staff"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
|
||||
assert!(
|
||||
!p.decide(
|
||||
&user(&["owners"]),
|
||||
Action::Transition,
|
||||
&bucket,
|
||||
ctx("open", "active")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
assert!(
|
||||
!p.decide(
|
||||
&user(&["staff"]),
|
||||
Action::Transition,
|
||||
&bucket,
|
||||
ctx("open", "invited")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_submitter_moves_their_own_record_only_with_the_matching_email() {
|
||||
let p = site();
|
||||
let bucket = Resource::Bucket { name: "subscribers".into() };
|
||||
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
|
||||
let ctx = |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")).allow);
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })).allow);
|
||||
let bucket = Resource::Bucket {
|
||||
name: "subscribers".into(),
|
||||
};
|
||||
let me = Principal::Submitter {
|
||||
record: "r1".into(),
|
||||
email: "a@b.c".into(),
|
||||
};
|
||||
let ctx =
|
||||
|stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
|
||||
assert!(
|
||||
p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c"))
|
||||
.allow
|
||||
);
|
||||
assert!(
|
||||
!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z"))
|
||||
.allow
|
||||
);
|
||||
assert!(
|
||||
!p.decide(
|
||||
&me,
|
||||
Action::SelfTransition,
|
||||
&bucket,
|
||||
json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })
|
||||
)
|
||||
.allow
|
||||
);
|
||||
// A signed-in owner has no such rule: the link is the credential.
|
||||
assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
|
||||
assert!(
|
||||
!p.decide(
|
||||
&user(&["owners"]),
|
||||
Action::SelfTransition,
|
||||
&bucket,
|
||||
ctx("a@b.c")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
}
|
||||
|
||||
// The applicant written to for a missing drawing sends it from
|
||||
@@ -590,15 +792,52 @@ mod tests {
|
||||
.unwrap();
|
||||
let p = Policy::from_content(&questions, &aggregates);
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
let bucket = Resource::Bucket { name: "cases".into() };
|
||||
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
|
||||
let bucket = Resource::Bucket {
|
||||
name: "cases".into(),
|
||||
};
|
||||
let me = Principal::Submitter {
|
||||
record: "r1".into(),
|
||||
email: "a@b.c".into(),
|
||||
};
|
||||
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to, "stored_email": "a@b.c" });
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("incomplete", "complete")).allow);
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow);
|
||||
assert!(
|
||||
p.decide(
|
||||
&me,
|
||||
Action::SelfTransition,
|
||||
&bucket,
|
||||
ctx("incomplete", "complete")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
assert!(
|
||||
p.decide(
|
||||
&me,
|
||||
Action::SelfTransition,
|
||||
&bucket,
|
||||
ctx("complete", "withdrawn")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
// And only where it was said: the paperwork cannot be sent
|
||||
// after the case is already withdrawn.
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow);
|
||||
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow);
|
||||
assert!(
|
||||
!p.decide(
|
||||
&me,
|
||||
Action::SelfTransition,
|
||||
&bucket,
|
||||
ctx("withdrawn", "complete")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
assert!(
|
||||
!p.decide(
|
||||
&me,
|
||||
Action::SelfTransition,
|
||||
&bucket,
|
||||
ctx("complete", "complete")
|
||||
)
|
||||
.allow
|
||||
);
|
||||
}
|
||||
|
||||
// Omitted, `from` is the initial state alone - what every
|
||||
@@ -606,8 +845,17 @@ mod tests {
|
||||
#[test]
|
||||
fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() {
|
||||
let p = site();
|
||||
assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed"));
|
||||
assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1);
|
||||
assert!(p
|
||||
.rules
|
||||
.iter()
|
||||
.any(|r| r.id == "self:subscribers:open>unsubscribed"));
|
||||
assert_eq!(
|
||||
p.rules
|
||||
.iter()
|
||||
.filter(|r| r.id.starts_with("self:subscribers:"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// "On arrival" is the bucket's own first state, not the word
|
||||
@@ -633,20 +881,49 @@ mod tests {
|
||||
"the rule must start where the records do: {:?}",
|
||||
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
|
||||
);
|
||||
let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() };
|
||||
let bucket = Resource::Bucket { name: "watchers".into() };
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow);
|
||||
let me = Principal::Submitter {
|
||||
record: "r".into(),
|
||||
email: "a@b.c".into(),
|
||||
};
|
||||
let bucket = Resource::Bucket {
|
||||
name: "watchers".into(),
|
||||
};
|
||||
assert!(
|
||||
p.decide(
|
||||
&me,
|
||||
Action::SelfTransition,
|
||||
&bucket,
|
||||
json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })
|
||||
)
|
||||
.allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invites_and_automation() {
|
||||
let p = site();
|
||||
let owners = Resource::Group { name: "owners".into() };
|
||||
let owners = Resource::Group {
|
||||
name: "owners".into(),
|
||||
};
|
||||
assert!(p.allows(&user(&["owners"]), Action::Invite, &owners));
|
||||
assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners));
|
||||
let bucket = Resource::Bucket { name: "applicants".into() };
|
||||
assert!(p.allows(&Principal::Service { name: "automation".into() }, Action::AutomationRead, &bucket));
|
||||
assert!(!p.allows(&Principal::Service { name: "someone".into() }, Action::AutomationRead, &bucket));
|
||||
let bucket = Resource::Bucket {
|
||||
name: "applicants".into(),
|
||||
};
|
||||
assert!(p.allows(
|
||||
&Principal::Service {
|
||||
name: "automation".into()
|
||||
},
|
||||
Action::AutomationRead,
|
||||
&bucket
|
||||
));
|
||||
assert!(!p.allows(
|
||||
&Principal::Service {
|
||||
name: "someone".into()
|
||||
},
|
||||
Action::AutomationRead,
|
||||
&bucket
|
||||
));
|
||||
assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket));
|
||||
}
|
||||
|
||||
@@ -657,12 +934,18 @@ mod tests {
|
||||
r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
||||
assert!(
|
||||
p.decide(&q.principal, q.action, &q.resource, q.context)
|
||||
.allow
|
||||
);
|
||||
let q: Query = serde_json::from_str(
|
||||
r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
|
||||
assert!(
|
||||
p.decide(&q.principal, q.action, &q.resource, q.context)
|
||||
.allow
|
||||
);
|
||||
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
|
||||
}
|
||||
|
||||
|
||||
+20
-7
@@ -78,7 +78,9 @@ fn is_zero(n: &u32) -> bool {
|
||||
|
||||
impl LeavesSpec {
|
||||
pub fn millis(&self) -> i64 {
|
||||
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
|
||||
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes))
|
||||
* 60
|
||||
* 1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +202,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
let mut event_for_state = HashMap::new();
|
||||
let mut state_for_event = HashMap::new();
|
||||
for (state, raw_state) in &raw_schema.states {
|
||||
if let Some(existing) = state_for_event.insert(raw_state.event.clone(), state.clone())
|
||||
{
|
||||
if let Some(existing) = state_for_event.insert(raw_state.event.clone(), state.clone()) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: event {:?} used by both state {:?} and {:?}",
|
||||
raw_state.event, existing, state
|
||||
@@ -238,7 +239,9 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
}
|
||||
if let Some(group) = raw_state.grants {
|
||||
if group.trim().is_empty() {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group"
|
||||
);
|
||||
}
|
||||
grants_for_state.insert(state.clone(), group);
|
||||
}
|
||||
@@ -248,7 +251,11 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
}
|
||||
// A clock may only make a move a person could have
|
||||
// made, so it is held to the same graph.
|
||||
if !raw_schema.transitions.get(&state).is_some_and(|tos| tos.contains(&after.to)) {
|
||||
if !raw_schema
|
||||
.transitions
|
||||
.get(&state)
|
||||
.is_some_and(|tos| tos.contains(&after.to))
|
||||
{
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: after.to {:?} is not a declared transition from it",
|
||||
after.to
|
||||
@@ -261,7 +268,10 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
|
||||
}
|
||||
let through_a_record = crate::mail::parent_of(&mail.to).is_some();
|
||||
if !mail.to_submitter() && !through_a_record && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
|
||||
if !mail.to_submitter()
|
||||
&& !through_a_record
|
||||
&& !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace))
|
||||
{
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter`, one address, or a field of the record this one belongs to like `{{notice.email}}`, not {:?}",
|
||||
mail.to
|
||||
@@ -334,7 +344,10 @@ pub fn replay(schema: &AggregateSchema, id: &str, events: &[Event]) -> Option<Ag
|
||||
pub enum TransitionError {
|
||||
/// No creation event found for this id - nothing to transition.
|
||||
UnknownAggregate,
|
||||
DisallowedTransition { from: String, to: String },
|
||||
DisallowedTransition {
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
/// A concurrent writer advanced this aggregate past the sequence
|
||||
/// this call read (see `events::store::append_event`'s CAS).
|
||||
Conflict,
|
||||
|
||||
+28
-6
@@ -37,7 +37,11 @@ pub async fn run(state: AppState) {
|
||||
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let questions = state.questions.load();
|
||||
let events: Vec<_> = questions.values().filter(|q| q.event.is_some()).cloned().collect();
|
||||
let events: Vec<_> = questions
|
||||
.values()
|
||||
.filter(|q| q.event.is_some())
|
||||
.cloned()
|
||||
.collect();
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -100,7 +104,9 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
let mut refreshed = answer.clone();
|
||||
refreshed.responses = responses.clone();
|
||||
if let Some(store) = &store {
|
||||
store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?;
|
||||
store
|
||||
.put(&id, serde_json::to_vec(&refreshed)?.into())
|
||||
.await?;
|
||||
tracing::info!(question = %q.id, "event record refreshed from content");
|
||||
}
|
||||
}
|
||||
@@ -108,15 +114,31 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
if answer.state == schema.initial && now_ms >= ends {
|
||||
let target = "awaiting_summary";
|
||||
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
|
||||
match crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms).await {
|
||||
match crate::aggregates::transition(
|
||||
&state.jetstream,
|
||||
schema,
|
||||
&id,
|
||||
target,
|
||||
payload.clone(),
|
||||
now_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
|
||||
crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
crate::aggregates::transition(
|
||||
&state.jetstream,
|
||||
schema,
|
||||
&id,
|
||||
target,
|
||||
payload.clone(),
|
||||
now_ms,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("{e}")),
|
||||
}
|
||||
|
||||
+134
-25
@@ -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)?;
|
||||
@@ -107,7 +113,9 @@ pub async fn store_answer(
|
||||
// submission the NATS notification event has already recorded"
|
||||
// policy as the KV write above.
|
||||
if let Some(schema) = aggregates.get(bucket) {
|
||||
if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await {
|
||||
if let Err(e) =
|
||||
crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await
|
||||
{
|
||||
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
|
||||
}
|
||||
}
|
||||
@@ -121,17 +129,27 @@ pub const OWNER_FIELD: &str = "by";
|
||||
|
||||
/// Every record in `bucket` that belongs to `username`, newest first.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
|
||||
pub async fn own_records(
|
||||
js: &async_nats::jetstream::Context,
|
||||
bucket: &str,
|
||||
username: &str,
|
||||
) -> Vec<Answer> {
|
||||
use futures::TryStreamExt;
|
||||
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
|
||||
let Ok(store) = js.get_key_value(bucket).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys: Vec<String> = match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut own = Vec::new();
|
||||
for key in keys {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
||||
let Ok(Some(bytes)) = store.get(&key).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
|
||||
own.push(answer);
|
||||
}
|
||||
@@ -145,9 +163,16 @@ pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, user
|
||||
/// it (the event log keeps them). Run after every store, so two sends
|
||||
/// racing each other - a double click - both settle on the same one.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
|
||||
pub async fn keep_newest_own(
|
||||
js: &async_nats::jetstream::Context,
|
||||
bucket: &str,
|
||||
initial: &str,
|
||||
username: &str,
|
||||
) {
|
||||
let own = own_records(js, bucket, username).await;
|
||||
let Ok(store) = js.get_key_value(bucket).await else { return };
|
||||
let Ok(store) = js.get_key_value(bucket).await else {
|
||||
return;
|
||||
};
|
||||
for older in own.iter().filter(|a| a.state == initial).skip(1) {
|
||||
if let Err(e) = store.delete(&older.id).await {
|
||||
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
|
||||
@@ -161,16 +186,39 @@ pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str,
|
||||
/// the address a list would open it from, and may be left empty to
|
||||
/// keep it.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
|
||||
pub async fn fill_own(
|
||||
js: &async_nats::jetstream::Context,
|
||||
question: &mut crate::content::Question,
|
||||
user: Option<&crate::auth::User>,
|
||||
) {
|
||||
let Some(user) = user else { return };
|
||||
for alt in &mut question.alternatives {
|
||||
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
|
||||
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
|
||||
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
|
||||
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(mine) = own_records(js, &bucket, &user.username)
|
||||
.await
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for req in alt
|
||||
.features
|
||||
.iter_mut()
|
||||
.flat_map(|f| f.requirements.iter_mut())
|
||||
{
|
||||
if req.kind == "hidden" {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
|
||||
let Some(value) = mine
|
||||
.responses
|
||||
.get(&req.name)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if req.kind == "file" {
|
||||
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
|
||||
req.optional = true;
|
||||
@@ -211,7 +259,12 @@ pub async fn transition_answers(
|
||||
.load()
|
||||
.at(storyline)
|
||||
.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"))?;
|
||||
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
@@ -327,7 +380,9 @@ async fn apply_transition(
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::from_user(Some(user)),
|
||||
crate::access::Action::Transition,
|
||||
&crate::access::Resource::Bucket { name: bucket.to_string() },
|
||||
&crate::access::Resource::Bucket {
|
||||
name: bucket.to_string(),
|
||||
},
|
||||
serde_json::json!({ "from": answer.state, "to": item.to }),
|
||||
);
|
||||
if !decision.allow {
|
||||
@@ -364,7 +419,15 @@ async fn apply_transition(
|
||||
.put(&item.item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses, answer.storyline).await;
|
||||
crate::mail::on_state(
|
||||
state,
|
||||
bucket,
|
||||
&item.item_id,
|
||||
&item.to,
|
||||
&answer.responses,
|
||||
answer.storyline,
|
||||
)
|
||||
.await;
|
||||
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
||||
// An expired invite sent again: the move back is the asking.
|
||||
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
||||
@@ -374,7 +437,12 @@ async fn apply_transition(
|
||||
// The decision extends the DAG as a child of the answer's own
|
||||
// submission hash, published like any other answer.
|
||||
let parent_hashes = vec![item.item_id.clone()];
|
||||
let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
let chain_hash = hash_node(
|
||||
question_id,
|
||||
&parent_hashes,
|
||||
&decision_payload_for_event,
|
||||
decided_ms,
|
||||
);
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
@@ -441,13 +509,22 @@ pub async fn self_transition_answer(
|
||||
// policy compares them and knows the one move a submitter may make.
|
||||
// Refusal reads "not found" so the endpoint cannot be used to probe
|
||||
// which records or emails exist.
|
||||
let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let stored_email = answer
|
||||
.responses
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let decision = crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() },
|
||||
&crate::access::Principal::Submitter {
|
||||
record: item_id.clone(),
|
||||
email: email.clone(),
|
||||
},
|
||||
crate::access::Action::SelfTransition,
|
||||
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
|
||||
&crate::access::Resource::Bucket {
|
||||
name: self_transition.bucket.clone(),
|
||||
},
|
||||
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
|
||||
);
|
||||
if !decision.allow {
|
||||
@@ -462,8 +539,15 @@ pub async fn self_transition_answer(
|
||||
.get(&self_transition.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| OPEN_STATE.to_string());
|
||||
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
|
||||
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
||||
let in_reach = self_transition
|
||||
.from_states(&initial)
|
||||
.iter()
|
||||
.any(|s| s == &answer.state);
|
||||
return Err(ServerFnError::new(if in_reach {
|
||||
"not found"
|
||||
} else {
|
||||
"already processed"
|
||||
}));
|
||||
}
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
@@ -493,11 +577,31 @@ pub async fn self_transition_answer(
|
||||
.put(&item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses, answer.storyline).await;
|
||||
crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
|
||||
crate::mail::on_state(
|
||||
&state,
|
||||
&self_transition.bucket,
|
||||
&item_id,
|
||||
&self_transition.to,
|
||||
&answer.responses,
|
||||
answer.storyline,
|
||||
)
|
||||
.await;
|
||||
crate::people::on_state(
|
||||
&state,
|
||||
&self_transition.bucket,
|
||||
&item_id,
|
||||
&self_transition.to,
|
||||
&answer.responses,
|
||||
)
|
||||
.await;
|
||||
|
||||
let parent_hashes = vec![item_id.clone()];
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||
let chain_hash = hash_node(
|
||||
&question_id,
|
||||
&parent_hashes,
|
||||
&decision_payload_for_event,
|
||||
decided_ms,
|
||||
);
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
@@ -537,6 +641,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
},
|
||||
Answer {
|
||||
id: "def456".to_string(),
|
||||
@@ -549,10 +654,14 @@ 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(
|
||||
answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(),
|
||||
answers
|
||||
.iter()
|
||||
.map(|a| serde_json::to_value(a).unwrap())
|
||||
.collect(),
|
||||
);
|
||||
let round_tripped: Vec<Answer> =
|
||||
serde_json::from_value(value).expect("Answer list round-trips");
|
||||
|
||||
+759
-135
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -53,7 +53,9 @@ pub async fn record_node(
|
||||
alternative: Some(alternative.to_string()),
|
||||
timestamp_ms,
|
||||
};
|
||||
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
|
||||
store
|
||||
.put(chain_hash, serde_json::to_vec(&node)?.into())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+38
-12
@@ -9,7 +9,10 @@ use super::*;
|
||||
/// config; a file that exists but doesn't parse or validate is a real
|
||||
/// error, surfaced at boot rather than papered over.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<Storylines<SiteConfig>> {
|
||||
pub async fn load_site_from_gitea(
|
||||
repo_url: &str,
|
||||
branch: &str,
|
||||
) -> anyhow::Result<Storylines<SiteConfig>> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
||||
@@ -24,8 +27,8 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
|
||||
let arity = super::variants::arity(&files)?;
|
||||
let mut each = Vec::with_capacity(arity);
|
||||
for n in 0..arity {
|
||||
let site: SiteConfig =
|
||||
serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
||||
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);
|
||||
}
|
||||
@@ -40,7 +43,10 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
|
||||
Ok(format!(
|
||||
"{}/api/v1/repos/{owner}/{repo}/raw",
|
||||
gitea_api_base(repo_url)?
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -114,7 +120,8 @@ pub(crate) async fn fetch_gitea_file(
|
||||
pub async fn load_aggregates_from_gitea(
|
||||
repo_url: &str,
|
||||
branch: &str,
|
||||
) -> anyhow::Result<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>> {
|
||||
) -> anyhow::Result<Storylines<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>>
|
||||
{
|
||||
let (owner, repo) = parse_owner_repo(repo_url)?;
|
||||
let api_base = gitea_api_base(repo_url)?;
|
||||
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
|
||||
@@ -123,7 +130,10 @@ pub async fn load_aggregates_from_gitea(
|
||||
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))
|
||||
.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 })
|
||||
}
|
||||
@@ -183,7 +193,11 @@ pub async fn load_questions_from_gitea(
|
||||
#[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)] {
|
||||
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");
|
||||
}
|
||||
@@ -219,9 +233,13 @@ pub async fn watch_for_reload(
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
subdir: String,
|
||||
questions: std::sync::Arc<arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>>,
|
||||
questions: std::sync::Arc<
|
||||
arc_swap::ArcSwap<Storylines<std::collections::HashMap<String, Question>>>,
|
||||
>,
|
||||
aggregates: std::sync::Arc<
|
||||
arc_swap::ArcSwap<Storylines<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<Storylines<SiteConfig>>>,
|
||||
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
|
||||
@@ -255,14 +273,23 @@ pub async fn watch_for_reload(
|
||||
};
|
||||
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
||||
Ok(loaded) => {
|
||||
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| same_arity(loaded.arity(), loaded_aggregates.arity(), loaded_site.arity())) {
|
||||
if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| {
|
||||
same_arity(
|
||||
loaded.arity(),
|
||||
loaded_aggregates.arity(),
|
||||
loaded_site.arity(),
|
||||
)
|
||||
}) {
|
||||
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
|
||||
continue;
|
||||
}
|
||||
let count = loaded.len();
|
||||
// The policy is compiled from the content it gates, so
|
||||
// it swaps in the same breath.
|
||||
access.store(std::sync::Arc::new(crate::access::Policy::from_content(&loaded, &loaded_aggregates)));
|
||||
access.store(std::sync::Arc::new(crate::access::Policy::from_content(
|
||||
&loaded,
|
||||
&loaded_aggregates,
|
||||
)));
|
||||
questions.store(std::sync::Arc::new(loaded));
|
||||
aggregates.store(std::sync::Arc::new(loaded_aggregates));
|
||||
site.store(std::sync::Arc::new(loaded_site));
|
||||
@@ -275,4 +302,3 @@ pub async fn watch_for_reload(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+115
-15
@@ -49,15 +49,56 @@ pub struct GiteaRepoQuery {
|
||||
pub async fn site_asset_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> axum::response::Response {
|
||||
serve_repo_file(&state, &path, false).await
|
||||
}
|
||||
|
||||
/// `GET /.well-known/{*path}` - the content repo overlaid on the one part
|
||||
/// of the URL space that can never collide with a question: RFC 8615
|
||||
/// reserves `/.well-known/`, and a question's id is derived from a file
|
||||
/// name under `questions/`, which cannot begin with a dot. A repo ships
|
||||
/// `.well-known/security.txt`, `.well-known/assetlinks.json`,
|
||||
/// `.well-known/matrix/server` and so on at the path they are served at,
|
||||
/// and a miss is a real 404 rather than the app shell answering 200 with
|
||||
/// an HTML page - which is what a client probing for one of these would
|
||||
/// otherwise have to parse.
|
||||
///
|
||||
/// Paths Caddy claims in front of portal (mail autoconfiguration, the
|
||||
/// CalDAV and CardDAV redirects) never reach here.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn well_known_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> axum::response::Response {
|
||||
serve_repo_file(&state, &format!(".well-known/{path}"), true).await
|
||||
}
|
||||
|
||||
/// Proxies one repo-relative file from the content repo's default branch.
|
||||
/// `well_known` only changes what an extensionless file is typed as: under
|
||||
/// `/.well-known/` those are JSON by convention (`matrix/server`,
|
||||
/// `nodeinfo`), while elsewhere a file with no extension is an unknown.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn serve_repo_file(
|
||||
state: &crate::server::AppState,
|
||||
path: &str,
|
||||
well_known: bool,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
if !is_safe_site_path(&path) {
|
||||
if !is_safe_site_path(path) {
|
||||
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
|
||||
}
|
||||
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
|
||||
let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) {
|
||||
let url = format!(
|
||||
"{}/{path}?ref={}",
|
||||
state.content_raw_base, state.content_branch
|
||||
);
|
||||
let resp = match crate::http::client()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("site asset {path}: {e}");
|
||||
@@ -79,12 +120,26 @@ pub async fn site_asset_handler(
|
||||
Some("avif") => "image/avif",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("woff2") => "font/woff2",
|
||||
Some("txt") => "text/plain; charset=utf-8",
|
||||
Some("xml") => "application/xml; charset=utf-8",
|
||||
Some("webmanifest") => "application/manifest+json",
|
||||
// An Apple configuration profile only offers to install when it
|
||||
// arrives under this type; anything else is just a download.
|
||||
Some("mobileconfig") => "application/x-apple-aspen-config",
|
||||
// A file with no extension at all: rsplit yields the whole name.
|
||||
_ if well_known && !path.rsplit('/').next().unwrap_or_default().contains('.') => {
|
||||
"application/json; charset=utf-8"
|
||||
}
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
// Photographs are large and do not change under a name, so a
|
||||
// browser may keep them for an hour; everything else revalidates,
|
||||
// so a content push shows on reload.
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") {
|
||||
"public, max-age=3600"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
(
|
||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
|
||||
bytes,
|
||||
@@ -114,14 +169,22 @@ pub async fn gitea_repo_handler(
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
};
|
||||
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
|
||||
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"invalid owner/repo".to_string(),
|
||||
));
|
||||
}
|
||||
let GiteaRepoQuery { owner, repo } = query;
|
||||
|
||||
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
|
||||
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?;
|
||||
.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
format!("fetching {api_url}: {e}"),
|
||||
)
|
||||
})?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
@@ -168,27 +231,38 @@ pub async fn automation_kv_handler(
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if expected.is_empty() || presented != expected {
|
||||
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"unauthorized".to_string(),
|
||||
));
|
||||
}
|
||||
// The token authenticates the automation service; what it may read
|
||||
// is the policy's to say, like every other principal.
|
||||
let decision = crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::Service { name: "automation".to_string() },
|
||||
&crate::access::Principal::Service {
|
||||
name: "automation".to_string(),
|
||||
},
|
||||
crate::access::Action::AutomationRead,
|
||||
&crate::access::Resource::Bucket { name: bucket.clone() },
|
||||
&crate::access::Resource::Bucket {
|
||||
name: bucket.clone(),
|
||||
},
|
||||
serde_json::json!({}),
|
||||
);
|
||||
if !decision.allow {
|
||||
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"not authorized".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let store = state
|
||||
.jetstream
|
||||
.get_key_value(&bucket)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
|
||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
format!("bucket unavailable: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
let keys: Vec<String> = store
|
||||
@@ -209,3 +283,29 @@ pub async fn automation_kv_handler(
|
||||
Ok(axum::Json(serde_json::Value::Array(items)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The overlay's whole safety argument: `/.well-known/` is reserved by
|
||||
/// RFC 8615 and a question's id comes from a file under `questions/`, so
|
||||
/// a repo file served at the site root cannot shadow a page - and the
|
||||
/// path it is fetched by still cannot climb out of the repo.
|
||||
#[test]
|
||||
fn well_known_paths_stay_inside_the_repo() {
|
||||
for path in [
|
||||
".well-known/security.txt",
|
||||
".well-known/matrix/server",
|
||||
".well-known/assetlinks.json",
|
||||
] {
|
||||
assert!(is_safe_site_path(path), "{path} should be servable");
|
||||
}
|
||||
for path in [
|
||||
".well-known/../questions/index.yaml",
|
||||
".well-known//escape",
|
||||
"/.well-known/security.txt",
|
||||
] {
|
||||
assert!(!is_safe_site_path(path), "{path} should be refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,80 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
|
||||
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
||||
}
|
||||
|
||||
/// Markdown that a person wrote into a record - a bio on a card - as
|
||||
/// opposed to the site's own content. Paragraphs, line breaks as they
|
||||
/// were typed, lists, emphasis and links; no images (a picture has its
|
||||
/// own field, and an image in text is a way to load something from
|
||||
/// elsewhere into every visitor's page), no raw HTML, headings as plain
|
||||
/// paragraphs. Links go to https, mailto or tel only, and say they are
|
||||
/// the writer's, not the site's (`rel="nofollow ugc"`).
|
||||
pub fn render_written_markdown(text: &str) -> String {
|
||||
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
|
||||
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
|
||||
let mut in_dropped_link = 0usize;
|
||||
let mut in_image = 0usize;
|
||||
let filtered = parser.filter_map(|event| match event {
|
||||
Event::Html(_) | Event::InlineHtml(_) => None,
|
||||
Event::Start(Tag::Image { .. }) => {
|
||||
in_image += 1;
|
||||
None
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
in_image = in_image.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
_ if in_image > 0 => None,
|
||||
Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
|
||||
Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
|
||||
// What someone typed on a new line is on a new line.
|
||||
Event::SoftBreak => Some(Event::HardBreak),
|
||||
Event::Start(Tag::Link { dest_url, .. })
|
||||
if !(dest_url.starts_with("https://")
|
||||
|| dest_url.starts_with("mailto:")
|
||||
|| dest_url.starts_with("tel:")) =>
|
||||
{
|
||||
in_dropped_link += 1;
|
||||
None
|
||||
}
|
||||
Event::End(TagEnd::Link) if in_dropped_link > 0 => {
|
||||
in_dropped_link -= 1;
|
||||
None
|
||||
}
|
||||
other => Some(other),
|
||||
});
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, filtered);
|
||||
out.trim()
|
||||
.replace("<a href=", "<a rel=\"nofollow ugc\" href=")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod written_tests {
|
||||
use super::render_written_markdown as md;
|
||||
|
||||
#[test]
|
||||
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
|
||||
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
|
||||
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
|
||||
assert!(md("**Sivilingeniør**, *NTNU*")
|
||||
.contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
|
||||
assert_eq!(
|
||||
md("[Westra](https://westra.no)"),
|
||||
"<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>"
|
||||
);
|
||||
// Everything that would put something else in the page is gone.
|
||||
assert_eq!(md("<script>alert(1)</script>"), "");
|
||||
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
|
||||
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
|
||||
assert_eq!(
|
||||
md("[home](/kontor)"),
|
||||
"<p>home</p>",
|
||||
"a site path is the site's to link, not a bio's"
|
||||
);
|
||||
assert_eq!(
|
||||
md("before  after"),
|
||||
"<p>before after</p>"
|
||||
);
|
||||
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
|
||||
}
|
||||
}
|
||||
|
||||
+358
-83
@@ -13,19 +13,19 @@ use serde::{Deserialize, Serialize};
|
||||
// Loading is server-only; the rest is shared with the browser build.
|
||||
#[cfg(feature = "ssr")]
|
||||
mod gitea;
|
||||
#[cfg(feature = "ssr")]
|
||||
mod variants;
|
||||
mod handlers;
|
||||
mod markdown;
|
||||
mod validate;
|
||||
#[cfg(feature = "ssr")]
|
||||
mod variants;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use gitea::*;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use variants::*;
|
||||
pub use handlers::*;
|
||||
pub use markdown::*;
|
||||
pub use validate::*;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use variants::*;
|
||||
|
||||
/// One page: a prompt plus the paths on from it. The `questions/`
|
||||
/// directory tree IS the URL tree (`index.yaml` names its directory,
|
||||
@@ -129,7 +129,10 @@ impl Place {
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) {
|
||||
return Err(format!("lat {} / lon {} is not a point on Earth", self.lat, self.lon));
|
||||
return Err(format!(
|
||||
"lat {} / lon {} is not a point on Earth",
|
||||
self.lat, self.lon
|
||||
));
|
||||
}
|
||||
if !(0.0..=22.0).contains(&self.zoom) {
|
||||
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
|
||||
@@ -158,13 +161,23 @@ pub fn parse_duration_ms(text: &str) -> Result<i64, String> {
|
||||
let mut total: i64 = 0;
|
||||
let mut seen = false;
|
||||
for token in text.split_whitespace() {
|
||||
let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len());
|
||||
let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?;
|
||||
let (num, unit) = token.split_at(
|
||||
token
|
||||
.trim_end_matches(|c: char| c.is_ascii_alphabetic())
|
||||
.len(),
|
||||
);
|
||||
let n: i64 = num
|
||||
.parse()
|
||||
.map_err(|_| format!("bad duration {text:?}: {token:?}"))?;
|
||||
let per = match unit {
|
||||
"m" => 60_000,
|
||||
"h" => 3_600_000,
|
||||
"d" => 86_400_000,
|
||||
_ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad duration {text:?}: unit {unit:?} (use m, h, d)"
|
||||
))
|
||||
}
|
||||
};
|
||||
total += n * per;
|
||||
seen = true;
|
||||
@@ -228,7 +241,9 @@ pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>)
|
||||
for alt in &mut question.alternatives {
|
||||
for feature in &mut alt.features {
|
||||
for req in &mut feature.requirements {
|
||||
let Some(value) = req.value.as_deref() else { continue };
|
||||
let Some(value) = req.value.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let filled = match value {
|
||||
"{user.email}" => user.and_then(|u| u.email.clone()),
|
||||
"{user.name}" => user.map(|u| u.display_name.clone()),
|
||||
@@ -274,7 +289,13 @@ pub fn template_action(action: &str, responses: &serde_json::Value) -> Option<St
|
||||
// A URL segment: keep it to what a segment can carry.
|
||||
let safe: String = scalar
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || "-_.".contains(c) {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&safe);
|
||||
rest = &rest[end + 1..];
|
||||
@@ -391,6 +412,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)]
|
||||
@@ -512,6 +540,18 @@ pub struct ResourceSpec {
|
||||
/// it where the silence needs a voice ("Nothing released yet").
|
||||
#[serde(default)]
|
||||
pub empty: Option<String>,
|
||||
/// Show at most this many items, applied after `jq`. For a feed
|
||||
/// where only the newest matters: a releases card wants the cut you
|
||||
/// should run, not every cut there has ever been, and a page that
|
||||
/// grows by one card per release gets longer forever.
|
||||
///
|
||||
/// After `jq` rather than before, so it caps what the page will
|
||||
/// actually render whatever the filter did — and so content can
|
||||
/// reorder with jq and still take the top of its own ordering. A
|
||||
/// source that yields a single record has no length and passes
|
||||
/// through untouched. `None` shows everything.
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
|
||||
@@ -520,18 +560,27 @@ pub struct ResourceSpec {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ResourceSource {
|
||||
Kv { bucket: String },
|
||||
GiteaStarred { username: String },
|
||||
Kv {
|
||||
bucket: String,
|
||||
},
|
||||
GiteaStarred {
|
||||
username: String,
|
||||
},
|
||||
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
|
||||
/// for private repos too (shape the link with jq: a private
|
||||
/// release's html_url 404s for anonymous visitors, so map it to
|
||||
/// null unless the repo is public).
|
||||
GiteaReleases { owner: String, repo: String },
|
||||
GiteaReleases {
|
||||
owner: String,
|
||||
repo: String,
|
||||
},
|
||||
/// Any other HTTPS JSON endpoint. Scheme-restricted and checked
|
||||
/// against loopback/private/link-local addresses at fetch time
|
||||
/// (`resource::fetch_url_resource`) - a server-side fetch of a
|
||||
/// content-supplied URL is SSRF surface, so it fails closed.
|
||||
Url { url: String },
|
||||
Url {
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResourceSpec {
|
||||
@@ -767,6 +816,14 @@ pub struct SiteConfig {
|
||||
/// intrinsic pixel height.
|
||||
#[serde(default)]
|
||||
pub wordmark_height: Option<f32>,
|
||||
/// Draw an SVG wordmark in the page's own ink instead of its own
|
||||
/// colours: the file becomes a mask over the text colour, so it
|
||||
/// follows the page's light and dark (and any theme a site adds),
|
||||
/// where an `<img>` only ever knows the system's. Opacity in the
|
||||
/// file still shows, so a lighter line stays lighter. For a
|
||||
/// one-colour mark; a mark with a colour of its own keeps `false`.
|
||||
#[serde(default)]
|
||||
pub wordmark_ink: bool,
|
||||
/// BCP 47-ish language code for the portal's own chrome strings
|
||||
/// ("Asked by", the nav lead...) and the html lang attribute.
|
||||
/// `None` means "en"; unknown codes fall back to English per key.
|
||||
@@ -799,6 +856,28 @@ pub struct SiteConfig {
|
||||
/// skipped, never half-sent (see `mail.rs`).
|
||||
#[serde(default)]
|
||||
pub mail: Option<MailConfig>,
|
||||
/// Where `/profile.mobileconfig` gets its values. Absent means the
|
||||
/// site offers no configuration profile and that route is a 404.
|
||||
#[serde(default)]
|
||||
pub profile: Option<ProfileConfig>,
|
||||
}
|
||||
|
||||
/// An Apple configuration profile rendered from the mail server's own PACC
|
||||
/// document - see `profile.rs`. Nothing here describes a server: `pacc` says
|
||||
/// where the description already lives, so hosts and ports are not written
|
||||
/// down a second time to drift from the first.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProfileConfig {
|
||||
/// The PACC document, normally
|
||||
/// `https://<mail host>/.well-known/user-agent-configuration.json`.
|
||||
pub pacc: String,
|
||||
/// What the accounts are called on the device. Defaults to the site's
|
||||
/// own host.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Shown as the profile's publisher. Defaults to `name`.
|
||||
#[serde(default)]
|
||||
pub organization: Option<String>,
|
||||
}
|
||||
|
||||
/// The sender identity, and the one built-in mail's wording.
|
||||
@@ -840,7 +919,10 @@ pub struct HeroConfig {
|
||||
|
||||
impl Default for HeroConfig {
|
||||
fn default() -> Self {
|
||||
Self { kind: default_hero_kind(), module: None }
|
||||
Self {
|
||||
kind: default_hero_kind(),
|
||||
module: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -880,6 +962,14 @@ impl SiteConfig {
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(profile) = &self.profile {
|
||||
if !profile.pacc.starts_with("https://") {
|
||||
anyhow::bail!(
|
||||
"site.yaml: profile.pacc {:?} must be an https:// URL (the PACC document the profile is built from)",
|
||||
profile.pacc
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(icon) = &self.favicon {
|
||||
let ok = [".svg", ".png", ".ico"].iter().any(|e| icon.ends_with(e));
|
||||
if !is_safe_site_path(icon) || !ok {
|
||||
@@ -1057,7 +1147,12 @@ pub fn earned_followups(
|
||||
queue.push_back(q);
|
||||
}
|
||||
while let Some(q) = queue.pop_front() {
|
||||
for action in q.alternatives.iter().filter(|a| asks_nothing(a)).filter_map(|a| a.action.as_deref()) {
|
||||
for action in q
|
||||
.alternatives
|
||||
.iter()
|
||||
.filter(|a| asks_nothing(a))
|
||||
.filter_map(|a| a.action.as_deref())
|
||||
{
|
||||
let Some(id) = action_target_id(questions, action) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1150,9 +1245,13 @@ pub fn build_questions(
|
||||
|
||||
/// 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>>> {
|
||||
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<_>>>()?;
|
||||
let each = (0..arity)
|
||||
.map(|n| build_one_storyline(&variants::storyline(files, n)))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
Ok(variants::Storylines { each })
|
||||
}
|
||||
|
||||
@@ -1168,8 +1267,8 @@ fn build_one_storyline(
|
||||
if name != "_section.yaml" {
|
||||
continue;
|
||||
}
|
||||
let mut section: SectionConfig = serde_yaml::from_str(raw)
|
||||
.map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
|
||||
let mut section: SectionConfig =
|
||||
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
|
||||
let dir = dir_from_path(rel);
|
||||
if let Some(rc) = §ion.requires_chain {
|
||||
section.requires_chain = Some(resolve_ref(&dir, rc));
|
||||
@@ -1274,7 +1373,9 @@ aggregates:
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
fn question_with_transitions(
|
||||
transitions_yaml: &str,
|
||||
) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
r#"
|
||||
id: /t
|
||||
@@ -1294,7 +1395,6 @@ alternatives:
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
}
|
||||
|
||||
|
||||
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
r#"
|
||||
@@ -1308,6 +1408,7 @@ alternatives:
|
||||
public: true
|
||||
source: {{ kind: kv, bucket: things }}
|
||||
- name: Object
|
||||
action: /object
|
||||
record_as: things
|
||||
features:
|
||||
- name: Yours
|
||||
@@ -1328,9 +1429,13 @@ alternatives:
|
||||
let good = question_with_record(" - { name: case, type: record, of: things }");
|
||||
validate_questions(&good, &schema_fixture()).unwrap();
|
||||
|
||||
let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }");
|
||||
let nowhere =
|
||||
question_with_record(" - { name: case, type: record, of: nothing_declared }");
|
||||
let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("has no aggregate declared"), "{err}");
|
||||
assert!(
|
||||
err.to_string().contains("has no aggregate declared"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let unsaid = question_with_record(" - { name: case, type: record }");
|
||||
let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err();
|
||||
@@ -1339,12 +1444,19 @@ alternatives:
|
||||
// `of` means one thing, so it belongs to one type.
|
||||
let stray = question_with_record(" - { name: case, of: things }");
|
||||
let err = validate_questions(&stray, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("only a type: record field has"), "{err}");
|
||||
assert!(
|
||||
err.to_string().contains("only a type: record field has"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
// The id arrives on the link; it is not looked up.
|
||||
let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }");
|
||||
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("the link it came in on carries the id"), "{err}");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("the link it came in on carries the id"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
// A state that grants a group makes an account for someone, so the
|
||||
@@ -1356,7 +1468,9 @@ alternatives:
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let asks = question_with_record(" - { name: name }\n - { name: email, type: email }");
|
||||
let asks = question_with_record(
|
||||
" - { name: name }\n - { name: email, type: email }",
|
||||
);
|
||||
validate_questions(&asks, &granting).unwrap();
|
||||
|
||||
let no_name = question_with_record(" - { name: email, type: email }");
|
||||
@@ -1379,7 +1493,10 @@ alternatives:
|
||||
let undeclared = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n",
|
||||
);
|
||||
assert!(undeclared.unwrap_err().to_string().contains("not a declared transition"));
|
||||
assert!(undeclared
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("not a declared transition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1405,6 +1522,7 @@ id: /t
|
||||
name: Where is it?
|
||||
alternatives:
|
||||
- name: A
|
||||
action: /t
|
||||
features:
|
||||
- name: Where
|
||||
requirements:
|
||||
@@ -1429,7 +1547,10 @@ alternatives:
|
||||
" bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }",
|
||||
);
|
||||
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("the visitor puts the pin"), "{err}");
|
||||
assert!(
|
||||
err.to_string().contains("the visitor puts the pin"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1439,7 +1560,13 @@ alternatives:
|
||||
let no_zoom = question_with_location(" value: \"59.8938,11.0332\"");
|
||||
validate_questions(&no_zoom, &schema_fixture()).unwrap();
|
||||
|
||||
for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] {
|
||||
for bad in [
|
||||
"\"59.8938\"",
|
||||
"\"91,11\"",
|
||||
"\"59,200\"",
|
||||
"\"here\"",
|
||||
"\"59,11,15,3\"",
|
||||
] {
|
||||
let questions = question_with_location(&format!(" value: {bad}"));
|
||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("opens the map"), "{bad}: {err}");
|
||||
@@ -1448,7 +1575,12 @@ alternatives:
|
||||
|
||||
#[test]
|
||||
fn a_page_about_a_place_says_where_and_how_close() {
|
||||
let place = Place { lat: 59.8938, lon: 11.0332, zoom: 15.0, name: Some("Tomter".into()) };
|
||||
let place = Place {
|
||||
lat: 59.8938,
|
||||
lon: 11.0332,
|
||||
zoom: 15.0,
|
||||
name: Some("Tomter".into()),
|
||||
};
|
||||
place.validate().unwrap();
|
||||
assert_eq!(place.as_value(), "59.8938,11.0332,15");
|
||||
// The default is a street and its neighbours, not the globe.
|
||||
@@ -1457,10 +1589,30 @@ alternatives:
|
||||
assert!(bare.name.is_none());
|
||||
|
||||
for bad in [
|
||||
Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None },
|
||||
Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None },
|
||||
Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None },
|
||||
Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) },
|
||||
Place {
|
||||
lat: 91.0,
|
||||
lon: 11.0,
|
||||
zoom: 15.0,
|
||||
name: None,
|
||||
},
|
||||
Place {
|
||||
lat: 59.0,
|
||||
lon: 181.0,
|
||||
zoom: 15.0,
|
||||
name: None,
|
||||
},
|
||||
Place {
|
||||
lat: 59.0,
|
||||
lon: 11.0,
|
||||
zoom: 30.0,
|
||||
name: None,
|
||||
},
|
||||
Place {
|
||||
lat: 59.0,
|
||||
lon: 11.0,
|
||||
zoom: 15.0,
|
||||
name: Some(" ".into()),
|
||||
},
|
||||
] {
|
||||
assert!(bad.validate().is_err(), "{bad:?}");
|
||||
}
|
||||
@@ -1468,7 +1620,8 @@ alternatives:
|
||||
|
||||
#[test]
|
||||
fn undeclared_from_state_is_rejected() {
|
||||
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
||||
let questions =
|
||||
question_with_transitions(" - { from: bogus, to: middle, label: X }");
|
||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("transition.from"));
|
||||
}
|
||||
@@ -1477,19 +1630,23 @@ alternatives:
|
||||
fn undeclared_edge_is_rejected() {
|
||||
// Both states exist, but open -> done skips a step the graph
|
||||
// never declared.
|
||||
let questions = question_with_transitions(" - { from: open, to: done, label: Skip }");
|
||||
let questions =
|
||||
question_with_transitions(" - { from: open, to: done, label: Skip }");
|
||||
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("not a declared edge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_action_is_rejected() {
|
||||
let question: Question =
|
||||
serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n")
|
||||
.unwrap();
|
||||
let question: Question = serde_yaml::from_str(
|
||||
"id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n",
|
||||
)
|
||||
.unwrap();
|
||||
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
|
||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||
assert!(err.to_string().contains("does not match any declared question id"));
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("does not match any declared question id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1508,6 +1665,7 @@ id: /b
|
||||
name: B
|
||||
alternatives:
|
||||
- name: A
|
||||
action: /b
|
||||
features:
|
||||
- name: ""
|
||||
requirements:
|
||||
@@ -1549,7 +1707,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)])
|
||||
@@ -1576,7 +1734,9 @@ alternatives:
|
||||
" - { name: email, type: email, relay: \"wss://relay.redoal.com\" }",
|
||||
);
|
||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||
assert!(err.to_string().contains("relay only makes sense on type: gesture"));
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("relay only makes sense on type: gesture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1615,7 +1775,8 @@ alternatives:
|
||||
let absolute: SiteConfig =
|
||||
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
|
||||
assert!(absolute.validate().is_err());
|
||||
let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
|
||||
let ok: SiteConfig =
|
||||
serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
|
||||
assert!(ok.validate().is_ok());
|
||||
}
|
||||
|
||||
@@ -1635,7 +1796,10 @@ alternatives:
|
||||
assert_eq!(route_from_path("index.yaml"), "/");
|
||||
assert_eq!(route_from_path("applied.yaml"), "/applied");
|
||||
assert_eq!(route_from_path("develop/index.yaml"), "/develop");
|
||||
assert_eq!(route_from_path("develop/proposal.yaml"), "/develop/proposal");
|
||||
assert_eq!(
|
||||
route_from_path("develop/proposal.yaml"),
|
||||
"/develop/proposal"
|
||||
);
|
||||
assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]");
|
||||
}
|
||||
|
||||
@@ -1650,8 +1814,10 @@ alternatives:
|
||||
|
||||
#[test]
|
||||
fn dynamic_patterns_capture_segments() {
|
||||
assert_eq!(path_matches("/review/[record]", "/review/abc"),
|
||||
Some(vec![("record".into(), "abc".into())]));
|
||||
assert_eq!(
|
||||
path_matches("/review/[record]", "/review/abc"),
|
||||
Some(vec![("record".into(), "abc".into())])
|
||||
);
|
||||
assert_eq!(path_matches("/review/[record]", "/review"), None);
|
||||
assert_eq!(path_matches("/review/[record]", "/other/abc"), None);
|
||||
assert_eq!(path_matches("/review", "/review"), Some(vec![]));
|
||||
@@ -1660,25 +1826,55 @@ alternatives:
|
||||
#[test]
|
||||
fn tree_becomes_router_with_inference_and_sections() {
|
||||
let files = vec![
|
||||
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string()),
|
||||
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
|
||||
("develop/index.yaml".to_string(), "name: Develop\nalternatives:\n - name: propose\n action: proposal\n".to_string()),
|
||||
("develop/proposal.yaml".to_string(), "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string()),
|
||||
("develop/proposed.yaml".to_string(), "name: Proposed\n".to_string()),
|
||||
("review/_section.yaml".to_string(), "qualifies: portal_owners\n".to_string()),
|
||||
("review/index.yaml".to_string(), "name: Review\n".to_string()),
|
||||
("review/_draft.yaml".to_string(), "not even valid yaml: [\n".to_string()),
|
||||
(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: go\n action: applied\n".to_string(),
|
||||
),
|
||||
(
|
||||
"applied.yaml".to_string(),
|
||||
"name: Applied\nfollowup: true\n".to_string(),
|
||||
),
|
||||
(
|
||||
"develop/index.yaml".to_string(),
|
||||
"name: Develop\nalternatives:\n - name: propose\n action: proposal\n"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"develop/proposal.yaml".to_string(),
|
||||
"name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string(),
|
||||
),
|
||||
(
|
||||
"develop/proposed.yaml".to_string(),
|
||||
"name: Proposed\n".to_string(),
|
||||
),
|
||||
(
|
||||
"review/_section.yaml".to_string(),
|
||||
"qualifies: portal_owners\n".to_string(),
|
||||
),
|
||||
(
|
||||
"review/index.yaml".to_string(),
|
||||
"name: Review\n".to_string(),
|
||||
),
|
||||
(
|
||||
"review/_draft.yaml".to_string(),
|
||||
"not even valid yaml: [\n".to_string(),
|
||||
),
|
||||
];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
|
||||
// Derived ids and resolved relative actions.
|
||||
assert_eq!(questions["/"].alternatives[0].action.as_deref(), Some("/applied"));
|
||||
assert_eq!(
|
||||
questions["/"].alternatives[0].action.as_deref(),
|
||||
Some("/applied")
|
||||
);
|
||||
assert_eq!(
|
||||
questions["/develop"].alternatives[0].action.as_deref(),
|
||||
Some("/develop/proposal")
|
||||
);
|
||||
assert_eq!(
|
||||
questions["/develop/proposal"].alternatives[0].action.as_deref(),
|
||||
questions["/develop/proposal"].alternatives[0]
|
||||
.action
|
||||
.as_deref(),
|
||||
Some("/develop/proposed")
|
||||
);
|
||||
|
||||
@@ -1690,7 +1886,10 @@ alternatives:
|
||||
assert!(questions["/develop/proposal"].is_followup());
|
||||
|
||||
// Section inheritance gates the directory; drafts are skipped.
|
||||
assert_eq!(questions["/review"].qualifies.as_deref(), Some("portal_owners"));
|
||||
assert_eq!(
|
||||
questions["/review"].qualifies.as_deref(),
|
||||
Some("portal_owners")
|
||||
);
|
||||
assert_eq!(questions["/"].qualifies, None);
|
||||
assert!(!questions.contains_key("/review/_draft"));
|
||||
}
|
||||
@@ -1721,7 +1920,10 @@ alternatives:
|
||||
};
|
||||
|
||||
// Templated actions resolve to the dynamic page's pattern.
|
||||
assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]"));
|
||||
assert_eq!(
|
||||
action_target_id(&questions, "/shape/{curve.key}").as_deref(),
|
||||
Some("/shape/[key]")
|
||||
);
|
||||
assert_eq!(action_target_id(&questions, "/nowhere"), None);
|
||||
|
||||
// No chain, no followups: standing on the front door earns none
|
||||
@@ -1732,9 +1934,18 @@ alternatives:
|
||||
assert_eq!(earned("/applied", &[]), set(&[]));
|
||||
|
||||
// A verified answer earns exactly where it sent the visitor.
|
||||
assert_eq!(unlocked_by(&questions, "/", Some("apply")), vec!["/applied"]);
|
||||
assert_eq!(unlocked_by(&questions, "/", Some("draw")), vec!["/shape/[key]"]);
|
||||
assert_eq!(unlocked_by(&questions, "/kontakt", Some("send")), vec!["/takk"]);
|
||||
assert_eq!(
|
||||
unlocked_by(&questions, "/", Some("apply")),
|
||||
vec!["/applied"]
|
||||
);
|
||||
assert_eq!(
|
||||
unlocked_by(&questions, "/", Some("draw")),
|
||||
vec!["/shape/[key]"]
|
||||
);
|
||||
assert_eq!(
|
||||
unlocked_by(&questions, "/kontakt", Some("send")),
|
||||
vec!["/takk"]
|
||||
);
|
||||
assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"]));
|
||||
// An old node without the alternative: the answered question's
|
||||
// recording alternatives, and nothing beyond that question.
|
||||
@@ -1746,7 +1957,10 @@ alternatives:
|
||||
// "watch" records an answer, so /subscribed is not earned by
|
||||
// having drawn a shape; "back" leads to the front door, which
|
||||
// is never a followup and ends the walk.
|
||||
assert_eq!(earned("/shape/abc", &["/shape/[key]"]), set(&["/shape/[key]"]));
|
||||
assert_eq!(
|
||||
earned("/shape/abc", &["/shape/[key]"]),
|
||||
set(&["/shape/[key]"])
|
||||
);
|
||||
assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"]));
|
||||
// The email-link page is earned by nothing on the site.
|
||||
assert!(!earned("/", &["/applied"]).contains("/watching"));
|
||||
@@ -1787,7 +2001,12 @@ alternatives:
|
||||
let page = resolve_question(&questions, "/review/abc123").unwrap();
|
||||
assert_eq!(page.id, "/review/abc123");
|
||||
assert_eq!(
|
||||
page.alternatives[0].features[0].resource.as_ref().unwrap().key.as_deref(),
|
||||
page.alternatives[0].features[0]
|
||||
.resource
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.key
|
||||
.as_deref(),
|
||||
Some("abc123")
|
||||
);
|
||||
assert!(resolve_question(&questions, "/review").is_none());
|
||||
@@ -1805,7 +2024,10 @@ alternatives:
|
||||
),
|
||||
];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape"));
|
||||
assert_eq!(
|
||||
questions["/shaped"].requires_chain.as_deref(),
|
||||
Some("/shape")
|
||||
);
|
||||
assert!(validate_questions(&questions, &Default::default()).is_ok());
|
||||
|
||||
let dangling = vec![(
|
||||
@@ -1820,7 +2042,8 @@ 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();
|
||||
@@ -1831,7 +2054,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(),
|
||||
@@ -1855,9 +2078,13 @@ alternatives:
|
||||
let files = vec![
|
||||
(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: go\n action: /review/[record]\n".to_string(),
|
||||
"name: Home\nalternatives:\n - name: go\n action: /review/[record]\n"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"review/[record].yaml".to_string(),
|
||||
"name: Record\n".to_string(),
|
||||
),
|
||||
("review/[record].yaml".to_string(), "name: Record\n".to_string()),
|
||||
];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
assert!(validate_questions(&questions, &Default::default()).is_err());
|
||||
@@ -1900,7 +2127,9 @@ alternatives:
|
||||
.unwrap();
|
||||
let mut qs = std::collections::HashMap::new();
|
||||
qs.insert(bad.id.clone(), bad);
|
||||
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
|
||||
let err = validate_questions(&qs, &Default::default())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("bad starts"), "{err}");
|
||||
|
||||
let dynamic: Question = serde_yaml::from_str(
|
||||
@@ -1909,7 +2138,9 @@ alternatives:
|
||||
.unwrap();
|
||||
let mut qs = std::collections::HashMap::new();
|
||||
qs.insert(dynamic.id.clone(), dynamic);
|
||||
let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string();
|
||||
let err = validate_questions(&qs, &Default::default())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("dynamic page can't carry an event"), "{err}");
|
||||
}
|
||||
|
||||
@@ -1918,14 +2149,20 @@ alternatives:
|
||||
let merged = with_builtin_aggregates(Default::default());
|
||||
let schema = &merged[EVENTS_BUCKET];
|
||||
assert_eq!(schema.initial, "announced");
|
||||
assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]);
|
||||
assert_eq!(
|
||||
schema.allowed("announced"),
|
||||
["awaiting_summary", "summarized"]
|
||||
);
|
||||
assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]);
|
||||
|
||||
let content = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine");
|
||||
assert_eq!(
|
||||
with_builtin_aggregates(content)[EVENTS_BUCKET].initial,
|
||||
"mine"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1951,20 +2188,39 @@ alternatives:
|
||||
fn a_signed_in_person_is_not_asked_who_they_are() {
|
||||
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
|
||||
let q: Question = serde_yaml::from_str(yaml).unwrap();
|
||||
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
|
||||
let user = crate::auth::User {
|
||||
sub: "s".into(),
|
||||
username: "kari".into(),
|
||||
display_name: "Kari N".into(),
|
||||
groups: vec![],
|
||||
phone: None,
|
||||
email: Some("kari@vel.no".into()),
|
||||
};
|
||||
let mut signed_in = q.clone();
|
||||
fill_from_user(&mut signed_in, Some(&user));
|
||||
let reqs = &signed_in.alternatives[0].features[0].requirements;
|
||||
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
|
||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
|
||||
assert_eq!(
|
||||
(reqs[0].kind.as_str(), reqs[0].value.as_deref()),
|
||||
("hidden", Some("kari@vel.no"))
|
||||
);
|
||||
assert_eq!(
|
||||
(reqs[1].kind.as_str(), reqs[1].value.as_deref()),
|
||||
("hidden", Some("Kari N"))
|
||||
);
|
||||
// Mixed text is not a user field, and is left as written.
|
||||
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
|
||||
// A visitor is asked: the placeholder goes, the field stays.
|
||||
let mut visitor = q.clone();
|
||||
fill_from_user(&mut visitor, None);
|
||||
let reqs = &visitor.alternatives[0].features[0].requirements;
|
||||
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
|
||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
|
||||
assert_eq!(
|
||||
(reqs[0].kind.as_str(), reqs[0].value.as_deref()),
|
||||
("email", None)
|
||||
);
|
||||
assert_eq!(
|
||||
(reqs[1].kind.as_str(), reqs[1].value.as_deref()),
|
||||
("text", None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2010,8 +2266,14 @@ alternatives:
|
||||
render_inline_markdown(""),
|
||||
"<img src=\"https://x.no/j.jpg\" alt=\"J\" />"
|
||||
);
|
||||
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
|
||||
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
|
||||
assert_eq!(
|
||||
render_inline_markdown("[ok](/shape)"),
|
||||
"<a href=\"/shape\">ok</a>"
|
||||
);
|
||||
assert_eq!(
|
||||
render_inline_markdown("[mail](mailto:bl@uhhm.no)"),
|
||||
"<a href=\"mailto:bl@uhhm.no\">mail</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2033,7 +2295,10 @@ alternatives:
|
||||
#[test]
|
||||
fn action_templates_from_responses() {
|
||||
let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3});
|
||||
assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98"));
|
||||
assert_eq!(
|
||||
template_action("/shape/{curve.key}", &r).as_deref(),
|
||||
Some("/shape/20aa98")
|
||||
);
|
||||
assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y"));
|
||||
assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape"));
|
||||
assert!(template_action("/shape/{curve.missing}", &r).is_none());
|
||||
@@ -2051,7 +2316,12 @@ alternatives:
|
||||
let mut qs = std::collections::HashMap::new();
|
||||
qs.insert(q.id.clone(), q);
|
||||
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
||||
assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98"));
|
||||
assert_eq!(
|
||||
page.alternatives[0].features[0].requirements[0]
|
||||
.value
|
||||
.as_deref(),
|
||||
Some("20aa98")
|
||||
);
|
||||
|
||||
let q: Question = serde_yaml::from_str(
|
||||
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n",
|
||||
@@ -2060,7 +2330,12 @@ alternatives:
|
||||
let mut qs = std::collections::HashMap::new();
|
||||
qs.insert(q.id.clone(), q);
|
||||
let page = resolve_question(&qs, "/shape/20aa98").unwrap();
|
||||
match &page.alternatives[0].features[0].resource.as_ref().unwrap().source {
|
||||
match &page.alternatives[0].features[0]
|
||||
.resource
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.source
|
||||
{
|
||||
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
|
||||
_ => panic!(),
|
||||
}
|
||||
|
||||
+87
-19
@@ -107,12 +107,18 @@ pub fn with_builtin_aggregates(
|
||||
/// runtime's own `portal_events` (a warning, not a failure: a repo
|
||||
/// with no event pages has nothing to attend).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn bucket_is_read(questions: &std::collections::HashMap<String, Question>, bucket: &str) -> bool {
|
||||
pub fn bucket_is_read(
|
||||
questions: &std::collections::HashMap<String, Question>,
|
||||
bucket: &str,
|
||||
) -> bool {
|
||||
questions.values().any(|q| {
|
||||
q.alternatives.iter().any(|a| {
|
||||
a.features
|
||||
.iter()
|
||||
.any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket))
|
||||
a.features.iter().any(|f| {
|
||||
f.resource
|
||||
.as_ref()
|
||||
.and_then(|r| r.bucket())
|
||||
.is_some_and(|b| b == bucket)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -125,12 +131,21 @@ pub fn validate_questions(
|
||||
// A mail to the submitter needs an address to go to: every form
|
||||
// that records into that bucket has to collect a required email.
|
||||
for (bucket, schema) in aggregates {
|
||||
let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect();
|
||||
let submitter_states: Vec<&String> = schema
|
||||
.mail_for_state
|
||||
.iter()
|
||||
.filter(|(_, m)| m.to_submitter())
|
||||
.map(|(s, _)| s)
|
||||
.collect();
|
||||
if submitter_states.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
for alternative in question
|
||||
.alternatives
|
||||
.iter()
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
|
||||
{
|
||||
let collects_email = alternative
|
||||
.features
|
||||
.iter()
|
||||
@@ -154,10 +169,20 @@ pub fn validate_questions(
|
||||
anyhow::bail!("question {:?}: slideshow has no slides", question.id);
|
||||
}
|
||||
if !(20..=100).contains(&show.max_height) {
|
||||
anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height);
|
||||
anyhow::bail!(
|
||||
"question {:?}: slideshow max_height is {} svh; between 20 and 100",
|
||||
question.id,
|
||||
show.max_height
|
||||
);
|
||||
}
|
||||
for slide in &show.slides {
|
||||
let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/'));
|
||||
let ok = slide.image.starts_with("https://")
|
||||
|| crate::content::is_safe_site_path(
|
||||
slide
|
||||
.image
|
||||
.trim_start_matches("/site/")
|
||||
.trim_start_matches('/'),
|
||||
);
|
||||
if !ok {
|
||||
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
|
||||
}
|
||||
@@ -171,10 +196,20 @@ pub fn validate_questions(
|
||||
// has nowhere to go.
|
||||
for (bucket, schema) in aggregates {
|
||||
for (state, spec) in &schema.mail_for_state {
|
||||
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
|
||||
let Some(parent) = crate::mail::parent_of(&spec.to) else {
|
||||
continue;
|
||||
};
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
|
||||
for alternative in question
|
||||
.alternatives
|
||||
.iter()
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
|
||||
{
|
||||
let has = alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| &f.requirements)
|
||||
.any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
|
||||
if !has {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
|
||||
@@ -199,7 +234,11 @@ pub fn validate_questions(
|
||||
}
|
||||
granting.sort();
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
for alternative in question
|
||||
.alternatives
|
||||
.iter()
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
|
||||
{
|
||||
let required = |name: &str, kind: &str| {
|
||||
alternative
|
||||
.features
|
||||
@@ -220,7 +259,10 @@ pub fn validate_questions(
|
||||
for question in questions.values() {
|
||||
if let Some(event) = &question.event {
|
||||
if question.is_dynamic() {
|
||||
anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id);
|
||||
anyhow::bail!(
|
||||
"question {:?}: a dynamic page can't carry an event",
|
||||
question.id
|
||||
);
|
||||
}
|
||||
if let Err(e) = event.window_ms() {
|
||||
anyhow::bail!("question {:?}: event: {e}", question.id);
|
||||
@@ -361,10 +403,12 @@ pub fn validate_questions(
|
||||
question.id, alternative.name, feature.name, requirement.name, requirement.kind
|
||||
);
|
||||
}
|
||||
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
|
||||
validate_relay_url(relay).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if requirement.kind == "gesture"
|
||||
&& (requirement.resource.is_some() || requirement.bind.is_some())
|
||||
@@ -388,8 +432,12 @@ pub fn validate_questions(
|
||||
let parts: Vec<&str> = value.split(',').map(str::trim).collect();
|
||||
let ok = (2..=3).contains(&parts.len())
|
||||
&& parts.iter().all(|p| p.parse::<f64>().is_ok())
|
||||
&& parts[0].parse::<f64>().is_ok_and(|lat| (-90.0..=90.0).contains(&lat))
|
||||
&& parts[1].parse::<f64>().is_ok_and(|lon| (-180.0..=180.0).contains(&lon));
|
||||
&& parts[0]
|
||||
.parse::<f64>()
|
||||
.is_ok_and(|lat| (-90.0..=90.0).contains(&lat))
|
||||
&& parts[1]
|
||||
.parse::<f64>()
|
||||
.is_ok_and(|lon| (-180.0..=180.0).contains(&lon));
|
||||
if !ok {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}",
|
||||
@@ -468,7 +516,11 @@ pub fn validate_questions(
|
||||
question.id, alternative.name, feature.name, transition.to, bucket
|
||||
);
|
||||
}
|
||||
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
|
||||
if !schema
|
||||
.allowed(&transition.from)
|
||||
.iter()
|
||||
.any(|s| s == &transition.to)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
||||
@@ -512,6 +564,23 @@ 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",
|
||||
@@ -535,4 +604,3 @@ pub fn validate_questions(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+83
-17
@@ -31,7 +31,16 @@
|
||||
use serde_yaml::Value;
|
||||
|
||||
/// Keys whose value is one string to a person.
|
||||
const TEXT: &[&str] = &["name", "description", "label", "placeholder", "subject", "body", "empty", "title"];
|
||||
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"];
|
||||
@@ -41,7 +50,12 @@ const TEXT_LIST: &[&str] = &["consequence", "encouragements"];
|
||||
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")
|
||||
key == "name"
|
||||
&& path
|
||||
.iter()
|
||||
.rev()
|
||||
.nth(2)
|
||||
.is_some_and(|p| p == "requirements")
|
||||
}
|
||||
|
||||
fn is_strings(seq: &[Value]) -> bool {
|
||||
@@ -49,7 +63,11 @@ fn is_strings(seq: &[Value]) -> bool {
|
||||
}
|
||||
|
||||
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())))
|
||||
!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.
|
||||
@@ -60,8 +78,18 @@ fn counts(value: &Value, path: &mut Vec<String>, out: &mut Vec<(String, usize)>)
|
||||
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()),
|
||||
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 {
|
||||
@@ -90,8 +118,18 @@ fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
|
||||
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()),
|
||||
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 {
|
||||
@@ -118,7 +156,9 @@ fn pick(value: &mut Value, path: &mut Vec<String>, n: usize) {
|
||||
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 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)));
|
||||
@@ -135,14 +175,19 @@ 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 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()))
|
||||
(
|
||||
rel.clone(),
|
||||
serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -192,17 +237,32 @@ mod tests {
|
||||
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());
|
||||
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?");
|
||||
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());
|
||||
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())];
|
||||
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![
|
||||
@@ -210,12 +270,18 @@ mod tests {
|
||||
("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}");
|
||||
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"] };
|
||||
let s = Storylines {
|
||||
each: vec!["a", "b"],
|
||||
};
|
||||
assert_eq!(*s, "a");
|
||||
assert_eq!(*s.at(1), "b");
|
||||
assert_eq!(*s.at(7), "b");
|
||||
|
||||
+86
-23
@@ -63,8 +63,13 @@ pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, no
|
||||
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let aggregates = state.aggregates.load();
|
||||
for schema in aggregates.values().filter(|s| !s.leaves_for_state.is_empty()) {
|
||||
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { continue };
|
||||
for schema in aggregates
|
||||
.values()
|
||||
.filter(|s| !s.leaves_for_state.is_empty())
|
||||
{
|
||||
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else {
|
||||
continue;
|
||||
};
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
@@ -73,12 +78,20 @@ pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
||||
let Ok(Some(bytes)) = store.get(&id).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if has_left(schema, &answer, now_ms) {
|
||||
match store.delete(&id).await {
|
||||
Ok(()) => tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list"),
|
||||
Err(e) => tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}"),
|
||||
Ok(()) => {
|
||||
tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +106,11 @@ fn waiting_since(answer: &Answer) -> i64 {
|
||||
}
|
||||
|
||||
/// Whether a record in `state` has waited out its deadline by `now_ms`.
|
||||
pub fn due(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> Option<String> {
|
||||
pub fn due(
|
||||
schema: &crate::aggregates::AggregateSchema,
|
||||
answer: &Answer,
|
||||
now_ms: i64,
|
||||
) -> Option<String> {
|
||||
let after = schema.after_for(&answer.state)?;
|
||||
let deadline = waiting_since(answer) + i64::from(after.days) * DAY_MS;
|
||||
(now_ms >= deadline).then(|| after.to.clone())
|
||||
@@ -120,13 +137,24 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(mut answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
||||
let Some(target) = due(schema, &answer, now_ms) else { continue };
|
||||
let Ok(Some(bytes)) = store.get(&id).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut answer) = serde_json::from_slice::<Answer>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some(target) = due(schema, &answer, now_ms) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({ "to": target, "item": id, "by": BY });
|
||||
if let Err(e) = crate::aggregates::transition(
|
||||
&state.jetstream, schema, &id, &target, payload.clone(), now_ms,
|
||||
&state.jetstream,
|
||||
schema,
|
||||
&id,
|
||||
&target,
|
||||
payload.clone(),
|
||||
now_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -143,11 +171,20 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(&id, data.into()).await;
|
||||
}
|
||||
crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses, answer.storyline).await;
|
||||
crate::mail::on_state(
|
||||
state,
|
||||
&schema.bucket,
|
||||
&id,
|
||||
&target,
|
||||
&answer.responses,
|
||||
answer.storyline,
|
||||
)
|
||||
.await;
|
||||
crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await;
|
||||
|
||||
let parent_hashes = vec![id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
let chain_hash =
|
||||
crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
if let Err(e) = emit_answer_submitted(
|
||||
&state.nats,
|
||||
&AnswerSubmitted {
|
||||
@@ -175,7 +212,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn schema(yaml: &str) -> crate::aggregates::AggregateSchema {
|
||||
crate::aggregates::parse_aggregates_yaml(yaml).unwrap().into_values().next().unwrap()
|
||||
crate::aggregates::parse_aggregates_yaml(yaml)
|
||||
.unwrap()
|
||||
.into_values()
|
||||
.next()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const HEARINGS: &str = r#"
|
||||
@@ -192,9 +233,16 @@ aggregates:
|
||||
|
||||
fn waiting(state: &str, since_ms: i64) -> Answer {
|
||||
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![],
|
||||
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![],
|
||||
finished: false,
|
||||
storyline: 0,
|
||||
}
|
||||
}
|
||||
@@ -207,7 +255,10 @@ aggregates:
|
||||
let t = 1_000_000_000_000;
|
||||
let mut answer = waiting("gone", t - 60 * 60 * 1000);
|
||||
answer.decided_ms = Some(t);
|
||||
assert!(!has_left(&s, &answer, t + 9 * 60 * 1000), "measured from entering the state, not from sending");
|
||||
assert!(
|
||||
!has_left(&s, &answer, t + 9 * 60 * 1000),
|
||||
"measured from entering the state, not from sending"
|
||||
);
|
||||
assert!(has_left(&s, &answer, t + 10 * 60 * 1000));
|
||||
// A state without it keeps its records.
|
||||
assert!(!has_left(&s, &waiting("open", 0), t));
|
||||
@@ -221,7 +272,10 @@ aggregates:
|
||||
let answer = waiting("open", sent);
|
||||
assert_eq!(due(&s, &answer, sent), None);
|
||||
assert_eq!(due(&s, &answer, sent + 41 * DAY_MS), None);
|
||||
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), Some("no_answer".into()));
|
||||
assert_eq!(
|
||||
due(&s, &answer, sent + 42 * DAY_MS),
|
||||
Some("no_answer".into())
|
||||
);
|
||||
}
|
||||
|
||||
// The six weeks run from the last move, not from the day the case
|
||||
@@ -233,7 +287,10 @@ aggregates:
|
||||
let mut answer = waiting("open", sent);
|
||||
answer.decided_ms = Some(sent + 40 * DAY_MS);
|
||||
assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), None);
|
||||
assert_eq!(due(&s, &answer, sent + 82 * DAY_MS), Some("no_answer".into()));
|
||||
assert_eq!(
|
||||
due(&s, &answer, sent + 82 * DAY_MS),
|
||||
Some("no_answer".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -248,15 +305,21 @@ aggregates:
|
||||
// not when the hour comes round.
|
||||
#[test]
|
||||
fn a_deadline_onto_an_undeclared_edge_is_refused_on_load() {
|
||||
let bad = HEARINGS.replace("to: no_answer }", "to: answered }").replace("open: [answered, no_answer]", "open: [no_answer]");
|
||||
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
|
||||
let bad = HEARINGS
|
||||
.replace("to: no_answer }", "to: answered }")
|
||||
.replace("open: [answered, no_answer]", "open: [no_answer]");
|
||||
let err = crate::aggregates::parse_aggregates_yaml(&bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("not a declared transition"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deadline_of_no_days_is_refused() {
|
||||
let bad = HEARINGS.replace("days: 42", "days: 0");
|
||||
let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string();
|
||||
let err = crate::aggregates::parse_aggregates_yaml(&bad)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("at least one"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -109,11 +109,16 @@ pub async fn load_events(
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("fetching messages: {e}"))?
|
||||
{
|
||||
let info = message.info().map_err(|e| anyhow::anyhow!("reading message info: {e}"))?;
|
||||
let info = message
|
||||
.info()
|
||||
.map_err(|e| anyhow::anyhow!("reading message info: {e}"))?;
|
||||
let mut event: Event = serde_json::from_slice(&message.payload)?;
|
||||
event.sequence = info.stream_sequence;
|
||||
events.push(event);
|
||||
message.ack().await.map_err(|e| anyhow::anyhow!("acking: {e}"))?;
|
||||
message
|
||||
.ack()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("acking: {e}"))?;
|
||||
}
|
||||
events.sort_by_key(|e| e.sequence);
|
||||
|
||||
|
||||
+23
-2
@@ -24,7 +24,14 @@ 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", "upload_failed") => "The file could not be sent, so nothing was. Try again, or with a smaller file.",
|
||||
("en", "show_older") => "Show older",
|
||||
("en", "for_this_device") => "For this device",
|
||||
("en", "all_downloads") => "All downloads",
|
||||
("en", "release_notes") => "release notes",
|
||||
("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.",
|
||||
("en", "upload_forbidden") => "Files cannot be sent from here by you, so nothing was sent. Are you signed in?",
|
||||
("en", "current_file") => "Now:",
|
||||
("en", "keep_file") => "Choose a new one to replace it, or leave it as it is.",
|
||||
("en", "nothing_here") => "Nothing here",
|
||||
@@ -57,7 +64,14 @@ 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", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen, eller med en mindre fil.",
|
||||
("no", "show_older") => "Vis eldre",
|
||||
("no", "for_this_device") => "Til denne enheten",
|
||||
("no", "all_downloads") => "Alle nedlastinger",
|
||||
("no", "release_notes") => "utgivelsesnotater",
|
||||
("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.",
|
||||
("no", "upload_forbidden") => "Du kan ikke sende filer herfra, så ingenting ble sendt. Er du logget inn?",
|
||||
("no", "current_file") => "Nå:",
|
||||
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
|
||||
("no", "nothing_here") => "Ingenting her",
|
||||
@@ -108,7 +122,14 @@ mod tests {
|
||||
"sign_in",
|
||||
"sign_out",
|
||||
"nothing_here_yet",
|
||||
"show_older",
|
||||
"for_this_device",
|
||||
"all_downloads",
|
||||
"release_notes",
|
||||
"upload_failed",
|
||||
"upload_too_large",
|
||||
"upload_unavailable",
|
||||
"upload_forbidden",
|
||||
"current_file",
|
||||
"keep_file",
|
||||
"nothing_here",
|
||||
|
||||
+9
-6
@@ -31,15 +31,18 @@ pub mod people;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod mail;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod seed;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod memberships;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod report;
|
||||
/// The lint: `portal lint`, what a content repo's CI runs (once uhhm/iris).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod lint;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod memberships;
|
||||
/// The Apple configuration profile at `/profile.mobileconfig`.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod profile;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod report;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod seed;
|
||||
/// The trainer: `portal plan`, `portal build`, `portal report` and the
|
||||
/// rest (once uhhm/portal-trainer).
|
||||
#[cfg(feature = "ssr")]
|
||||
|
||||
+123
-25
@@ -8,7 +8,9 @@
|
||||
//! than compiled there - keeps that repo's CI coupling to "run a
|
||||
//! static binary," not "build a Rust workspace."
|
||||
|
||||
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir};
|
||||
use crate::lint::local::{
|
||||
load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir,
|
||||
};
|
||||
use crate::lint::needs;
|
||||
use crate::{aggregates, content};
|
||||
|
||||
@@ -24,6 +26,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
let mut skim = false;
|
||||
let mut sim_out: Option<String> = None;
|
||||
let mut show_access = false;
|
||||
let mut findings_out: Option<String> = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
@@ -35,9 +38,13 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
"--skim" => skim = true,
|
||||
"--needs-sim" => sim_out = args.next(),
|
||||
"--access" => show_access = true,
|
||||
"--findings" => findings_out = args.next(),
|
||||
"--needs-score" => answers_in = args.next(),
|
||||
"--min-accuracy" => {
|
||||
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
|
||||
min_accuracy = args
|
||||
.next()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(min_accuracy)
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
@@ -56,23 +63,41 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
} else if let Some(repo_url) = repo {
|
||||
// The first storyline of each, as the checks read it; the
|
||||
// loaders have held every list of texts to the site's arity.
|
||||
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?.at(0).clone();
|
||||
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?.at(0).clone();
|
||||
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir)
|
||||
.await?
|
||||
.at(0)
|
||||
.clone();
|
||||
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch)
|
||||
.await?
|
||||
.at(0)
|
||||
.clone();
|
||||
// load_site_from_gitea already validates; a missing file is
|
||||
// the default config, same as at portal boot.
|
||||
let site = content::load_site_from_gitea(&repo_url, &branch).await?.at(0).clone();
|
||||
let site = content::load_site_from_gitea(&repo_url, &branch)
|
||||
.await?
|
||||
.at(0)
|
||||
.clone();
|
||||
// Optional: a fetch failure means the repo declares no needs.
|
||||
let needs_raw = crate::http::get_text(&format!("{}/needs.yaml?ref={branch}", content::gitea_raw_base(&repo_url)?), crate::http::GITEA_CAP).await.ok();
|
||||
let needs_raw = crate::http::get_text(
|
||||
&format!(
|
||||
"{}/needs.yaml?ref={branch}",
|
||||
content::gitea_raw_base(&repo_url)?
|
||||
),
|
||||
crate::http::GITEA_CAP,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
(questions, aggregates_map, site, needs_raw)
|
||||
} else {
|
||||
eprintln!(
|
||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--findings <out.jsonl>] [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
if let Err(e) = site.validate() {
|
||||
eprintln!("FAIL: {e}");
|
||||
write_findings(findings_out.as_deref(), &[schema_finding(&e.to_string())]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
|
||||
@@ -103,17 +128,29 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
eprintln!("FAIL: access policy does not validate: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
|
||||
println!(
|
||||
"OK: access policy, {} rule(s), validates",
|
||||
policy.rules.len()
|
||||
);
|
||||
if show_access {
|
||||
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
|
||||
println!(
|
||||
"\n{:<16} {:<44} {:<40} {}",
|
||||
"ACTION", "RESOURCE", "WHO", "WHEN"
|
||||
);
|
||||
let mut rows = policy.rules.clone();
|
||||
rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)));
|
||||
rows.sort_by(|a, b| {
|
||||
(&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who))
|
||||
});
|
||||
for r in rows {
|
||||
println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when);
|
||||
println!(
|
||||
"{:<16} {:<44} {:<40} {}",
|
||||
r.action, r.resource, r.who, r.when
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
check_needs(
|
||||
findings_out.as_deref(),
|
||||
needs_raw.as_deref(),
|
||||
&questions,
|
||||
&aggregates_map,
|
||||
@@ -126,15 +163,73 @@ 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.
|
||||
let mut all = vec![schema_finding(&e.to_string())];
|
||||
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
|
||||
let findings = needs::check(&file, &questions, &aggregates_map);
|
||||
print_findings(&findings);
|
||||
all.extend(findings);
|
||||
}
|
||||
write_findings(findings_out.as_deref(), &all);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A content error the schema pass stopped at, as a finding: the repo
|
||||
/// is its scope and the message its subject, since it has no other.
|
||||
fn schema_finding(message: &str) -> needs::Finding {
|
||||
needs::Finding {
|
||||
need: String::new(),
|
||||
level: needs::Level::Fail,
|
||||
message: message.to_string(),
|
||||
scope: "repo",
|
||||
code: "schema",
|
||||
subject: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--findings`: every finding as one JSON line (id, level, code,
|
||||
/// scope, need, subject, message, fix), the same ones the text output
|
||||
/// prints - an empty file when there are none, so a reader can tell
|
||||
/// "clean" from "not run".
|
||||
fn write_findings(out: Option<&str>, findings: &[needs::Finding]) {
|
||||
let Some(out) = out else { return };
|
||||
let lines: String = findings
|
||||
.iter()
|
||||
.map(|f| format!("{}\n", f.to_json()))
|
||||
.collect();
|
||||
if let Err(e) = std::fs::write(out, lines) {
|
||||
eprintln!("WARN: could not write the findings to {out}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn check_needs(
|
||||
findings_out: Option<&str>,
|
||||
raw: Option<&str>,
|
||||
questions: &std::collections::HashMap<String, content::Question>,
|
||||
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
|
||||
@@ -145,6 +240,7 @@ fn check_needs(
|
||||
min_accuracy: f64,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(raw) = raw else {
|
||||
write_findings(findings_out, &[]);
|
||||
if tasks_out.is_some() || answers_in.is_some() {
|
||||
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
|
||||
std::process::exit(1);
|
||||
@@ -155,28 +251,25 @@ fn check_needs(
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
write_findings(findings_out, &[schema_finding(&e.to_string())]);
|
||||
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(&findings);
|
||||
write_findings(findings_out, &findings);
|
||||
for need in &file.needs {
|
||||
if let Some(path) = needs::path_for(questions, need) {
|
||||
let steps: Vec<String> = path
|
||||
.iter()
|
||||
.map(|s| format!("{} [{}]", s.page, s.alternative))
|
||||
.collect();
|
||||
println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in);
|
||||
println!(
|
||||
"need {:?}: {} -> {:?}",
|
||||
need.id,
|
||||
steps.join(" -> "),
|
||||
need.lands_in
|
||||
);
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
@@ -210,9 +303,14 @@ fn check_needs(
|
||||
println!("wrote {} wording task(s) to {out}", tasks.len());
|
||||
}
|
||||
if let Some(path) = answers_in {
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||
let raw =
|
||||
std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||
let mut answers = Vec::new();
|
||||
for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||
for (n, line) in raw
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.trim().is_empty())
|
||||
{
|
||||
answers.push(
|
||||
serde_json::from_str::<needs::Answer>(line)
|
||||
.map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?,
|
||||
|
||||
+2
-2
@@ -22,7 +22,8 @@ pub fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
|
||||
// The first storyline, held to the arity (content::variants).
|
||||
let files = vec![("site.yaml".to_string(), raw)];
|
||||
content::arity(&files)?;
|
||||
serde_yaml::from_str(&content::storyline(&files, 0)[0].1).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
|
||||
serde_yaml::from_str(&content::storyline(&files, 0)[0].1)
|
||||
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
|
||||
}
|
||||
|
||||
/// The offline counterpart to `content::load_aggregates_from_gitea` -
|
||||
@@ -102,4 +103,3 @@ pub fn load_needs_from_dir(dir: &str) -> anyhow::Result<Option<String>> {
|
||||
.map(Some)
|
||||
.map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display()))
|
||||
}
|
||||
|
||||
|
||||
@@ -50,4 +50,3 @@ pub async fn run(mut argv: Vec<String>) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+755
-55
File diff suppressed because it is too large
Load Diff
+104
-20
@@ -14,14 +14,19 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use futures::StreamExt;
|
||||
use crate::events::store::{ensure_stream, load_events};
|
||||
use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir};
|
||||
use crate::lint::needs;
|
||||
use crate::{aggregates, answers, content};
|
||||
use futures::StreamExt;
|
||||
|
||||
pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
|
||||
let value = |flag: &str| {
|
||||
argv.iter()
|
||||
.position(|a| a == flag)
|
||||
.and_then(|i| argv.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let usage = "usage: iris replay --path <questions dir> --nats <url> (--cases <cases.jsonl> | --report-only)";
|
||||
let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?;
|
||||
let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?;
|
||||
@@ -32,31 +37,71 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
|
||||
let questions = load_from_dir(&dir)?;
|
||||
let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?);
|
||||
let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?;
|
||||
let file = needs::parse(
|
||||
&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?,
|
||||
)?;
|
||||
let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?);
|
||||
// The same call the site makes at boot.
|
||||
ensure_stream(&js).await?;
|
||||
|
||||
if let Some(path) = cases_path {
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||
let raw =
|
||||
std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
|
||||
let (mut created, mut moved, mut refused) = (0, 0, 0);
|
||||
for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
|
||||
let case: serde_json::Value = serde_json::from_str(line)?;
|
||||
let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue };
|
||||
let Some(schema) = schemas.get(&need.lands_in) else { continue };
|
||||
let Some(need) = file
|
||||
.needs
|
||||
.iter()
|
||||
.find(|need| case["need"] == need.id.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(schema) = schemas.get(&need.lands_in) else {
|
||||
continue;
|
||||
};
|
||||
// Only a case that got as far as submitting leaves a record.
|
||||
let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect();
|
||||
let states: Vec<&str> = case["states"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| s.as_str())
|
||||
.collect();
|
||||
if states.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned());
|
||||
let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default();
|
||||
let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]);
|
||||
let id = format!(
|
||||
"sim-{}-{}-{n}",
|
||||
case["persona"].as_str().unwrap_or("x"),
|
||||
case["run"]
|
||||
);
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now, 0).await?;
|
||||
answers::store_answer(
|
||||
&js,
|
||||
&schemas,
|
||||
&need.lands_in,
|
||||
id.clone(),
|
||||
&page,
|
||||
&alternative,
|
||||
&case["record"],
|
||||
now,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
created += 1;
|
||||
for target in &states[1..] {
|
||||
match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await {
|
||||
match aggregates::transition(
|
||||
&js,
|
||||
schema,
|
||||
&id,
|
||||
target,
|
||||
serde_json::json!({"by": "simulation"}),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => moved += 1,
|
||||
Err(e) => {
|
||||
refused += 1;
|
||||
@@ -72,33 +117,72 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
// Read every bucket back the way the engine knows it: by replaying
|
||||
// each record's own event log.
|
||||
let mut by_bucket: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
|
||||
for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::<std::collections::BTreeSet<_>>() {
|
||||
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue };
|
||||
for bucket in file
|
||||
.needs
|
||||
.iter()
|
||||
.map(|n| n.lands_in.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
{
|
||||
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut keys = store.keys().await?;
|
||||
while let Some(Ok(id)) = keys.next().await {
|
||||
let events = load_events(&js, &bucket, &id).await?;
|
||||
if let Some(record) = aggregates::replay(schema, &id, &events) {
|
||||
*by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1;
|
||||
*by_bucket
|
||||
.entry(bucket.clone())
|
||||
.or_default()
|
||||
.entry(record.state)
|
||||
.or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state");
|
||||
println!(
|
||||
"{:<18} {:>8} {:>9} {:>9} {}",
|
||||
"bucket", "records", "finished", "success", "by state"
|
||||
);
|
||||
let mut buckets: Vec<_> = by_bucket.iter().collect();
|
||||
buckets.sort();
|
||||
for (bucket, states) in buckets {
|
||||
let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect();
|
||||
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| needs_here.iter().any(|n| pick(n).contains(state));
|
||||
let needs_here: Vec<&needs::Need> = file
|
||||
.needs
|
||||
.iter()
|
||||
.filter(|n| &n.lands_in == bucket)
|
||||
.collect();
|
||||
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| {
|
||||
needs_here.iter().any(|n| pick(n).contains(state))
|
||||
};
|
||||
let total: usize = states.values().sum();
|
||||
let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum();
|
||||
let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum();
|
||||
let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) };
|
||||
let finished: usize = states
|
||||
.iter()
|
||||
.filter(|(s, _)| is(|n| &n.done_when, s))
|
||||
.map(|(_, c)| c)
|
||||
.sum();
|
||||
let success: usize = states
|
||||
.iter()
|
||||
.filter(|(s, _)| is(|n| &n.success, s))
|
||||
.map(|(_, c)| c)
|
||||
.sum();
|
||||
let pct = |n: usize| {
|
||||
if total == 0 {
|
||||
"-".to_string()
|
||||
} else {
|
||||
format!("{:.0}%", n as f64 * 100.0 / total as f64)
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"{:<18} {:>8} {:>9} {:>9} {}",
|
||||
bucket,
|
||||
total,
|
||||
pct(finished),
|
||||
pct(success),
|
||||
states.iter().map(|(s, c)| format!("{s} {c}")).collect::<Vec<_>>().join(", ")
|
||||
states
|
||||
.iter()
|
||||
.map(|(s, c)| format!("{s} {c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+517
-70
@@ -74,7 +74,10 @@ pub struct Incoming {
|
||||
/// address can be forged. The sender must therefore also match the
|
||||
/// address the record already holds, which is the one portal wrote to
|
||||
/// in the first place.
|
||||
pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<crate::answers::Note, String> {
|
||||
pub fn note_for(
|
||||
incoming: &Incoming,
|
||||
answer: &crate::answers::Answer,
|
||||
) -> Result<crate::answers::Note, String> {
|
||||
if incoming.v != CONTRACT {
|
||||
return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v));
|
||||
}
|
||||
@@ -90,7 +93,10 @@ pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result<
|
||||
.filter(|e| !e.is_empty())
|
||||
.ok_or_else(|| "the record holds no address to compare the sender with".to_string())?;
|
||||
if address_of(&incoming.from) != held {
|
||||
return Err(format!("{} is not the address this record was written to", incoming.from));
|
||||
return Err(format!(
|
||||
"{} is not the address this record was written to",
|
||||
incoming.from
|
||||
));
|
||||
}
|
||||
if answer.notes.iter().any(|n| n.id == incoming.id) {
|
||||
return Err("this reply is already on the record".to_string());
|
||||
@@ -128,7 +134,10 @@ pub async fn receive(state: crate::server::AppState) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail");
|
||||
tracing::info!(
|
||||
subject = RECEIVED_SUBJECT,
|
||||
"listening for replies to case mail"
|
||||
);
|
||||
while let Some(msg) = sub.next().await {
|
||||
let incoming: Incoming = match serde_json::from_slice(&msg.payload) {
|
||||
Ok(i) => i,
|
||||
@@ -138,8 +147,12 @@ pub async fn receive(state: crate::server::AppState) {
|
||||
}
|
||||
};
|
||||
match note_onto(&state, &incoming).await {
|
||||
Ok(()) => tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record"),
|
||||
Err(e) => tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}"),
|
||||
Ok(()) => {
|
||||
tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +160,9 @@ pub async fn receive(state: crate::server::AppState) {
|
||||
/// The record's own bucket is the only one a reply may touch, and the
|
||||
/// note is appended under the same read-modify-write the desks use.
|
||||
async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyhow::Result<()> {
|
||||
if !state.aggregates.load().contains_key(&incoming.bucket) && !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket) {
|
||||
if !state.aggregates.load().contains_key(&incoming.bucket)
|
||||
&& !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket)
|
||||
{
|
||||
anyhow::bail!("{} is not a bucket of this site", incoming.bucket);
|
||||
}
|
||||
let store = state.jetstream.get_key_value(&incoming.bucket).await?;
|
||||
@@ -158,14 +173,18 @@ async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyh
|
||||
let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?;
|
||||
let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?;
|
||||
answer.notes.push(note);
|
||||
store.put(&incoming.record, serde_json::to_vec(&answer)?.into()).await?;
|
||||
store
|
||||
.put(&incoming.record, serde_json::to_vec(&answer)?.into())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Idempotent, called by portal at boot and by gdo. A week of
|
||||
/// retention: a mail nobody delivered in a week is stale, not owed.
|
||||
/// The duplicate window has to fit inside it.
|
||||
pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
|
||||
pub async fn ensure_stream(
|
||||
js: &async_nats::jetstream::Context,
|
||||
) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
|
||||
Ok(js
|
||||
.get_or_create_stream(async_nats::jetstream::stream::Config {
|
||||
name: STREAM.to_string(),
|
||||
@@ -213,7 +232,8 @@ pub fn render(template: &str, vars: &serde_json::Map<String, serde_json::Value>)
|
||||
/// in the body is dropped, the same rule as page descriptions.
|
||||
pub fn to_html(markdown: &str) -> String {
|
||||
use pulldown_cmark::{html, Event, Options, Parser};
|
||||
let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
|
||||
let parser = Parser::new_ext(markdown, Options::empty())
|
||||
.filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
|
||||
let mut body = String::new();
|
||||
html::push_html(&mut body, parser);
|
||||
format!(
|
||||
@@ -221,28 +241,46 @@ pub fn to_html(markdown: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn public_url() -> String {
|
||||
std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string()
|
||||
pub(crate) fn public_url() -> String {
|
||||
std::env::var("PUBLIC_URL")
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn site_name(site: &crate::content::SiteConfig) -> String {
|
||||
site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string())
|
||||
site.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string())
|
||||
}
|
||||
|
||||
/// The variables a state mail may use: every field of the record,
|
||||
/// then the runtime's own.
|
||||
pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
|
||||
pub fn vars_for(
|
||||
bucket: &str,
|
||||
id: &str,
|
||||
state: &str,
|
||||
responses: &serde_json::Value,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut vars = serde_json::Map::new();
|
||||
if let Some(fields) = responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
vars.insert(k.clone(), match v {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||
serde_json::Value::Null => serde_json::Value::String(String::new()),
|
||||
other => serde_json::Value::String(other.to_string()),
|
||||
});
|
||||
vars.insert(
|
||||
k.clone(),
|
||||
match v {
|
||||
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||
serde_json::Value::Null => serde_json::Value::String(String::new()),
|
||||
other => serde_json::Value::String(other.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let email = responses.get("email").and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
||||
let email = responses
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
vars.insert("email".into(), email.into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("chain".into(), id.into());
|
||||
@@ -251,6 +289,85 @@ 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> {
|
||||
@@ -264,7 +381,12 @@ pub fn parent_of(to: &str) -> Option<&str> {
|
||||
/// `bucket`, the record it names, read from its own bucket. A
|
||||
/// reference that does not resolve adds nothing, and a template that
|
||||
/// needed it renders empty there.
|
||||
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
|
||||
async fn parent_vars(
|
||||
state: &crate::server::AppState,
|
||||
bucket: &str,
|
||||
responses: &serde_json::Value,
|
||||
vars: &mut serde_json::Map<String, serde_json::Value>,
|
||||
) {
|
||||
let questions = state.questions.load();
|
||||
let refs: Vec<(String, String)> = questions
|
||||
.values()
|
||||
@@ -275,10 +397,22 @@ async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &
|
||||
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
|
||||
.collect();
|
||||
for (field, of) in refs {
|
||||
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
|
||||
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
|
||||
let Ok(Some(bytes)) = store.get(id).await else { continue };
|
||||
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
let Some(id) = responses
|
||||
.get(&field)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(store) = state.jetstream.get_key_value(&of).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Some(bytes)) = store.get(id).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(fields) = parent.responses.as_object() {
|
||||
for (k, v) in fields {
|
||||
let text = match v {
|
||||
@@ -298,7 +432,9 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
|
||||
let mut headers = async_nats::HeaderMap::new();
|
||||
headers.insert("Nats-Msg-Id", mail.id.as_str());
|
||||
let bytes = serde_json::to_vec(mail)?;
|
||||
js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?;
|
||||
js.publish_with_headers(SUBJECT, headers, bytes.into())
|
||||
.await?
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -306,10 +442,24 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
|
||||
/// if anything. Best-effort by design - the transition is already
|
||||
/// committed, and a mail that cannot be rendered is logged, never a
|
||||
/// failed request.
|
||||
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, storyline: u32) {
|
||||
pub async fn on_state(
|
||||
state: &crate::server::AppState,
|
||||
bucket: &str,
|
||||
id: &str,
|
||||
to_state: &str,
|
||||
responses: &serde_json::Value,
|
||||
storyline: u32,
|
||||
) {
|
||||
// The mail tells the storyline the person read.
|
||||
let storyline = storyline as usize;
|
||||
let Some(spec) = state.aggregates.load().at(storyline).get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
|
||||
let Some(spec) = state
|
||||
.aggregates
|
||||
.load()
|
||||
.at(storyline)
|
||||
.get(bucket)
|
||||
.and_then(|s| s.mail_for(to_state))
|
||||
.cloned()
|
||||
else {
|
||||
// A suggestion has its own words for this unless the content
|
||||
// gave the state a mail, which then is the one sent.
|
||||
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
|
||||
@@ -320,16 +470,54 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
||||
let site_storylines = state.site.load();
|
||||
let site = site_storylines.at(storyline);
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
|
||||
tracing::warn!(
|
||||
bucket,
|
||||
to_state,
|
||||
"a mail is declared for this state, but site.yaml has no `mail.from` - not sent"
|
||||
);
|
||||
return;
|
||||
};
|
||||
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('@')) {
|
||||
match vars
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|e| e.contains('@'))
|
||||
{
|
||||
Some(e) => e.to_string(),
|
||||
None => {
|
||||
tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent");
|
||||
tracing::warn!(
|
||||
bucket,
|
||||
id,
|
||||
to_state,
|
||||
"mail to the submitter, but the record carries no email - not sent"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -363,14 +551,24 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
||||
/// quotes, and past the first three only how many more - someone who
|
||||
/// answers for a whole site is not read its table of contents.
|
||||
pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
|
||||
let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") };
|
||||
let quote = |q: &String| {
|
||||
if norwegian {
|
||||
format!("«{q}»")
|
||||
} else {
|
||||
format!("\u{201c}{q}\u{201d}")
|
||||
}
|
||||
};
|
||||
let and = if norwegian { "og" } else { "and" };
|
||||
match questions {
|
||||
[] => String::new(),
|
||||
[one] => quote(one),
|
||||
few if few.len() <= 3 => {
|
||||
let quoted: Vec<String> = few.iter().map(quote).collect();
|
||||
format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
|
||||
format!(
|
||||
"{} {and} {}",
|
||||
quoted[..quoted.len() - 1].join(", "),
|
||||
quoted[quoted.len() - 1]
|
||||
)
|
||||
}
|
||||
many => {
|
||||
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
|
||||
@@ -390,13 +588,20 @@ pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
|
||||
/// a question and they had no account: who they are to the site (the
|
||||
/// person listed as asking that question), and the link that lets them
|
||||
/// sign in and suggest changes to it. Built in, in the site's language.
|
||||
pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) {
|
||||
pub async fn on_responsible(
|
||||
state: &crate::server::AppState,
|
||||
invited: &crate::people::Invited,
|
||||
questions: &[String],
|
||||
) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told");
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let norwegian = site
|
||||
.lang
|
||||
.as_deref()
|
||||
.is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let list = questions_listed(questions, norwegian);
|
||||
let (subject, body) = if norwegian {
|
||||
(
|
||||
@@ -416,12 +621,24 @@ 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.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.extend(site_vars(&site));
|
||||
vars.insert(
|
||||
"link".into(),
|
||||
invited.reset_link.clone().unwrap_or_default().into(),
|
||||
);
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
@@ -476,32 +693,65 @@ pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str
|
||||
|
||||
/// A suggestion moved: tell whoever made it. Replies go to the site's
|
||||
/// inbox, which is who decided.
|
||||
async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
|
||||
async fn on_suggestion_state(
|
||||
state: &crate::server::AppState,
|
||||
id: &str,
|
||||
to_state: &str,
|
||||
responses: &serde_json::Value,
|
||||
) {
|
||||
let site = state.site.load();
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let norwegian = site
|
||||
.lang
|
||||
.as_deref()
|
||||
.is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
|
||||
return;
|
||||
};
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told");
|
||||
tracing::warn!(
|
||||
id,
|
||||
to_state,
|
||||
"a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
||||
let field = |name: &str| {
|
||||
responses
|
||||
.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
let to = field("email");
|
||||
if !to.contains('@') {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
|
||||
tracing::warn!(
|
||||
id,
|
||||
to_state,
|
||||
"a suggestion moved, but the record carries no email - not sent"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
||||
let mut vars = serde_json::Map::new();
|
||||
let quoted: String = field("text")
|
||||
.lines()
|
||||
.map(|l| format!("> {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
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());
|
||||
vars.insert(
|
||||
"page".into(),
|
||||
format!("{}{}", public_url(), field("page")).into(),
|
||||
);
|
||||
vars.insert("quoted".into(), quoted.into());
|
||||
let text = render(body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
|
||||
id: format!(
|
||||
"{}:{id}:{to_state}",
|
||||
crate::content::EDIT_SUGGESTIONS_BUCKET
|
||||
),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
@@ -518,14 +768,24 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state
|
||||
/// A change suggested by the person responsible for a page, to the
|
||||
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
|
||||
/// suggester as reply-to so answering the mail answers them.
|
||||
pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) {
|
||||
pub async fn on_edit_suggestion(
|
||||
state: &crate::server::AppState,
|
||||
question_id: &str,
|
||||
question_name: &str,
|
||||
by_name: &str,
|
||||
by_email: &str,
|
||||
text: &str,
|
||||
) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
|
||||
return;
|
||||
};
|
||||
let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let norwegian = site
|
||||
.lang
|
||||
.as_deref()
|
||||
.is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, lead) = if norwegian {
|
||||
(format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url()))
|
||||
} else {
|
||||
@@ -553,14 +813,86 @@ 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 {
|
||||
tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only");
|
||||
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 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 (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 +922,11 @@ 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.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.extend(site_vars(&site));
|
||||
vars.insert(
|
||||
"link".into(),
|
||||
invited.reset_link.clone().unwrap_or_default().into(),
|
||||
);
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
@@ -613,6 +947,57 @@ 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]
|
||||
@@ -621,10 +1006,22 @@ mod tests {
|
||||
assert_eq!(questions_listed(&q(0), true), "");
|
||||
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
|
||||
assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
|
||||
assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til");
|
||||
assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
|
||||
assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
|
||||
assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions");
|
||||
assert_eq!(
|
||||
questions_listed(&q(4), true),
|
||||
"«Q1?», «Q2?», «Q3?» og ett spørsmål til"
|
||||
);
|
||||
assert_eq!(
|
||||
questions_listed(&q(17), true),
|
||||
"«Q1?», «Q2?», «Q3?» og 14 spørsmål til"
|
||||
);
|
||||
assert_eq!(
|
||||
questions_listed(&q(2), false),
|
||||
"\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}"
|
||||
);
|
||||
assert_eq!(
|
||||
questions_listed(&q(5), false),
|
||||
"\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -632,8 +1029,14 @@ mod tests {
|
||||
for norwegian in [true, false] {
|
||||
for state in ["discussed", "done", "declined"] {
|
||||
let (subject, body) = suggestion_words(state, norwegian).expect(state);
|
||||
assert!(subject.contains("{question}"), "{state}: the subject names the question");
|
||||
assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
|
||||
assert!(
|
||||
subject.contains("{question}"),
|
||||
"{state}: the subject names the question"
|
||||
);
|
||||
assert!(
|
||||
body.contains("{page}") && body.contains("{quoted}"),
|
||||
"{state}: the body says where and what"
|
||||
);
|
||||
}
|
||||
// Arriving is told to the inbox, not back to who sent it.
|
||||
assert!(suggestion_words("open", norwegian).is_none());
|
||||
@@ -654,14 +1057,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn templates_take_fields_and_leave_stray_braces_alone() {
|
||||
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
|
||||
let responses =
|
||||
serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
|
||||
let vars = vars_for("reports", "abc", "fixed", &responses);
|
||||
assert_eq!(render("Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", &vars), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no");
|
||||
assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {");
|
||||
assert_eq!(
|
||||
render(
|
||||
"Hi {name}, {count} at {site}/decide?chain={chain}&email={email}",
|
||||
&vars
|
||||
),
|
||||
"Hi Kari, 3 at /decide?chain=abc&email=kari@x.no"
|
||||
);
|
||||
assert_eq!(
|
||||
render("{unknown} {hva} {not a key} {", &vars),
|
||||
" {not a key} {"
|
||||
);
|
||||
assert_eq!(vars["bucket"], "reports");
|
||||
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)");
|
||||
@@ -694,7 +1120,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(older.subject, "s");
|
||||
assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\""));
|
||||
assert!(serde_json::to_string(&older)
|
||||
.unwrap()
|
||||
.contains("\"html\":\"h\""));
|
||||
}
|
||||
|
||||
fn record(email: &str) -> crate::answers::Answer {
|
||||
@@ -709,6 +1137,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +1156,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_reply_from_the_address_on_the_record_becomes_a_note() {
|
||||
let note = note_for(&reply("Kari <KARI@x.no>", " It is fixed now. "), &record("kari@x.no")).unwrap();
|
||||
let note = note_for(
|
||||
&reply("Kari <KARI@x.no>", " It is fixed now. "),
|
||||
&record("kari@x.no"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(note.from, "kari@x.no");
|
||||
assert_eq!(note.text, "It is fixed now.");
|
||||
assert_eq!(note.id, "mail-1");
|
||||
@@ -738,21 +1171,33 @@ mod tests {
|
||||
fn a_reply_from_anyone_else_is_refused() {
|
||||
// The address a reply arrives on is unguessable, but a From is
|
||||
// a claim, and a mailbox is a door anyone may knock on.
|
||||
let err = note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err();
|
||||
assert!(err.contains("not the address this record was written to"), "{err}");
|
||||
let err =
|
||||
note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err();
|
||||
assert!(
|
||||
err.contains("not the address this record was written to"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
// Nothing to compare against is also a refusal, not a pass.
|
||||
let mut anonymous = record("kari@x.no");
|
||||
anonymous.responses = serde_json::json!({ "where": "The bend" });
|
||||
assert!(note_for(&reply("kari@x.no", "hi"), &anonymous).unwrap_err().contains("no address"));
|
||||
assert!(note_for(&reply("kari@x.no", "hi"), &anonymous)
|
||||
.unwrap_err()
|
||||
.contains("no address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_reply_and_a_repeated_one_add_nothing() {
|
||||
assert!(note_for(&reply("kari@x.no", " \n "), &record("kari@x.no")).unwrap_err().contains("no text"));
|
||||
assert!(
|
||||
note_for(&reply("kari@x.no", " \n "), &record("kari@x.no"))
|
||||
.unwrap_err()
|
||||
.contains("no text")
|
||||
);
|
||||
|
||||
let mut already = record("kari@x.no");
|
||||
already.notes.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap());
|
||||
already
|
||||
.notes
|
||||
.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap());
|
||||
let err = note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap_err();
|
||||
assert!(err.contains("already on the record"), "{err}");
|
||||
}
|
||||
@@ -761,7 +1206,9 @@ mod tests {
|
||||
fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() {
|
||||
let mut future = reply("kari@x.no", "hello");
|
||||
future.v = CONTRACT + 1;
|
||||
assert!(note_for(&future, &record("kari@x.no")).unwrap_err().contains("contract"));
|
||||
assert!(note_for(&future, &record("kari@x.no"))
|
||||
.unwrap_err()
|
||||
.contains("contract"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+54
-22
@@ -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,14 +20,21 @@ 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
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn serve() -> anyhow::Result<()> {
|
||||
use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::Request,
|
||||
response::IntoResponse,
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use portal::app::{shell, App};
|
||||
@@ -56,12 +64,15 @@ async fn serve() -> anyhow::Result<()> {
|
||||
let gitea_base = content::gitea_api_base(&content_repo)?;
|
||||
let content_raw_base = content::gitea_raw_base(&content_repo)?;
|
||||
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
|
||||
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||
let questions =
|
||||
content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
|
||||
content::validate_questions(&questions, &aggregates)?;
|
||||
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
|
||||
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 aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
|
||||
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
|
||||
@@ -127,7 +138,12 @@ async fn serve() -> anyhow::Result<()> {
|
||||
|
||||
// The same decisions, answerable by other apps over NATS, for a
|
||||
// person named by phone number or address as well as by groups.
|
||||
tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone())));
|
||||
tokio::spawn(portal::access::serve(
|
||||
state.nats.clone(),
|
||||
site_name_for_subjects(),
|
||||
state.access.clone(),
|
||||
Some(state.clone()),
|
||||
));
|
||||
// Announced pages: keep their desk records current, close ended
|
||||
// windows (see announce.rs).
|
||||
tokio::spawn(portal::announce::run(state.clone()));
|
||||
@@ -201,18 +217,17 @@ async fn serve() -> anyhow::Result<()> {
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
|
||||
None => tracing::warn!(
|
||||
"rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"
|
||||
),
|
||||
}
|
||||
|
||||
async fn server_fn_handler(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
leptos_axum::handle_server_fns_with_context(
|
||||
move || provide_context(state.clone()),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
leptos_axum::handle_server_fns_with_context(move || provide_context(state.clone()), request)
|
||||
.await
|
||||
}
|
||||
|
||||
// The app's own Leptos route list includes a root-level wildcard
|
||||
@@ -263,10 +278,7 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
||||
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
||||
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
|
||||
.route_service(
|
||||
"/swiper-element-bundle.min.js",
|
||||
ServeFile::new(swiper_path),
|
||||
)
|
||||
.route_service("/swiper-element-bundle.min.js", ServeFile::new(swiper_path))
|
||||
// Same name every release, so revalidated like /pkg.
|
||||
.route_service(
|
||||
"/page-motion.js",
|
||||
@@ -290,7 +302,9 @@ async fn serve() -> anyhow::Result<()> {
|
||||
// with room for the multipart framing.
|
||||
.route(
|
||||
"/upload",
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(
|
||||
upload::MAX_UPLOAD_BYTES + 64 * 1024,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/attachment/{bucket}/{record}/{field}",
|
||||
@@ -301,7 +315,12 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.route("/report", get(portal::report::handler))
|
||||
// Content-shipped assets (hero module etc.), same-origin.
|
||||
.route("/site/{*path}", get(content::site_asset_handler))
|
||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
||||
.route("/.well-known/{*path}", get(content::well_known_handler))
|
||||
.route("/profile.mobileconfig", get(portal::profile::handler))
|
||||
.route(
|
||||
"/automation/kv/{bucket}",
|
||||
get(content::automation_kv_handler),
|
||||
)
|
||||
// Maps, proxied: the browser asks this origin for every tile
|
||||
// and every still, so nothing third-party loads in a page and
|
||||
// the token stays here (see maps.rs).
|
||||
@@ -323,7 +342,10 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
||||
.layer(session_layer)
|
||||
// Which storyline this visitor reads, before anything renders.
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), portal::server::storyline::assign));
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
portal::server::storyline::assign,
|
||||
));
|
||||
// Outermost, so a caller who is over the limit is turned away
|
||||
// before a session is loaded or a handler is entered.
|
||||
let app = match governor {
|
||||
@@ -361,7 +383,17 @@ fn site_name_for_subjects() -> String {
|
||||
let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
|
||||
let folded: String = raw
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_lowercase() } else { '_' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if folded.is_empty() { "portal".to_string() } else { folded }
|
||||
if folded.is_empty() {
|
||||
"portal".to_string()
|
||||
} else {
|
||||
folded
|
||||
}
|
||||
}
|
||||
|
||||
+76
-15
@@ -42,7 +42,11 @@ fn style_id() -> String {
|
||||
let (Some(owner), Some(id), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
[owner, id].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'))
|
||||
[owner, id].iter().all(|p| {
|
||||
!p.is_empty()
|
||||
&& p.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "mapbox/streets-v12".to_string())
|
||||
}
|
||||
@@ -50,7 +54,9 @@ fn style_id() -> String {
|
||||
/// The instance's Mapbox token, or `None` when maps are off.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn token() -> Option<String> {
|
||||
std::env::var("MAPBOX_TOKEN").ok().filter(|t| !t.trim().is_empty())
|
||||
std::env::var("MAPBOX_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -64,7 +70,11 @@ pub fn enabled() -> bool {
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn style() -> Response {
|
||||
if !enabled() {
|
||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"maps are not configured on this instance",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let style = serde_json::json!({
|
||||
"version": 8,
|
||||
@@ -79,7 +89,10 @@ pub async fn style() -> Response {
|
||||
"layers": [{ "id": "base", "type": "raster", "source": "base" }],
|
||||
});
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/json"), (header::CACHE_CONTROL, "public, max-age=3600")],
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/json"),
|
||||
(header::CACHE_CONTROL, "public, max-age=3600"),
|
||||
],
|
||||
style.to_string(),
|
||||
)
|
||||
.into_response()
|
||||
@@ -90,7 +103,11 @@ pub async fn style() -> Response {
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
|
||||
let Some(token) = token() else {
|
||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"maps are not configured on this instance",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
if z > 22 || x >= 1 << z.min(22) || y >= 1 << z.min(22) {
|
||||
return (StatusCode::BAD_REQUEST, "no such tile").into_response();
|
||||
@@ -108,13 +125,21 @@ pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response {
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
|
||||
let Some(token) = token() else {
|
||||
return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response();
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"maps are not configured on this instance",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Some((lon, lat, zoom)) = parse_place(&place) else {
|
||||
return (StatusCode::BAD_REQUEST, "expected <lon>,<lat>,<zoom>").into_response();
|
||||
};
|
||||
let Some((w, h)) = parse_size(&size) else {
|
||||
return (StatusCode::BAD_REQUEST, "expected <width>x<height>, at most 1280").into_response();
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"expected <width>x<height>, at most 1280",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
// The pin is drawn by Mapbox so the image is self-contained; the
|
||||
// marker colour is portal's ink, not a brand colour.
|
||||
@@ -127,7 +152,12 @@ pub async fn still(Path((place, size)): Path<(String, String)>) -> Response {
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
|
||||
let resp = match crate::http::client().get(url).send().await.and_then(|r| r.error_for_status()) {
|
||||
let resp = match crate::http::client()
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("map upstream: {e}");
|
||||
@@ -143,7 +173,10 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
|
||||
match crate::http::bytes(resp, crate::http::ASSET_CAP).await {
|
||||
Ok(bytes) => (
|
||||
[
|
||||
(header::CONTENT_TYPE, upstream.unwrap_or_else(|| mime.to_string())),
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
upstream.unwrap_or_else(|| mime.to_string()),
|
||||
),
|
||||
(header::CACHE_CONTROL, cache.to_string()),
|
||||
],
|
||||
axum::body::Bytes::from(bytes),
|
||||
@@ -159,16 +192,25 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response {
|
||||
#[cfg(feature = "ssr")]
|
||||
fn parse_place(place: &str) -> Option<(String, String, String)> {
|
||||
let mut parts = place.split(',');
|
||||
let (Some(lon), Some(lat), Some(zoom), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
|
||||
let (Some(lon), Some(lat), Some(zoom), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let lon: f64 = lon.parse().ok()?;
|
||||
let lat: f64 = lat.parse().ok()?;
|
||||
let zoom: f64 = zoom.parse().ok()?;
|
||||
if !(-180.0..=180.0).contains(&lon) || !(-85.0511..=85.0511).contains(&lat) || !(0.0..=22.0).contains(&zoom) {
|
||||
if !(-180.0..=180.0).contains(&lon)
|
||||
|| !(-85.0511..=85.0511).contains(&lat)
|
||||
|| !(0.0..=22.0).contains(&zoom)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((format!("{lon:.5}"), format!("{lat:.5}"), format!("{zoom:.2}")))
|
||||
Some((
|
||||
format!("{lon:.5}"),
|
||||
format!("{lat:.5}"),
|
||||
format!("{zoom:.2}"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -203,7 +245,15 @@ mod tests {
|
||||
parse_place("11.123456,59.987654,13.5"),
|
||||
Some(("11.12346".into(), "59.98765".into(), "13.50".into()))
|
||||
);
|
||||
for bad in ["11,59", "11,59,13,0", "200,59,13", "11,95,13", "11,59,30", "11,59,x", "11,59,13?a=b"] {
|
||||
for bad in [
|
||||
"11,59",
|
||||
"11,59,13,0",
|
||||
"200,59,13",
|
||||
"11,95,13",
|
||||
"11,59,30",
|
||||
"11,59,x",
|
||||
"11,59,13?a=b",
|
||||
] {
|
||||
assert_eq!(parse_place(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -212,7 +262,15 @@ mod tests {
|
||||
#[cfg(feature = "ssr")]
|
||||
fn a_size_is_two_numbers_within_what_the_service_draws() {
|
||||
assert_eq!(parse_size("600x300"), Some((600, 300)));
|
||||
for bad in ["600", "0x300", "600x0", "2000x300", "600x2000", "600x300x2", "sixx300"] {
|
||||
for bad in [
|
||||
"600",
|
||||
"0x300",
|
||||
"600x0",
|
||||
"2000x300",
|
||||
"600x2000",
|
||||
"600x300x2",
|
||||
"sixx300",
|
||||
] {
|
||||
assert_eq!(parse_size(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -221,7 +279,10 @@ mod tests {
|
||||
fn a_location_is_a_lat_and_a_lon_and_a_zoom_it_may_leave_out() {
|
||||
let v = serde_json::json!({ "lat": 59.5, "lon": 11.0, "zoom": 16 });
|
||||
assert_eq!(read(&v), Some((59.5, 11.0, 16.0)));
|
||||
assert_eq!(read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })), Some((59.5, 11.0, 13.0)));
|
||||
assert_eq!(
|
||||
read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })),
|
||||
Some((59.5, 11.0, 13.0))
|
||||
);
|
||||
for not in [
|
||||
serde_json::json!({ "lat": 91.0, "lon": 11.0 }),
|
||||
serde_json::json!({ "lat": 59.5 }),
|
||||
|
||||
+221
-45
@@ -32,7 +32,10 @@ pub const BY: &str = "portal";
|
||||
|
||||
/// The country a phone number without one is in, as a calling code.
|
||||
fn default_country() -> String {
|
||||
std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string())
|
||||
std::env::var("PHONE_COUNTRY")
|
||||
.ok()
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.unwrap_or_else(|| "+47".to_string())
|
||||
}
|
||||
|
||||
/// Who a person is, as far as a record or a session knows.
|
||||
@@ -69,7 +72,11 @@ pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn normalize_email(raw: &str) -> Option<String> {
|
||||
let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||
let e = raw
|
||||
.trim()
|
||||
.trim_start_matches("mailto:")
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
|
||||
}
|
||||
|
||||
@@ -88,7 +95,8 @@ impl Identity {
|
||||
let country = default_country();
|
||||
Identity {
|
||||
sub: None,
|
||||
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)),
|
||||
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"])
|
||||
.and_then(|p| normalize_phone(&p, &country)),
|
||||
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
|
||||
}
|
||||
}
|
||||
@@ -97,7 +105,10 @@ impl Identity {
|
||||
let country = default_country();
|
||||
Identity {
|
||||
sub: Some(user.sub.clone()),
|
||||
phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)),
|
||||
phone: user
|
||||
.phone
|
||||
.as_deref()
|
||||
.and_then(|p| normalize_phone(p, &country)),
|
||||
email: user.email.as_deref().and_then(normalize_email),
|
||||
}
|
||||
}
|
||||
@@ -107,8 +118,16 @@ impl Identity {
|
||||
pub fn from_contact(contact: &str) -> Self {
|
||||
let country = default_country();
|
||||
match normalize_email(contact) {
|
||||
Some(email) => Identity { sub: None, phone: None, email: Some(email) },
|
||||
None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None },
|
||||
Some(email) => Identity {
|
||||
sub: None,
|
||||
phone: None,
|
||||
email: Some(email),
|
||||
},
|
||||
None => Identity {
|
||||
sub: None,
|
||||
phone: normalize_phone(contact, &country),
|
||||
email: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +139,26 @@ impl Identity {
|
||||
.as_deref()
|
||||
.or(self.email.as_deref())
|
||||
.or(self.sub.as_deref())
|
||||
.map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect())
|
||||
.map(|k| {
|
||||
k.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a record about `other` is about this person: the same
|
||||
/// subject, phone number or address, any one of them.
|
||||
pub fn matches(&self, other: &Identity) -> bool {
|
||||
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
|
||||
same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email)
|
||||
same(&self.sub, &other.sub)
|
||||
|| same(&self.phone, &other.phone)
|
||||
|| same(&self.email, &other.email)
|
||||
}
|
||||
|
||||
/// What is worth writing on a record from a login: everything the
|
||||
@@ -149,14 +180,34 @@ pub fn record_id(group: &str, key: &str) -> String {
|
||||
/// The identity a record holds.
|
||||
pub fn identity_of(answer: &Answer) -> Identity {
|
||||
Identity {
|
||||
sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
sub: answer
|
||||
.responses
|
||||
.get("sub")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
phone: answer
|
||||
.responses
|
||||
.get("phone")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
email: answer
|
||||
.responses
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group_of(answer: &Answer) -> String {
|
||||
answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string()
|
||||
answer
|
||||
.responses
|
||||
.get("group")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The groups `who` is an active member of, from every record.
|
||||
@@ -172,19 +223,26 @@ pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
|
||||
groups
|
||||
}
|
||||
|
||||
async fn store(state: &crate::server::AppState) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
||||
async fn store(
|
||||
state: &crate::server::AppState,
|
||||
) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
||||
match state.jetstream.get_key_value(BUCKET).await {
|
||||
Ok(store) => Ok(store),
|
||||
Err(_) => Ok(state
|
||||
.jetstream
|
||||
.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() })
|
||||
.create_key_value(async_nats::jetstream::kv::Config {
|
||||
bucket: BUCKET.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every membership record.
|
||||
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
||||
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return Vec::new() };
|
||||
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
@@ -205,18 +263,36 @@ pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
||||
|
||||
/// Moves one record the way a desk button would: through the event
|
||||
/// log, onto the projection, out on the answers subject.
|
||||
async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result<Answer> {
|
||||
async fn move_to(
|
||||
state: &crate::server::AppState,
|
||||
store: &async_nats::jetstream::kv::Store,
|
||||
mut answer: Answer,
|
||||
target: &str,
|
||||
by: &str,
|
||||
) -> anyhow::Result<Answer> {
|
||||
let aggregates = state.aggregates.load();
|
||||
let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
||||
let schema = aggregates
|
||||
.get(BUCKET)
|
||||
.ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
|
||||
crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
crate::answers::transition_or_reseed(
|
||||
&state.jetstream,
|
||||
schema,
|
||||
&answer.id,
|
||||
target,
|
||||
&answer.state,
|
||||
payload.clone(),
|
||||
now_ms,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
answer.state = target.to_string();
|
||||
answer.decided_ms = Some(now_ms);
|
||||
answer.decided_by = Some(by.to_string());
|
||||
store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?;
|
||||
store
|
||||
.put(&answer.id, serde_json::to_vec(&answer)?.into())
|
||||
.await?;
|
||||
let parent_hashes = vec![answer.id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
let event = crate::events::AnswerSubmitted {
|
||||
@@ -236,15 +312,29 @@ async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream:
|
||||
/// `invited`; a removed one is invited again; an invited or active one
|
||||
/// is left as it is, so a second grant costs nothing. `created` in
|
||||
/// what comes back says whether the person had a place already.
|
||||
pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result<crate::people::Invited> {
|
||||
let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?;
|
||||
pub async fn grant(
|
||||
state: &crate::server::AppState,
|
||||
group: &str,
|
||||
name: &str,
|
||||
who: &Identity,
|
||||
by: &str,
|
||||
) -> anyhow::Result<crate::people::Invited> {
|
||||
let key = who.key().ok_or_else(|| {
|
||||
anyhow::anyhow!("a membership needs a phone number or an address to know the person by")
|
||||
})?;
|
||||
let store = store(state).await?;
|
||||
// The person's record in this group, however it was keyed: a desk
|
||||
// that invited by address and a decision that granted by phone
|
||||
// number are talking about one person once a sign-in has joined
|
||||
// the two, and must not make two.
|
||||
let existing: Option<Answer> = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
|
||||
let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key));
|
||||
let existing: Option<Answer> = all(state)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
|
||||
let id = existing
|
||||
.as_ref()
|
||||
.map(|a| a.id.clone())
|
||||
.unwrap_or_else(|| record_id(group, &key));
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let invited = |created: bool| crate::people::Invited {
|
||||
username: key.clone(),
|
||||
@@ -254,6 +344,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 {
|
||||
@@ -267,7 +359,18 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
||||
"name": name, "group": group, "by": by,
|
||||
"phone": who.phone, "email": who.email, "sub": who.sub,
|
||||
});
|
||||
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms, 0).await?;
|
||||
crate::answers::store_answer(
|
||||
&state.jetstream,
|
||||
&state.aggregates.load(),
|
||||
BUCKET,
|
||||
id,
|
||||
"/",
|
||||
"Membership",
|
||||
&responses,
|
||||
now_ms,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
Ok(invited(true))
|
||||
}
|
||||
}
|
||||
@@ -275,11 +378,22 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
||||
|
||||
/// Takes a person out of a group: the record moves to `removed` and
|
||||
/// stays, so the desk can see who was in and put them back.
|
||||
pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result<bool> {
|
||||
let Some(key) = who.key() else { return Ok(false) };
|
||||
pub async fn revoke(
|
||||
state: &crate::server::AppState,
|
||||
group: &str,
|
||||
who: &Identity,
|
||||
by: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
let Some(key) = who.key() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let store = store(state).await?;
|
||||
let id = record_id(group, &key);
|
||||
let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::<Answer>(&b).ok()) else {
|
||||
let Some(answer) = store
|
||||
.get(&id)
|
||||
.await?
|
||||
.and_then(|b| serde_json::from_slice::<Answer>(&b).ok())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if answer.state == REMOVED {
|
||||
@@ -294,10 +408,15 @@ pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity
|
||||
/// and their active groups come back to be their groups for the
|
||||
/// session. Their `sub` is remembered, so the next login matches on
|
||||
/// it before anything else.
|
||||
pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec<String> {
|
||||
pub async fn on_signed_in(
|
||||
state: &crate::server::AppState,
|
||||
user: &crate::auth::User,
|
||||
) -> Vec<String> {
|
||||
let who = Identity::from_user(user);
|
||||
let records = all(state).await;
|
||||
let Ok(store) = store(state).await else { return groups_of(&records, &who) };
|
||||
let Ok(store) = store(state).await else {
|
||||
return groups_of(&records, &who);
|
||||
};
|
||||
let mut settled = Vec::with_capacity(records.len());
|
||||
for answer in records {
|
||||
let held = identity_of(&answer);
|
||||
@@ -339,7 +458,13 @@ pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::U
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer {
|
||||
fn record(
|
||||
group: &str,
|
||||
state: &str,
|
||||
phone: Option<&str>,
|
||||
email: Option<&str>,
|
||||
sub: Option<&str>,
|
||||
) -> Answer {
|
||||
Answer {
|
||||
id: record_id(group, "k"),
|
||||
question_id: "/".into(),
|
||||
@@ -351,44 +476,95 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_number_is_one_string_for_everyone() {
|
||||
assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into()));
|
||||
assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into()));
|
||||
assert_eq!(
|
||||
normalize_phone("912 34 567", "+47"),
|
||||
Some("+4791234567".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_phone("+47 912-34-567", "+47"),
|
||||
Some("+4791234567".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_phone("004791234567", "+47"),
|
||||
Some("+4791234567".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_phone("91234567", "+46"),
|
||||
Some("+4691234567".into())
|
||||
);
|
||||
assert_eq!(normalize_phone("kari@vel.no", "+47"), None);
|
||||
assert_eq!(normalize_phone("123", "+47"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_is_their_phone_first_and_their_address_second() {
|
||||
let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }));
|
||||
let both = Identity::from_responses(
|
||||
&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }),
|
||||
);
|
||||
assert_eq!(both.key().as_deref(), Some("_4791234567"));
|
||||
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
|
||||
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" }));
|
||||
assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no"));
|
||||
assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no"));
|
||||
assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567"));
|
||||
assert_eq!(
|
||||
Identity::from_contact("mailto:Post@Vel.no")
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("post@vel.no")
|
||||
);
|
||||
assert_eq!(
|
||||
Identity::from_contact("912 34 567").phone.as_deref(),
|
||||
Some("+4791234567")
|
||||
);
|
||||
assert!(Identity::default().key().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
|
||||
let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||
assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari));
|
||||
assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari));
|
||||
assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari));
|
||||
assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari));
|
||||
let kari = Identity {
|
||||
sub: Some("abc".into()),
|
||||
phone: Some("+4791234567".into()),
|
||||
email: Some("kari@vel.no".into()),
|
||||
};
|
||||
assert!(Identity {
|
||||
sub: None,
|
||||
phone: Some("+4791234567".into()),
|
||||
email: None
|
||||
}
|
||||
.matches(&kari));
|
||||
assert!(Identity {
|
||||
sub: None,
|
||||
phone: None,
|
||||
email: Some("kari@vel.no".into())
|
||||
}
|
||||
.matches(&kari));
|
||||
assert!(Identity {
|
||||
sub: Some("abc".into()),
|
||||
phone: None,
|
||||
email: Some("old@vel.no".into())
|
||||
}
|
||||
.matches(&kari));
|
||||
assert!(!Identity {
|
||||
sub: None,
|
||||
phone: Some("+4799999999".into()),
|
||||
email: Some("ola@vel.no".into())
|
||||
}
|
||||
.matches(&kari));
|
||||
assert!(!Identity::default().matches(&kari));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_memberships_are_groups() {
|
||||
let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||
let kari = Identity {
|
||||
sub: None,
|
||||
phone: Some("+4791234567".into()),
|
||||
email: Some("kari@vel.no".into()),
|
||||
};
|
||||
let records = vec![
|
||||
record("styret", ACTIVE, Some("+4791234567"), None, None),
|
||||
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
|
||||
|
||||
+340
-68
@@ -37,14 +37,25 @@ pub enum People {
|
||||
impl People {
|
||||
/// `None` only when Kanidm is asked for and not configured.
|
||||
pub fn from_env() -> Option<Self> {
|
||||
match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() {
|
||||
match std::env::var("PEOPLE_BACKEND")
|
||||
.ok()
|
||||
.map(|b| b.trim().to_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("memberships") => Some(People::Memberships),
|
||||
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
|
||||
Some(other) => {
|
||||
tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships");
|
||||
tracing::warn!(
|
||||
backend = other,
|
||||
"PEOPLE_BACKEND is neither kanidm nor memberships; using memberships"
|
||||
);
|
||||
Some(People::Memberships)
|
||||
}
|
||||
None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)),
|
||||
None => Some(
|
||||
Kanidm::from_env()
|
||||
.map(People::Kanidm)
|
||||
.unwrap_or(People::Memberships),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +66,19 @@ impl People {
|
||||
/// through Kanidm an account and a one-time link, through
|
||||
/// memberships a record that becomes theirs when they sign in.
|
||||
/// `by` is who asked, for the record.
|
||||
pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result<Invited> {
|
||||
pub async fn invite(
|
||||
&self,
|
||||
state: &crate::server::AppState,
|
||||
name: &str,
|
||||
who: &crate::memberships::Identity,
|
||||
group: &str,
|
||||
by: &str,
|
||||
) -> anyhow::Result<Invited> {
|
||||
match self {
|
||||
People::Kanidm(kanidm) => {
|
||||
let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?;
|
||||
let email = who.email.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Kanidm knows people by their address, and this one has none")
|
||||
})?;
|
||||
kanidm.invite(name, email, group).await
|
||||
}
|
||||
People::Memberships => {
|
||||
@@ -75,7 +95,9 @@ impl People {
|
||||
invited.link_expires_ms = account.link_expires_ms;
|
||||
invited.created = account.created;
|
||||
}
|
||||
Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"),
|
||||
Err(e) => {
|
||||
tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(invited)
|
||||
@@ -85,10 +107,19 @@ impl People {
|
||||
|
||||
/// Takes a person out of a group. Through Kanidm only what portal
|
||||
/// put there (see `Invited.already_member`); memberships remember.
|
||||
pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> {
|
||||
pub async fn remove(
|
||||
&self,
|
||||
state: &crate::server::AppState,
|
||||
who: &crate::memberships::Identity,
|
||||
username: &str,
|
||||
group: &str,
|
||||
by: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
match self {
|
||||
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
|
||||
People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()),
|
||||
People::Memberships => crate::memberships::revoke(state, group, who, by)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +158,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
|
||||
@@ -134,7 +171,9 @@ pub struct Invited {
|
||||
fn expiry_ms(value: &serde_json::Value) -> Option<i64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|t| t.timestamp_millis()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -157,7 +196,11 @@ fn members_from(body: &str) -> Vec<String> {
|
||||
fn has_credentials_from(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty()))
|
||||
.and_then(|v| {
|
||||
v.get("creds")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|c| !c.is_empty())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -178,7 +221,15 @@ fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
|
||||
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
|
||||
fn name_from_search(body: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
v.get("entries")?.as_array()?.first()?.get("attrs")?.get("name")?.as_array()?.first()?.as_str().map(str::to_string)
|
||||
v.get("entries")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("attrs")?
|
||||
.get("name")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub struct Kanidm {
|
||||
@@ -196,13 +247,30 @@ impl Kanidm {
|
||||
/// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with
|
||||
/// a clear message instead of a half-made account.
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string();
|
||||
let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?;
|
||||
let group_prefix = std::env::var("KANIDM_GROUP_PREFIX").unwrap_or_default().trim().to_string();
|
||||
Some(Kanidm { base, token, group_prefix })
|
||||
let base = std::env::var("KANIDM_URL")
|
||||
.ok()?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let token = std::env::var("KANIDM_API_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())?;
|
||||
let group_prefix = std::env::var("KANIDM_GROUP_PREFIX")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
Some(Kanidm {
|
||||
base,
|
||||
token,
|
||||
group_prefix,
|
||||
})
|
||||
}
|
||||
|
||||
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
|
||||
async fn call(
|
||||
&self,
|
||||
method: openidconnect::reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<(u16, String)> {
|
||||
let mut req = crate::http::client()
|
||||
.request(method, format!("{}{path}", self.base))
|
||||
.bearer_auth(&self.token)
|
||||
@@ -217,8 +285,15 @@ impl Kanidm {
|
||||
}
|
||||
|
||||
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
|
||||
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
|
||||
let (status, text) = self
|
||||
.call(
|
||||
openidconnect::reqwest::Method::GET,
|
||||
&format!("/v1/person/{username}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
person_lookup_found(status, &text)
|
||||
.ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
|
||||
}
|
||||
|
||||
/// The account that already carries this email, if the token may
|
||||
@@ -269,7 +344,13 @@ impl Kanidm {
|
||||
|
||||
/// Who is in a group, by account name.
|
||||
pub async fn members(&self, group: &str) -> anyhow::Result<Vec<String>> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), None).await?;
|
||||
let (status, text) = self
|
||||
.call(
|
||||
openidconnect::reqwest::Method::GET,
|
||||
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
|
||||
}
|
||||
@@ -280,7 +361,11 @@ impl Kanidm {
|
||||
/// service account manages, like adding them was.
|
||||
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username])))
|
||||
.call(
|
||||
openidconnect::reqwest::Method::DELETE,
|
||||
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
|
||||
Some(serde_json::json!([username])),
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
|
||||
@@ -291,7 +376,13 @@ impl Kanidm {
|
||||
/// Whether Kanidm shows a credential for this person. `false` is
|
||||
/// "not that it can see", never "none": see `has_credentials_from`.
|
||||
pub async fn has_credentials(&self, username: &str) -> anyhow::Result<bool> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_status"), None).await?;
|
||||
let (status, text) = self
|
||||
.call(
|
||||
openidconnect::reqwest::Method::GET,
|
||||
&format!("/v1/person/{username}/_credential/_status"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: credential status of {username}: http {status}");
|
||||
}
|
||||
@@ -302,7 +393,11 @@ impl Kanidm {
|
||||
/// passkey, and when it runs out.
|
||||
pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option<i64>)> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), None)
|
||||
.call(
|
||||
openidconnect::reqwest::Method::GET,
|
||||
&format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
||||
@@ -314,10 +409,18 @@ impl Kanidm {
|
||||
expiry_time: Option<serde_json::Value>,
|
||||
}
|
||||
let intent: Intent = serde_json::from_str(&text)?;
|
||||
Ok((format!("{}/ui/reset?token={}", self.base, intent.token), intent.expiry_time.as_ref().and_then(expiry_ms)))
|
||||
Ok((
|
||||
format!("{}/ui/reset?token={}", self.base, intent.token),
|
||||
intent.expiry_time.as_ref().and_then(expiry_ms),
|
||||
))
|
||||
}
|
||||
|
||||
async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result<Invited> {
|
||||
async fn onboard(
|
||||
&self,
|
||||
name: &str,
|
||||
email: &str,
|
||||
group: Option<&str>,
|
||||
) -> anyhow::Result<Invited> {
|
||||
let existing = self.find_by_mail(email).await?;
|
||||
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
||||
let created = if existing.is_some() || self.person_exists(&username).await? {
|
||||
@@ -345,9 +448,17 @@ impl Kanidm {
|
||||
// were here before is what says whose the membership is; a
|
||||
// failed read says "not before", which errs toward portal
|
||||
// tidying up after itself.
|
||||
already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username));
|
||||
already_member = !created
|
||||
&& self
|
||||
.members(group)
|
||||
.await
|
||||
.is_ok_and(|m| m.contains(&username));
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username])))
|
||||
.call(
|
||||
openidconnect::reqwest::Method::POST,
|
||||
&format!("/v1/group/{}{group}/_attr/member", self.group_prefix),
|
||||
Some(serde_json::json!([username])),
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
||||
@@ -367,6 +478,8 @@ impl Kanidm {
|
||||
reset_link,
|
||||
link_expires_ms,
|
||||
created,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
})
|
||||
}
|
||||
@@ -385,7 +498,10 @@ fn link_runs_out(answer: &crate::answers::Answer) -> i64 {
|
||||
|
||||
/// The account an invite record is about.
|
||||
fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
||||
answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
answer
|
||||
.responses
|
||||
.get("invited")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// Where a waiting invite goes now, if anywhere. Pure: what is known
|
||||
@@ -393,7 +509,11 @@ fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
||||
///
|
||||
/// An invite that made no account has no link to wait for: the person
|
||||
/// had their way in already, and was only given a place.
|
||||
pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> Option<&'static str> {
|
||||
pub fn invite_due(
|
||||
answer: &crate::answers::Answer,
|
||||
is_in: bool,
|
||||
now_ms: i64,
|
||||
) -> Option<&'static str> {
|
||||
if answer.state != crate::answers::OPEN_STATE {
|
||||
return None;
|
||||
}
|
||||
@@ -410,7 +530,11 @@ pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) ->
|
||||
/// An invite nobody at a desk made - the first person, someone the
|
||||
/// content names as responsible - on file like any other, so the desk
|
||||
/// that reads `portal_invites` sees it, and can send it again.
|
||||
pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) {
|
||||
pub async fn record(
|
||||
state: &crate::server::AppState,
|
||||
invited: &Invited,
|
||||
responsible_for: &[String],
|
||||
) {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let mut responses = serde_json::json!({
|
||||
"name": invited.display_name,
|
||||
@@ -421,23 +545,53 @@ pub async fn record(state: &crate::server::AppState, invited: &Invited, responsi
|
||||
responses["responsible_for"] = serde_json::json!(responsible_for);
|
||||
}
|
||||
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
|
||||
if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms, 0).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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an invite record, the way a desk button would: through the
|
||||
/// event log, onto the projection, and out on the answers subject.
|
||||
async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: crate::answers::Answer, target: &str) {
|
||||
async fn move_invite(
|
||||
state: &crate::server::AppState,
|
||||
store: &async_nats::jetstream::kv::Store,
|
||||
mut answer: crate::answers::Answer,
|
||||
target: &str,
|
||||
) {
|
||||
let aggregates = state.aggregates.load();
|
||||
let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else {
|
||||
let Some(schema) = aggregates
|
||||
.get(INVITES_BUCKET)
|
||||
.filter(|s| s.allowed(&answer.state).iter().any(|t| t == target))
|
||||
else {
|
||||
// A content repo may declare the bucket itself, without this
|
||||
// state. Then the invite stays where its own graph has it.
|
||||
return;
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY });
|
||||
if let Err(e) = crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms).await {
|
||||
if let Err(e) = crate::answers::transition_or_reseed(
|
||||
&state.jetstream,
|
||||
schema,
|
||||
&answer.id,
|
||||
target,
|
||||
&answer.state,
|
||||
payload.clone(),
|
||||
now_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}");
|
||||
return;
|
||||
}
|
||||
@@ -446,7 +600,11 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr
|
||||
answer.decided_by = Some(BY.to_string());
|
||||
// The link is a way in for as long as it is on file; one that is
|
||||
// used or run out is only something to leak.
|
||||
if let Some(invited) = answer.responses.get_mut("invited").and_then(|i| i.as_object_mut()) {
|
||||
if let Some(invited) = answer
|
||||
.responses
|
||||
.get_mut("invited")
|
||||
.and_then(|i| i.as_object_mut())
|
||||
{
|
||||
invited.insert("reset_link".into(), serde_json::Value::Null);
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
@@ -470,17 +628,33 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr
|
||||
}
|
||||
|
||||
/// Every invite on file that is still waiting in one of `states`.
|
||||
async fn invites_in(state: &crate::server::AppState, states: &[&str]) -> Option<(async_nats::jetstream::kv::Store, Vec<crate::answers::Answer>)> {
|
||||
async fn invites_in(
|
||||
state: &crate::server::AppState,
|
||||
states: &[&str],
|
||||
) -> Option<(
|
||||
async_nats::jetstream::kv::Store,
|
||||
Vec<crate::answers::Answer>,
|
||||
)> {
|
||||
// Made on first write: a site that has invited nobody has no bucket.
|
||||
let store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?;
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
store.keys().await.ok()?.try_collect().await.unwrap_or_default()
|
||||
store
|
||||
.keys()
|
||||
.await
|
||||
.ok()?
|
||||
.try_collect()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
let Ok(Some(bytes)) = store.get(&id).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if states.contains(&answer.state.as_str()) {
|
||||
found.push(answer);
|
||||
}
|
||||
@@ -501,12 +675,17 @@ pub async fn run(state: crate::server::AppState) {
|
||||
/// One pass over the waiting invites: done for whoever Kanidm shows a
|
||||
/// credential for, expired for a link that ran out.
|
||||
pub async fn sweep_invites(state: &crate::server::AppState) {
|
||||
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return };
|
||||
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else {
|
||||
return;
|
||||
};
|
||||
let kanidm = Kanidm::from_env();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
for answer in waiting {
|
||||
let is_in = match (&kanidm, invited_of(&answer)) {
|
||||
(Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false),
|
||||
(Some(kanidm), Some(invited)) if invited.created => kanidm
|
||||
.has_credentials(&invited.username)
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(target) = invite_due(&answer, is_in, now_ms) {
|
||||
@@ -519,14 +698,23 @@ pub async fn sweep_invites(state: &crate::server::AppState) {
|
||||
/// address it went to for someone whose account goes by another name.
|
||||
pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool {
|
||||
let address = invited.email.trim().to_lowercase();
|
||||
invited.username == user.username || (!address.is_empty() && user.email.as_deref().is_some_and(|e| e.trim().to_lowercase() == address))
|
||||
invited.username == user.username
|
||||
|| (!address.is_empty()
|
||||
&& user
|
||||
.email
|
||||
.as_deref()
|
||||
.is_some_and(|e| e.trim().to_lowercase() == address))
|
||||
}
|
||||
|
||||
/// Someone signed in: whatever invites were waiting for them, or had
|
||||
/// run out, are done. This is the one sure sign a link was used -
|
||||
/// they are here.
|
||||
pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) {
|
||||
let Some((store, waiting)) = invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await else { return };
|
||||
let Some((store, waiting)) =
|
||||
invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for answer in waiting {
|
||||
if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) {
|
||||
move_invite(&state, &store, answer, INVITE_DONE).await;
|
||||
@@ -539,11 +727,20 @@ pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::Use
|
||||
/// time, which for someone the content names as responsible is the one
|
||||
/// that says so.
|
||||
pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
|
||||
let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { return };
|
||||
let Ok(Some(bytes)) = store.get(id).await else { return };
|
||||
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { return };
|
||||
let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else {
|
||||
return;
|
||||
};
|
||||
let Ok(Some(bytes)) = store.get(id).await else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else {
|
||||
return;
|
||||
};
|
||||
let Some(mut invited) = invited_of(&answer) else {
|
||||
tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent");
|
||||
tracing::warn!(
|
||||
id,
|
||||
"an invite was sent again, but the record names no account - nothing sent"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(kanidm) = Kanidm::from_env() else {
|
||||
@@ -560,7 +757,10 @@ pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
|
||||
invited.reset_link = Some(link);
|
||||
invited.link_expires_ms = expires;
|
||||
if let Some(map) = answer.responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
map.insert(
|
||||
"invited".to_string(),
|
||||
serde_json::to_value(&invited).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(id, data.into()).await;
|
||||
@@ -584,7 +784,13 @@ pub fn username_for(email: &str) -> String {
|
||||
let local = email.split('@').next().unwrap_or(email).to_lowercase();
|
||||
let mut out: String = local
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { c } else { '-' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() {
|
||||
out.remove(0);
|
||||
@@ -637,17 +843,33 @@ pub async fn on_state(
|
||||
return;
|
||||
};
|
||||
let Some(people) = People::from_env() else {
|
||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED);
|
||||
tracing::warn!(
|
||||
bucket,
|
||||
id,
|
||||
to_state,
|
||||
group,
|
||||
"this state grants a group, but {} - nobody was added",
|
||||
People::NOT_CONFIGURED
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some((name, who)) = who_for(responses) else {
|
||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added");
|
||||
return;
|
||||
};
|
||||
let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await {
|
||||
let invited = match people
|
||||
.invite(state, &name, &who, &group, &format!("{bucket}:{id}"))
|
||||
.await
|
||||
{
|
||||
Ok(invited) => invited,
|
||||
Err(e) => {
|
||||
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
|
||||
tracing::error!(
|
||||
bucket,
|
||||
id,
|
||||
to_state,
|
||||
group,
|
||||
"granting the group failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -665,14 +887,20 @@ mod tests {
|
||||
#[test]
|
||||
fn usernames_are_the_email_local_part_made_safe() {
|
||||
assert_eq!(username_for("jorn@westra-engineering.no"), "jorn");
|
||||
assert_eq!(username_for("Ole.Nordmann+site@Example.com"), "ole.nordmann-site");
|
||||
assert_eq!(
|
||||
username_for("Ole.Nordmann+site@Example.com"),
|
||||
"ole.nordmann-site"
|
||||
);
|
||||
assert_eq!(username_for("42@x.no"), "person");
|
||||
assert_eq!(username_for("Ærlig@x.no"), "rlig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_by_mail_yields_the_accounts_own_name() {
|
||||
assert_eq!(name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), Some("ole.n".into()));
|
||||
assert_eq!(
|
||||
name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#),
|
||||
Some("ole.n".into())
|
||||
);
|
||||
assert_eq!(name_from_search(r#"{"entries":[]}"#), None);
|
||||
assert_eq!(name_from_search("null"), None);
|
||||
assert_eq!(name_from_search("not json"), None);
|
||||
@@ -680,21 +908,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_link_runs_out_when_kanidm_says_in_either_spelling() {
|
||||
assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000));
|
||||
assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000));
|
||||
assert_eq!(
|
||||
expiry_ms(&serde_json::json!(1790793550)),
|
||||
Some(1790793550000)
|
||||
);
|
||||
assert_eq!(
|
||||
expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")),
|
||||
Some(1790769600000)
|
||||
);
|
||||
assert_eq!(expiry_ms(&serde_json::json!(null)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_are_account_names_and_an_empty_group_is_null() {
|
||||
assert_eq!(members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), vec!["kari", "ola"]);
|
||||
assert_eq!(
|
||||
members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#),
|
||||
vec!["kari", "ola"]
|
||||
);
|
||||
assert!(members_from("null").is_empty());
|
||||
assert!(members_from("nonsense").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_listed_credential_counts() {
|
||||
assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#));
|
||||
assert!(has_credentials_from(
|
||||
r#"{"creds":[{"type_":"PasswordMfa"}]}"#
|
||||
));
|
||||
assert!(!has_credentials_from(r#"{"creds":[]}"#));
|
||||
assert!(!has_credentials_from("null"));
|
||||
}
|
||||
@@ -714,6 +953,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,24 +961,53 @@ mod tests {
|
||||
fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() {
|
||||
let day = LINK_SECONDS * 1000;
|
||||
// Still within the link's life, and nobody has seen them: wait.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 4_999), None);
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED));
|
||||
assert_eq!(
|
||||
invite_due(&invite("open", true, Some(5_000)), false, 4_999),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
invite_due(&invite("open", true, Some(5_000)), false, 5_000),
|
||||
Some(INVITE_EXPIRED)
|
||||
);
|
||||
// In is in, however long the link had left.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 10), Some(INVITE_DONE));
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE));
|
||||
assert_eq!(
|
||||
invite_due(&invite("open", true, Some(5_000)), true, 10),
|
||||
Some(INVITE_DONE)
|
||||
);
|
||||
assert_eq!(
|
||||
invite_due(&invite("open", true, Some(5_000)), true, 9_000),
|
||||
Some(INVITE_DONE)
|
||||
);
|
||||
// A record from before links carried their end: a day from when it was sent.
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day - 1), None);
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED));
|
||||
assert_eq!(
|
||||
invite_due(&invite("open", true, None), false, 1_000 + day - 1),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
invite_due(&invite("open", true, None), false, 1_000 + day),
|
||||
Some(INVITE_EXPIRED)
|
||||
);
|
||||
// Someone who had an account was only given a place: nothing to wait for.
|
||||
assert_eq!(invite_due(&invite("open", false, None), false, 10), Some(INVITE_DONE));
|
||||
assert_eq!(
|
||||
invite_due(&invite("open", false, None), false, 10),
|
||||
Some(INVITE_DONE)
|
||||
);
|
||||
// Only a waiting invite moves.
|
||||
assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None);
|
||||
assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None);
|
||||
assert_eq!(
|
||||
invite_due(&invite("expired", true, Some(5_000)), false, 9_000),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
invite_due(&invite("done", true, Some(5_000)), true, 9_000),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invite_is_theirs_by_account_or_by_address() {
|
||||
let invited: Invited = serde_json::from_value(invite("open", true, None).responses["invited"].clone()).unwrap();
|
||||
let invited: Invited =
|
||||
serde_json::from_value(invite("open", true, None).responses["invited"].clone())
|
||||
.unwrap();
|
||||
let user = |username: &str, email: Option<&str>| crate::auth::User {
|
||||
sub: "s".into(),
|
||||
username: username.into(),
|
||||
@@ -757,7 +1026,10 @@ mod tests {
|
||||
fn a_null_person_answer_means_nobody_there() {
|
||||
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
||||
assert_eq!(person_lookup_found(200, "null\n"), Some(false));
|
||||
assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true));
|
||||
assert_eq!(
|
||||
person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(person_lookup_found(404, ""), Some(false));
|
||||
assert_eq!(person_lookup_found(401, "unauthorized"), None);
|
||||
}
|
||||
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
//! An Apple configuration profile, rendered from the mail server's own
|
||||
//! PACC document.
|
||||
//!
|
||||
//! iOS has no autoconfiguration for IMAP accounts: Apple Mail reads neither
|
||||
//! Mozilla autoconfig nor Microsoft Autodiscover (those are for ActiveSync),
|
||||
//! so adding an account by hand means the device probing for ports. A probe
|
||||
//! that meets a *filtered* port - a firewall that drops rather than refuses -
|
||||
//! waits out a TCP timeout and the account is reported as unverifiable, which
|
||||
//! reads to the person as "wrong password". A profile ends the guessing: it
|
||||
//! states the hosts and the ports, and carries mail, calendars and contacts in
|
||||
//! one install.
|
||||
//!
|
||||
//! What it does not do is invent any of those values. `site.yaml` names a
|
||||
//! **PACC** document (`/.well-known/user-agent-configuration.json`, which
|
||||
//! Stalwart generates and which is defined to live on the mail domain), and
|
||||
//! every host, port and collection path in the profile comes from there. So
|
||||
//! the ports are never written down in two places, and a server that moves a
|
||||
//! collection moves it in the profile too.
|
||||
//!
|
||||
//! The profile asks for the address and the password at install time rather
|
||||
//! than embedding them: it holds nothing personal, so one document serves
|
||||
//! everyone with an account and it can be fetched by anyone - it says no more
|
||||
//! than the PACC document it is built from already says in public.
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::content::ProfileConfig;
|
||||
|
||||
/// The parts of a PACC document this renders from. Unknown protocols and
|
||||
/// members are ignored: PACC grows, and a profile only needs what iOS can
|
||||
/// be told about.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Pacc {
|
||||
#[serde(default)]
|
||||
protocols: Protocols,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Protocols {
|
||||
#[serde(default)]
|
||||
imap: Option<HostPort>,
|
||||
#[serde(default)]
|
||||
smtp: Option<HostPort>,
|
||||
#[serde(default)]
|
||||
caldav: Option<Endpoint>,
|
||||
#[serde(default)]
|
||||
carddav: Option<Endpoint>,
|
||||
}
|
||||
|
||||
/// A protocol spoken on a host. PACC may state the port; when it does not,
|
||||
/// the caller supplies the registered implicit-TLS default.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct HostPort {
|
||||
host: String,
|
||||
#[serde(default)]
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Endpoint {
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Host, port and path of a DAV collection URL. A collection whose URL
|
||||
/// cannot be parsed is left out of the profile rather than guessed at.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn split_url(url: &str) -> Option<(String, u16, String)> {
|
||||
let rest = url.strip_prefix("https://")?;
|
||||
let (authority, path) = match rest.find('/') {
|
||||
Some(i) => (&rest[..i], &rest[i..]),
|
||||
None => (rest, "/"),
|
||||
};
|
||||
let (host, port) = match authority.rsplit_once(':') {
|
||||
Some((h, p)) => (h.to_string(), p.parse().ok()?),
|
||||
None => (authority.to_string(), 443),
|
||||
};
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((host, port, path.to_string()))
|
||||
}
|
||||
|
||||
/// A UUID derived from what the payload is for, not drawn at random: iOS
|
||||
/// replaces an installed profile whose identifier and UUID it already has, and
|
||||
/// stacks a duplicate when they differ. Rendering the document twice has to
|
||||
/// produce the same ids, so reinstalling updates the account instead of adding
|
||||
/// a second one beside it.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn stable_uuid(seed: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let h = Sha256::digest(seed.as_bytes());
|
||||
let b: Vec<String> = h.iter().take(16).map(|x| format!("{x:02X}")).collect();
|
||||
let s = b.join("");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&s[0..8],
|
||||
&s[8..12],
|
||||
&s[12..16],
|
||||
&s[16..20],
|
||||
&s[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn string(key: &str, value: &str) -> String {
|
||||
format!(
|
||||
"\t\t\t<key>{key}</key>\n\t\t\t<string>{}</string>\n",
|
||||
esc(value)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn integer(key: &str, value: u16) -> String {
|
||||
format!("\t\t\t<key>{key}</key>\n\t\t\t<integer>{value}</integer>\n")
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn boolean(key: &str, value: bool) -> String {
|
||||
format!(
|
||||
"\t\t\t<key>{key}</key>\n\t\t\t<{}/>\n",
|
||||
if value { "true" } else { "false" }
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the profile. `domain` is the site's own host, which seeds the
|
||||
/// identifiers and names the accounts on the device.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn render(pacc: &str, domain: &str, cfg: &ProfileConfig) -> anyhow::Result<String> {
|
||||
let pacc: Pacc = serde_json::from_str(pacc)?;
|
||||
let name = cfg.name.clone().unwrap_or_else(|| domain.to_string());
|
||||
let base = format!("{domain}.profile");
|
||||
let mut payloads = String::new();
|
||||
let mut count = 0;
|
||||
|
||||
// Mail. Both halves have to be there: a profile with an incoming server
|
||||
// and no outgoing one installs an account that cannot send.
|
||||
if let (Some(imap), Some(smtp)) = (&pacc.protocols.imap, &pacc.protocols.smtp) {
|
||||
count += 1;
|
||||
payloads.push_str("\t\t<dict>\n");
|
||||
payloads.push_str(&string("PayloadType", "com.apple.mail.managed"));
|
||||
payloads.push_str(&integer("PayloadVersion", 1));
|
||||
payloads.push_str(&string("PayloadIdentifier", &format!("{base}.mail")));
|
||||
payloads.push_str(&string(
|
||||
"PayloadUUID",
|
||||
&stable_uuid(&format!("{base}.mail")),
|
||||
));
|
||||
payloads.push_str(&string("PayloadDisplayName", &format!("{name} mail")));
|
||||
payloads.push_str(&string("EmailAccountType", "EmailTypeIMAP"));
|
||||
payloads.push_str(&string("EmailAccountDescription", &name));
|
||||
payloads.push_str(&string("IncomingMailServerHostName", &imap.host));
|
||||
payloads.push_str(&integer(
|
||||
"IncomingMailServerPortNumber",
|
||||
imap.port.unwrap_or(993),
|
||||
));
|
||||
payloads.push_str(&boolean("IncomingMailServerUseSSL", true));
|
||||
payloads.push_str(&string(
|
||||
"IncomingMailServerAuthentication",
|
||||
"EmailAuthPassword",
|
||||
));
|
||||
payloads.push_str(&string("OutgoingMailServerHostName", &smtp.host));
|
||||
payloads.push_str(&integer(
|
||||
"OutgoingMailServerPortNumber",
|
||||
smtp.port.unwrap_or(465),
|
||||
));
|
||||
payloads.push_str(&boolean("OutgoingMailServerUseSSL", true));
|
||||
payloads.push_str(&string(
|
||||
"OutgoingMailServerAuthentication",
|
||||
"EmailAuthPassword",
|
||||
));
|
||||
// The one password is the same both ways, so the person types it once.
|
||||
payloads.push_str(&boolean("OutgoingPasswordSameAsIncomingPassword", true));
|
||||
payloads.push_str("\t\t</dict>\n");
|
||||
}
|
||||
|
||||
for (proto, endpoint, kind, label) in [
|
||||
(
|
||||
"caldav",
|
||||
pacc.protocols.caldav.as_ref(),
|
||||
"com.apple.caldav.account",
|
||||
"calendars",
|
||||
),
|
||||
(
|
||||
"carddav",
|
||||
pacc.protocols.carddav.as_ref(),
|
||||
"com.apple.carddav.account",
|
||||
"contacts",
|
||||
),
|
||||
] {
|
||||
let Some((host, port, path)) = endpoint.and_then(|e| split_url(&e.url)) else {
|
||||
continue;
|
||||
};
|
||||
count += 1;
|
||||
let prefix = if proto == "caldav" {
|
||||
"CalDAV"
|
||||
} else {
|
||||
"CardDAV"
|
||||
};
|
||||
payloads.push_str("\t\t<dict>\n");
|
||||
payloads.push_str(&string("PayloadType", kind));
|
||||
payloads.push_str(&integer("PayloadVersion", 1));
|
||||
payloads.push_str(&string("PayloadIdentifier", &format!("{base}.{proto}")));
|
||||
payloads.push_str(&string(
|
||||
"PayloadUUID",
|
||||
&stable_uuid(&format!("{base}.{proto}")),
|
||||
));
|
||||
payloads.push_str(&string("PayloadDisplayName", &format!("{name} {label}")));
|
||||
payloads.push_str(&string(&format!("{prefix}AccountDescription"), &name));
|
||||
payloads.push_str(&string(&format!("{prefix}HostName"), &host));
|
||||
payloads.push_str(&integer(&format!("{prefix}Port"), port));
|
||||
payloads.push_str(&boolean(&format!("{prefix}UseSSL"), true));
|
||||
payloads.push_str(&string(&format!("{prefix}PrincipalURL"), &path));
|
||||
payloads.push_str("\t\t</dict>\n");
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
anyhow::bail!("PACC document describes no protocol iOS can be configured with");
|
||||
}
|
||||
|
||||
let organization = cfg.organization.clone().unwrap_or_else(|| name.clone());
|
||||
Ok(format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
|
||||
<plist version=\"1.0\">\n\
|
||||
<dict>\n\
|
||||
\t<key>PayloadType</key>\n\t<string>Configuration</string>\n\
|
||||
\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\
|
||||
\t<key>PayloadIdentifier</key>\n\t<string>{base}</string>\n\
|
||||
\t<key>PayloadUUID</key>\n\t<string>{uuid}</string>\n\
|
||||
\t<key>PayloadDisplayName</key>\n\t<string>{display}</string>\n\
|
||||
\t<key>PayloadOrganization</key>\n\t<string>{org}</string>\n\
|
||||
\t<key>PayloadDescription</key>\n\t<string>{desc}</string>\n\
|
||||
\t<key>PayloadRemovalDisallowed</key>\n\t<false/>\n\
|
||||
\t<key>PayloadContent</key>\n\t<array>\n{payloads}\t</array>\n\
|
||||
</dict>\n</plist>\n",
|
||||
base = esc(&base),
|
||||
uuid = stable_uuid(&base),
|
||||
display = esc(&name),
|
||||
org = esc(&organization),
|
||||
desc = esc(&format!(
|
||||
"Sets up {name} on this device. You will be asked for your address and password."
|
||||
)),
|
||||
payloads = payloads,
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /profile.mobileconfig` - the profile for this site, or 404 when
|
||||
/// `site.yaml` declares no `profile:`. Served as
|
||||
/// `application/x-apple-aspen-config`, without which iOS downloads the file
|
||||
/// instead of offering to install it.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = site.profile.clone() else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"this site offers no configuration profile",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let doc = match crate::http::client()
|
||||
.get(&cfg.pacc)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("profile: fetching {}: {e}", cfg.pacc);
|
||||
return (StatusCode::BAD_GATEWAY, "cannot reach the mail server").into_response();
|
||||
}
|
||||
};
|
||||
let body = match crate::http::bytes(doc, crate::http::ASSET_CAP).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
|
||||
};
|
||||
let text = match String::from_utf8(body) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return (StatusCode::BAD_GATEWAY, "PACC document is not UTF-8").into_response(),
|
||||
};
|
||||
let domain = crate::mail::public_url();
|
||||
let domain = domain.trim_start_matches("https://").trim_end_matches('/');
|
||||
match render(&text, domain, &cfg) {
|
||||
Ok(plist) => (
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/x-apple-aspen-config"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
plist,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::warn!("profile: {e}");
|
||||
(StatusCode::BAD_GATEWAY, e.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "ssr"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Stalwart 0.16's own PACC document, as served. Note what it does *not*
|
||||
/// say: imap and smtp carry a host and no port, which is why the renderer
|
||||
/// has to supply the registered implicit-TLS defaults.
|
||||
const PACC: &str = r#"{"protocols":{"jmap":{"url":"https://mail.example.no/jmap/session"},
|
||||
"imap":{"host":"mail.example.no"},"pop3":{"host":"mail.example.no"},
|
||||
"smtp":{"host":"mail.example.no"},"caldav":{"url":"https://mail.example.no/dav/cal/"},
|
||||
"carddav":{"url":"https://mail.example.no/dav/card/"},
|
||||
"webdav":{"url":"https://mail.example.no/dav/file/"},
|
||||
"managesieve":{"host":"mail.example.no"}},
|
||||
"authentication":{"oauth-public":{"issuer":"https://mail.example.no"},"password":true},
|
||||
"info":{"provider":{"name":"Stalwart"}}}"#;
|
||||
|
||||
fn cfg() -> ProfileConfig {
|
||||
ProfileConfig {
|
||||
pacc: "https://mail.example.no/.well-known/user-agent-configuration.json".into(),
|
||||
name: Some("Example".into()),
|
||||
organization: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_value_comes_from_the_pacc_document() {
|
||||
let p = render(PACC, "example.no", &cfg()).unwrap();
|
||||
assert!(p.contains("<string>com.apple.mail.managed</string>"));
|
||||
// PayloadVersion is an integer everywhere, top level and in each
|
||||
// payload: iOS reads the profile as a plist and the key is typed.
|
||||
assert_eq!(
|
||||
p.matches("<key>PayloadVersion</key>\n\t\t\t<integer>1</integer>")
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
assert!(!p.contains("<key>PayloadVersion</key>\n\t\t\t<string>"));
|
||||
assert!(p.contains("<string>com.apple.caldav.account</string>"));
|
||||
assert!(p.contains("<string>com.apple.carddav.account</string>"));
|
||||
assert!(p.contains(
|
||||
"<key>IncomingMailServerHostName</key>\n\t\t\t<string>mail.example.no</string>"
|
||||
));
|
||||
// The ports PACC left out, filled in from the registry, not invented.
|
||||
assert!(p.contains("<key>IncomingMailServerPortNumber</key>\n\t\t\t<integer>993</integer>"));
|
||||
assert!(p.contains("<key>OutgoingMailServerPortNumber</key>\n\t\t\t<integer>465</integer>"));
|
||||
// A DAV URL becomes a host, a port and the collection's own path.
|
||||
assert!(p.contains("<key>CalDAVHostName</key>\n\t\t\t<string>mail.example.no</string>"));
|
||||
assert!(p.contains("<key>CalDAVPrincipalURL</key>\n\t\t\t<string>/dav/cal/</string>"));
|
||||
assert!(p.contains("<key>CardDAVPrincipalURL</key>\n\t\t\t<string>/dav/card/</string>"));
|
||||
assert!(p.contains("<key>CalDAVPort</key>\n\t\t\t<integer>443</integer>"));
|
||||
}
|
||||
|
||||
/// Nothing personal in the document: no address and no username, so iOS
|
||||
/// asks at install and one profile serves everyone with an account.
|
||||
#[test]
|
||||
fn the_profile_holds_no_credentials() {
|
||||
let p = render(PACC, "example.no", &cfg()).unwrap();
|
||||
for key in [
|
||||
"EmailAddress",
|
||||
"IncomingMailServerUsername",
|
||||
"OutgoingMailServerUsername",
|
||||
"CalDAVUsername",
|
||||
"CardDAVUsername",
|
||||
"CalDAVPassword",
|
||||
"Password",
|
||||
] {
|
||||
assert!(
|
||||
!p.contains(&format!("<key>{key}</key>")),
|
||||
"{key} must not be in the profile"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendering twice has to give the same ids, or a reinstall adds a second
|
||||
/// account beside the first instead of replacing it.
|
||||
#[test]
|
||||
fn identifiers_are_stable_across_renders() {
|
||||
let a = render(PACC, "example.no", &cfg()).unwrap();
|
||||
let b = render(PACC, "example.no", &cfg()).unwrap();
|
||||
assert_eq!(a, b);
|
||||
// ...and belong to this site alone.
|
||||
let other = render(PACC, "other.no", &cfg()).unwrap();
|
||||
assert_ne!(a, other);
|
||||
}
|
||||
|
||||
/// A PACC document that describes a port explicitly wins over the default.
|
||||
#[test]
|
||||
fn a_stated_port_is_used() {
|
||||
let pacc = r#"{"protocols":{"imap":{"host":"m.example.no","port":1993},
|
||||
"smtp":{"host":"m.example.no","port":1465}}}"#;
|
||||
let p = render(pacc, "example.no", &cfg()).unwrap();
|
||||
assert!(p.contains("<integer>1993</integer>"));
|
||||
assert!(p.contains("<integer>1465</integer>"));
|
||||
}
|
||||
|
||||
/// Half a mail account is worse than none: an account that cannot send is
|
||||
/// not installed, and a document with nothing iOS understands is an error
|
||||
/// rather than an empty profile that looks like it worked.
|
||||
#[test]
|
||||
fn an_incomplete_document_is_refused() {
|
||||
let incoming_only = r#"{"protocols":{"imap":{"host":"m.example.no"}}}"#;
|
||||
assert!(render(incoming_only, "example.no", &cfg()).is_err());
|
||||
let jmap_only = r#"{"protocols":{"jmap":{"url":"https://m.example.no/jmap/session"}}}"#;
|
||||
assert!(render(jmap_only, "example.no", &cfg()).is_err());
|
||||
}
|
||||
|
||||
/// A name with an ampersand in it must not break the plist.
|
||||
#[test]
|
||||
fn names_are_escaped() {
|
||||
let c = ProfileConfig {
|
||||
name: Some("Smith & Co <mail>".into()),
|
||||
..cfg()
|
||||
};
|
||||
let p = render(PACC, "example.no", &c).unwrap();
|
||||
assert!(p.contains("Smith & Co <mail>"));
|
||||
assert!(!p.contains("Smith & Co"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dav_url_that_cannot_be_parsed_is_left_out() {
|
||||
assert_eq!(
|
||||
split_url("https://m.example.no:8443/dav/cal/"),
|
||||
Some(("m.example.no".into(), 8443, "/dav/cal/".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
split_url("https://m.example.no"),
|
||||
Some(("m.example.no".into(), 443, "/".into()))
|
||||
);
|
||||
assert_eq!(split_url("http://m.example.no/dav/cal/"), None);
|
||||
assert_eq!(split_url("https://"), None);
|
||||
}
|
||||
}
|
||||
+21
-6
@@ -23,14 +23,19 @@ pub struct Report {
|
||||
}
|
||||
|
||||
pub async fn gather(state: &crate::server::AppState) -> Report {
|
||||
let mut report = Report { storylines: state.questions.load().arity(), buckets: BTreeMap::new() };
|
||||
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 Ok(store) = state.jetstream.get_key_value(bucket).await else {
|
||||
continue;
|
||||
};
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
@@ -40,14 +45,24 @@ pub async fn gather(state: &crate::server::AppState) -> Report {
|
||||
};
|
||||
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;
|
||||
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> {
|
||||
pub async fn handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
) -> axum::Json<Report> {
|
||||
axum::Json(gather(&state).await)
|
||||
}
|
||||
|
||||
+310
-45
@@ -27,13 +27,18 @@ pub async fn get_resource(
|
||||
// `Resource::refetch()` after a fully-empty-params call failed with
|
||||
// "missing field `params`", even though the very first load (always
|
||||
// server-resolved) never did.
|
||||
#[server(default)]
|
||||
params: std::collections::HashMap<String, String>,
|
||||
#[server(default)] params: std::collections::HashMap<String, String>,
|
||||
) -> Result<serde_json::Value, ServerFnError> {
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(
|
||||
&state,
|
||||
crate::server::storyline::current().await,
|
||||
&question_id,
|
||||
&alternative,
|
||||
&feature_name,
|
||||
)?;
|
||||
let resource = feature
|
||||
.resource
|
||||
.as_ref()
|
||||
@@ -56,13 +61,18 @@ pub async fn get_requirement_options(
|
||||
feature_name: String,
|
||||
requirement_name: String,
|
||||
// See get_resource's `params` for why this is needed.
|
||||
#[server(default)]
|
||||
params: std::collections::HashMap<String, String>,
|
||||
#[server(default)] params: std::collections::HashMap<String, String>,
|
||||
) -> Result<serde_json::Value, ServerFnError> {
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(
|
||||
&state,
|
||||
crate::server::storyline::current().await,
|
||||
&question_id,
|
||||
&alternative,
|
||||
&feature_name,
|
||||
)?;
|
||||
let requirement = feature
|
||||
.requirements
|
||||
.iter()
|
||||
@@ -82,7 +92,13 @@ pub async fn get_requirement_options(
|
||||
.resource
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
|
||||
authorize_resource(
|
||||
&state,
|
||||
&question_id,
|
||||
&alternative,
|
||||
&format!("{feature_name}|{requirement_name}"),
|
||||
)
|
||||
.await?;
|
||||
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
|
||||
}
|
||||
|
||||
@@ -97,13 +113,18 @@ pub async fn get_requirement_binding(
|
||||
feature_name: String,
|
||||
requirement_name: String,
|
||||
// See get_resource's `params` for why this is needed.
|
||||
#[server(default)]
|
||||
params: std::collections::HashMap<String, String>,
|
||||
#[server(default)] params: std::collections::HashMap<String, String>,
|
||||
) -> Result<serde_json::Value, ServerFnError> {
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
||||
let feature = find_feature(
|
||||
&state,
|
||||
crate::server::storyline::current().await,
|
||||
&question_id,
|
||||
&alternative,
|
||||
&feature_name,
|
||||
)?;
|
||||
let requirement = feature
|
||||
.requirements
|
||||
.iter()
|
||||
@@ -113,7 +134,13 @@ pub async fn get_requirement_binding(
|
||||
.bind
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
||||
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
|
||||
authorize_resource(
|
||||
&state,
|
||||
&question_id,
|
||||
&alternative,
|
||||
&format!("{feature_name}|{requirement_name}|bind"),
|
||||
)
|
||||
.await?;
|
||||
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
|
||||
}
|
||||
|
||||
@@ -138,8 +165,9 @@ fn find_feature(
|
||||
// also what substitutes the URL segment into the page's resource
|
||||
// keys - so this lookup is where a `/review/<record>` page's
|
||||
// feature acquires its record-specific key.
|
||||
let question = crate::content::resolve_question(state.questions.load().at(storyline), question_id)
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
let question =
|
||||
crate::content::resolve_question(state.questions.load().at(storyline), question_id)
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
question
|
||||
.alternatives
|
||||
.iter()
|
||||
@@ -232,9 +260,16 @@ pub(crate) fn matches_filter(
|
||||
} else {
|
||||
filter.equals.clone()
|
||||
};
|
||||
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
|
||||
let wanted =
|
||||
crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
|
||||
// Addresses compare without case; everything else as written.
|
||||
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
|
||||
let same = |found: &str| {
|
||||
if filter.equals.contains("{user.email}") {
|
||||
found.trim().eq_ignore_ascii_case(&wanted)
|
||||
} else {
|
||||
found == wanted
|
||||
}
|
||||
};
|
||||
let found = value
|
||||
.get("responses")
|
||||
.and_then(|r| r.get(&filter.field))
|
||||
@@ -254,16 +289,24 @@ pub(crate) const USER_EMAIL: &str = "__user_email";
|
||||
/// The caller's parameters with the session's person put in and
|
||||
/// anything pretending to be them taken out.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
|
||||
pub(crate) fn with_person(
|
||||
mut params: std::collections::HashMap<String, String>,
|
||||
user: Option<&crate::auth::User>,
|
||||
) -> std::collections::HashMap<String, String> {
|
||||
params.retain(|k, _| !k.starts_with("__"));
|
||||
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
|
||||
if let Some(email) = user
|
||||
.and_then(|u| u.email.as_deref())
|
||||
.filter(|e| !e.trim().is_empty())
|
||||
{
|
||||
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
|
||||
async fn as_the_person_asking(
|
||||
params: std::collections::HashMap<String, String>,
|
||||
) -> std::collections::HashMap<String, String> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
|
||||
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
|
||||
@@ -300,8 +343,8 @@ async fn fetch_resource_value(
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
// A state filter holds for one record as well as a
|
||||
// listing, or naming the key would walk around it.
|
||||
if !shows_state(resource, &value) || !matches_filter(resource, &value, params) {
|
||||
@@ -322,12 +365,19 @@ async fn fetch_resource_value(
|
||||
for key in keys {
|
||||
if let Ok(Some(bytes)) = store.get(&key).await {
|
||||
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
if shows_state(resource, &value) && matches_filter(resource, &value, params) {
|
||||
if shows_state(resource, &value)
|
||||
&& matches_filter(resource, &value, params)
|
||||
{
|
||||
items.push(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)
|
||||
}
|
||||
}
|
||||
@@ -336,18 +386,89 @@ async fn fetch_resource_value(
|
||||
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
|
||||
}
|
||||
ResourceSource::GiteaReleases { owner, repo } => {
|
||||
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
|
||||
.await?
|
||||
fetch_gitea_json(
|
||||
state,
|
||||
&format!("/api/v1/repos/{owner}/{repo}/releases"),
|
||||
params,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
|
||||
};
|
||||
|
||||
match &resource.jq {
|
||||
Some(filter) => apply_jq(filter, &value),
|
||||
None => Ok(value),
|
||||
let shaped = match &resource.jq {
|
||||
Some(filter) => apply_jq(filter, &value)?,
|
||||
None => value,
|
||||
};
|
||||
Ok(apply_limit(resource.limit, shaped))
|
||||
}
|
||||
|
||||
/// Keep at most `n` items, after `jq` has had its say.
|
||||
///
|
||||
/// Anything that is not a list passes through: a single record has no
|
||||
/// length to cap, and silently turning one into an empty list would be
|
||||
/// worse than ignoring a limit that cannot apply. A limit of zero does
|
||||
/// mean nothing — the resource's `empty` line speaks instead, which is
|
||||
/// a coherent thing for content to ask for.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn apply_limit(limit: Option<usize>, value: serde_json::Value) -> serde_json::Value {
|
||||
let Some(n) = limit else { return value };
|
||||
match value {
|
||||
serde_json::Value::Array(mut items) => {
|
||||
items.truncate(n);
|
||||
serde_json::Value::Array(items)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -531,9 +652,16 @@ pub fn apply_jq(
|
||||
let input_val = read::parse_single(input_text.as_bytes())
|
||||
.map_err(|e| ServerFnError::new(format!("internal: re-parsing json for jq: {e:?}")))?;
|
||||
|
||||
let program = File { code: filter_src, path: () };
|
||||
let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs());
|
||||
let funs = jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs());
|
||||
let program = File {
|
||||
code: filter_src,
|
||||
path: (),
|
||||
};
|
||||
let defs = jaq_core::defs()
|
||||
.chain(jaq_std::defs())
|
||||
.chain(jaq_json::defs());
|
||||
let funs = jaq_core::funs()
|
||||
.chain(jaq_std::funs())
|
||||
.chain(jaq_json::funs());
|
||||
let loader = Loader::new(defs);
|
||||
let arena = Arena::default();
|
||||
let modules = loader
|
||||
@@ -547,7 +675,8 @@ pub fn apply_jq(
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
for result in filter.id.run((ctx, input_val)).map(unwrap_valr) {
|
||||
let val = result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?;
|
||||
let val =
|
||||
result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?;
|
||||
let text = val.to_string();
|
||||
let json: serde_json::Value = serde_json::from_str(&text)
|
||||
.map_err(|e| ServerFnError::new(format!("internal: parsing jq output: {e}")))?;
|
||||
@@ -573,16 +702,30 @@ mod tests {
|
||||
#[test]
|
||||
fn a_listing_can_be_narrowed_to_the_states_worth_showing() {
|
||||
let all = spec("source: { kind: kv, bucket: cases }\npublic: true\n");
|
||||
let decided = spec("source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n");
|
||||
let decided = spec(
|
||||
"source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n",
|
||||
);
|
||||
for state in ["approved", "refused", "withdrawn", "open"] {
|
||||
assert!(shows_state(&all, &record(state, serde_json::json!({}))));
|
||||
}
|
||||
assert!(shows_state(&decided, &record("approved", serde_json::json!({}))));
|
||||
assert!(shows_state(&decided, &record("refused", serde_json::json!({}))));
|
||||
assert!(!shows_state(&decided, &record("withdrawn", serde_json::json!({}))));
|
||||
assert!(shows_state(
|
||||
&decided,
|
||||
&record("approved", serde_json::json!({}))
|
||||
));
|
||||
assert!(shows_state(
|
||||
&decided,
|
||||
&record("refused", serde_json::json!({}))
|
||||
));
|
||||
assert!(!shows_state(
|
||||
&decided,
|
||||
&record("withdrawn", serde_json::json!({}))
|
||||
));
|
||||
// Something that is not an aggregate has no state to filter on
|
||||
// and is left alone.
|
||||
assert!(shows_state(&decided, &serde_json::json!({ "name": "a file" })));
|
||||
assert!(shows_state(
|
||||
&decided,
|
||||
&serde_json::json!({ "name": "a file" })
|
||||
));
|
||||
}
|
||||
|
||||
// The other half of a record belonging to a record: the
|
||||
@@ -591,9 +734,15 @@ mod tests {
|
||||
fn a_listing_can_be_narrowed_to_the_children_of_one_record() {
|
||||
let children = spec("source: { kind: kv, bucket: objections }\nrequires_group: case_officers\nwhere: { field: case, equals: \"{key}\" }\n");
|
||||
let params = std::collections::HashMap::from([("key".to_string(), "abc123".to_string())]);
|
||||
let mine = record("open", serde_json::json!({ "case": "abc123", "your_objection": "the sun" }));
|
||||
let mine = record(
|
||||
"open",
|
||||
serde_json::json!({ "case": "abc123", "your_objection": "the sun" }),
|
||||
);
|
||||
let theirs = record("open", serde_json::json!({ "case": "def456" }));
|
||||
let neither = record("open", serde_json::json!({ "your_objection": "no case named" }));
|
||||
let neither = record(
|
||||
"open",
|
||||
serde_json::json!({ "your_objection": "no case named" }),
|
||||
);
|
||||
assert!(matches_filter(&children, &mine, ¶ms));
|
||||
assert!(!matches_filter(&children, &theirs, ¶ms));
|
||||
// A record that did not say is not everyone's child.
|
||||
@@ -603,13 +752,25 @@ mod tests {
|
||||
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
|
||||
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
|
||||
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
|
||||
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
|
||||
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
|
||||
let kari = crate::auth::User {
|
||||
sub: "s".into(),
|
||||
username: "kari".into(),
|
||||
display_name: "Kari".into(),
|
||||
groups: vec![],
|
||||
phone: None,
|
||||
email: Some("kari@vel.no".into()),
|
||||
};
|
||||
let forged: std::collections::HashMap<String, String> =
|
||||
[(USER_EMAIL.to_string(), "ola@vel.no".to_string())]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let asked = with_person(forged.clone(), Some(&kari));
|
||||
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
|
||||
// Nobody signed in: the forged parameter is dropped, and nothing matches.
|
||||
let anonymous = with_person(forged, None);
|
||||
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
|
||||
assert!(
|
||||
!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous)
|
||||
);
|
||||
// No filter, no narrowing: every resource written before this
|
||||
// existed still lists its whole bucket.
|
||||
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
||||
@@ -620,8 +781,74 @@ mod tests {
|
||||
fn a_filter_can_name_a_value_outright() {
|
||||
let fixed = spec("source: { kind: kv, bucket: cases }\npublic: true\nwhere: { field: which_authority, equals: The county }\n");
|
||||
let params = std::collections::HashMap::new();
|
||||
assert!(matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "The county" })), ¶ms));
|
||||
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms));
|
||||
assert!(matches_filter(
|
||||
&fixed,
|
||||
&record(
|
||||
"open",
|
||||
serde_json::json!({ "which_authority": "The county" })
|
||||
),
|
||||
¶ms
|
||||
));
|
||||
assert!(!matches_filter(
|
||||
&fixed,
|
||||
&record(
|
||||
"open",
|
||||
serde_json::json!({ "which_authority": "Fire service" })
|
||||
),
|
||||
¶ms
|
||||
));
|
||||
}
|
||||
|
||||
/// `limit` caps what the page renders, and does it after `jq` so
|
||||
/// content can order with the filter and take the top of its own
|
||||
/// ordering. The releases card is the case: the newest cut is the
|
||||
/// one you should run, and a page that grows a card per release
|
||||
/// gets longer forever.
|
||||
#[test]
|
||||
fn limit_caps_a_list_after_the_filter() {
|
||||
let releases = serde_json::json!([
|
||||
{"name": "redoal 0.1.17", "tag_name": "v0.1.17", "body": "newest"},
|
||||
{"name": "redoal 0.1.16", "tag_name": "v0.1.16", "body": "older"},
|
||||
{"name": "redoal 0.1.15", "tag_name": "v0.1.15", "body": "oldest"}
|
||||
]);
|
||||
// The filter redoal/questions actually declares: bare objects,
|
||||
// never a wrapped array — the frontend re-wraps.
|
||||
let filter = ".[] | {name: .name, description: .body, tag: .tag_name, url: null}";
|
||||
let shaped = apply_jq(filter, &releases).expect("filter runs");
|
||||
|
||||
let all = apply_limit(None, shaped.clone());
|
||||
assert_eq!(
|
||||
all.as_array().map(Vec::len),
|
||||
Some(3),
|
||||
"no limit shows everything"
|
||||
);
|
||||
|
||||
let latest = apply_limit(Some(1), shaped.clone());
|
||||
let rows = latest.as_array().expect("still a list");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(
|
||||
rows[0]["tag"], "v0.1.17",
|
||||
"and it is the newest, which Gitea returns first"
|
||||
);
|
||||
|
||||
let two = apply_limit(Some(2), shaped.clone());
|
||||
assert_eq!(two.as_array().map(Vec::len), Some(2));
|
||||
|
||||
// Asking for more than there is is not an error.
|
||||
let plenty = apply_limit(Some(99), shaped);
|
||||
assert_eq!(plenty.as_array().map(Vec::len), Some(3));
|
||||
}
|
||||
|
||||
/// A limit that cannot apply is ignored rather than guessed at.
|
||||
#[test]
|
||||
fn limit_leaves_a_single_record_alone() {
|
||||
let one = serde_json::json!({"name": "a record", "state": "open"});
|
||||
assert_eq!(apply_limit(Some(1), one.clone()), one);
|
||||
assert_eq!(apply_limit(Some(0), one.clone()), one, "no length to cap");
|
||||
|
||||
// Zero on a list does mean nothing — the `empty` line speaks.
|
||||
let list = serde_json::json!([{"a": 1}, {"a": 2}]);
|
||||
assert_eq!(apply_limit(Some(0), list).as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
|
||||
/// A canned, Gitea-API-shaped fixture - the same fields the real
|
||||
@@ -718,7 +945,11 @@ mod tests {
|
||||
assert_eq!(items[0]["name"], "varde bring-up");
|
||||
assert_eq!(items[0]["tag"], "v0.3.0");
|
||||
assert_eq!(items[0]["description"], "Embedded varde-core on iOS.");
|
||||
assert_eq!(items[0]["url"], serde_json::Value::Null, "no public link while private");
|
||||
assert_eq!(
|
||||
items[0]["url"],
|
||||
serde_json::Value::Null,
|
||||
"no public link while private"
|
||||
);
|
||||
assert!(items[0].get("html_url").is_none());
|
||||
}
|
||||
|
||||
@@ -740,8 +971,7 @@ mod tests {
|
||||
("path".to_string(), "questions/a b.yaml".to_string()),
|
||||
("ref".to_string(), "main".to_string()),
|
||||
]);
|
||||
let (url, leftover) =
|
||||
template_url("https://x.example/api/contents/{path}", ¶ms);
|
||||
let (url, leftover) = template_url("https://x.example/api/contents/{path}", ¶ms);
|
||||
// `/` survives, the space percent-encodes, and the param that
|
||||
// matched a placeholder is consumed.
|
||||
assert_eq!(url, "https://x.example/api/contents/questions/a%20b.yaml");
|
||||
@@ -758,3 +988,38 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
+202
-47
@@ -58,10 +58,15 @@ pub const SEED_BUCKET: &str = "portal_seed";
|
||||
pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<String> {
|
||||
let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for q in questions.values() {
|
||||
let Some(group) = q.qualifies.as_deref() else { continue };
|
||||
let Some(group) = q.qualifies.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
for alt in &q.alternatives {
|
||||
if let Some(invite) = &alt.invite {
|
||||
reach.entry(group).or_default().insert(invite.group.as_str());
|
||||
reach
|
||||
.entry(group)
|
||||
.or_default()
|
||||
.insert(invite.group.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +78,19 @@ pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<St
|
||||
|
||||
/// The bucket key for one seed: stable, and within NATS key rules.
|
||||
pub fn seed_key(email: &str, group: &str) -> String {
|
||||
let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::<String>();
|
||||
let clean = |s: &str| {
|
||||
s.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
};
|
||||
format!("{}.{}", clean(group), clean(email))
|
||||
}
|
||||
|
||||
@@ -82,7 +99,9 @@ pub fn seed_key(email: &str, group: &str) -> String {
|
||||
/// questions they answer for, in page order. A page a group answers
|
||||
/// for (`responsible.group`) names nobody: the board's inbox is not a
|
||||
/// person, and its members have their accounts through the desk.
|
||||
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
|
||||
pub fn responsible_people(
|
||||
questions: &HashMap<String, Question>,
|
||||
) -> BTreeMap<String, (String, Vec<String>)> {
|
||||
let mut ids: Vec<&String> = questions.keys().collect();
|
||||
ids.sort();
|
||||
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
|
||||
@@ -94,7 +113,9 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
|
||||
}
|
||||
let email = contact_email(&r.contact);
|
||||
let Some(email) = email else { continue };
|
||||
let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
|
||||
let entry = out
|
||||
.entry(email)
|
||||
.or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
|
||||
if !entry.1.contains(&q.name) {
|
||||
entry.1.push(q.name.clone());
|
||||
}
|
||||
@@ -105,7 +126,11 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
|
||||
/// A responsible `contact` as an address: `mailto:` off, lowercased;
|
||||
/// `None` for a phone number or anything else that is not one.
|
||||
pub fn contact_email(contact: &str) -> Option<String> {
|
||||
let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||
let c = contact
|
||||
.trim()
|
||||
.trim_start_matches("mailto:")
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
|
||||
}
|
||||
|
||||
@@ -126,7 +151,10 @@ static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
pub async fn run(state: crate::server::AppState) {
|
||||
// Subscribed before the first pass, so content that changes while
|
||||
// it runs is not missed.
|
||||
let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await;
|
||||
let reloads = state
|
||||
.nats
|
||||
.subscribe(crate::content::CONTENT_RELOADED_SUBJECT)
|
||||
.await;
|
||||
tokio::join!(seed_admin(&state), responsible_until_done(&state), async {
|
||||
let mut reloads = match reloads {
|
||||
Ok(reloads) => reloads,
|
||||
@@ -158,7 +186,11 @@ async fn responsible_until_done(state: &crate::server::AppState) {
|
||||
tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again");
|
||||
return;
|
||||
}
|
||||
tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute");
|
||||
tracing::warn!(
|
||||
left,
|
||||
attempt,
|
||||
"some responsible people could not be set up, trying again in a minute"
|
||||
);
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
}
|
||||
@@ -166,7 +198,14 @@ async fn responsible_until_done(state: &crate::server::AppState) {
|
||||
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
|
||||
match state.jetstream.get_key_value(SEED_BUCKET).await {
|
||||
Ok(store) => Some(store),
|
||||
Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await {
|
||||
Err(_) => match state
|
||||
.jetstream
|
||||
.create_key_value(async_nats::jetstream::kv::Config {
|
||||
bucket: SEED_BUCKET.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(store) => Some(store),
|
||||
Err(e) => {
|
||||
tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
|
||||
@@ -177,7 +216,10 @@ async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jets
|
||||
}
|
||||
|
||||
fn seed_admin_email() -> Option<String> {
|
||||
std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@'))
|
||||
std::env::var("SEED_ADMIN_EMAIL")
|
||||
.ok()
|
||||
.map(|e| e.trim().to_lowercase())
|
||||
.filter(|e| e.contains('@'))
|
||||
}
|
||||
|
||||
/// Who portal put in the responsible group and the content no longer
|
||||
@@ -204,24 +246,37 @@ pub fn no_longer_responsible(
|
||||
/// it could not finish with.
|
||||
async fn responsible(state: &crate::server::AppState) -> usize {
|
||||
let people = responsible_people(&state.questions.load());
|
||||
let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
|
||||
let group = std::env::var("RESPONSIBLE_GROUP")
|
||||
.ok()
|
||||
.map(|g| g.trim().to_string())
|
||||
.filter(|g| !g.is_empty());
|
||||
if people.is_empty() && group.is_none() {
|
||||
return 0;
|
||||
}
|
||||
let Some(people_backend) = crate::people::People::from_env() else {
|
||||
if !people.is_empty() {
|
||||
tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED);
|
||||
tracing::info!(
|
||||
people = people.len(),
|
||||
"the content names responsible people, but {} - no accounts made",
|
||||
crate::people::People::NOT_CONFIGURED
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
let Some(store) = seed_bucket(state).await else { return 0 };
|
||||
let Some(store) = seed_bucket(state).await else {
|
||||
return 0;
|
||||
};
|
||||
let mut left = 0;
|
||||
for (email, (name, questions)) in &people {
|
||||
let key = seed_key(email, group.as_deref().unwrap_or("responsible"));
|
||||
if let Ok(Some(_)) = store.get(&key).await {
|
||||
continue;
|
||||
}
|
||||
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() };
|
||||
let name = if name.is_empty() {
|
||||
email.split('@').next().unwrap_or("").to_string()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
let who = crate::memberships::Identity::from_contact(email);
|
||||
// With a group, one call makes the account if there is none and
|
||||
// adds them either way; without one, only a missing account is
|
||||
@@ -229,21 +284,30 @@ async fn responsible(state: &crate::server::AppState) -> usize {
|
||||
// way a person is mailed only when an account was made for
|
||||
// them: someone who had one already has their way in.
|
||||
let made = match (&group, &people_backend) {
|
||||
(Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await,
|
||||
(None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await {
|
||||
Ok(true) => {
|
||||
let _ = store.put(&key, "existing".into()).await;
|
||||
continue;
|
||||
(Some(group), people_backend) => {
|
||||
people_backend
|
||||
.invite(state, &name, &who, group, "responsible")
|
||||
.await
|
||||
}
|
||||
(None, crate::people::People::Kanidm(kanidm)) => {
|
||||
match kanidm.has_person_with_mail(email).await {
|
||||
Ok(true) => {
|
||||
let _ = store.put(&key, "existing".into()).await;
|
||||
continue;
|
||||
}
|
||||
Ok(false) => kanidm.account(&name, email).await,
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
Ok(false) => kanidm.account(&name, email).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
}
|
||||
(None, crate::people::People::Memberships) => continue,
|
||||
};
|
||||
match made {
|
||||
Ok(invited) => {
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||
let _ = state
|
||||
.nats
|
||||
.publish(crate::people::INVITED_SUBJECT, bytes.clone().into())
|
||||
.await;
|
||||
let _ = store.put(&key, bytes.into()).await;
|
||||
}
|
||||
if invited.created {
|
||||
@@ -274,14 +338,21 @@ async fn responsible(state: &crate::server::AppState) -> usize {
|
||||
};
|
||||
let mut on_file = Vec::new();
|
||||
for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else { continue };
|
||||
let Ok(Some(bytes)) = store.get(&key).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
on_file.push((key, invited));
|
||||
}
|
||||
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
|
||||
if !invited.already_member {
|
||||
let who = crate::memberships::Identity::from_contact(&invited.email);
|
||||
if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await {
|
||||
if let Err(e) = people_backend
|
||||
.remove(state, &who, &invited.username, &group, "responsible")
|
||||
.await
|
||||
{
|
||||
left += 1;
|
||||
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
|
||||
continue;
|
||||
@@ -298,13 +369,24 @@ async fn responsible(state: &crate::server::AppState) -> usize {
|
||||
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
|
||||
/// their number.
|
||||
async fn seed_admin(state: &crate::server::AppState) {
|
||||
let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
|
||||
let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@'));
|
||||
let phone = std::env::var("SEED_ADMIN_PHONE")
|
||||
.ok()
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty());
|
||||
let email = std::env::var("SEED_ADMIN_EMAIL")
|
||||
.ok()
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| e.contains('@'));
|
||||
let (Some(contact), who) = (
|
||||
phone.clone().or_else(|| email.clone()),
|
||||
crate::memberships::Identity {
|
||||
sub: None,
|
||||
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||
phone: phone.as_deref().and_then(|p| {
|
||||
crate::memberships::normalize_phone(
|
||||
p,
|
||||
&std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()),
|
||||
)
|
||||
}),
|
||||
email: email.as_deref().map(|e| e.to_lowercase()),
|
||||
},
|
||||
) else {
|
||||
@@ -316,7 +398,11 @@ async fn seed_admin(state: &crate::server::AppState) {
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string());
|
||||
let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) {
|
||||
let group = match std::env::var("SEED_GROUP")
|
||||
.ok()
|
||||
.map(|g| g.trim().to_string())
|
||||
.filter(|g| !g.is_empty())
|
||||
{
|
||||
Some(g) => g,
|
||||
None => match most_privileged_group(&state.questions.load()) {
|
||||
Some(g) => g,
|
||||
@@ -327,13 +413,22 @@ async fn seed_admin(state: &crate::server::AppState) {
|
||||
},
|
||||
};
|
||||
let key = seed_key(&email, &group);
|
||||
let Some(store) = seed_bucket(state).await else { return };
|
||||
let Some(store) = seed_bucket(state).await else {
|
||||
return;
|
||||
};
|
||||
if let Ok(Some(_)) = store.get(&key).await {
|
||||
tracing::info!(group, "the first person was already invited into this group; not again");
|
||||
tracing::info!(
|
||||
group,
|
||||
"the first person was already invited into this group; not again"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(people) = crate::people::People::from_env() else {
|
||||
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED);
|
||||
tracing::warn!(
|
||||
group,
|
||||
"SEED_ADMIN_EMAIL is set, but {} - nobody was seeded",
|
||||
crate::people::People::NOT_CONFIGURED
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -343,7 +438,11 @@ async fn seed_admin(state: &crate::server::AppState) {
|
||||
match people.invite(state, &name, &who, &group, "seed").await {
|
||||
Ok(invited) => break invited,
|
||||
Err(e) if attempt < ATTEMPTS => {
|
||||
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
|
||||
tracing::warn!(
|
||||
group,
|
||||
attempt,
|
||||
"seeding the first person failed, trying again in a minute: {e}"
|
||||
);
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -353,7 +452,10 @@ async fn seed_admin(state: &crate::server::AppState) {
|
||||
}
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||
let _ = state
|
||||
.nats
|
||||
.publish(crate::people::INVITED_SUBJECT, bytes.clone().into())
|
||||
.await;
|
||||
if let Err(e) = store.put(&key, bytes.into()).await {
|
||||
tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
|
||||
}
|
||||
@@ -374,7 +476,17 @@ mod tests {
|
||||
.iter()
|
||||
.map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
|
||||
.collect();
|
||||
let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives });
|
||||
let yaml = format!(
|
||||
"name: Q?\n{}alternatives:\n{}",
|
||||
qualifies
|
||||
.map(|q| format!("qualifies: {q}\n"))
|
||||
.unwrap_or_default(),
|
||||
if alternatives.is_empty() {
|
||||
" - name: A\n".to_string()
|
||||
} else {
|
||||
alternatives
|
||||
}
|
||||
);
|
||||
let mut q: Question = serde_yaml::from_str(&yaml).unwrap();
|
||||
q.id = id.to_string();
|
||||
(id.to_string(), q)
|
||||
@@ -384,7 +496,11 @@ mod tests {
|
||||
fn the_first_person_goes_where_the_most_invitations_can_be_made() {
|
||||
let qs: HashMap<String, Question> = [
|
||||
page("/", None, &[]),
|
||||
page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]),
|
||||
page(
|
||||
"/styret",
|
||||
Some("styret"),
|
||||
&["styret", "kasserer", "trafikk", "komiteer"],
|
||||
),
|
||||
page("/kasserer", Some("kasserer"), &["kasserer"]),
|
||||
page("/trafikk", Some("trafikk"), &["trafikk"]),
|
||||
]
|
||||
@@ -392,7 +508,10 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
|
||||
// A tie goes to the name that sorts first, every time.
|
||||
let tie: HashMap<String, Question> = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect();
|
||||
let tie: HashMap<String, Question> =
|
||||
[page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(most_privileged_group(&tie).as_deref(), Some("a"));
|
||||
// No invitations anywhere: nobody to seed into without SEED_GROUP.
|
||||
let none: HashMap<String, Question> = [page("/", None, &[])].into_iter().collect();
|
||||
@@ -410,6 +529,8 @@ mod tests {
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created: true,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
},
|
||||
)
|
||||
@@ -417,26 +538,54 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() {
|
||||
let people: BTreeMap<String, (String, Vec<String>)> = [("kari@vel.no".to_string(), ("Kari".to_string(), vec!["Hva?".to_string()]))].into_iter().collect();
|
||||
let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)];
|
||||
let people: BTreeMap<String, (String, Vec<String>)> = [(
|
||||
"kari@vel.no".to_string(),
|
||||
("Kari".to_string(), vec!["Hva?".to_string()]),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let filed = vec![
|
||||
on_file("kari@vel.no", false),
|
||||
on_file("Ola@vel.no", false),
|
||||
on_file("leder@vel.no", false),
|
||||
];
|
||||
let gone = no_longer_responsible(&filed, &people, Some("leder@vel.no"));
|
||||
assert_eq!(gone.iter().map(|(_, i)| i.username.as_str()).collect::<Vec<_>>(), vec!["Ola"]);
|
||||
assert_eq!(
|
||||
gone.iter()
|
||||
.map(|(_, i)| i.username.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Ola"]
|
||||
);
|
||||
assert_eq!(gone[0].0, "redaktor.ola_vel_no");
|
||||
// Nobody named at all: everyone portal put there leaves.
|
||||
assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3);
|
||||
assert_eq!(
|
||||
no_longer_responsible(&filed, &BTreeMap::new(), None).len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_named_responsible_is_found_once_by_their_address() {
|
||||
let mut qs: HashMap<String, Question> = HashMap::new();
|
||||
for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] {
|
||||
for (id, name, contact) in [
|
||||
("/", "Hva gjør vi?", "mailto:Styret@Vel.no"),
|
||||
("/melde-fra", "Hva vil du melde?", "styret@vel.no"),
|
||||
("/b", "Ringe?", "+47 900 00 000"),
|
||||
("/c", "Trafikk?", "trafikk@vel.no"),
|
||||
] {
|
||||
let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap();
|
||||
q.id = id.to_string();
|
||||
qs.insert(id.to_string(), q);
|
||||
}
|
||||
let people = responsible_people(&qs);
|
||||
assert_eq!(people.keys().collect::<Vec<_>>(), ["styret@vel.no", "trafikk@vel.no"]);
|
||||
assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
|
||||
assert_eq!(
|
||||
people.keys().collect::<Vec<_>>(),
|
||||
["styret@vel.no", "trafikk@vel.no"]
|
||||
);
|
||||
assert_eq!(
|
||||
people["styret@vel.no"].1,
|
||||
["Hva gjør vi?", "Hva vil du melde?"]
|
||||
);
|
||||
assert_eq!(contact_email("+47 900 00 000"), None);
|
||||
// A group's inbox is nobody: its members have the desk.
|
||||
let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap();
|
||||
@@ -447,7 +596,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_seed_is_remembered_by_address_and_group() {
|
||||
assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no");
|
||||
assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer"));
|
||||
assert_eq!(
|
||||
seed_key(" Kari.Lien@Tomtervel.no ", "styret"),
|
||||
"styret.kari_lien_tomtervel_no"
|
||||
);
|
||||
assert_ne!(
|
||||
seed_key("kari@x.no", "styret"),
|
||||
seed_key("kari@x.no", "kasserer")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -28,7 +28,9 @@ pub struct AppState {
|
||||
/// hot-swapped alongside `questions` on the same reload. A bucket
|
||||
/// with no entry here isn't event-sourced - plain KV mutate-in-place
|
||||
/// still works (see `answers.rs`).
|
||||
pub aggregates: Arc<ArcSwap<crate::content::Storylines<HashMap<String, crate::aggregates::AggregateSchema>>>>,
|
||||
pub aggregates: Arc<
|
||||
ArcSwap<crate::content::Storylines<HashMap<String, crate::aggregates::AggregateSchema>>>,
|
||||
>,
|
||||
/// Site branding from the content repo's optional `site.yaml`
|
||||
/// (title, wordmark, hero kind) - hot-swapped with `questions`/
|
||||
/// `aggregates` on the same reload. Default = the historical uhhm
|
||||
|
||||
+32
-14
@@ -13,8 +13,8 @@ use axum::{
|
||||
use openidconnect::{
|
||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
|
||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||
OAuth2TokenResponse, TokenResponse,
|
||||
EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
|
||||
RedirectUrl, Scope, TokenResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
@@ -148,7 +148,10 @@ pub async fn login(
|
||||
Query(params): Query<LoginParams>,
|
||||
) -> Result<Redirect, HandlerError> {
|
||||
if let Some(redirect) = params.redirect.filter(|r| is_safe_redirect(r)) {
|
||||
session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?;
|
||||
session
|
||||
.insert(REDIRECT_KEY, redirect)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
}
|
||||
|
||||
let oidc = state.oidc.configured()?;
|
||||
@@ -168,7 +171,10 @@ pub async fn login(
|
||||
// What else to ask the provider for: `phoneNumber` for one that
|
||||
// verifies numbers (Vipps). Only when asked, because Kanidm denies
|
||||
// a sign-in that requests a scope the person does not hold.
|
||||
for scope in std::env::var("OIDC_EXTRA_SCOPES").unwrap_or_default().split_whitespace() {
|
||||
for scope in std::env::var("OIDC_EXTRA_SCOPES")
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
{
|
||||
request = request.add_scope(Scope::new(scope.to_string()));
|
||||
}
|
||||
let (auth_url, csrf_state, nonce) = request.url();
|
||||
@@ -206,8 +212,7 @@ pub async fn callback(
|
||||
let pkce_verifier: Option<String> = session.remove(PKCE_KEY).await.map_err(internal)?;
|
||||
let nonce: Option<String> = session.remove(NONCE_KEY).await.map_err(internal)?;
|
||||
|
||||
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) =
|
||||
(stored_csrf, pkce_verifier, nonce)
|
||||
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) = (stored_csrf, pkce_verifier, nonce)
|
||||
else {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -262,12 +267,19 @@ pub async fn callback(
|
||||
// The verified phone number lives at userinfo, not in the ID token,
|
||||
// for providers that give one (Vipps). A provider that has none, or
|
||||
// no userinfo endpoint, leaves it empty; nothing depends on it.
|
||||
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
|
||||
Ok(request) => request
|
||||
.request_async(&oidc.http)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
|
||||
let phone = match oidc
|
||||
.client
|
||||
.user_info(token_response.access_token().clone(), None)
|
||||
{
|
||||
Ok(request) => {
|
||||
request
|
||||
.request_async(&oidc.http)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info: CoreUserInfoClaims| {
|
||||
info.phone_number().map(|p| p.as_str().to_string())
|
||||
})
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let mut user = User {
|
||||
@@ -295,7 +307,9 @@ pub async fn callback(
|
||||
.map_err(internal)?;
|
||||
|
||||
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
|
||||
let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string());
|
||||
let target = redirect
|
||||
.filter(|r| is_safe_redirect(r))
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
// Being here is what an invite was for (people.rs): off the
|
||||
// request, since nobody signing in should wait on a desk's list.
|
||||
@@ -338,6 +352,10 @@ fn extract_groups_claim<T: serde::Serialize>(id_token: &T) -> Vec<String> {
|
||||
payload
|
||||
.get("groups")
|
||||
.and_then(|g| g.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
+20
-5
@@ -21,7 +21,10 @@ 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('=')))
|
||||
.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)
|
||||
}
|
||||
@@ -31,14 +34,23 @@ 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 {
|
||||
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 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() {
|
||||
if let Ok(value) =
|
||||
format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse()
|
||||
{
|
||||
res.headers_mut().append(SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +72,10 @@ mod tests {
|
||||
|
||||
#[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("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);
|
||||
|
||||
+8
-2
@@ -37,7 +37,9 @@ pub struct NatsStore {
|
||||
|
||||
impl std::fmt::Debug for NatsStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NatsStore").field("bucket", &BUCKET).finish()
|
||||
f.debug_struct("NatsStore")
|
||||
.field("bucket", &BUCKET)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +75,11 @@ impl tower_sessions::SessionStore for NatsStore {
|
||||
async fn create(&self, record: &mut Record) -> Result<()> {
|
||||
for _ in 0..ID_ATTEMPTS {
|
||||
let bytes = Self::encode(record)?;
|
||||
match self.store.create(&record.id.to_string(), bytes.into()).await {
|
||||
match self
|
||||
.store
|
||||
.create(&record.id.to_string(), bytes.into())
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => record.id = Id::default(),
|
||||
}
|
||||
|
||||
+1778
-422
File diff suppressed because it is too large
Load Diff
+34
-7
@@ -27,23 +27,42 @@ pub fn alternative(alt: &Value, full: bool) -> String {
|
||||
out.push_str(&format!(": {d}"));
|
||||
}
|
||||
out.push('\n');
|
||||
if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) {
|
||||
if let Some(button) = alt["consequence"]
|
||||
.as_array()
|
||||
.and_then(|c| c.first())
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
out.push_str(&format!(" Button: {button}\n"));
|
||||
}
|
||||
for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
for e in alt["encouragements"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
{
|
||||
out.push_str(&format!(" {e}\n"));
|
||||
}
|
||||
for f in alt["features"].as_array().into_iter().flatten() {
|
||||
let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim());
|
||||
let (name, desc, icon) = (
|
||||
s(&f["name"]).trim(),
|
||||
s(&f["description"]).trim(),
|
||||
s(&f["icon"]).trim(),
|
||||
);
|
||||
let mut line = String::from(" ");
|
||||
if !icon.is_empty() {
|
||||
line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon)));
|
||||
line.push_str(&format!(
|
||||
"[icon {}] ",
|
||||
icon.rsplit(':').next().unwrap_or(icon)
|
||||
));
|
||||
}
|
||||
if !name.is_empty() {
|
||||
line.push_str(name);
|
||||
}
|
||||
if !desc.is_empty() {
|
||||
line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " }));
|
||||
line.push_str(&format!(
|
||||
"{}{desc}",
|
||||
if name.is_empty() { "" } else { ": " }
|
||||
));
|
||||
}
|
||||
if !line.trim().is_empty() {
|
||||
out.push_str(line.trim_end());
|
||||
@@ -56,8 +75,16 @@ pub fn alternative(alt: &Value, full: bool) -> String {
|
||||
.map(|r| {
|
||||
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
|
||||
let hint = s(&r["placeholder"]).trim();
|
||||
let opt = if r["optional"] == true { " (optional)" } else { "" };
|
||||
if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") }
|
||||
let opt = if r["optional"] == true {
|
||||
" (optional)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if hint.is_empty() {
|
||||
format!("{label}{opt}")
|
||||
} else {
|
||||
format!("{label}{opt} e.g. {hint}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !fields.is_empty() {
|
||||
|
||||
+455
-29
@@ -74,7 +74,15 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
// sampling, so a score comes with its spread. 0 is the run
|
||||
// every earlier score used.
|
||||
let seed: u64 = value("--seed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, repo.as_deref(), seed)?);
|
||||
print!(
|
||||
"{}",
|
||||
answer_blind(
|
||||
&cmd,
|
||||
&std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?,
|
||||
repo.as_deref(),
|
||||
seed
|
||||
)?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let opts = parse_args()?;
|
||||
@@ -103,11 +111,16 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
std::fs::write(opts.out.join("needs.yaml"), needs)?;
|
||||
// A placeholder front door, so the lint gets as far as parsing
|
||||
// the spec; every need fails, a parse error is what we look for.
|
||||
std::fs::write(opts.out.join("questions/index.yaml"), "name: Placeholder?\n")?;
|
||||
std::fs::write(
|
||||
opts.out.join("questions/index.yaml"),
|
||||
"name: Placeholder?\n",
|
||||
)?;
|
||||
let (_, report) = lint(&opts, &[])?;
|
||||
if let Some(line) = report.lines().find(|l| l.contains("parsing needs.yaml")) {
|
||||
eprintln!("needs round {round}: {line}");
|
||||
feedback = format!("\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n");
|
||||
feedback = format!(
|
||||
"\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
eprintln!("needs round {round}: needs.yaml parses, frozen");
|
||||
@@ -116,9 +129,14 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
break;
|
||||
}
|
||||
if !feedback.is_empty() {
|
||||
bail!("needs.yaml never parsed in {} round(s); the replies are in {}/llm/", opts.rounds, opts.out.display());
|
||||
bail!(
|
||||
"needs.yaml never parsed in {} round(s); the replies are in {}/llm/",
|
||||
opts.rounds,
|
||||
opts.out.display()
|
||||
);
|
||||
}
|
||||
let needs = std::fs::read_to_string(opts.out.join("needs.yaml")).context("no needs.yaml was produced")?;
|
||||
let needs = std::fs::read_to_string(opts.out.join("needs.yaml"))
|
||||
.context("no needs.yaml was produced")?;
|
||||
|
||||
// Phase 2: structure. The page-writing model sees the needs and
|
||||
// never the personas: pages written with the test in view route
|
||||
@@ -132,34 +150,120 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
.to_string();
|
||||
let mut green = false;
|
||||
let mut report = String::new();
|
||||
// What the lint found, by id, round over round (`--findings`): what
|
||||
// a round closed, what it left, what it broke. `None` while the
|
||||
// lint is one that predates `--findings`; then the whole report
|
||||
// goes back, as it always did.
|
||||
let mut ledger = Ledger::default();
|
||||
let mut tracked = true;
|
||||
let mut undone = String::new();
|
||||
std::fs::create_dir_all(opts.out.join("findings"))?;
|
||||
for round in 1..=opts.rounds {
|
||||
let prompt = if round == 1 {
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nWrite the content repo that meets this frozen `needs.yaml`: `aggregates.yaml`, `site.yaml`, and every page under `questions/` - the front door, followup pages, role-gated pages, one desk per handling group. Do not return needs.yaml.\n\n## Intake\n\n{intake}\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n{FORMAT}"
|
||||
)
|
||||
} else if tracked {
|
||||
let (heading, files) = match named_files(&opts.out, ledger.open.values()) {
|
||||
Some(files) => ("Files the findings name", dump_files(&opts.out, &files)?),
|
||||
None => ("Current files", dump(&opts.out)?),
|
||||
};
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo. Below is every finding still open, each with the change that closes it. Make those changes and nothing else: findings already closed stay closed. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Open findings\n\n{}\n{undone}## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## {heading}\n\n{files}\n\n{FORMAT}",
|
||||
ledger.open_list(round)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo below. Fix it. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Verifier output\n\n```\n{report}\n```\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## Current files\n\n{}\n\n{FORMAT}",
|
||||
dump(&opts.out)?
|
||||
)
|
||||
};
|
||||
let before = snapshot(&opts.out)?;
|
||||
match parse_files(&llm(&opts.llm, &prompt)?) {
|
||||
Ok(files) => apply(&opts.out, files)?,
|
||||
Err(_) => eprintln!("structure round {round}: the reply had no file blocks"),
|
||||
}
|
||||
let (ok, r) = lint(&opts, &[])?;
|
||||
let (ok, r, found) = lint_findings(
|
||||
&opts,
|
||||
&opts.out.join(format!("findings/round-{round}.jsonl")),
|
||||
)?;
|
||||
report = r;
|
||||
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
|
||||
eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") });
|
||||
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
|
||||
let Some(found) = found else {
|
||||
tracked = false;
|
||||
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
|
||||
eprintln!(
|
||||
"structure round {round}: {}",
|
||||
if ok {
|
||||
"green".to_string()
|
||||
} else {
|
||||
format!("{fails} FAIL line(s)")
|
||||
}
|
||||
);
|
||||
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
|
||||
if ok {
|
||||
green = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let previous = ledger.clone();
|
||||
let progress = ledger.update(
|
||||
round,
|
||||
found.into_iter().filter(|f| f.level == "fail").collect(),
|
||||
);
|
||||
let line = format!(
|
||||
"structure round {round}: {}",
|
||||
progress.summary(ledger.open.len())
|
||||
);
|
||||
eprintln!("{line}");
|
||||
log.push(line);
|
||||
undone.clear();
|
||||
if ok {
|
||||
green = true;
|
||||
break;
|
||||
}
|
||||
if round == 1 {
|
||||
continue;
|
||||
}
|
||||
// A fix that reopens what an earlier round closed is undone,
|
||||
// and the model is told what it broke, rather than chasing the
|
||||
// same findings round after round.
|
||||
if !progress.reopened.is_empty() {
|
||||
restore(&opts.out, before)?;
|
||||
ledger = previous;
|
||||
undone = format!(
|
||||
"## Your last change was undone\n\nIt reopened findings an earlier round had closed:\n\n{}\nClose the open findings without touching what made these pass.\n\n",
|
||||
progress.reopened.iter().map(|f| format!("- [{}] {}\n", f.id, f.message)).collect::<String>()
|
||||
);
|
||||
eprintln!(
|
||||
"structure round {round}: reopened {} closed finding(s), undone",
|
||||
progress.reopened.len()
|
||||
);
|
||||
log.push(format!(
|
||||
"structure round {round}: undone (reopened {})",
|
||||
progress.reopened.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// No progress, no more rounds: another pass at the same findings
|
||||
// is how a site gets over-trained. What is left is reported.
|
||||
if progress.fixed.is_empty() || ledger.open.len() >= previous.open.len() {
|
||||
eprintln!("structure round {round}: no finding closed, stopping");
|
||||
log.push(format!("structure round {round}: stalled"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !green {
|
||||
std::fs::write(opts.out.join("run.log"), format!("{}\n\n{report}", log.join("\n")))?;
|
||||
bail!("structure never went green in {} round(s); last verifier output is in run.log", opts.rounds);
|
||||
let open = if tracked {
|
||||
format!("\n\nStill open:\n{}", ledger.open_list(opts.rounds + 1))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
std::fs::write(
|
||||
opts.out.join("run.log"),
|
||||
format!("{}{open}\n\n{report}", log.join("\n")),
|
||||
)?;
|
||||
bail!("structure did not go green; what is still open is in run.log");
|
||||
}
|
||||
|
||||
// Phase 3: wording.
|
||||
@@ -167,22 +271,48 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
let mut defects = String::new();
|
||||
let mut summary = Vec::new();
|
||||
for skim in [true, false] {
|
||||
let tasks_path = opts.out.join(if skim { "tasks-skim.jsonl" } else { "tasks.jsonl" });
|
||||
let mut args = vec!["--needs-tasks".to_string(), tasks_path.display().to_string()];
|
||||
let tasks_path = opts.out.join(if skim {
|
||||
"tasks-skim.jsonl"
|
||||
} else {
|
||||
"tasks.jsonl"
|
||||
});
|
||||
let mut args = vec![
|
||||
"--needs-tasks".to_string(),
|
||||
tasks_path.display().to_string(),
|
||||
];
|
||||
if skim {
|
||||
args.push("--skim".to_string());
|
||||
}
|
||||
lint(&opts, &args)?;
|
||||
let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?, Some(&opts.out), 0)?;
|
||||
let answers_path = opts.out.join(if skim { "answers-skim.jsonl" } else { "answers.jsonl" });
|
||||
let answers = answer_blind(
|
||||
opts.visitor.as_deref().unwrap_or(&opts.llm),
|
||||
&std::fs::read_to_string(&tasks_path)?,
|
||||
Some(&opts.out),
|
||||
0,
|
||||
)?;
|
||||
let answers_path = opts.out.join(if skim {
|
||||
"answers-skim.jsonl"
|
||||
} else {
|
||||
"answers.jsonl"
|
||||
});
|
||||
std::fs::write(&answers_path, answers)?;
|
||||
let mut args = vec!["--needs-score".to_string(), answers_path.display().to_string()];
|
||||
let mut args = vec![
|
||||
"--needs-score".to_string(),
|
||||
answers_path.display().to_string(),
|
||||
];
|
||||
if skim {
|
||||
args.push("--skim".to_string());
|
||||
}
|
||||
let (_, scored) = lint(&opts, &args)?;
|
||||
let mode = if skim { "headings only" } else { "headings and descriptions" };
|
||||
for line in scored.lines().filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT")) {
|
||||
let mode = if skim {
|
||||
"headings only"
|
||||
} else {
|
||||
"headings and descriptions"
|
||||
};
|
||||
for line in scored
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT"))
|
||||
{
|
||||
defects.push_str(&format!("[{mode}] {line}\n"));
|
||||
}
|
||||
if let Some(line) = scored.lines().find(|l| l.starts_with("wording:")) {
|
||||
@@ -191,7 +321,10 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
}
|
||||
eprintln!("wording round {round}: {}", summary.join(" | "));
|
||||
log.push(format!("wording round {round}: {}", summary.join(" | ")));
|
||||
if defects.is_empty() || round == opts.wording_rounds {
|
||||
// A visitor who chose right, hesitantly, is not a defect worth a
|
||||
// rewrite on its own: only a misroute is. Hesitations ride along
|
||||
// as context when there is one.
|
||||
if !defects.contains("MISROUTE") || round == opts.wording_rounds {
|
||||
break;
|
||||
}
|
||||
let personas = needs_full.split("\npersonas:").nth(1).unwrap_or_default();
|
||||
@@ -311,6 +444,188 @@ fn lint(opts: &Opts, extra: &[String]) -> anyhow::Result<(bool, String)> {
|
||||
Ok((output.status.success(), report))
|
||||
}
|
||||
|
||||
/// One finding as the lint's `--findings` writes it (docs/lint.md).
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
struct Finding {
|
||||
id: String,
|
||||
level: String,
|
||||
code: String,
|
||||
#[serde(default)]
|
||||
need: String,
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
fix: Option<String>,
|
||||
}
|
||||
|
||||
/// The lint with `--findings` into `out`, and what it found; `None` for
|
||||
/// the findings when the lint predates the flag (then it is run again
|
||||
/// the old way, and only its text comes back).
|
||||
fn lint_findings(opts: &Opts, out: &Path) -> anyhow::Result<(bool, String, Option<Vec<Finding>>)> {
|
||||
let (ok, report) = lint(opts, &["--findings".to_string(), out.display().to_string()])?;
|
||||
if report.contains("unknown argument: --findings") {
|
||||
let (ok, report) = lint(opts, &[])?;
|
||||
return Ok((ok, report, None));
|
||||
}
|
||||
let raw = std::fs::read_to_string(out).unwrap_or_default();
|
||||
let found = raw
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
serde_json::from_str::<Finding>(l).with_context(|| format!("reading {}", out.display()))
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
Ok((ok, report, Some(found)))
|
||||
}
|
||||
|
||||
/// Every failing finding by id, across rounds.
|
||||
#[derive(Clone, Default)]
|
||||
struct Ledger {
|
||||
open: BTreeMap<String, Finding>,
|
||||
/// The round each open finding was first seen in.
|
||||
since: BTreeMap<String, usize>,
|
||||
/// Ids a round has closed, so one that comes back is a regression,
|
||||
/// not news.
|
||||
closed: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
struct Progress {
|
||||
fixed: Vec<Finding>,
|
||||
new: Vec<Finding>,
|
||||
still: usize,
|
||||
reopened: Vec<Finding>,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
fn summary(&self, open: usize) -> String {
|
||||
if open == 0 {
|
||||
return format!("green (fixed {})", self.fixed.len());
|
||||
}
|
||||
format!(
|
||||
"{open} open - fixed {}, still open {}, new {}, reopened {}",
|
||||
self.fixed.len(),
|
||||
self.still,
|
||||
self.new.len(),
|
||||
self.reopened.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
fn update(&mut self, round: usize, fails: Vec<Finding>) -> Progress {
|
||||
let now: BTreeMap<String, Finding> = fails.into_iter().map(|f| (f.id.clone(), f)).collect();
|
||||
let fixed: Vec<Finding> = self
|
||||
.open
|
||||
.values()
|
||||
.filter(|f| !now.contains_key(&f.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut new = Vec::new();
|
||||
let mut reopened = Vec::new();
|
||||
let mut still = 0;
|
||||
for (id, f) in &now {
|
||||
if self.open.contains_key(id) {
|
||||
still += 1;
|
||||
} else if self.closed.contains(id) {
|
||||
reopened.push(f.clone());
|
||||
} else {
|
||||
new.push(f.clone());
|
||||
}
|
||||
self.since.entry(id.clone()).or_insert(round);
|
||||
}
|
||||
for f in &fixed {
|
||||
self.closed.insert(f.id.clone());
|
||||
self.since.remove(&f.id);
|
||||
}
|
||||
self.open = now;
|
||||
Progress {
|
||||
fixed,
|
||||
new,
|
||||
still,
|
||||
reopened,
|
||||
}
|
||||
}
|
||||
|
||||
/// The open findings for a prompt or a log: id, how long open, the
|
||||
/// message and its fix.
|
||||
fn open_list(&self, round: usize) -> String {
|
||||
self.open
|
||||
.values()
|
||||
.map(|f| {
|
||||
let age = match self.since.get(&f.id) {
|
||||
Some(&first) if first + 1 < round => format!(", open since round {first}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let scope = if f.need.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {:?}", f.need)
|
||||
};
|
||||
format!("- [{}] {}{scope}{age}: {}\n", f.id, f.code, f.message)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The files open findings point at - every desk page their fixes name,
|
||||
/// plus aggregates.yaml - when that is enough to act on. `None` when a
|
||||
/// finding is about getting around the site (a path, an entry page, a
|
||||
/// missing desk) or names no page: then the model needs to see it all.
|
||||
fn named_files<'a>(
|
||||
out: &Path,
|
||||
findings: impl Iterator<Item = &'a Finding>,
|
||||
) -> Option<Vec<PathBuf>> {
|
||||
const WHOLE_SITE: &[&str] = &[
|
||||
"schema",
|
||||
"entry-missing",
|
||||
"entry-gated",
|
||||
"path-missing",
|
||||
"path-closed",
|
||||
"path-over-budget",
|
||||
"desk-missing",
|
||||
"need-duplicate",
|
||||
];
|
||||
let mut files = vec![out.join("aggregates.yaml")];
|
||||
for f in findings {
|
||||
if WHOLE_SITE.contains(&f.code.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let text = format!("{} {}", f.message, f.fix.as_deref().unwrap_or_default());
|
||||
for page in text.split_whitespace().filter(|w| w.starts_with('/')) {
|
||||
let page = page.trim_end_matches(|c: char| {
|
||||
!(c.is_alphanumeric() || c == '_' || c == '-' || c == '/')
|
||||
});
|
||||
let rel = page.trim_start_matches('/');
|
||||
let candidates = if rel.is_empty() {
|
||||
vec![out.join("questions/index.yaml")]
|
||||
} else {
|
||||
vec![
|
||||
out.join(format!("questions/{rel}.yaml")),
|
||||
out.join(format!("questions/{rel}/index.yaml")),
|
||||
]
|
||||
};
|
||||
if let Some(path) = candidates.into_iter().find(|p| p.exists()) {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(files)
|
||||
}
|
||||
|
||||
/// `dump`, for the named files only.
|
||||
fn dump_files(out: &Path, files: &[PathBuf]) -> anyhow::Result<String> {
|
||||
let mut text = String::new();
|
||||
for path in files.iter().filter(|p| p.exists()) {
|
||||
let rel = path.strip_prefix(out).unwrap_or(path).display().to_string();
|
||||
text.push_str(&format!(
|
||||
"<<<FILE {rel}>>>\n{}<<<END>>>\n\n",
|
||||
std::fs::read_to_string(path)?
|
||||
));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// `<<<FILE path>>> ... <<<END>>>` blocks. Anything outside a block is
|
||||
/// ignored, so a model that adds a sentence does no harm.
|
||||
fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
|
||||
@@ -363,7 +678,9 @@ fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
|
||||
/// trusted: repo-relative, yaml, and only where content lives.
|
||||
fn allowed(path: &str) -> bool {
|
||||
let p = Path::new(path);
|
||||
let plain = p.components().all(|c| matches!(c, std::path::Component::Normal(_)));
|
||||
let plain = p
|
||||
.components()
|
||||
.all(|c| matches!(c, std::path::Component::Normal(_)));
|
||||
plain
|
||||
&& p.extension().and_then(|e| e.to_str()) == Some("yaml")
|
||||
&& (matches!(path, "aggregates.yaml" | "site.yaml") || path.starts_with("questions/"))
|
||||
@@ -434,8 +751,15 @@ fn restore(out: &Path, before: Vec<(PathBuf, String)>) -> anyhow::Result<()> {
|
||||
fn dump(out: &Path) -> anyhow::Result<String> {
|
||||
let mut text = String::new();
|
||||
for path in content_files(out)? {
|
||||
let rel = path.strip_prefix(out).unwrap_or(&path).display().to_string();
|
||||
text.push_str(&format!("<<<FILE {rel}>>>\n{}<<<END>>>\n\n", std::fs::read_to_string(&path)?));
|
||||
let rel = path
|
||||
.strip_prefix(out)
|
||||
.unwrap_or(&path)
|
||||
.display()
|
||||
.to_string();
|
||||
text.push_str(&format!(
|
||||
"<<<FILE {rel}>>>\n{}<<<END>>>\n\n",
|
||||
std::fs::read_to_string(&path)?
|
||||
));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
@@ -453,7 +777,12 @@ fn dump(out: &Path) -> anyhow::Result<String> {
|
||||
/// card portal renders: button, encouragement, sections with icon and
|
||||
/// guidance, fields with placeholders. Headings-only tasks (`--skim`,
|
||||
/// where the task's text is the heading itself) stay headings only.
|
||||
pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_offset: u64) -> anyhow::Result<String> {
|
||||
pub(crate) fn answer_blind(
|
||||
cmd: &str,
|
||||
tasks: &str,
|
||||
repo: Option<&Path>,
|
||||
seed_offset: u64,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut answers = String::new();
|
||||
for line in tasks.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let task: serde_json::Value = serde_json::from_str(line)?;
|
||||
@@ -467,8 +796,14 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
||||
.flatten()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default()))
|
||||
.collect();
|
||||
let skim = options.iter().all(|(name, text)| text.is_empty() || text == name);
|
||||
let page = if skim { None } else { repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) };
|
||||
let skim = options
|
||||
.iter()
|
||||
.all(|(name, text)| text.is_empty() || text == name);
|
||||
let page = if skim {
|
||||
None
|
||||
} else {
|
||||
repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/")))
|
||||
};
|
||||
let seed = fnv(id).wrapping_add(seed_offset.wrapping_mul(0x9E37_79B9_7F4A_7C15) as _);
|
||||
shuffle(&mut options, seed);
|
||||
// Options are numbered and the answer is the number: with a
|
||||
@@ -496,14 +831,25 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
||||
let mut head = page.as_ref().map(card::page_head).unwrap_or_default();
|
||||
// What the page says before it offers anything (the opener, the
|
||||
// look around): read, not picked.
|
||||
if let Some(context) = task["state"]["page"].as_str().filter(|c| !c.trim().is_empty()) {
|
||||
if let Some(context) = task["state"]["page"]
|
||||
.as_str()
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
{
|
||||
head.push_str(&format!("The page first says:\n{context}\n\n"));
|
||||
}
|
||||
let prompt = format!(
|
||||
"You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": <the option's number>, \"confidence\": 0.00}}",
|
||||
listed.join("\n\n")
|
||||
);
|
||||
let reply = llm_env(cmd, &prompt, &[("FORMAT", "json"), ("SEED", &seed.to_string()), ("TEMPERATURE", "0.2")])?;
|
||||
let reply = llm_env(
|
||||
cmd,
|
||||
&prompt,
|
||||
&[
|
||||
("FORMAT", "json"),
|
||||
("SEED", &seed.to_string()),
|
||||
("TEMPERATURE", "0.2"),
|
||||
],
|
||||
)?;
|
||||
let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null);
|
||||
let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect();
|
||||
let raw = match &parsed["choice"] {
|
||||
@@ -516,7 +862,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
||||
_ => simulate::resolve_choice(&names, &raw),
|
||||
};
|
||||
let confidence = parsed["confidence"].as_f64().unwrap_or(0.0);
|
||||
answers.push_str(&serde_json::to_string(&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }))?);
|
||||
answers.push_str(&serde_json::to_string(
|
||||
&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }),
|
||||
)?);
|
||||
answers.push('\n');
|
||||
}
|
||||
Ok(answers)
|
||||
@@ -524,7 +872,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off
|
||||
|
||||
/// FNV-1a over the task id: a seed that is the same on every run.
|
||||
fn fnv(text: &str) -> u32 {
|
||||
text.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193))
|
||||
text.bytes().fold(0x811c_9dc5u32, |h, b| {
|
||||
(h ^ u32::from(b)).wrapping_mul(0x0100_0193)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fisher-Yates on a small xorshift, so the order is fixed by the seed.
|
||||
@@ -591,3 +941,79 @@ mod tests {
|
||||
assert!(!allowed(".gitea/workflows/deploy.yaml"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ledger_tests {
|
||||
use super::*;
|
||||
|
||||
fn f(id: &str, code: &str, message: &str) -> Finding {
|
||||
Finding {
|
||||
id: id.into(),
|
||||
level: "fail".into(),
|
||||
code: code.into(),
|
||||
need: "hire".into(),
|
||||
message: message.into(),
|
||||
fix: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_is_told_what_it_closed_kept_added_and_broke() {
|
||||
let mut ledger = Ledger::default();
|
||||
let p = ledger.update(1, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
|
||||
assert_eq!(
|
||||
(p.fixed.len(), p.new.len(), p.still, p.reopened.len()),
|
||||
(0, 2, 0, 0)
|
||||
);
|
||||
let p = ledger.update(
|
||||
2,
|
||||
vec![f("b", "strand", "y"), f("c", "edge-unoffered", "z")],
|
||||
);
|
||||
assert_eq!(
|
||||
(p.fixed.len(), p.new.len(), p.still, p.reopened.len()),
|
||||
(1, 1, 1, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
p.summary(ledger.open.len()),
|
||||
"2 open - fixed 1, still open 1, new 1, reopened 0"
|
||||
);
|
||||
// "a" was closed in round 2; its return is a regression.
|
||||
let p = ledger.update(3, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
|
||||
assert_eq!(
|
||||
p.reopened.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
|
||||
["a"]
|
||||
);
|
||||
assert!(ledger
|
||||
.open_list(4)
|
||||
.contains("[b] strand \"hire\", open since round 1: y"));
|
||||
let p = ledger.update(4, vec![]);
|
||||
assert_eq!(p.summary(ledger.open.len()), "green (fixed 2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fix_round_sees_the_pages_its_findings_name() {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("questions/desk")).unwrap();
|
||||
std::fs::write(dir.join("aggregates.yaml"), "x").unwrap();
|
||||
std::fs::write(dir.join("questions/desk/index.yaml"), "x").unwrap();
|
||||
std::fs::write(dir.join("questions/index.yaml"), "x").unwrap();
|
||||
let mut strand = f("a", "strand", "records strand in \"quoted\"");
|
||||
strand.fix =
|
||||
Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk".into());
|
||||
let files = named_files(&dir, [&strand].into_iter()).unwrap();
|
||||
assert_eq!(
|
||||
files,
|
||||
vec![
|
||||
dir.join("aggregates.yaml"),
|
||||
dir.join("questions/desk/index.yaml")
|
||||
]
|
||||
);
|
||||
// A finding about getting around the site needs all of it.
|
||||
assert!(named_files(
|
||||
&dir,
|
||||
[&strand, &f("b", "path-missing", "no path")].into_iter()
|
||||
)
|
||||
.is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
+44
-19
@@ -184,13 +184,16 @@ rest. Each is one line in the plan, and the builder does the wiring:
|
||||
- **Carry a text word for word.** `documents` puts bylaws or terms on
|
||||
a page of their own, one card per section, from a markdown file
|
||||
beside the plan. Nothing rewords them.
|
||||
- **Say who you are.** `values` are the business's own statements -
|
||||
what it stands on, the experience behind it - carried word for word
|
||||
into a "who we are" card on the front door. A need with `presents:
|
||||
true` and `watched_by: [public]` puts its records in that card, all
|
||||
of them, as cards: the people who work there, entered from inside
|
||||
(`enters_as`), each with a `picture` field that shows as their
|
||||
portrait.
|
||||
- **Say who you are.** A need with `presents: true` and
|
||||
`watched_by: [public]` (the people who work there, the work they
|
||||
have done, each with a `picture` field) and `values` (the business's
|
||||
own statements, carried word for word) make a page of their own,
|
||||
"who are we?", with nothing to do on it: one card per presented list
|
||||
and one for the values. The front door leads there.
|
||||
- **Give every need someone to play it.** A persona per need,
|
||||
including the ones a business fills in from the inside (a delivery,
|
||||
a card on the front page): a page nobody plays is first read by the
|
||||
person it is for. `portal build` names the needs no persona tries.
|
||||
- **Let people keep their own.** `one_per_person: true` on a need
|
||||
entered from inside gives each signed-in person one record there:
|
||||
their own, which the form opens with and which sending again
|
||||
@@ -281,7 +284,11 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
|
||||
let doc: serde_yaml::Value =
|
||||
serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?;
|
||||
Some(doc.get("personas").cloned().ok_or_else(|| anyhow!("{path} has no `personas:`"))?)
|
||||
Some(
|
||||
doc.get("personas")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("{path} has no `personas:`"))?,
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -320,13 +327,14 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
if let Some(list) = personas.as_mut() {
|
||||
let mapped = map_personas(&cmd, &plan, list)?;
|
||||
if mapped > 0 {
|
||||
eprintln!("plan round {round}: {mapped} persona(s) mapped to this plan's needs");
|
||||
eprintln!(
|
||||
"plan round {round}: {mapped} persona(s) mapped to this plan's needs"
|
||||
);
|
||||
}
|
||||
text.push_str("\n# Held out from every model that writes words.\n");
|
||||
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([(
|
||||
serde_yaml::Value::from("personas"),
|
||||
list.clone(),
|
||||
)]))?);
|
||||
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([
|
||||
(serde_yaml::Value::from("personas"), list.clone()),
|
||||
]))?);
|
||||
}
|
||||
let whole: Plan =
|
||||
serde_yaml::from_str(&text).context("the plan with its personas attached")?;
|
||||
@@ -408,25 +416,42 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
/// not have, or a persona the model leaves out, stays as it was.
|
||||
fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result<usize> {
|
||||
let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect();
|
||||
let Some(list) = personas.as_sequence_mut() else { return Ok(0) };
|
||||
let Some(list) = personas.as_sequence_mut() else {
|
||||
return Ok(0);
|
||||
};
|
||||
let strangers: Vec<(String, String)> = list
|
||||
.iter()
|
||||
.filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default()))
|
||||
.map(|p| (p["id"].as_str().unwrap_or_default().to_string(), p["text"].as_str().unwrap_or_default().to_string()))
|
||||
.map(|p| {
|
||||
(
|
||||
p["id"].as_str().unwrap_or_default().to_string(),
|
||||
p["text"].as_str().unwrap_or_default().to_string(),
|
||||
)
|
||||
})
|
||||
.filter(|(id, _)| !id.is_empty())
|
||||
.collect();
|
||||
if strangers.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let needs: Vec<String> = plan.needs.iter().map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)).collect();
|
||||
let people: Vec<String> = strangers.iter().map(|(id, text)| format!("- {id}: {text}")).collect();
|
||||
let needs: Vec<String> = plan
|
||||
.needs
|
||||
.iter()
|
||||
.map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants))
|
||||
.collect();
|
||||
let people: Vec<String> = strangers
|
||||
.iter()
|
||||
.map(|(id, text)| format!("- {id}: {text}"))
|
||||
.collect();
|
||||
let prompt = format!(
|
||||
"A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.",
|
||||
needs.join("\n"),
|
||||
people.join("\n")
|
||||
);
|
||||
let reply = crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
|
||||
let Some(mapping) = first_object(&reply) else { return Ok(0) };
|
||||
let reply =
|
||||
crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
|
||||
let Some(mapping) = first_object(&reply) else {
|
||||
return Ok(0);
|
||||
};
|
||||
let mut mapped = 0;
|
||||
for persona in list.iter_mut() {
|
||||
let id = persona["id"].as_str().unwrap_or_default().to_string();
|
||||
|
||||
+367
-75
@@ -34,11 +34,16 @@ use crate::trainer::card;
|
||||
use crate::trainer::simulate::ask;
|
||||
|
||||
fn flag(argv: &[String], name: &str) -> Option<String> {
|
||||
argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned()
|
||||
argv.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| argv.get(i + 1))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn load(path: &str) -> anyhow::Result<Value> {
|
||||
Ok(serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)?)
|
||||
Ok(serde_json::from_str(
|
||||
&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// LIX = words per sentence + 100 * long words / words.
|
||||
@@ -50,7 +55,11 @@ pub fn lix(text: &str) -> f64 {
|
||||
if words.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sentences = text.split(['.', '!', '?', ':', '\n']).filter(|s| s.chars().any(char::is_alphabetic)).count().max(1);
|
||||
let sentences = text
|
||||
.split(['.', '!', '?', ':', '\n'])
|
||||
.filter(|s| s.chars().any(char::is_alphabetic))
|
||||
.count()
|
||||
.max(1);
|
||||
let long = words.iter().filter(|w| w.chars().count() > 6).count();
|
||||
words.len() as f64 / sentences as f64 + 100.0 * long as f64 / words.len() as f64
|
||||
}
|
||||
@@ -74,7 +83,10 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
|
||||
};
|
||||
// The cards that lead nowhere (the opener, the look around) are
|
||||
// read before the options; the gutted twin loses them too.
|
||||
if let Some(context) = step["context"].as_str().filter(|c| !c.trim().is_empty() && !gutted) {
|
||||
if let Some(context) = step["context"]
|
||||
.as_str()
|
||||
.filter(|c| !c.trim().is_empty() && !gutted)
|
||||
{
|
||||
text.push_str(&format!("\n{context}\n"));
|
||||
}
|
||||
for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() {
|
||||
@@ -105,7 +117,12 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
|
||||
// tells a reader how many things they will be asked, and a model
|
||||
// answers "can you tell what you will be asked" with yes to that
|
||||
// (measured: 2 of 2 on every need).
|
||||
let fields: Vec<&str> = step["fields"].as_array().into_iter().flatten().filter_map(|f| f["label"].as_str()).collect();
|
||||
let fields: Vec<&str> = step["fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|f| f["label"].as_str())
|
||||
.collect();
|
||||
if !gutted && !fields.is_empty() {
|
||||
text.push_str(&format!(
|
||||
"\nThe form under \"{}\" asks for: {}.\n",
|
||||
@@ -121,13 +138,24 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte
|
||||
|
||||
fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
|
||||
if let Some(next) = followup {
|
||||
text.push_str(&format!("\nAfter you send it, the next page says:\n{}\n", next["name"].as_str().unwrap_or_default()));
|
||||
if let Some(d) = next["description"].as_str().filter(|d| !d.trim().is_empty()) {
|
||||
text.push_str(&format!(
|
||||
"\nAfter you send it, the next page says:\n{}\n",
|
||||
next["name"].as_str().unwrap_or_default()
|
||||
));
|
||||
if let Some(d) = next["description"]
|
||||
.as_str()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
{
|
||||
text.push_str(&format!("{}\n", d.trim()));
|
||||
}
|
||||
for alt in next["alternatives"].as_array().into_iter().flatten() {
|
||||
let d = alt["description"].as_str().unwrap_or_default().trim();
|
||||
text.push_str(&format!("- {}{}{}\n", alt["name"].as_str().unwrap_or_default(), if d.is_empty() { "" } else { ": " }, d));
|
||||
text.push_str(&format!(
|
||||
"- {}{}{}\n",
|
||||
alt["name"].as_str().unwrap_or_default(),
|
||||
if d.is_empty() { "" } else { ": " },
|
||||
d
|
||||
));
|
||||
}
|
||||
}
|
||||
text
|
||||
@@ -137,12 +165,30 @@ fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
|
||||
/// description, and the followup. Headings and labels are left out.
|
||||
fn prose_text(step: &Value, followup: Option<&Value>) -> String {
|
||||
let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()];
|
||||
prose.extend(step["options"].as_array().into_iter().flatten().filter_map(|o| o["description"].as_str()).map(str::to_string));
|
||||
prose.extend(
|
||||
step["options"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| o["description"].as_str())
|
||||
.map(str::to_string),
|
||||
);
|
||||
if let Some(next) = followup {
|
||||
prose.extend(next["description"].as_str().map(str::to_string));
|
||||
prose.extend(next["alternatives"].as_array().into_iter().flatten().filter_map(|a| a["description"].as_str()).map(str::to_string));
|
||||
prose.extend(
|
||||
next["alternatives"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|a| a["description"].as_str())
|
||||
.map(str::to_string),
|
||||
);
|
||||
}
|
||||
prose.into_iter().filter(|p| !p.trim().is_empty()).collect::<Vec<_>>().join("\n")
|
||||
prose
|
||||
.into_iter()
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The page a visitor lands on after the step's alternative: read from
|
||||
@@ -152,7 +198,11 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option<Value> {
|
||||
let repo = repo?;
|
||||
let page = load_page(repo, step["page"].as_str()?)?;
|
||||
let alternative = step["alternative"].as_str()?;
|
||||
let action = page["alternatives"].as_array()?.iter().find(|a| a["name"] == alternative)?["action"].as_str()?;
|
||||
let action = page["alternatives"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|a| a["name"] == alternative)?["action"]
|
||||
.as_str()?;
|
||||
load_page(repo, action)
|
||||
}
|
||||
|
||||
@@ -187,15 +237,33 @@ fn s(v: &Value) -> String {
|
||||
/// the button for it. The outcome is put in plain words from the
|
||||
/// state's own name, never from the label, and the buttons are listed
|
||||
/// in a seeded shuffle.
|
||||
fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result<String> {
|
||||
fn button_pick(
|
||||
cmd: &str,
|
||||
group: &str,
|
||||
bucket: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
labels: &[&str],
|
||||
seed: usize,
|
||||
words: &Value,
|
||||
) -> anyhow::Result<String> {
|
||||
// The state in the plan's own words when the spec carries them,
|
||||
// else the id made readable.
|
||||
let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " "));
|
||||
let plain = |id: &str| {
|
||||
words[id]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| id.replace(['_', '-'], " "))
|
||||
};
|
||||
let mut order: Vec<&str> = labels.to_vec();
|
||||
// A fixed rotation per move: the right answer is not always first.
|
||||
let by = seed % order.len().max(1);
|
||||
order.rotate_left(by);
|
||||
let listed: Vec<String> = order.iter().enumerate().map(|(i, l)| format!("{}. {l}", i + 1)).collect();
|
||||
let listed: Vec<String> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| format!("{}. {l}", i + 1))
|
||||
.collect();
|
||||
let prompt = format!(
|
||||
"You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": <the button's number>}}",
|
||||
plain(group),
|
||||
@@ -206,7 +274,10 @@ fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, label
|
||||
);
|
||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||
let n = reply["choice"].as_u64().unwrap_or(0) as usize;
|
||||
Ok(order.get(n.wrapping_sub(1)).map(|l| l.to_string()).unwrap_or_default())
|
||||
Ok(order
|
||||
.get(n.wrapping_sub(1))
|
||||
.map(|l| l.to_string())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Every `mail:` on a state in the repo's aggregates.yaml, as
|
||||
@@ -228,7 +299,12 @@ pub(crate) fn mail_texts(repo: &Path) -> Option<Vec<(String, String, String)>> {
|
||||
}
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
};
|
||||
Some(raw_mails(repo, &doc).into_iter().map(|(name, subject, body)| (name, strip(&subject), strip(&body))).collect())
|
||||
Some(
|
||||
raw_mails(repo, &doc)
|
||||
.into_iter()
|
||||
.map(|(name, subject, body)| (name, strip(&subject), strip(&body)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Every mail the site sends, as written: the state mails in
|
||||
@@ -248,10 +324,17 @@ fn raw_mails(repo: &Path, aggregates: &Value) -> Vec<(String, String, String)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let site: Value = std::fs::read_to_string(repo.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let site: Value = std::fs::read_to_string(repo.join("site.yaml"))
|
||||
.ok()
|
||||
.and_then(|t| serde_yaml::from_str(&t).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let invite = &site["mail"]["invite"];
|
||||
if invite.is_object() {
|
||||
out.push(("invite".to_string(), invite["subject"].as_str().unwrap_or_default().to_string(), invite["body"].as_str().unwrap_or_default().to_string()));
|
||||
out.push((
|
||||
"invite".to_string(),
|
||||
invite["subject"].as_str().unwrap_or_default().to_string(),
|
||||
invite["body"].as_str().unwrap_or_default().to_string(),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -265,7 +348,12 @@ fn as_received(template: &str, site_name: &str) -> String {
|
||||
out.push_str(&rest[..i]);
|
||||
let after = &rest[i + 1..];
|
||||
match after.find('}') {
|
||||
Some(j) if after[..j].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') && j > 0 => {
|
||||
Some(j)
|
||||
if after[..j]
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||
&& j > 0 =>
|
||||
{
|
||||
out.push_str(&match &after[..j] {
|
||||
"site" => "https://example.site".to_string(),
|
||||
"chain" => "8f3a1c".to_string(),
|
||||
@@ -287,19 +375,38 @@ fn as_received(template: &str, site_name: &str) -> String {
|
||||
}
|
||||
|
||||
const MAIL_QUESTIONS: [(&str, &str); 3] = [
|
||||
("happened", "Do you know, from this mail alone, what has happened and to what?"),
|
||||
("now", "Do you know what, if anything, you can or must do now, and how?"),
|
||||
("who", "Do you know who sent it, and how you would answer them?"),
|
||||
(
|
||||
"happened",
|
||||
"Do you know, from this mail alone, what has happened and to what?",
|
||||
),
|
||||
(
|
||||
"now",
|
||||
"Do you know what, if anything, you can or must do now, and how?",
|
||||
),
|
||||
(
|
||||
"who",
|
||||
"Do you know who sent it, and how you would answer them?",
|
||||
),
|
||||
];
|
||||
|
||||
/// Three yes-or-no questions on one mail, from the person it is for.
|
||||
fn mail_understanding(cmd: &str, reader: &str, subject: &str, body: &str, seed: usize) -> anyhow::Result<[bool; 3]> {
|
||||
fn mail_understanding(
|
||||
cmd: &str,
|
||||
reader: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
seed: usize,
|
||||
) -> anyhow::Result<[bool; 3]> {
|
||||
let prompt = format!(
|
||||
"You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}",
|
||||
MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
||||
);
|
||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||
Ok([reply["happened"] == true, reply["now"] == true, reply["who"] == true])
|
||||
Ok([
|
||||
reply["happened"] == true,
|
||||
reply["now"] == true,
|
||||
reply["who"] == true,
|
||||
])
|
||||
}
|
||||
|
||||
/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or
|
||||
@@ -309,9 +416,14 @@ pub(crate) fn load_page(repo: &Path, route: &str) -> Option<Value> {
|
||||
let candidates = if rel.is_empty() {
|
||||
vec![repo.join("questions/index.yaml")]
|
||||
} else {
|
||||
vec![repo.join(format!("questions/{rel}.yaml")), repo.join(format!("questions/{rel}/index.yaml"))]
|
||||
vec![
|
||||
repo.join(format!("questions/{rel}.yaml")),
|
||||
repo.join(format!("questions/{rel}/index.yaml")),
|
||||
]
|
||||
};
|
||||
let text = candidates.iter().find_map(|p| std::fs::read_to_string(p).ok())?;
|
||||
let text = candidates
|
||||
.iter()
|
||||
.find_map(|p| std::fs::read_to_string(p).ok())?;
|
||||
serde_yaml::from_str(&text).ok()
|
||||
}
|
||||
|
||||
@@ -326,7 +438,12 @@ const MAX_REQUIRED: usize = 5;
|
||||
fn page_load(step: &Value) -> (usize, usize) {
|
||||
(
|
||||
step["options"].as_array().map_or(0, Vec::len),
|
||||
step["fields"].as_array().into_iter().flatten().filter(|f| f["optional"] != true).count(),
|
||||
step["fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f["optional"] != true)
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -344,7 +461,12 @@ fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Resul
|
||||
QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
|
||||
);
|
||||
let reply = ask(cmd, &prompt, &seed.to_string())?;
|
||||
Ok([reply["which"] == true, reply["where"] == true, reply["asked"] == true, reply["next"] == true])
|
||||
Ok([
|
||||
reply["which"] == true,
|
||||
reply["where"] == true,
|
||||
reply["asked"] == true,
|
||||
reply["next"] == true,
|
||||
])
|
||||
}
|
||||
|
||||
pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
@@ -365,7 +487,10 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
// that desk's buttons for the bucket.
|
||||
let desks = desk_buttons(&model);
|
||||
if !desks.is_empty() {
|
||||
println!("{:<20} {:<24} {:>8} {}", "desk", "bucket", "buttons", "mistaken for");
|
||||
println!(
|
||||
"{:<20} {:<24} {:>8} {}",
|
||||
"desk", "bucket", "buttons", "mistaken for"
|
||||
);
|
||||
for (page, group, bucket, moves) in &desks {
|
||||
// A desk row shows only the buttons that start from the
|
||||
// row's state, so those are the ones the worker chooses
|
||||
@@ -374,12 +499,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
let mut asked = 0;
|
||||
let mut mistaken = Vec::new();
|
||||
for (i, (from, to, label)) in moves.iter().enumerate() {
|
||||
let labels: Vec<&str> = moves.iter().filter(|(f, _, _)| f == from).map(|(_, _, l)| l.as_str()).collect();
|
||||
let labels: Vec<&str> = moves
|
||||
.iter()
|
||||
.filter(|(f, _, _)| f == from)
|
||||
.map(|(_, _, l)| l.as_str())
|
||||
.collect();
|
||||
if labels.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
asked += 1;
|
||||
let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?;
|
||||
let picked = button_pick(
|
||||
&cmd,
|
||||
group,
|
||||
bucket,
|
||||
from,
|
||||
to,
|
||||
&labels,
|
||||
i,
|
||||
&model["buckets"][bucket]["words"],
|
||||
)?;
|
||||
if picked == *label {
|
||||
right += 1;
|
||||
} else {
|
||||
@@ -389,8 +527,18 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
if asked == 0 {
|
||||
continue;
|
||||
}
|
||||
println!("{:<20} {:<24} {:>3} of {:<2} {}", page, bucket, right, asked, mistaken.join("; "));
|
||||
report.insert(format!("desk {page} {bucket}"), json!({ "right": right, "of": asked, "mistaken": mistaken }));
|
||||
println!(
|
||||
"{:<20} {:<24} {:>3} of {:<2} {}",
|
||||
page,
|
||||
bucket,
|
||||
right,
|
||||
asked,
|
||||
mistaken.join("; ")
|
||||
);
|
||||
report.insert(
|
||||
format!("desk {page} {bucket}"),
|
||||
json!({ "right": right, "of": asked, "mistaken": mistaken }),
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
@@ -404,12 +552,23 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
if let Some(mails) = repo.as_deref().and_then(mail_texts) {
|
||||
if !mails.is_empty() {
|
||||
let dir = repo.as_deref().unwrap_or(Path::new("."));
|
||||
let site: Value = std::fs::read_to_string(dir.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let site: Value = std::fs::read_to_string(dir.join("site.yaml"))
|
||||
.ok()
|
||||
.and_then(|t| serde_yaml::from_str(&t).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let site_name = site["title"].as_str().unwrap_or("the site").to_string();
|
||||
let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml"))
|
||||
.ok()
|
||||
.and_then(|t| serde_yaml::from_str(&t).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let raw = raw_mails(dir, &aggregates);
|
||||
println!("{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says");
|
||||
for (i, ((name, subject, body), (_, raw_subject, raw_body))) in mails.iter().zip(&raw).enumerate() {
|
||||
println!(
|
||||
"{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
|
||||
"mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says"
|
||||
);
|
||||
for (i, ((name, subject, body), (_, raw_subject, raw_body))) in
|
||||
mails.iter().zip(&raw).enumerate()
|
||||
{
|
||||
let text = format!("{subject}. {body}");
|
||||
let score = lix(&text);
|
||||
let reader = if name == "invite" {
|
||||
@@ -417,30 +576,59 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
} else {
|
||||
format!("someone who sent a form to {site_name} some time ago")
|
||||
};
|
||||
let (subject_in, body_in) = (as_received(raw_subject, &site_name), as_received(raw_body, &site_name));
|
||||
let (subject_in, body_in) = (
|
||||
as_received(raw_subject, &site_name),
|
||||
as_received(raw_body, &site_name),
|
||||
);
|
||||
let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?;
|
||||
let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?;
|
||||
let yn = |b: bool| if b { "yes" } else { "no" };
|
||||
let failed: Vec<&str> = MAIL_QUESTIONS.iter().zip(twin).filter(|(_, t)| *t).map(|((k, _), _)| *k).collect();
|
||||
let failed: Vec<&str> = MAIL_QUESTIONS
|
||||
.iter()
|
||||
.zip(twin)
|
||||
.filter(|(_, t)| *t)
|
||||
.map(|((k, _), _)| *k)
|
||||
.collect();
|
||||
println!(
|
||||
"{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}",
|
||||
name, score, lix_band(score), text.split_whitespace().count(), yn(got[0]), yn(got[1]), yn(got[2]),
|
||||
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
|
||||
name,
|
||||
score,
|
||||
lix_band(score),
|
||||
text.split_whitespace().count(),
|
||||
yn(got[0]),
|
||||
yn(got[1]),
|
||||
yn(got[2]),
|
||||
if failed.is_empty() {
|
||||
"ok".to_string()
|
||||
} else {
|
||||
format!("FAILED: {}", failed.join(", "))
|
||||
}
|
||||
);
|
||||
report.insert(
|
||||
format!("mail {name}"),
|
||||
json!({
|
||||
"lix": score, "words": text.split_whitespace().count(),
|
||||
"understanding": { "happened": got[0], "now": got[1], "who": got[2] },
|
||||
"control_ok": failed.is_empty(),
|
||||
}),
|
||||
);
|
||||
report.insert(format!("mail {name}"), json!({
|
||||
"lix": score, "words": text.split_whitespace().count(),
|
||||
"understanding": { "happened": got[0], "now": got[1], "who": got[2] },
|
||||
"control_ok": failed.is_empty(),
|
||||
}));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load");
|
||||
println!(
|
||||
"{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
|
||||
"need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load"
|
||||
);
|
||||
for need in model["needs"].as_array().into_iter().flatten() {
|
||||
let id = need["id"].as_str().unwrap_or_default();
|
||||
let steps = need["steps"].as_array().cloned().unwrap_or_default();
|
||||
let personas: Vec<&Value> = model["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).collect();
|
||||
let personas: Vec<&Value> = model["personas"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|p| p["need"] == id)
|
||||
.collect();
|
||||
if personas.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -450,15 +638,30 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
// after sending.
|
||||
let mut pages = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let followup = if i + 1 == steps.len() { followup_page(repo.as_deref(), step) } else { None };
|
||||
let page = repo.as_deref().and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
|
||||
let row = if steps.len() > 1 { format!("{id} @{}", step["page"].as_str().unwrap_or_default()) } else { id.to_string() };
|
||||
let followup = if i + 1 == steps.len() {
|
||||
followup_page(repo.as_deref(), step)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let page = repo
|
||||
.as_deref()
|
||||
.and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
|
||||
let row = if steps.len() > 1 {
|
||||
format!("{id} @{}", step["page"].as_str().unwrap_or_default())
|
||||
} else {
|
||||
id.to_string()
|
||||
};
|
||||
let readability = lix(&prose_text(step, followup.as_ref()));
|
||||
let (mut yes, mut control) = ([0usize; N], [0usize; N]);
|
||||
for (i, persona) in personas.iter().enumerate() {
|
||||
let who = persona["text"].as_str().unwrap_or_default();
|
||||
for (tally, gutted) in [(&mut yes, false), (&mut control, true)] {
|
||||
let answers = understanding(&cmd, who, &page_text(step, page.as_ref(), followup.as_ref(), gutted), i)?;
|
||||
let answers = understanding(
|
||||
&cmd,
|
||||
who,
|
||||
&page_text(step, page.as_ref(), followup.as_ref(), gutted),
|
||||
i,
|
||||
)?;
|
||||
for (slot, answer) in tally.iter_mut().zip(answers) {
|
||||
*slot += answer as usize;
|
||||
}
|
||||
@@ -466,12 +669,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
}
|
||||
let n = personas.len();
|
||||
let measured = |k: usize| k != 3 || followup.is_some();
|
||||
let share = |k: usize| if measured(k) { format!("{}/{n}", yes[k]) } else { "-".to_string() };
|
||||
let share = |k: usize| {
|
||||
if measured(k) {
|
||||
format!("{}/{n}", yes[k])
|
||||
} else {
|
||||
"-".to_string()
|
||||
}
|
||||
};
|
||||
// The twin has no headings, no descriptions, no cards and no
|
||||
// followup: every question must collapse on it, or that
|
||||
// question is agreeing with anything.
|
||||
let control_ok: Vec<bool> = (0..N).map(|k| !measured(k) || control[k] * 2 < n.max(1)).collect();
|
||||
let failed: Vec<&str> = QUESTIONS.iter().zip(&control_ok).filter(|(_, ok)| !**ok).map(|((k, _), _)| *k).collect();
|
||||
let control_ok: Vec<bool> = (0..N)
|
||||
.map(|k| !measured(k) || control[k] * 2 < n.max(1))
|
||||
.collect();
|
||||
let failed: Vec<&str> = QUESTIONS
|
||||
.iter()
|
||||
.zip(&control_ok)
|
||||
.filter(|(_, ok)| !**ok)
|
||||
.map(|((k, _), _)| *k)
|
||||
.collect();
|
||||
let (options, required) = page_load(step);
|
||||
println!(
|
||||
"{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
|
||||
@@ -487,12 +703,24 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
control[0],
|
||||
control[1],
|
||||
control[2],
|
||||
if measured(3) { control[3].to_string() } else { "-".to_string() },
|
||||
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
|
||||
if measured(3) {
|
||||
control[3].to_string()
|
||||
} else {
|
||||
"-".to_string()
|
||||
},
|
||||
if failed.is_empty() {
|
||||
"ok".to_string()
|
||||
} else {
|
||||
format!("FAILED: {}", failed.join(", "))
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"{options} options, {required} required{}",
|
||||
if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" }
|
||||
if options > MAX_OPTIONS || required > MAX_REQUIRED {
|
||||
" - SPLIT"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
);
|
||||
pages.push(json!({
|
||||
@@ -505,9 +733,15 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
|
||||
"control_ok": failed.is_empty(),
|
||||
}));
|
||||
}
|
||||
report.insert(id.to_string(), json!({ "steps": pages.len(), "pages": pages }));
|
||||
report.insert(
|
||||
id.to_string(),
|
||||
json!({ "steps": pages.len(), "pages": pages }),
|
||||
);
|
||||
}
|
||||
std::fs::write(out.join("rate.json"), serde_json::to_string_pretty(&report)?)?;
|
||||
std::fs::write(
|
||||
out.join("rate.json"),
|
||||
serde_json::to_string_pretty(&report)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -517,22 +751,49 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
|
||||
let b = load(&flag(argv, "--b").ok_or_else(|| anyhow!(usage))?)?;
|
||||
let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string());
|
||||
|
||||
println!("{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", "need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B");
|
||||
println!(
|
||||
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
|
||||
"need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B"
|
||||
);
|
||||
println!("{:<28} {:^26} {:^26}", "", "friendlier", "clearer");
|
||||
for need_a in a["needs"].as_array().into_iter().flatten() {
|
||||
let id = need_a["id"].as_str().unwrap_or_default();
|
||||
let Some(need_b) = b["needs"].as_array().into_iter().flatten().find(|n| n["id"] == id) else { continue };
|
||||
let (Some(step_a), Some(step_b)) = (need_a["steps"].as_array().and_then(|s| s.first()), need_b["steps"].as_array().and_then(|s| s.first())) else { continue };
|
||||
let (text_a, text_b) = (page_text(step_a, None, None, false), page_text(step_b, None, None, false));
|
||||
let Some(need_b) = b["needs"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|n| n["id"] == id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (Some(step_a), Some(step_b)) = (
|
||||
need_a["steps"].as_array().and_then(|s| s.first()),
|
||||
need_b["steps"].as_array().and_then(|s| s.first()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let (text_a, text_b) = (
|
||||
page_text(step_a, None, None, false),
|
||||
page_text(step_b, None, None, false),
|
||||
);
|
||||
if text_a == text_b {
|
||||
continue;
|
||||
}
|
||||
// [friendlier, clearer] x [A, B, none]
|
||||
let mut wins = [[0usize; 3]; 2];
|
||||
for (i, persona) in a["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).enumerate() {
|
||||
for (i, persona) in a["personas"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|p| p["need"] == id)
|
||||
.enumerate()
|
||||
{
|
||||
let who = persona["text"].as_str().unwrap_or_default();
|
||||
let mut picks: [[&str; 2]; 2] = [["", ""], ["", ""]];
|
||||
for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)].into_iter().enumerate() {
|
||||
for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let prompt = format!(
|
||||
"You are this person: {who}\n\nTwo versions of the same web page. They lead to the same thing; only the wording differs.\n\n=== VERSION 1 ===\n{first}\n=== VERSION 2 ===\n{second}\n\nAs yourself: which version makes you feel more welcome and at ease, and which one makes it clearer what to do?\nReply as JSON: {{\"friendlier\": 1 or 2, \"clearer\": 1 or 2}}"
|
||||
);
|
||||
@@ -554,7 +815,13 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
|
||||
}
|
||||
println!(
|
||||
"{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}",
|
||||
id, wins[0][0], wins[0][1], wins[0][2], wins[1][0], wins[1][1], wins[1][2],
|
||||
id,
|
||||
wins[0][0],
|
||||
wins[0][1],
|
||||
wins[0][2],
|
||||
wins[1][0],
|
||||
wins[1][1],
|
||||
wins[1][2],
|
||||
format!("{:.0}->{:.0}", lix(&text_a), lix(&text_b))
|
||||
);
|
||||
}
|
||||
@@ -592,29 +859,46 @@ mod tests {
|
||||
{"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]},
|
||||
{"name": "Work with us", "description": "For crews."}]});
|
||||
let rich = page_text(&step, Some(&repo_page), Some(&next), false);
|
||||
assert!(rich.contains("Button: Send it") && rich.contains("We answer within two days.") && rich.contains("Asks for: Email"));
|
||||
assert!(
|
||||
rich.contains("Button: Send it")
|
||||
&& rich.contains("We answer within two days.")
|
||||
&& rich.contains("Asks for: Email")
|
||||
);
|
||||
assert!(!rich.contains("asks for: Email, The build"));
|
||||
let twin = page_text(&step, Some(&repo_page), Some(&next), true);
|
||||
assert!(!twin.contains("Send it") && !twin.contains("Your brief"));
|
||||
let twin = page_text(&step, None, Some(&next), true);
|
||||
assert!(twin.contains("What brings you here?") && twin.contains("Option 2"));
|
||||
assert!(!twin.contains("asks for"));
|
||||
assert!(!twin.contains("Get a price") && !twin.contains("Email") && !twin.contains("project manager"));
|
||||
assert!(
|
||||
!twin.contains("Get a price")
|
||||
&& !twin.contains("Email")
|
||||
&& !twin.contains("project manager")
|
||||
);
|
||||
// Readability is measured on the sentences, not the headings.
|
||||
let prose = prose_text(&step, Some(&next));
|
||||
assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it.");
|
||||
assert_eq!(
|
||||
prose,
|
||||
"What brings you here?\nFor owners.\nFor crews.\nA project manager reads it."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() {
|
||||
let got = as_received("Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", "Tomter Vel");
|
||||
let got = as_received(
|
||||
"Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}",
|
||||
"Tomter Vel",
|
||||
);
|
||||
assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}");
|
||||
let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap();
|
||||
std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap();
|
||||
let mails = mail_texts(&dir).unwrap();
|
||||
assert_eq!(mails.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(), ["b:open", "invite"]);
|
||||
assert_eq!(
|
||||
mails.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(),
|
||||
["b:open", "invite"]
|
||||
);
|
||||
assert_eq!(mails[0].2, "Vi har det, .");
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
@@ -623,8 +907,16 @@ mod tests {
|
||||
fn the_followup_page_is_read_from_the_repo_by_following_the_action() {
|
||||
let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("questions/received")).unwrap();
|
||||
std::fs::write(dir.join("questions/index.yaml"), "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n").unwrap();
|
||||
std::fs::write(dir.join("questions/received/price.yaml"), "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("questions/index.yaml"),
|
||||
"name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("questions/received/price.yaml"),
|
||||
"name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n",
|
||||
)
|
||||
.unwrap();
|
||||
let step = json!({"page": "/", "alternative": "Get a price"});
|
||||
let next = followup_page(Some(&dir), &step).unwrap();
|
||||
assert_eq!(next["name"], "What happens now?");
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+84
-17
@@ -47,19 +47,35 @@ struct Showing {
|
||||
/// Per bucket, per storyline index, how many arrived and how many
|
||||
/// ended in a state the need calls success (or, without `success`,
|
||||
/// any finished state).
|
||||
fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>, needs: &[Need]) -> BTreeMap<String, Vec<Showing>> {
|
||||
fn showings(
|
||||
counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
|
||||
needs: &[Need],
|
||||
) -> BTreeMap<String, Vec<Showing>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (bucket, per_story) in counts {
|
||||
let well: Vec<&str> = needs
|
||||
.iter()
|
||||
.filter(|n| &n.lands_in == bucket)
|
||||
.flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() })
|
||||
.flat_map(|n| {
|
||||
if n.success.is_empty() {
|
||||
n.done_when.iter()
|
||||
} else {
|
||||
n.success.iter()
|
||||
}
|
||||
})
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let arity = per_story.keys().filter_map(|k| k.parse::<usize>().ok()).max().map(|m| m + 1).unwrap_or(1);
|
||||
let arity = per_story
|
||||
.keys()
|
||||
.filter_map(|k| k.parse::<usize>().ok())
|
||||
.max()
|
||||
.map(|m| m + 1)
|
||||
.unwrap_or(1);
|
||||
let mut rows: Vec<Showing> = (0..arity).map(|_| Showing::default()).collect();
|
||||
for (story, states) in per_story {
|
||||
let Ok(n) = story.parse::<usize>() else { continue };
|
||||
let Ok(n) = story.parse::<usize>() else {
|
||||
continue;
|
||||
};
|
||||
for (state, count) in states {
|
||||
rows[n].arrived += count;
|
||||
if well.contains(&state.as_str()) {
|
||||
@@ -73,16 +89,32 @@ fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
|
||||
}
|
||||
|
||||
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
|
||||
let site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]"))?;
|
||||
let value = |flag: &str| {
|
||||
argv.iter()
|
||||
.position(|a| a == flag)
|
||||
.and_then(|i| argv.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let site = value("--site").ok_or_else(|| {
|
||||
anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]")
|
||||
})?;
|
||||
let url = format!("{}/report", site.trim_end_matches('/'));
|
||||
let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?;
|
||||
let fetched = std::process::Command::new("curl")
|
||||
.args(["-sSf", "--max-time", "30", &url])
|
||||
.output()
|
||||
.context("running curl")?;
|
||||
if !fetched.status.success() {
|
||||
bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim());
|
||||
bail!(
|
||||
"could not read {url}: {}",
|
||||
String::from_utf8_lossy(&fetched.stderr).trim()
|
||||
);
|
||||
}
|
||||
let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
|
||||
let report: Report =
|
||||
serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
|
||||
let needs: Needs = match value("--needs") {
|
||||
Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?,
|
||||
Some(path) => serde_yaml::from_str(
|
||||
&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?,
|
||||
)?,
|
||||
None => Needs::default(),
|
||||
};
|
||||
println!("{site}: {} storyline(s)", report.storylines);
|
||||
@@ -97,11 +129,26 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
for (bucket, per_story) in &rows {
|
||||
println!("\n{bucket}");
|
||||
for (n, showing) in per_story.iter().enumerate() {
|
||||
let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() };
|
||||
println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well);
|
||||
let rate = if showing.arrived > 0 {
|
||||
format!(
|
||||
"{:.0}%",
|
||||
100.0 * showing.ended_well as f64 / showing.arrived as f64
|
||||
)
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
println!(
|
||||
" storyline {n}: {} arrived, {} ended well ({rate})",
|
||||
showing.arrived, showing.ended_well
|
||||
);
|
||||
}
|
||||
if per_story.len() > 1 {
|
||||
let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0);
|
||||
let best = per_story
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, s)| (s.ended_well, s.arrived))
|
||||
.map(|(n, _)| n)
|
||||
.unwrap_or(0);
|
||||
println!(" most so far: storyline {best}");
|
||||
}
|
||||
}
|
||||
@@ -116,12 +163,32 @@ mod tests {
|
||||
fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() {
|
||||
let counts: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>> =
|
||||
serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap();
|
||||
let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }];
|
||||
let needs = vec![Need {
|
||||
lands_in: "hazards".into(),
|
||||
done_when: vec!["fixed".into(), "dropped".into()],
|
||||
success: vec!["fixed".into()],
|
||||
}];
|
||||
let rows = showings(&counts, &needs);
|
||||
assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 });
|
||||
assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 });
|
||||
assert_eq!(
|
||||
rows["hazards"][0],
|
||||
Showing {
|
||||
arrived: 7,
|
||||
ended_well: 4
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
rows["hazards"][1],
|
||||
Showing {
|
||||
arrived: 12,
|
||||
ended_well: 9
|
||||
}
|
||||
);
|
||||
// Without a success list, any finished state counts.
|
||||
let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }];
|
||||
let any = vec![Need {
|
||||
lands_in: "hazards".into(),
|
||||
done_when: vec!["fixed".into(), "dropped".into()],
|
||||
success: vec![],
|
||||
}];
|
||||
assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11);
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -11,7 +11,12 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
let flag = |name: &str| argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned();
|
||||
let flag = |name: &str| {
|
||||
argv.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| argv.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let usage = "usage: portal-trainer score --site <content dir> --out <dir> [--iris <path>] [--llm 'cmd']";
|
||||
let site = PathBuf::from(flag("--site").ok_or_else(|| anyhow!(usage))?);
|
||||
let out = PathBuf::from(flag("--out").ok_or_else(|| anyhow!(usage))?);
|
||||
@@ -27,52 +32,102 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
let tasks_skim = out.join("tasks-skim.jsonl");
|
||||
let model = out.join("model.json");
|
||||
let mut log = String::new();
|
||||
log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)])?);
|
||||
log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_skim), "--skim"])?);
|
||||
log.push_str(&lint(
|
||||
&iris,
|
||||
&questions,
|
||||
&["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)],
|
||||
)?);
|
||||
log.push_str(&lint(
|
||||
&iris,
|
||||
&questions,
|
||||
&["--needs-tasks", &s(&tasks_skim), "--skim"],
|
||||
)?);
|
||||
std::fs::write(out.join("iris.log"), &log)?;
|
||||
|
||||
let mut summary = serde_json::Map::new();
|
||||
for (mode, tasks, skim) in [("full", &tasks_full, false), ("skim", &tasks_skim, true)] {
|
||||
eprintln!("score: the visitor answers {mode}");
|
||||
let answers = crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?;
|
||||
let answers =
|
||||
crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?;
|
||||
let answers_path = out.join(format!("answers-{mode}.jsonl"));
|
||||
std::fs::write(&answers_path, &answers)?;
|
||||
let (tasks_arg, answers_arg) = (s(tasks), s(&answers_path));
|
||||
let mut args = vec!["--needs-tasks", tasks_arg.as_str(), "--needs-score", answers_arg.as_str()];
|
||||
let mut args = vec![
|
||||
"--needs-tasks",
|
||||
tasks_arg.as_str(),
|
||||
"--needs-score",
|
||||
answers_arg.as_str(),
|
||||
];
|
||||
if skim {
|
||||
args.push("--skim");
|
||||
}
|
||||
let report = lint(&iris, &questions, &args)?;
|
||||
println!("== {}", if skim { "headings only" } else { "with descriptions" });
|
||||
println!(
|
||||
"== {}",
|
||||
if skim {
|
||||
"headings only"
|
||||
} else {
|
||||
"with descriptions"
|
||||
}
|
||||
);
|
||||
let mut right = None;
|
||||
for line in report.lines() {
|
||||
if line.starts_with("MISROUTE") || line.starts_with("HESITANT") || line.starts_with("UNANSWERED") {
|
||||
if line.starts_with("MISROUTE")
|
||||
|| line.starts_with("HESITANT")
|
||||
|| line.starts_with("UNANSWERED")
|
||||
{
|
||||
println!("{line}");
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("wording: ") {
|
||||
// "wording: 19/27 routed right (70%)"
|
||||
let mut parts = rest.split_whitespace().next().unwrap_or_default().split('/');
|
||||
let mut parts = rest
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split('/');
|
||||
let a: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
let b: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
right = Some((a, b));
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
let (a, b) = right.ok_or_else(|| anyhow!("iris printed no wording score for {mode}; its output:\n{report}"))?;
|
||||
let (a, b) = right.ok_or_else(|| {
|
||||
anyhow!("iris printed no wording score for {mode}; its output:\n{report}")
|
||||
})?;
|
||||
summary.insert(mode.to_string(), serde_json::json!({ "right": a, "of": b }));
|
||||
}
|
||||
|
||||
println!("== rate");
|
||||
let rated = out.join("rated");
|
||||
let rate_argv: Vec<String> = ["rate", "--model", &s(&model), "--out", &s(&rated), "--repo", &s(&site), "--llm", &cmd].iter().map(|x| x.to_string()).collect();
|
||||
let rate_argv: Vec<String> = [
|
||||
"rate",
|
||||
"--model",
|
||||
&s(&model),
|
||||
"--out",
|
||||
&s(&rated),
|
||||
"--repo",
|
||||
&s(&site),
|
||||
"--llm",
|
||||
&cmd,
|
||||
]
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect();
|
||||
crate::trainer::rate::rate(&rate_argv)?;
|
||||
|
||||
summary.insert("site".into(), serde_json::json!(s(&site)));
|
||||
summary.insert("visitor".into(), serde_json::json!(cmd));
|
||||
std::fs::write(out.join("summary.json"), serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?)?;
|
||||
std::fs::write(
|
||||
out.join("summary.json"),
|
||||
serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?,
|
||||
)?;
|
||||
println!(
|
||||
"== summary: with descriptions {} of {}, headings only {} of {} (rate.json in {})",
|
||||
summary["full"]["right"], summary["full"]["of"], summary["skim"]["right"], summary["skim"]["of"], rated.display()
|
||||
summary["full"]["right"],
|
||||
summary["full"]["of"],
|
||||
summary["skim"]["right"],
|
||||
summary["skim"]["of"],
|
||||
rated.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -92,9 +147,17 @@ fn lint(iris: &str, questions: &Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("running {iris} (pass --iris <path> if it is not on PATH)"))?;
|
||||
let text = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !output.status.success() {
|
||||
bail!("{iris} check --path {} {} failed:\n{text}", questions.display(), args.join(" "));
|
||||
bail!(
|
||||
"{iris} check --path {} {} failed:\n{text}",
|
||||
questions.display(),
|
||||
args.join(" ")
|
||||
);
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
+183
-37
@@ -127,7 +127,12 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
continue;
|
||||
}
|
||||
let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()];
|
||||
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
let done: Vec<&str> = need["done_when"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
for (run, route) in all_routes(bucket, &done).into_iter().enumerate() {
|
||||
let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()];
|
||||
states.extend(route.iter().map(|m| m.to.clone()));
|
||||
@@ -137,7 +142,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
run,
|
||||
target: states.last().cloned().unwrap_or_default(),
|
||||
record: BTreeMap::from([("note".to_string(), json!("dry run"))]),
|
||||
pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(),
|
||||
pressed: route
|
||||
.iter()
|
||||
.map(|m| format!("{} -> {}", m.from, m.label))
|
||||
.collect(),
|
||||
moved_by: route.iter().map(|m| m.by.clone()).collect(),
|
||||
touches: route.len(),
|
||||
ask_backs: Vec::new(),
|
||||
@@ -148,9 +156,16 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
|
||||
}
|
||||
}
|
||||
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
|
||||
std::fs::write(
|
||||
out.join("score.json"),
|
||||
serde_json::to_string_pretty(&scores)?,
|
||||
)?;
|
||||
let relays: usize = scores.values().map(|s| s.cases).sum();
|
||||
println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display());
|
||||
println!(
|
||||
"dry run: {relays} route(s) across {} need(s) written to {}",
|
||||
scores.len(),
|
||||
out.join("cases.jsonl").display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -180,7 +195,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
|
||||
}
|
||||
}
|
||||
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
|
||||
std::fs::write(
|
||||
out.join("score.json"),
|
||||
serde_json::to_string_pretty(&scores)?,
|
||||
)?;
|
||||
print_scores(&scores);
|
||||
Ok(())
|
||||
}
|
||||
@@ -257,7 +275,8 @@ fn play(
|
||||
);
|
||||
let reply = ask(visitor_cmd, &prompt, &seed(i * 10 + 1))?;
|
||||
if reply["abandon"] == true {
|
||||
case.outcome = Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
|
||||
case.outcome =
|
||||
Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string());
|
||||
return Ok(case);
|
||||
}
|
||||
if let Some(values) = reply["values"].as_object() {
|
||||
@@ -277,9 +296,18 @@ fn play(
|
||||
// collected, could this role really do what the button claims?
|
||||
let bucket_name = need["lands_in"].as_str().unwrap_or_default();
|
||||
let bucket = &model["buckets"][bucket_name];
|
||||
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
let done: Vec<&str> = need["done_when"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let initial = bucket["initial"].as_str().unwrap_or("open").to_string();
|
||||
let target = done.get(run % done.len().max(1)).copied().unwrap_or_default().to_string();
|
||||
let target = done
|
||||
.get(run % done.len().max(1))
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
case.target = target.clone();
|
||||
case.states.push(initial.clone());
|
||||
let Some(route) = route_to(bucket, &initial, &target) else {
|
||||
@@ -304,7 +332,11 @@ fn play(
|
||||
serde_json::to_string_pretty(&case.record)?,
|
||||
);
|
||||
let reply = ask(handler_cmd, &prompt, &seed(100 + step))?;
|
||||
let question = reply["need_to_know"].as_str().unwrap_or_default().trim().to_string();
|
||||
let question = reply["need_to_know"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if reply["can_act"] == false && !question.is_empty() {
|
||||
let answer = ask(
|
||||
visitor_cmd,
|
||||
@@ -314,7 +346,10 @@ fn play(
|
||||
&seed(200 + step),
|
||||
)?;
|
||||
let n = case.ask_backs.len() + 1;
|
||||
case.record.insert(format!("follow_up_{n}"), json!({ "asked": question, "answered": answer["answer"] }));
|
||||
case.record.insert(
|
||||
format!("follow_up_{n}"),
|
||||
json!({ "asked": question, "answered": answer["answer"] }),
|
||||
);
|
||||
case.ask_backs.push(AskBack {
|
||||
state: from.clone(),
|
||||
button: label.clone(),
|
||||
@@ -343,21 +378,47 @@ pub(crate) struct Move {
|
||||
/// desk, and the submitter's own link. Roles overlap on one aggregate,
|
||||
/// so a route is a relay, not one desk's work.
|
||||
pub(crate) fn all_moves(bucket: &Value) -> Vec<Move> {
|
||||
let legal = |from: &str, to: &str| bucket["transitions"][from].as_array().is_some_and(|a| a.iter().any(|x| x == to));
|
||||
let legal = |from: &str, to: &str| {
|
||||
bucket["transitions"][from]
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().any(|x| x == to))
|
||||
};
|
||||
let mut moves: Vec<Move> = Vec::new();
|
||||
for desk in bucket["desks"].as_array().into_iter().flatten() {
|
||||
for m in desk["moves"].as_array().into_iter().flatten() {
|
||||
let (Some(from), Some(label), Some(to)) = (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) else { continue };
|
||||
let (Some(from), Some(label), Some(to)) =
|
||||
(m["from"].as_str(), m["label"].as_str(), m["to"].as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) {
|
||||
moves.push(Move { from: from.into(), label: label.into(), to: to.into(), by: desk["group"].as_str().unwrap_or("ungated").into() });
|
||||
moves.push(Move {
|
||||
from: from.into(),
|
||||
label: label.into(),
|
||||
to: to.into(),
|
||||
by: desk["group"].as_str().unwrap_or("ungated").into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let states: Vec<String> = bucket["transitions"].as_object().map(|t| t.keys().cloned().collect()).unwrap_or_default();
|
||||
for to in bucket["self_moves"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
let states: Vec<String> = bucket["transitions"]
|
||||
.as_object()
|
||||
.map(|t| t.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
for to in bucket["self_moves"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
{
|
||||
for from in &states {
|
||||
if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) {
|
||||
moves.push(Move { from: from.clone(), label: "their own link".into(), to: to.into(), by: SUBMITTER.into() });
|
||||
moves.push(Move {
|
||||
from: from.clone(),
|
||||
label: "their own link".into(),
|
||||
to: to.into(),
|
||||
by: SUBMITTER.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,7 +449,13 @@ fn route_to(bucket: &Value, from: &str, to: &str) -> Option<Vec<Move>> {
|
||||
/// dry run walks, so that each button of each role is pressed at least
|
||||
/// once against the real engine.
|
||||
pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
|
||||
fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec<Move>, out: &mut Vec<Vec<Move>>) {
|
||||
fn walk(
|
||||
state: &str,
|
||||
done: &[&str],
|
||||
moves: &[Move],
|
||||
path: &mut Vec<Move>,
|
||||
out: &mut Vec<Vec<Move>>,
|
||||
) {
|
||||
if done.contains(&state) {
|
||||
out.push(path.clone());
|
||||
return;
|
||||
@@ -403,7 +470,13 @@ pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(bucket["initial"].as_str().unwrap_or("open"), done, &all_moves(bucket), &mut Vec::new(), &mut out);
|
||||
walk(
|
||||
bucket["initial"].as_str().unwrap_or("open"),
|
||||
done,
|
||||
&all_moves(bucket),
|
||||
&mut Vec::new(),
|
||||
&mut out,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -424,7 +497,11 @@ pub(crate) fn resolve_choice(names: &[&str], raw: &str) -> String {
|
||||
/// be (`FORMAT=json`, honoured by engines/ollama.sh) and the first
|
||||
/// object in the reply is taken either way.
|
||||
pub(crate) fn ask(cmd: &str, prompt: &str, seed: &str) -> anyhow::Result<Value> {
|
||||
let reply = llm_with(cmd, prompt, &[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")])?;
|
||||
let reply = llm_with(
|
||||
cmd,
|
||||
prompt,
|
||||
&[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")],
|
||||
)?;
|
||||
Ok(first_object(&reply).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
@@ -462,7 +539,11 @@ fn tally(score: &mut NeedScore, case: &Case) {
|
||||
score.buttons.entry(button.clone()).or_default().0 += 1;
|
||||
}
|
||||
for a in &case.ask_backs {
|
||||
score.buttons.entry(format!("{} -> {}", a.state, a.button)).or_default().1 += 1;
|
||||
score
|
||||
.buttons
|
||||
.entry(format!("{} -> {}", a.state, a.button))
|
||||
.or_default()
|
||||
.1 += 1;
|
||||
}
|
||||
match &case.outcome {
|
||||
Outcome::Finished(_) => score.finished += 1,
|
||||
@@ -476,7 +557,9 @@ fn tally(score: &mut NeedScore, case: &Case) {
|
||||
}
|
||||
Outcome::Abandoned(why) => {
|
||||
score.abandoned += 1;
|
||||
score.abandon_reasons.push(format!("{}: {why}", case.persona));
|
||||
score
|
||||
.abandon_reasons
|
||||
.push(format!("{}: {why}", case.persona));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +584,15 @@ fn per_case(n: usize, s: &NeedScore) -> String {
|
||||
fn print_scores(scores: &BTreeMap<String, NeedScore>) {
|
||||
println!(
|
||||
"\n{:<28} {:>5} {:>9} {:>9} {:>10} {:>9} {:>8} {:>10} {:>13}",
|
||||
"need", "cases", "finished", "misroute", "abandoned", "no route", "touches", "ask-backs", "unanswerable"
|
||||
"need",
|
||||
"cases",
|
||||
"finished",
|
||||
"misroute",
|
||||
"abandoned",
|
||||
"no route",
|
||||
"touches",
|
||||
"ask-backs",
|
||||
"unanswerable"
|
||||
);
|
||||
for (need, s) in scores {
|
||||
println!(
|
||||
@@ -529,7 +620,11 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
|
||||
" {need} [{} / {}]{} {}",
|
||||
a.state,
|
||||
a.button,
|
||||
if a.answerable { "" } else { " (visitor did not know)" },
|
||||
if a.answerable {
|
||||
""
|
||||
} else {
|
||||
" (visitor did not know)"
|
||||
},
|
||||
a.question
|
||||
);
|
||||
}
|
||||
@@ -550,10 +645,16 @@ fn print_scores(scores: &BTreeMap<String, NeedScore>) {
|
||||
pub fn compare(argv: &[String]) -> anyhow::Result<()> {
|
||||
let (a, b) = match (argv.get(1), argv.get(2)) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => return Err(anyhow!("usage: portal-trainer compare <a/score.json> <b/score.json>")),
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"usage: portal-trainer compare <a/score.json> <b/score.json>"
|
||||
))
|
||||
}
|
||||
};
|
||||
let load = |p: &String| -> anyhow::Result<BTreeMap<String, NeedScore>> {
|
||||
Ok(serde_json::from_str(&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?)?)
|
||||
Ok(serde_json::from_str(
|
||||
&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?,
|
||||
)?)
|
||||
};
|
||||
let (a, b) = (load(a)?, load(b)?);
|
||||
println!(
|
||||
@@ -565,11 +666,27 @@ pub fn compare(argv: &[String]) -> anyhow::Result<()> {
|
||||
println!(
|
||||
"{:<28} {:>14} {:>14} {:>14} {:>16} {:>16}",
|
||||
need,
|
||||
format!("{} -> {}", pct(sa.misrouted, sa.cases), pct(sb.misrouted, sb.cases)),
|
||||
format!("{} -> {}", pct(sa.abandoned, sa.cases), pct(sb.abandoned, sb.cases)),
|
||||
format!(
|
||||
"{} -> {}",
|
||||
pct(sa.misrouted, sa.cases),
|
||||
pct(sb.misrouted, sb.cases)
|
||||
),
|
||||
format!(
|
||||
"{} -> {}",
|
||||
pct(sa.abandoned, sa.cases),
|
||||
pct(sb.abandoned, sb.cases)
|
||||
),
|
||||
format!("{} -> {}", pct(sa.stuck, sa.cases), pct(sb.stuck, sb.cases)),
|
||||
format!("{} -> {}", per_case(sa.touches, sa), per_case(sb.touches, sb)),
|
||||
format!("{} -> {}", per_case(sa.ask_backs, sa), per_case(sb.ask_backs, sb)),
|
||||
format!(
|
||||
"{} -> {}",
|
||||
per_case(sa.touches, sa),
|
||||
per_case(sb.touches, sb)
|
||||
),
|
||||
format!(
|
||||
"{} -> {}",
|
||||
per_case(sa.ask_backs, sa),
|
||||
per_case(sb.ask_backs, sb)
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -589,8 +706,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_choice_is_matched_by_its_heading_however_the_model_dresses_it() {
|
||||
let names = ["Ask about working", "Ask about working with us", "Get a price"];
|
||||
assert_eq!(resolve_choice(&names, "Ask about working with us: For carpenters who..."), "Ask about working with us");
|
||||
let names = [
|
||||
"Ask about working",
|
||||
"Ask about working with us",
|
||||
"Get a price",
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_choice(&names, "Ask about working with us: For carpenters who..."),
|
||||
"Ask about working with us"
|
||||
);
|
||||
assert_eq!(resolve_choice(&names, " get a PRICE "), "Get a price");
|
||||
assert_eq!(resolve_choice(&names, "Something else"), "Something else");
|
||||
}
|
||||
@@ -620,7 +744,10 @@ mod tests {
|
||||
// Losing a quoted job is the other group's button, so the way
|
||||
// from open to that ending is a relay: two roles, one case.
|
||||
let route = route_to(&bucket(), "surveyed", "lost").unwrap();
|
||||
let relay: Vec<(&str, &str)> = route.iter().map(|m| (m.label.as_str(), m.by.as_str())).collect();
|
||||
let relay: Vec<(&str, &str)> = route
|
||||
.iter()
|
||||
.map(|m| (m.label.as_str(), m.by.as_str()))
|
||||
.collect();
|
||||
assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]);
|
||||
assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0);
|
||||
assert!(route_to(&bucket(), "won", "open").is_none());
|
||||
@@ -630,7 +757,10 @@ mod tests {
|
||||
fn the_submitters_own_link_is_a_move_like_any_other() {
|
||||
let mut b = bucket();
|
||||
b["self_moves"] = json!(["lost"]);
|
||||
let theirs: Vec<Move> = all_moves(&b).into_iter().filter(|m| m.by == SUBMITTER).collect();
|
||||
let theirs: Vec<Move> = all_moves(&b)
|
||||
.into_iter()
|
||||
.filter(|m| m.by == SUBMITTER)
|
||||
.collect();
|
||||
// Only where the machine allows it and no desk already holds it:
|
||||
// open->lost is the project manager's and quoted->lost the other
|
||||
// group's, which leaves surveyed->lost to the link.
|
||||
@@ -641,7 +771,10 @@ mod tests {
|
||||
#[test]
|
||||
fn a_dry_run_walks_every_route_to_every_ending() {
|
||||
let routes = all_routes(&bucket(), &["won", "lost"]);
|
||||
let ends: Vec<String> = routes.iter().map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)).collect();
|
||||
let ends: Vec<String> = routes
|
||||
.iter()
|
||||
.map(|r| format!("{}:{}", r.len(), r.last().unwrap().to))
|
||||
.collect();
|
||||
// open->lost; open->surveyed->quoted->won; and the relay
|
||||
// open->surveyed->quoted->lost, closed by the other group.
|
||||
assert_eq!(ends.len(), 3, "{ends:?}");
|
||||
@@ -664,15 +797,28 @@ mod tests {
|
||||
moved_by: vec!["pm".into(), "pm".into()],
|
||||
touches: 3,
|
||||
ask_backs: vec![
|
||||
AskBack { state: "open".into(), button: "Looked".into(), question: "Which floor?".into(), answerable: true },
|
||||
AskBack { state: "surveyed".into(), button: "Quote sent".into(), question: "Load-bearing?".into(), answerable: false },
|
||||
AskBack {
|
||||
state: "open".into(),
|
||||
button: "Looked".into(),
|
||||
question: "Which floor?".into(),
|
||||
answerable: true,
|
||||
},
|
||||
AskBack {
|
||||
state: "surveyed".into(),
|
||||
button: "Quote sent".into(),
|
||||
question: "Load-bearing?".into(),
|
||||
answerable: false,
|
||||
},
|
||||
],
|
||||
outcome,
|
||||
};
|
||||
tally(&mut score, &base(Outcome::Finished("won".into())));
|
||||
tally(&mut score, &base(Outcome::Stuck("no route".into())));
|
||||
tally(&mut score, &base(Outcome::Misrouted("Wrong door".into())));
|
||||
assert_eq!((score.cases, score.finished, score.stuck, score.misrouted), (3, 1, 1, 1));
|
||||
assert_eq!(
|
||||
(score.cases, score.finished, score.stuck, score.misrouted),
|
||||
(3, 1, 1, 1)
|
||||
);
|
||||
assert_eq!((score.ask_backs, score.unanswerable), (6, 3));
|
||||
}
|
||||
}
|
||||
|
||||
+44
-12
@@ -15,7 +15,7 @@ use serde::Serialize;
|
||||
|
||||
use crate::server::AppState;
|
||||
|
||||
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
|
||||
pub const MAX_UPLOAD_BYTES: usize = crate::app::MAX_UPLOAD_BYTES;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Garage {
|
||||
@@ -130,13 +130,18 @@ pub async fn upload(
|
||||
}
|
||||
}
|
||||
|
||||
let (filename, content_type, bytes) =
|
||||
file.ok_or((StatusCode::BAD_REQUEST, "no file field in request".to_string()))?;
|
||||
let (filename, content_type, bytes) = file.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"no file field in request".to_string(),
|
||||
))?;
|
||||
|
||||
// Nothing has been written yet. Everything below decides whether
|
||||
// anything will be.
|
||||
if !may_upload(&state, &session, &question_id, &alternative).await {
|
||||
return Err((StatusCode::FORBIDDEN, "not authorized to upload here".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"not authorized to upload here".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
|
||||
@@ -235,7 +240,10 @@ alternatives:
|
||||
#[test]
|
||||
fn an_upload_must_name_a_page_that_asked_for_a_file() {
|
||||
let qs = questions(PAGE);
|
||||
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), Some("/".to_string()));
|
||||
assert_eq!(
|
||||
page_taking_files(&qs, "/", "Send drawings"),
|
||||
Some("/".to_string())
|
||||
);
|
||||
// An alternative on the same page that takes no file.
|
||||
assert_eq!(page_taking_files(&qs, "/", "Just ask"), None);
|
||||
// Names that are not there at all.
|
||||
@@ -246,7 +254,10 @@ alternatives:
|
||||
|
||||
#[test]
|
||||
fn a_closed_alternative_takes_nothing() {
|
||||
let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n"));
|
||||
let qs = questions(&PAGE.replace(
|
||||
" record_as: enquiries\n",
|
||||
" record_as: enquiries\n disabled: true\n",
|
||||
));
|
||||
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), None);
|
||||
}
|
||||
}
|
||||
@@ -277,15 +288,24 @@ pub async fn attachment(
|
||||
// cannot be used to learn which buckets, records or fields exist.
|
||||
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
|
||||
|
||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
|
||||
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
|
||||
let store = state
|
||||
.jetstream
|
||||
.get_key_value(&bucket)
|
||||
.await
|
||||
.map_err(|_| missing())?;
|
||||
let bytes = store
|
||||
.get(&record)
|
||||
.await
|
||||
.map_err(|_| missing())?
|
||||
.ok_or_else(missing)?;
|
||||
let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?;
|
||||
// Through a list the caller may read, and one that shows this
|
||||
// record: a list of "my cases" opens my files and nobody else's.
|
||||
if !may_read_record(&state, &session, &bucket, &as_listed).await {
|
||||
return Err(missing());
|
||||
}
|
||||
let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?;
|
||||
let answer: crate::answers::Answer =
|
||||
serde_json::from_value(as_listed).map_err(|_| missing())?;
|
||||
let key = answer
|
||||
.responses
|
||||
.get(&field)
|
||||
@@ -310,13 +330,22 @@ pub async fn attachment(
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))?
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("reading the file failed: {e}"),
|
||||
)
|
||||
})?
|
||||
.into_bytes();
|
||||
|
||||
// The stored key is `<uuid>-<original name>`; the person who gets
|
||||
// the file should see the name they sent, not the uuid.
|
||||
let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key);
|
||||
let filename = filename.rsplit('/').next().unwrap_or(filename).replace(['"', '\\'], "");
|
||||
let filename = filename
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(filename)
|
||||
.replace(['"', '\\'], "");
|
||||
Ok((
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type),
|
||||
@@ -349,7 +378,10 @@ async fn may_read_record(
|
||||
question.alternatives.iter().any(|alternative| {
|
||||
alternative.features.iter().any(|feature| {
|
||||
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
|
||||
&& feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, ¶ms))
|
||||
&& feature
|
||||
.resource
|
||||
.as_ref()
|
||||
.is_some_and(|r| crate::resource::matches_filter(r, record, ¶ms))
|
||||
&& access.allows(
|
||||
&principal,
|
||||
crate::access::Action::Read,
|
||||
|
||||
+118
-9
@@ -162,10 +162,13 @@ main.not-found {
|
||||
|
||||
/* hero */
|
||||
|
||||
/* No room is kept at the top for who is here: that sits on the
|
||||
wordmark's own line now (.hero-top), and the 4rem it used to need
|
||||
read as dead space above the mark on a tall, narrow screen. */
|
||||
.hero {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 2.5rem;
|
||||
padding: 1.75rem 1.5rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -176,10 +179,6 @@ main.not-found {
|
||||
/* Who is here and the way in or out: top right of every page, on a
|
||||
site that has sign-in. */
|
||||
.session {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.5rem;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
align-items: baseline;
|
||||
@@ -201,6 +200,55 @@ main.not-found {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* The wordmark's line, with who is here on it: the mark centred and the
|
||||
session at the right edge, level with the mark. A skin that sets
|
||||
everything on one left edge spreads the two apart instead (see
|
||||
Klingenberg Bygg's and Gullberg's). On a narrow screen the session
|
||||
drops under the mark rather than over it. */
|
||||
.hero-top {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-top .session {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.hero-bar .hero-top {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-bar .hero-top .session {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.hero {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-bar {
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-top .session {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A page with pictures: the wordmark is a bar of its own, the strip
|
||||
runs the full width under it, and the question follows. */
|
||||
.hero-slides {
|
||||
@@ -244,6 +292,20 @@ main.not-found {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* A wordmark in the page's ink (site.yaml `wordmark_ink`): the file is a
|
||||
mask over the text colour. The invisible image inside gives the span
|
||||
the file's own size. */
|
||||
.wordmark-ink-mark {
|
||||
display: block;
|
||||
background: var(--ink);
|
||||
-webkit-mask: var(--wordmark-src) center / contain no-repeat;
|
||||
mask: var(--wordmark-src) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.wordmark-ink-mark img {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
@@ -1218,6 +1280,31 @@ 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;
|
||||
}
|
||||
|
||||
/* A releases manifest: the pick for this device, the rest folded. */
|
||||
.releases .pill { display: inline-block; }
|
||||
.releases pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0.4rem 0 0; }
|
||||
.releases-all summary { color: var(--ink-dim); font-size: 0.9rem; cursor: pointer; }
|
||||
.releases-all ul { margin: 0.4rem 0 0 1rem; font-size: 0.9rem; }
|
||||
|
||||
.show-older:hover {
|
||||
color: inherit;
|
||||
border-color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.answer-row {
|
||||
border: 0.06rem solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
@@ -1300,14 +1387,36 @@ textarea:focus {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.answer-card-text p {
|
||||
margin: 0;
|
||||
.card-field {
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink-dim);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.answer-card-text p:first-of-type {
|
||||
.card-field > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card-field > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-field p,
|
||||
.card-field ul,
|
||||
.card-field ol {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.card-field ul,
|
||||
.card-field ol {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.card-field a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The field right under the name - a role - reads as part of the name. */
|
||||
.answer-card-text .card-field:first-of-type {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
+223
-45
@@ -34,27 +34,51 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
// The submission: a KV record in the initial state, plus the first event.
|
||||
store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now, 0)
|
||||
.await
|
||||
.expect("store");
|
||||
store_answer(
|
||||
&js,
|
||||
&schemas,
|
||||
&bucket,
|
||||
"rec-1".into(),
|
||||
"/",
|
||||
"Hire us",
|
||||
&json!({"email": "a@b.example"}),
|
||||
now,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.expect("store");
|
||||
let store = js.get_key_value(&bucket).await.expect("bucket exists");
|
||||
let stored: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||
let stored: Answer =
|
||||
serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||
assert_eq!(stored.state, "open");
|
||||
assert_eq!(stored.responses["email"], "a@b.example");
|
||||
|
||||
// A desk move the machine allows.
|
||||
let moved = aggregates::transition(&js, schema, "rec-1", "quoted", json!({"by": "test"}), now + 1)
|
||||
.await
|
||||
.expect("open -> quoted is declared");
|
||||
let moved = aggregates::transition(
|
||||
&js,
|
||||
schema,
|
||||
"rec-1",
|
||||
"quoted",
|
||||
json!({"by": "test"}),
|
||||
now + 1,
|
||||
)
|
||||
.await
|
||||
.expect("open -> quoted is declared");
|
||||
assert_eq!(moved.state, "quoted");
|
||||
|
||||
// Moves the machine does not allow are refused, whoever asks.
|
||||
let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await;
|
||||
assert!(matches!(back, Err(TransitionError::DisallowedTransition { .. })), "{back:?}");
|
||||
assert!(
|
||||
matches!(back, Err(TransitionError::DisallowedTransition { .. })),
|
||||
"{back:?}"
|
||||
);
|
||||
let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await;
|
||||
assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}");
|
||||
let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await;
|
||||
assert!(matches!(nobody, Err(TransitionError::UnknownAggregate)), "{nobody:?}");
|
||||
assert!(
|
||||
matches!(nobody, Err(TransitionError::UnknownAggregate)),
|
||||
"{nobody:?}"
|
||||
);
|
||||
|
||||
// The record reads back from its own event log, in the state the
|
||||
// accepted moves left it, with a sequence that moved forward.
|
||||
@@ -67,7 +91,8 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
||||
// A reply to the mail this record was sent becomes a note on it,
|
||||
// through the same store the desks read - and moves nothing: the
|
||||
// record is still where the desk left it.
|
||||
let mut record: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||
let mut record: Answer =
|
||||
serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||
let state_before_the_note = record.state.clone();
|
||||
let reply = portal::mail::Incoming {
|
||||
v: portal::mail::CONTRACT,
|
||||
@@ -81,13 +106,20 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
||||
};
|
||||
let note = portal::mail::note_for(&reply, &record).expect("the address matches the record");
|
||||
record.notes.push(note);
|
||||
store.put("rec-1", serde_json::to_vec(&record).unwrap().into()).await.expect("note stored");
|
||||
store
|
||||
.put("rec-1", serde_json::to_vec(&record).unwrap().into())
|
||||
.await
|
||||
.expect("note stored");
|
||||
|
||||
let read_back: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||
let read_back: Answer =
|
||||
serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
||||
assert_eq!(read_back.notes.len(), 1);
|
||||
assert_eq!(read_back.notes[0].text, "Thursday suits us.");
|
||||
assert_eq!(read_back.notes[0].from, "a@b.example");
|
||||
assert_eq!(read_back.state, state_before_the_note, "a reply is a note, never a decision");
|
||||
assert_eq!(
|
||||
read_back.state, state_before_the_note,
|
||||
"a reply is a note, never a decision"
|
||||
);
|
||||
// The same reply read twice adds nothing.
|
||||
assert!(portal::mail::note_for(&reply, &read_back).is_err());
|
||||
// And the event log is untouched by it.
|
||||
@@ -122,17 +154,39 @@ async fn a_session_survives_the_process_that_made_it() {
|
||||
|
||||
// A second store over the same JetStream is what the next boot of
|
||||
// the binary has: nothing carried over in memory.
|
||||
let next_boot = portal::sessions::NatsStore::open(&js).await.expect("reopen");
|
||||
let found = next_boot.load(&record.id).await.expect("load").expect("still there");
|
||||
let next_boot = portal::sessions::NatsStore::open(&js)
|
||||
.await
|
||||
.expect("reopen");
|
||||
let found = next_boot
|
||||
.load(&record.id)
|
||||
.await
|
||||
.expect("load")
|
||||
.expect("still there");
|
||||
assert_eq!(found.data.get("who"), Some(&json!("ingrid")));
|
||||
|
||||
// `create` never writes over somebody else's session: asked to
|
||||
// create one whose id is taken, it takes a new id instead.
|
||||
let mut collide = Record { id: record.id, data: Default::default(), expiry_date: record.expiry_date };
|
||||
next_boot.create(&mut collide).await.expect("create beside it");
|
||||
assert_ne!(collide.id, record.id, "an id already in use must not be reused");
|
||||
let mut collide = Record {
|
||||
id: record.id,
|
||||
data: Default::default(),
|
||||
expiry_date: record.expiry_date,
|
||||
};
|
||||
next_boot
|
||||
.create(&mut collide)
|
||||
.await
|
||||
.expect("create beside it");
|
||||
assert_ne!(
|
||||
collide.id, record.id,
|
||||
"an id already in use must not be reused"
|
||||
);
|
||||
assert_eq!(
|
||||
next_boot.load(&record.id).await.expect("load").expect("original intact").data.get("who"),
|
||||
next_boot
|
||||
.load(&record.id)
|
||||
.await
|
||||
.expect("load")
|
||||
.expect("original intact")
|
||||
.data
|
||||
.get("who"),
|
||||
Some(&json!("ingrid")),
|
||||
"the original session must be untouched"
|
||||
);
|
||||
@@ -146,11 +200,19 @@ async fn a_session_survives_the_process_that_made_it() {
|
||||
- tower_sessions::cookie::time::Duration::minutes(1),
|
||||
};
|
||||
next_boot.save(&stale).await.expect("save stale");
|
||||
assert!(next_boot.load(&stale.id).await.expect("load stale").is_none());
|
||||
assert!(next_boot
|
||||
.load(&stale.id)
|
||||
.await
|
||||
.expect("load stale")
|
||||
.is_none());
|
||||
|
||||
// Signing out leaves nothing to read back.
|
||||
next_boot.delete(&record.id).await.expect("delete");
|
||||
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
|
||||
assert!(next_boot
|
||||
.load(&record.id)
|
||||
.await
|
||||
.expect("load after delete")
|
||||
.is_none());
|
||||
next_boot.delete(&collide.id).await.ok();
|
||||
}
|
||||
|
||||
@@ -175,12 +237,20 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
||||
let questions = std::collections::HashMap::new();
|
||||
let access = portal::access::Policy::from_content(&questions, &aggregates);
|
||||
let state = portal::server::AppState {
|
||||
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
|
||||
leptos_options: leptos::config::LeptosOptions::builder()
|
||||
.output_name("test")
|
||||
.build(),
|
||||
nats,
|
||||
jetstream: js,
|
||||
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(questions))),
|
||||
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(aggregates))),
|
||||
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(Default::default()))),
|
||||
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||
portal::content::Storylines::one(questions),
|
||||
)),
|
||||
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||
portal::content::Storylines::one(aggregates),
|
||||
)),
|
||||
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||
portal::content::Storylines::one(Default::default()),
|
||||
)),
|
||||
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
|
||||
gitea_base: String::new(),
|
||||
content_raw_base: String::new(),
|
||||
@@ -198,17 +268,37 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
||||
|
||||
// A desk invites Kari by phone number; a decision grants her a
|
||||
// second group by address. Both are invitations until she is seen.
|
||||
let by_phone = Identity { sub: None, phone: Some("+4791234567".into()), email: None };
|
||||
let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) };
|
||||
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant");
|
||||
let by_phone = Identity {
|
||||
sub: None,
|
||||
phone: Some("+4791234567".into()),
|
||||
email: None,
|
||||
};
|
||||
let by_mail = Identity {
|
||||
sub: None,
|
||||
phone: None,
|
||||
email: Some("kari@vel.no".into()),
|
||||
};
|
||||
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk")
|
||||
.await
|
||||
.expect("grant");
|
||||
assert!(first.created && first.reset_link.is_none());
|
||||
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again");
|
||||
assert!(!again.created, "a second invite into the same group finds the first");
|
||||
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail");
|
||||
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk")
|
||||
.await
|
||||
.expect("grant again");
|
||||
assert!(
|
||||
!again.created,
|
||||
"a second invite into the same group finds the first"
|
||||
);
|
||||
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc")
|
||||
.await
|
||||
.expect("grant by mail");
|
||||
let records = memberships::all(&state).await;
|
||||
assert_eq!(records.len(), 2);
|
||||
assert!(records.iter().all(|r| r.state == INVITED));
|
||||
assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group");
|
||||
assert!(
|
||||
memberships::groups_of(&records, &by_phone).is_empty(),
|
||||
"an invitation is not yet a group"
|
||||
);
|
||||
|
||||
// She signs in: the provider knows her subject, phone and address.
|
||||
let kari = portal::auth::User {
|
||||
@@ -233,18 +323,47 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
||||
}
|
||||
|
||||
// A desk takes her out of styret; the record stays, as removed.
|
||||
assert!(memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke"));
|
||||
assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out");
|
||||
assert!(memberships::revoke(&state, styret, &by_phone, "ola")
|
||||
.await
|
||||
.expect("revoke"));
|
||||
assert!(
|
||||
!memberships::revoke(&state, styret, &by_phone, "ola")
|
||||
.await
|
||||
.expect("revoke twice"),
|
||||
"already out"
|
||||
);
|
||||
let records = memberships::all(&state).await;
|
||||
let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap();
|
||||
let styret_record = records
|
||||
.iter()
|
||||
.find(|r| memberships::group_of(r) == styret)
|
||||
.unwrap();
|
||||
assert_eq!(styret_record.state, REMOVED);
|
||||
assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]);
|
||||
assert_eq!(
|
||||
memberships::groups_of(
|
||||
&records,
|
||||
&Identity {
|
||||
sub: Some("vipps-abc".into()),
|
||||
phone: None,
|
||||
email: None
|
||||
}
|
||||
),
|
||||
vec![medlemmer]
|
||||
);
|
||||
|
||||
// Invited again: the same record, invited, and hers at the next sign-in.
|
||||
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk").await.expect("re-grant");
|
||||
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk")
|
||||
.await
|
||||
.expect("re-grant");
|
||||
assert!(back.created);
|
||||
let records = memberships::all(&state).await;
|
||||
assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED);
|
||||
assert_eq!(
|
||||
records
|
||||
.iter()
|
||||
.find(|r| memberships::group_of(r) == styret)
|
||||
.unwrap()
|
||||
.state,
|
||||
INVITED
|
||||
);
|
||||
let groups = memberships::on_signed_in(&state, &kari).await;
|
||||
assert!(groups.contains(&styret.to_string()));
|
||||
}
|
||||
@@ -265,15 +384,74 @@ async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
|
||||
|
||||
// Kari's first card, then two sends of her second pressed at once,
|
||||
// and Ola's card beside them.
|
||||
store_answer(&js, &schemas, &bucket, "k1".into(), "/kontor/ansatt", "a", &card("Kari", "kari"), now, 0).await.unwrap();
|
||||
store_answer(&js, &schemas, &bucket, "o1".into(), "/kontor/ansatt", "a", &card("Ola", "ola"), now + 1, 0).await.unwrap();
|
||||
store_answer(&js, &schemas, &bucket, "k2".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
|
||||
store_answer(&js, &schemas, &bucket, "k3".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
|
||||
store_answer(
|
||||
&js,
|
||||
&schemas,
|
||||
&bucket,
|
||||
"k1".into(),
|
||||
"/kontor/ansatt",
|
||||
"a",
|
||||
&card("Kari", "kari"),
|
||||
now,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store_answer(
|
||||
&js,
|
||||
&schemas,
|
||||
&bucket,
|
||||
"o1".into(),
|
||||
"/kontor/ansatt",
|
||||
"a",
|
||||
&card("Ola", "ola"),
|
||||
now + 1,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store_answer(
|
||||
&js,
|
||||
&schemas,
|
||||
&bucket,
|
||||
"k2".into(),
|
||||
"/kontor/ansatt",
|
||||
"a",
|
||||
&card("Kari N.", "kari"),
|
||||
now + 2,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store_answer(
|
||||
&js,
|
||||
&schemas,
|
||||
&bucket,
|
||||
"k3".into(),
|
||||
"/kontor/ansatt",
|
||||
"a",
|
||||
&card("Kari N.", "kari"),
|
||||
now + 2,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Both racing sends settle after storing; both must keep the same one.
|
||||
let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari"));
|
||||
let (a, b) = tokio::join!(
|
||||
keep_newest_own(&js, &bucket, "open", "kari"),
|
||||
keep_newest_own(&js, &bucket, "open", "kari")
|
||||
);
|
||||
let _ = (a, b);
|
||||
|
||||
let kari = own_records(&js, &bucket, "kari").await;
|
||||
assert_eq!(kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), vec!["k3"], "one card, the newest");
|
||||
assert_eq!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched");
|
||||
assert_eq!(
|
||||
kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["k3"],
|
||||
"one card, the newest"
|
||||
);
|
||||
assert_eq!(
|
||||
own_records(&js, &bucket, "ola").await.len(),
|
||||
1,
|
||||
"nobody else's card is touched"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user