Compare commits

..
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5.5 e6c4f67db7 Who a business is gets a page of its own; inside forms ask real questions; build names untried needs
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:52:23 +02:00
Bendik Aagaard Lynghaug 19b7750292 Release 0.6.9
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m54s
2026-09-30 21:38:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 1a1723f8bb Changelog: 0.6.9
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:56 +02:00
bl 0d67675456 Merge pull request 'A bio is markdown, on stricter rules' (#18) from bio-markdown into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-09-30 19:38:55 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 eb982ec6e6 A bio is markdown, on stricter rules than the site's own
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:38:54 +02:00
Bendik Aagaard Lynghaug c6dffc384c Release 0.6.8
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 2m0s
2026-09-30 21:08:33 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db0972974f Changelog: 0.6.8
docs / deploy (push) Successful in 4s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:30 +02:00
bl 72faf44559 Merge pull request 'An upload that did not arrive says why' (#17) from upload-messages into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 19:08:30 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db89ea6400 An upload that did not arrive says why
Test / test (pull_request) Successful in 1m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 21:08:28 +02:00
Bendik Aagaard Lynghaug 95bffc221b Release 0.6.7
Test / test (push) Successful in 1m19s
Publish release / publish (push) Successful in 2m8s
2026-09-30 20:50:44 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 f5266cb347 Changelog: 0.6.7
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:42 +02:00
bl 248561e805 Merge pull request 'leaves_after: finished records leave their lists' (#16) from leaves-after into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
2026-09-30 18:50:41 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0dc25b4023 A record may leave its lists some time after reaching a state: leaves_after
Test / test (pull_request) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:50:28 +02:00
Bendik Aagaard Lynghaug 96c513d912 Release 0.6.6
Test / test (push) Successful in 1m16s
Publish release / publish (push) Successful in 2m7s
2026-09-30 20:39:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0cb1dda202 Changelog: 0.6.6
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:10 +02:00
bl ec9bf10b72 Merge pull request 'A card of one's own; no double sends; refusals shown' (#15) from own-card into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m14s
2026-09-30 18:39:09 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 809a6bdfe8 A card of one's own: one_per_person, prefilled, replaced on send; no double sends; refusals are shown
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:39:01 +02:00
Bendik Aagaard Lynghaug cae116b50c Release 0.6.5
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m54s
2026-09-30 20:10:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d8528ff1f9 Changelog: 0.6.5
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:10:56 +02:00
bl 6c2e0cc299 Merge pull request 'A need may be paused' (#14) from paused-needs into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
2026-09-30 18:10:56 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9368086d81 A need may be paused: its card stays, with no form and a button that does not press
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:09:14 +02:00
Bendik Aagaard Lynghaug e2497941c8 Release 0.6.4
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m55s
2026-09-30 19:52:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 55fc61c514 Changelog: 0.6.4
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:52:10 +02:00
bl 3b17e6440e Merge pull request 'Rate limit: 100 at once, 10 a second; the page's own files uncounted' (#13) from rate-limit into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
2026-09-30 17:52:09 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 87d4dda0eb Rate limit: a hundred at once, ten a second, and the page's own files are not counted
Test / test (pull_request) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:51:48 +02:00
Bendik Aagaard Lynghaug 3604683df3 Release 0.6.3
Test / test (push) Successful in 1m8s
Publish release / publish (push) Successful in 1m55s
2026-09-30 19:33:11 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 1193894b02 Changelog: 0.6.3
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:33:08 +02:00
bl 574d0bfe71 Merge pull request 'Pages move the way their addresses do; picture cards; who we are' (#12) from people-and-motion into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
2026-09-30 17:33:07 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 309e30f36d Pages move the way their addresses do; a record with a picture is a card; the trainer says who a business is
Test / test (pull_request) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:32:49 +02:00
bl 748241709a Merge pull request 'Klingenberg Bygg's plan; a plan carries a slideshow' (#11) from klingenberg-plan into main
Test / test (push) Successful in 1m15s
2026-09-30 15:56:50 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 a70690a736 Klingenberg Bygg's plan and words; a plan carries a slideshow; the words model is told what a follower's desk and an inside sent page are
Test / test (pull_request) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:56:24 +02:00
Bendik Aagaard Lynghaug 827a75be3d Release 0.6.2
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m14s
Publish release / publish (push) Successful in 2m3s
2026-09-30 17:39:57 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 4e0a56be13 Changelog: 0.6.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:39:55 +02:00
bl 04204bef23 Merge pull request 'The header knows who is here; my cases; controls that scale' (#10) from header into main
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m14s
2026-09-30 15:39:54 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c766dedc0e A stored file on a record shows as its name and opens from the row
Test / test (pull_request) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:38:32 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 a822f2175f Trainer: followed_by - a group whose members each see their own cases; file fields
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:36:08 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 f2b131ebbb KANIDM_GROUP_PREFIX: plain group names in content on a shared Kanidm
Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:34:21 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 6b72ae568a The header knows who is here; my cases; controls that scale with the text
Test / test (pull_request) Successful in 1m12s
A session link top right on a site with sign-in, and on a page with
pictures the wordmark as its own bar over the strip. A list may be
narrowed to the signed-in person's own records ({user.email}, from the
session), and an attachment follows the list that shows its record.
Form controls are sized in em: the date picker's icon and the file
picker's button scale and take the field's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:32:41 +02:00
Bendik Aagaard Lynghaug 9a96386444 Release 0.6.1
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m7s
Publish release / publish (push) Successful in 1m46s
2026-09-30 17:19:36 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 5c95b6d8b3 Changelog: 0.6.1; the bare binary sorts after the tarball
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:19:34 +02:00
bl d3a9e79351 Merge pull request 'A slideshow across a page' (#9) from slideshow into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m8s
2026-09-30 15:19:33 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c1a8433640 Slideshow: brace the closure
Test / test (pull_request) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:17:55 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 cfb050d3ef A slideshow across a page: pictures the full width of the screen, captions over them
Test / test (pull_request) Failing after 1m5s
slideshow on a page: max_height in svh, slides with an image (a path
in the content repo or https), a caption laid over the picture's foot,
and optionally the picture's size so its place is held before it
loads. Swiper upgrades it; before that it is a row that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:16:50 +02:00
bl fc708c3546 Merge pull request 'Trainer: documents - a text word for word, one card per section' (#8) from documents-as-cards into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m7s
2026-09-30 15:06:28 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 c7e0ee146f Trainer: documents - a text word for word, one card per section
Test / test (pull_request) Successful in 1m8s
Bylaws and terms are not the model's to write. documents in a plan
names a markdown file beside it; every heading with text under it is
one alternative on a page of its own, its paragraphs the card's words.
Tomter Vel's bylaws of 23.04.2018 are the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 17:05:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3bb517be0f Docs: vel.klingenbergbygg.no runs v0.6.0
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 8s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:40:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4eb1d03583 Changelog: 0.5.11 was tagged and never published
docs / deploy (push) Successful in 2s
Test / test (push) Failing after 35s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:38:53 +02:00
33 changed files with 3065 additions and 129 deletions
+4 -2
View File
@@ -71,7 +71,9 @@ jobs:
cp -r target/site "$stage/site" cp -r target/site "$stage/site"
tar -C "$stage" -czf "portal-$tag.tar.gz" portal hash.txt site tar -C "$stage" -czf "portal-$tag.tar.gz" portal hash.txt site
# The bare binary as well: a content repo's CI runs `portal lint` # The bare binary as well: a content repo's CI runs `portal lint`
# and needs nothing else from the release. # and needs nothing else from the release. Named to sort after
# the tarball: Gitea lists assets by name, and deploy scripts
# written before 0.6 take the first one.
cp "$stage/portal" portal-bin cp "$stage/portal" portal-bin
rm -rf "$stage" rm -rf "$stage"
@@ -101,5 +103,5 @@ jobs:
"$api/releases/$id/assets?name=portal-$tag.tar.gz" > /dev/null "$api/releases/$id/assets?name=portal-$tag.tar.gz" > /dev/null
curl -sf -X POST -H "$auth" \ curl -sf -X POST -H "$auth" \
-F "attachment=@portal-bin" \ -F "attachment=@portal-bin" \
"$api/releases/$id/assets?name=portal" > /dev/null "$api/releases/$id/assets?name=portal-x86_64-linux" > /dev/null
echo "published $tag" echo "published $tag"
+61 -2
View File
@@ -4,11 +4,70 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file). content repos pin the tag they run (see the end of this file).
## Unreleased
- 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.
## 0.6.6 (2026-09-30)
- `one_per_person: true` on an alternative: each signed-in person keeps one record there, their own. The form opens filled in with it, a file left unchosen keeps the one already there (shown beside the picker), and sending again replaces the card instead of adding one. Two sends at once settle on the newest. Trainer: `one_per_person` on a need entered from inside; Klingenberg Bygg's people each edit their own card on the front page.
- Send is disabled from the moment it is pressed. Files upload before the form is sent, and a second press in that time used to send it twice.
- A failed upload stops the send and says so. The form used to go without the file, which made a record that looked complete and was not.
- A refused send says why, under its button. It used to say nothing.
## 0.6.5 (2026-09-30)
- Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity.
## 0.6.4 (2026-09-30)
- The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it.
## 0.6.3 (2026-09-30)
- Pages move the way their addresses do. Deeper in the address comes in from the right, back up comes in from the left, and a page at the same depth comes from below or above, with the index on top. The cards on screen leave as layers of their own, one after another, and the next page's cards arrive the same way. The old page stays on screen until the new one has loaded, where it used to go blank in between. Only transforms and opacity are animated, through the browser's View Transitions (`public/page-motion.js`, before the router). Browsers without them, and anyone who asks for reduced motion, navigate as before.
- A record that carries a picture, in a list with no buttons, is shown as a card: the picture, the first field as the heading, the rest under it, with no field names, state or time. Cards are shown in the order they were put up. On a desk, the same picture is a thumbnail.
- Trainer: `values` in a plan and `presents: true` on a need make a "who we are" card on the front door. The business's own statements are carried word for word, beside every record the need presents: the people who work there, entered from inside, each with a `picture` field (a file that only takes JPEG, PNG or WebP) that shows as their portrait. A public list keeps the form's field order, so the first field heads each row.
## 0.6.2 (2026-09-30)
- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after.
- "My cases": `where: { field: email, equals: "{user.email}" }` lists the records that carry the signed-in person's own address, filled from the session and never from the caller; nobody signed in matches nothing. An attachment is fetched only through a list that shows its record, so a list of my cases opens my files and nobody else's.
- A stored file on a listed record shows as the name it was sent with and opens from the row, for whoever the list shows the record to. It used to show as its storage key, a string nobody could open.
- Form controls scale with the text around them (em, not rem): fields, pills, buttons and labels follow a site's body size. The date picker's calendar icon is the size of the text beside it, and the file picker's button takes the field's colours instead of the browser's.
- `KANIDM_GROUP_PREFIX`: a site on a Kanidm it shares with others keeps plain group names in its content; the prefix is put on when portal adds or removes a member, and the client's claim map takes it off.
- Fixed: `maps` never reached the browser (`serde(skip)`), so a page with a map drew it on the server and dropped it on hydration.
## 0.6.1 (2026-09-30)
- Trainer: `documents` in a plan. A text the site carries word for word (bylaws, terms) becomes a page of its own where every section is one alternative: its heading the card's, its paragraphs the card's words, from a markdown file beside the plan. No model touches it.
- `slideshow` on a page: a strip of pictures the full width of the screen under the question, `max_height` in svh, each picture with its caption laid over it. Swiper when the bundle has loaded, a sideways-scrolling row before. `/site/` serves `jpeg`, and lets a browser keep photographs for an hour.
- The release's bare binary is named `portal-x86_64-linux` (it was `portal` in 0.6.0 only). Gitea lists assets by name, and deploy scripts that take the first asset got the binary instead of the tarball.
## 0.6.0 (2026-09-30) ## 0.6.0 (2026-09-30)
- One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of `portal`: `portal serve` (the default), `portal lint`, and `portal plan`, `build`, `answer`, `rate`, `prefer`, `score`, `simulate`, `compare`, `report`, `generate`. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bare `portal` binary beside the tarball, so a content repo's CI downloads it and runs `portal lint`. No more iris releases; the pins that exist keep working. The trainer's `plans/`, `intakes/`, `engines/`, `sectors/` and its reference prompt live here now; its `runs/` stay in uhhm/portal-trainer. Docs gained "Training a site" and "The lint". - One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of `portal`: `portal serve` (the default), `portal lint`, and `portal plan`, `build`, `answer`, `rate`, `prefer`, `score`, `simulate`, `compare`, `report`, `generate`. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bare `portal` binary beside the tarball, so a content repo's CI downloads it and runs `portal lint`. No more iris releases; the pins that exist keep working. The trainer's `plans/`, `intakes/`, `engines/`, `sectors/` and its reference prompt live here now; its `runs/` stay in uhhm/portal-trainer. Docs gained "Training a site" and "The lint".
## 0.5.11 (2026-09-30) ## 0.5.11 (2026-09-30, tagged, never published)
The publish job failed at its lint step: iris, built against this tag, did not compile against the storylines change (the same two fixes that went into `src/lint/` when the lint moved in). Nothing was attached to the tag; 0.6.0 is the first release that ships storylines.
- Storylines (`src/content/variants.rs`, `docs/storylines.md`): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (the `portal_storyline` cookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field's `name`, `options`, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carry `storyline`, and `GET /report` gives counts per bucket, per storyline, per state, and nothing else. - Storylines (`src/content/variants.rs`, `docs/storylines.md`): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (the `portal_storyline` cookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field's `name`, `options`, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carry `storyline`, and `GET /report` gives counts per bucket, per storyline, per state, and nothing else.
@@ -408,4 +467,4 @@ hosts now, since kasse's runner deploys its own instances.
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 | | redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 | | westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 | | portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.10, memberships | | vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.6.0, memberships |
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.6.0" version = "0.6.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.6.0" version = "0.6.9"
edition = "2021" edition = "2021"
[lib] [lib]
+37
View File
@@ -76,6 +76,26 @@ nearest `_section.yaml`. `event` makes a page an occasion with a time;
opening any `type: location` field on the page there rather than on opening any `type: location` field on the page there rather than on
the whole world. the whole world.
### Pictures across the page
`slideshow` lays a strip of pictures under the question, the full
width of the screen: every picture as tall as the strip and as wide as
its own shape, swiped or arrowed through, its caption over its foot.
```yaml
slideshow:
max_height: 60 # svh; 60 unless set, between 20 and 100
slides:
- image: images/built/01.webp # a path in the content repo, or https://
caption: The canteen at the theatre, renewed
width: 3207 # the picture's own size, optional:
height: 1400 # holds its place before it has loaded
```
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.
## An alternative ## An alternative
One choice. Never two actions behind one heading: if two things can be One choice. Never two actions behind one heading: if two things can be
@@ -243,6 +263,23 @@ A spec with neither `public: true` nor `requires_group` is
unreachable: reads fail closed, and mutations always need a group unreachable: reads fail closed, and mutations always need a group
whatever `public` says. whatever `public` says.
`where: { field: email, equals: "{user.email}" }` is "my cases": the
records carrying the signed-in person's own address, read from the
session. A file on such a record is theirs alone to open.
A record that carries a picture (a `type: file` field holding a JPEG,
PNG, WebP, AVIF or GIF) is shown rather than read when the list has no
`transitions`: a card with the picture on top, the first field as its
heading, and the other fields under it, with no field names, state or
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. 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.
`states` and `where` narrow a listing. They matter most for a register `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 that is public by law — public about what was decided, not about what
was withdrawn — while the desk working the same bucket is a different was withdrawn — while the desk working the same bucket is a different
+1 -1
View File
@@ -15,7 +15,7 @@ content repo's CI downloads the release and runs it:
```yaml ```yaml
- run: | - run: |
curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal" -o portal curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o portal
chmod +x portal && ./portal lint --path questions chmod +x portal && ./portal lint --path questions
``` ```
+17 -7
View File
@@ -71,6 +71,7 @@ values.
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in | | `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
| `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise | | `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise |
| `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm | | `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm |
| `KANIDM_GROUP_PREFIX` | for a Kanidm shared with other sites: the content's `kunder` is the group `<prefix>kunder` there, and the OAuth2 client's claim map gives it back to portal as `kunder` |
| `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set | | `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set |
| `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold | | `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold |
| `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) | | `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) |
@@ -81,22 +82,31 @@ values.
| `GARAGE_*` | uploads | | `GARAGE_*` | uploads |
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off | | `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed | | `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below | | `PORTAL_RATE_BURST`, `PORTAL_RATE_PER_SECOND` | the rate limit; see below |
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
separate unit on the same host reading the same NATS. separate unit on the same host reading the same NATS.
## Rate limiting ## Rate limiting
Every request passes a per-caller-IP limit before a session is loaded Every request that makes portal do something passes a per-caller-IP
or a handler is entered: a burst of 30, refilled one every two limit before a session is loaded or a handler is entered: a burst of
seconds. A visitor filling in a form never reaches it (a page load 100, refilled at ten a second. More than a hundred requests in ten
fetches several resources at once); a script does. seconds is a script; someone reloading a page again and again stays
well inside it.
The files a page is made of, served from portal's own disk, are not
counted: the stylesheet and scripts under `/pkg`, the fonts, the
favicons, the vendored libraries. Refusing one of those is what turns
a reloaded page into an unstyled mess, and serving one costs next to
nothing. Anything that makes portal ask someone else is counted: a
content picture under `/site/` is fetched from Gitea, a map tile from
the map provider.
| | | | | |
|---|---| |---|---|
| `PORTAL_RATE_BURST` | how many requests at once, default 30 | | `PORTAL_RATE_BURST` | how many requests at once, default 100 |
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 | | `PORTAL_RATE_PER_SECOND` | requests refilled per second, default 10 |
Either set to `0` turns it off, for an instance behind something that Either set to `0` turns it off, for an instance behind something that
already does this. The caller is read from `X-Forwarded-For` or already does this. The caller is read from `X-Forwarded-For` or
+16
View File
@@ -116,6 +116,22 @@ the same transition path, fires the same mail, and lands on
The unit is days and the sweep is hourly. This is a deadline, not a The unit is days and the sweep is hourly. This is a deadline, not a
job queue. job queue.
### leaves_after
```yaml
taken_down:
event: taken_down
leaves_after: { minutes: 10 }
```
How long a record stays in its bucket's lists once it has entered the
state, when that is not for ever. A card taken off the front page
leaves the desk's list ten minutes later instead of standing there
greyed out, and the ten minutes are the time to see what was just
done. Days, hours and minutes add up, and a sweep takes records out
once a minute. Nothing is decided and no mail goes. The record leaves
the lists, and the event log keeps it.
## attended_by ## attended_by
```yaml ```yaml
+88
View File
@@ -0,0 +1,88 @@
# Vedtekter
### av 23.04.2018
##### § 1
Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd, forskjønnelse og utvikling og ellers ivareta Tomters interesser.
##### § 2
Alle som er bosatt i Tomter kan bli medlem av foreningen. Midlertidig utflyttede medlemmer kan beholde sitt medlemskap.
##### § 3
Årsmøte, som er vellets øverste myndighet, holdes innen utgangen av mars hvert år. Møtet kunngjøres med minst tre ukers varsel. Forslag skal også gjøres kjent minst to uker før møtet. Årsmøte godkjenner beretning, regnskap og budsjett. Den velger styre, komitéledere, og valgkomite.
##### § 4.
Ekstraordinært årsmøte holdes når styret, eller minst 15 medlemmer krever det i skriftlig form. Gjennomføringen følger samme prosedyre som ordinært årsmøte
##### § 5
Tomter Vel ledes av et styre på seks medlemmer. Leder, nestleder, sekretær, kasserer og to styremedlemmer, som alle velges på årsmøtet. Funksjonstiden er to år. Leder, sekretær og ett styremedlem velges det ene året, nestleder, kasserer og det andre styremedlemmet det andre året. Det skal dessuten på årsmøte velges ett varamedlem til styret med funksjonstid to år. Styret engasjerer revisor eller ekstern regnskapsfører. Oppnevning av andre komitémedlemmer og representanter foretas av styret etter behov. Komitéledere har møterett på styremøter.
##### § 6
Medlemsmøte avholdes når styret finner det nødvendig og skal bekjentgjøres minst en uke i forveien. Det bør avholdes minst ett medlemmsmøte mellom hvert årsmøte.
##### § 7.1
Styret skal:
iverksette årsmøtets vedtak og bestemmelser.
Stå for velforeningens daglige ledelse og ivareta medlemmenes interesser overfor andre instanser.
Stå for den alminnelige forvaltningen av foreningen, herunder forvalte velforeningens eiendeler og føre kontroll med foreningens økonomi.
etter behov opprette komitéer eller bemyndige personer for å løse spesielle oppgaver.
etter beste evne søke å oppfylle foreningens formål eller gjeldende regler.
sørge for at foreningen er forsvarlig forsikret.
##### § 7.2
Styret har disposisjonsrett over velforeningens midler til alminnelige administrative formål og rett til å bevilge beløp til gjennomføring av vedtak på årsmøtet. Alle utbetalinger skal attesteres av leder og kasserer før utbetaling. Styret har imidlertid ikke adgang til uten samtykke fra årsmøtet å treffe beslutninger av større betydning for foreningen.
##### § 7.3
Styret holder møte når leder bestemmer eller et flertall av styremedlemmene forlanger det. Varamedlemmet kan møte på samtlige styremøter, men har kun stemmerett når styret ikke er fulltallig.
Alle saker og all korrespondanse skal legges fram på styremøtet. Styrets vedtak skal protokolleres. Det skal fremgå av protokollen om vedtaket er enstemmig, og eventuelle dissenser protokolleres. Alle medlemmene har rett til innsyn i protokollene fra styremøtene. Innsyn kan nektes av styret i enkeltsaker, dersom hensynet til tredjemann tilsier det eller dersom andre saklige grunner for nektelse foreligger.
##### § 7.4
Styret er beslutningsdyktig når et flertall av styremedlemmene, deriblant leder eller nestleder, er tilstede. Leder eller i hans/hennes fravær nestleder, leder styrets møter. Styrebeslutninger krever alminnelig flertall. Ved stemmelikhet har fungerende leder dobbeltstemme.
Et styremedlem kan ikke være med å stemme i saker hvor dette har særinteresser.
##### § 8
Regnskapsåret går fra 01.01–31.12. Styret skal påse at regnskapet blir ført.
##### § 9
Medlemskontingenten er den som til enhver tid er/blir fastsatt av Årsmøte. Kontingenten innkreves av styret. Den betales forskuddsvis.
##### § 10
Tomter Vel kan bare oppløses ved vedtak på Årsmøte etter forslag fra styret. Til slikt vedtak kreves to tredjedels stemmeflertall blant de frammøtte medlemmer. Forslag om oppløsning skal gjøres kjent 3 uker før årsmøtet holdes. Eventuelle midler som Tomter Vel sitter inne med, anvendes da straks til formål som kommer Tomter til gode.
##### § 11
Styret eller årsmøtet oppretter komiteer for definerte formål. Komitéer skal bestå av komiteleder som rapporterer til styret. Komiteleder tilknytter et egnet antall personer til formålet og organiserer arbeidet.
##### § 12
Valgkomiteen består av to medlemmer med funksjonstid ett år. Valgkomiteen foreslår overfor Årsmøte medlemmer og varamedlemmer til styre, komitéer og valgkomite, samt revisor.
##### § 13
Tomter Vel skal ha en revisorer med funksjonstid to år. Revisoren skal revidere regnskapet og legge fram revisjonsberetning for Årsmøtet.
##### § 14
Forandringer i disse lover kan bare skje ved vedtak på Årsmøte. Slikt vedtak krever to tredjedels stemmeflertall blant de frammøtte medlemmene.
+312
View File
@@ -0,0 +1,312 @@
# Klingenberg Bygg: tilstand, prosjektering og søknad for bygg, for
# kommuner, borettslag, bedrifter og private. Bygget på det gamle
# spørreskjemaet (klingenbergbygg/kundeportal, questions.ts) og et
# intervju 2026-09-30: alle fire kundegrupper, én pult (Fredrik), og
# hele oppdraget på siden - fra henvendelse over befaring og tilbud til
# levert, med kunden innlogget for å følge saken og hente dokumentene.
# Færre felt enn det gamle skjemaet: det som må til for å ta kontakt og
# komme på befaring, resten tar vi der.
business: Klingenberg Bygg
language: "no"
contact: { name: Fredrik Borch Lynghaug, email: fredrik@klingenbergbygg.no }
mail: { from: "Klingenberg Bygg <portal@klingenbergbygg.no>", reply_to: fredrik@klingenbergbygg.no }
about: >
Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt**
og godkjent **søknad** for ditt bygg.
# Kontoret er pulten; kundene får konto i det de takker ja til et
# tilbud, og ser da sine egne oppdrag og dokumentene vi leverer.
groups:
- { id: kontor, label: Kontoret }
- { id: kunder, label: Kunder }
invites_by: [kontor]
# Forsiden åpner med det vi gjør, uten knapp: to trekk.
does:
- id: tilstand
for: tilstand
text: >
Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med
befaring, materialprøver og termofotografering. Skal bygget rives,
leverer vi miljøsaneringsrapport; energibruk og besparelser hører
med i vurderingen. Du får et tilbud og sier selv ja eller nei.
- id: soknad
for: soknad
text: >
Vi prosjekterer og søker: arbeidsbeskrivelse opp mot NS 3420,
fremdriftsplan, og byggesøknad med ansvarsrett. Vi har sentral
godkjenning innen flere fagområder. Når tilbudet er godtatt,
følger du oppdraget og henter dokumentene innlogget.
# 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: 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
who: en eier eller forvalter som må vite hvilken forfatning bygget er i, for en kommune, et borettslag, en bedrift eller seg selv
wants: en tilstandsrapport å planlegge og beslutte ut fra
lands_in: tilstandsrapporter
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "i arbeid", levert: "levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
# Kunden sier selv ja eller nei til tilbudet, gjennom sin egen lenke.
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
# Et godtatt tilbud er kundeforholdet: konto og plass blant kundene.
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: eiendom, type: select, options: [Skole eller annet offentlig bygg, Borettslag eller sameie, Næringsbygg, Enebolig, Leilighet, Fritidsbolig, Annet] }
- { name: adresse }
- { name: mangler, type: textarea, optional: true }
- { name: tegninger, type: file, optional: true }
- id: prosjekt
who: en byggherre som har planer for bygget og trenger dem prosjektert før noen kan bygge
wants: en arbeidsbeskrivelse og fremdriftsplan som gir det beste grunnlaget for utførelse
lands_in: prosjekteringer
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "i arbeid", levert: "levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: adresse }
- { name: planer, type: textarea }
- { name: oppstart, type: date, optional: true }
- { name: skisser, type: file, optional: true }
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
- id: soknad
paused: true
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
wants: en godkjent byggesøknad, med ansvarsrett der det kreves
lands_in: soknader
handled_by: kontor
followed_by: [kunder]
stages: [open, befaring, tilbud, akseptert, i_arbeid, levert, avslatt]
stages_text: { open: "mottatt", befaring: "forhåndskonferanse eller befaring avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", i_arbeid: "søknad under arbeid", levert: "søknad levert", avslatt: "avsluttet uten oppdrag" }
done_when: [levert, avslatt]
success: [levert]
turned_down_from: [open, befaring, tilbud]
moves_by: { akseptert: [submitter], avslatt: [submitter, kontor] }
mails: { open: submitter, befaring: submitter, tilbud: submitter, akseptert: fredrik@klingenbergbygg.no, levert: submitter }
grants: { akseptert: kunder }
max_fields: 4
collects:
- { name: name }
- { name: email, type: email }
- { name: telefon, type: tel, optional: true }
- { name: adresse }
- { name: tiltak, type: textarea }
- { name: tegninger, type: file, optional: true }
# Det kontoret leverer: rapporten, tegningene, vedtaket. Kunden får
# e-post og henter dokumentet innlogget, fra sin egen liste.
- id: leveranse
who: kontoret, når en rapport, tegninger eller et vedtak er klart til kunden
wants: at kunden får dokumentet og vet at det ligger der
enters_as: kontor
lands_in: leveranser
handled_by: kontor
followed_by: [kunder]
stages: [open, arkivert]
stages_text: { open: "levert", arkivert: "arkivert" }
done_when: [arkivert]
success: [arkivert]
mails: { open: submitter }
max_fields: 3
collects:
- { name: email, type: email }
- { name: hva }
- { name: dokument, type: file }
# Menneskene: hver ansatt har sitt eget kort på forsiden, med rolle,
# erfaring og portrett, og retter det selv. Kontoret kan ta et kort ned.
- id: ansatt
who: en av oss på kontoret, som vil vise hvem hen er og hva hen kan
wants: at den som vurderer oss ser hvem som gjør jobben
enters_as: kontor
one_per_person: true
# Et kort som er tatt ned, forsvinner fra kontorets liste etter ti minutter.
leaves_after: { sluttet: { minutes: 10 } }
lands_in: ansatte
handled_by: kontor
watched_by: [public]
presents: true
public_states: [open]
stages: [open, sluttet]
stages_text: { open: "vises på forsiden", sluttet: "sluttet" }
done_when: [sluttet]
success: [sluttet]
max_fields: 4
collects:
- { name: name }
- { name: rolle }
- { 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." }
- { id: sprekk, need: tilstand, text: "Det har kommet en sprekk midt på stueveggen og jeg vet ikke om det er farlig. Kan noen komme og se på det?" }
- { id: tilbygg, need: prosjekt, text: "Vi vil bygge på et tilbygg på lageret vårt og trenger noen som kan beskrive arbeidet så vi kan hente inn priser fra entreprenører." }
- { 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
slides:
- image: images/bildeserie/01.webp
width: 1867
height: 1400
caption: Tilstandsanalyse for politisk vurdering.
- image: images/bildeserie/02.webp
width: 2365
height: 1400
caption: Tilleggsisolert fasade uten fasadeendring. Tanum skole.
- image: images/bildeserie/03.webp
width: 1867
height: 1400
caption: Fundamenteringen av brannstasjonen i Ytre Enebakk
- image: images/bildeserie/04.webp
width: 1867
height: 1400
caption: Gammel stjernehimmel skal fornyes
- image: images/bildeserie/05.webp
width: 1050
height: 1400
caption: Sprekk midt på stuevegg
- image: images/bildeserie/06.webp
width: 1867
height: 1400
caption: Snart klart for overlevering! Løvenstad omsorgsboliger.
- image: images/bildeserie/07.webp
width: 1867
height: 1400
caption: Omlegging av vannforsyning før etablering av nye boliger
- image: images/bildeserie/08.webp
width: 1777
height: 1400
caption: Utbedring parkett i gymsal, sliping og lakking.
- image: images/bildeserie/09.webp
width: 3629
height: 1400
caption: Tilbygg på skole i drift, i Ås
- image: images/bildeserie/10.webp
width: 3207
height: 1400
caption: Fornying kantine Det Norske Teatret
- image: images/bildeserie/11.webp
width: 1050
height: 1400
caption: Solid veggprofil tilpasset lyd- og brannkrav
- image: images/bildeserie/12.webp
width: 1867
height: 1400
caption: Nye vinduer med tradisjonelt utseende «Restover»-glass som avslører utsikten fra Munkeramme på Hvitsten
- image: images/bildeserie/13.webp
width: 2571
height: 1400
caption: Kjerneved til verandaen og inngangspartiet til Muncheramme
- image: images/bildeserie/14.webp
width: 1050
height: 1400
caption: Drenering og riktige rammer før kunstgressbanen legges mesterfullt ned helt uten gummikuler
- image: images/bildeserie/15.webp
width: 2098
height: 1400
caption: Navnebror sprenger plass til ny brannstasjon
- image: images/bildeserie/16.webp
width: 1283
height: 1400
caption: Kontroll av betong før støping
- image: images/bildeserie/17.webp
width: 1050
height: 1400
caption: Etablering av ny byggekran
- image: images/bildeserie/18.webp
width: 1050
height: 1400
caption: Armeringskontroll før støping
- image: images/bildeserie/19.webp
width: 1867
height: 1400
caption: Brannstasjonens tårn under oppføring blir også klatrevegg
- 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.
- image: images/bildeserie/21.webp
width: 1867
height: 1400
caption: Nyetablering av vei.
- image: images/bildeserie/22.webp
width: 838
height: 1400
caption: Støpefeil med frosset treverk i søyle fra 1960, avdekket og utbedret
- image: images/bildeserie/23.webp
width: 2095
height: 1400
caption: Nytt kunstgress med varme på Fredrikstad stasjon.
- image: images/bildeserie/24.webp
width: 1050
height: 1400
caption: Sydvendte fasader er utsatt for slagregn. Dette bygget er på byantikvarens gule liste.
- image: images/bildeserie/25.webp
width: 1487
height: 1400
caption: pH-test av borprøver i mur fra forskjellige dybder
+8
View File
@@ -62,6 +62,14 @@ does:
nabohjelpsgruppa deg med en nabo som kan. Du sier selv fra når nabohjelpsgruppa deg med en nabo som kan. Du sier selv fra når
det er ordnet. det er ordnet.
# Vedtektene, ord for ord slik årsmøtet vedtok dem: en side der hver
# paragraf er sitt eget kort. Ingen modell rører teksten.
documents:
- id: vedtekter
question: Hva sier vedtektene?
description: Vedtektene for Tomter Vel, av 23.04.2018.
file: documents/tomtervel-vedtekter.md
doors: doors:
- id: medlem - id: medlem
who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding
+622
View File
@@ -0,0 +1,622 @@
{
"questions/index.yaml": {
"question": "Hvordan inkluderer vi ditt bygg i framtiden?",
"description": "Med vår erfaring får vi kartlagt **tilstand**, utarbeidet **prosjekt** og godkjent **søknad** for ditt bygg.",
"opener": {
"heading": "Dette gjør vi",
"description": "For kommuner, borettslag, bedrifter og private.",
"features": {
"tilstand": {
"heading": "Tilstand, målt og dokumentert",
"description": "Vi utarbeider tilstandsrapporter etter NS-EN 3424 og NS 3423, med befaring, materialprøver og termofotografering. Skal bygget rives, leverer vi miljøsaneringsrapport; energibruk og besparelser hører med i vurderingen. Vi har samarbeidet med [Drammen Kommune](https://drammen.kommune.no), [Nordre Follo Kommune](https://nordrefollo.kommune.no) og [Bølerskogens Borettslag](https://bolerskogenborettslag.no/).",
"icon": "clipboard-check"
},
"soknad": {
"heading": "Prosjekt og søknad, med ansvarsrett",
"description": "Vi lager arbeidsbeskrivelse opp mot [NS 3420](https://standard.no/fagomrader/ns-3420-/) og fremdriftsplan, og utarbeider byggesøknaden. Vi har sentral [godkjenning](https://sgregister.dibk.no/enterprises/976015711) innen flere fagområder og kan stå med det offentligrettslige ansvaret. Samarbeidspartner: [Westra Engineering AS](https://www.westra-engineering.no/).",
"icon": "file-check"
}
}
},
"needs": {
"tilstand": {
"heading": "Få kartlagt byggets tilstand",
"description": "Vi utarbeider tilstandsrapporter som representerer bygget med hensyn til fremtidige mål.",
"button": "Kom i gang",
"encouragement": "Mindre stillstand etter dette.",
"icon": "clipboard-list",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om bygget",
"form_description": "Det vi trenger for å komme på befaring. Har du tegninger, bilder eller video, tar vi gjerne imot dem.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"eiendom": "Hva slags eiendom er det?",
"mangler": "Hvilke mangler ønskes adressert?",
"tegninger": "Bilder, tegninger, skisser eller video"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"mangler": "Forfall, skader eller annet…"
}
},
"prosjekt": {
"heading": "Prosjektér dine planer med oss",
"description": "Ivareta eksisterende ressurser for et velfungerende og helhetlig bygg.",
"button": "Start samarbeidet",
"encouragement": "Gi det beste grunnlaget for utførelse.",
"icon": "ruler",
"road_title": "Slik går det videre",
"road_description": "Vi avtaler befaring og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger oppdraget og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om planene",
"form_description": "Beskriv hva du ønsker for bygget. Har du noe digitalt å vise oss, blir det lettere å vite hvor vi skal starte.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"planer": "Hva er dine planer for bygget?",
"oppstart": "Ønsket start",
"skisser": "Bilder, CAD-filer og skisser"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"planer": "Ønsker for bygget…"
}
},
"soknad": {
"heading": "Ta det første spadetaket med en godkjent søknad",
"description": "Er bygget ferdig prosjektert, kan vi utarbeide en byggesøknad og utførelsesbeskrivelse for offentlig godkjenning.",
"button": "Send henvendelse",
"encouragement": "Nesten som å ha gravemaskin!",
"icon": "file-text",
"road_title": "Slik går det videre",
"road_description": "Vi går gjennom det du har, avtaler befaring eller forhåndskonferanse, og sender deg et tilbud. Du sier selv ja eller nei, og med et ja får du en innlogging der du følger søknaden og henter dokumentene.",
"road_icon": "route",
"who_title": "Hvem leser dette",
"who_description": "Fredrik på kontoret leser henvendelsen og tar kontakt. Du når ham også på fredrik@klingenbergbygg.no.",
"who_icon": "users",
"form_title": "Om tiltaket",
"form_description": "Hva skal bygges, og hvor. Avhengig av størrelsen kan vi holde forhåndskonferanse med kommunen først.",
"labels": {
"name": "Navn",
"email": "E-post",
"telefon": "Telefon",
"adresse": "Hvor ligger eiendommen?",
"tiltak": "Hva går tiltaket ut på?",
"tegninger": "Tegninger og prosjektering"
},
"hints": {
"name": "Navn",
"email": "bruker@eksempel.no",
"telefon": "Telefonnummer",
"adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…"
},
"paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan."
}
},
"who": {
"heading": "Hvem består Klingenberg Bygg av?",
"description": "Mennesker, erfaringer og verdier.",
"button": "Bli kjent med oss"
}
},
"questions/received/tilstand.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om tilstandsrapport er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/prosjekt.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om prosjektering er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/received/soknad.yaml": {
"question": "Vent på svar på din henvendelse!",
"description": "Vi vil gå gjennom din henvendelse og om mulig komme med et tilbud så fort vi kan.",
"next_heading": "Vi tar kontakt for befaring",
"next_description": "Du har fått en e-post som bekrefter at henvendelsen om byggesøknad er mottatt. Fredrik tar kontakt for å avtale befaring; spørsmål i mellomtiden går til fredrik@klingenbergbygg.no."
},
"questions/decide/tilstand.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på tilstandsrapporten har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/prosjekt.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på prosjekteringen har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/decide/soknad.yaml": {
"question": "Vil du gå videre med tilbudet?",
"description": "Tilbudet på byggesøknaden har du fått i egen e-post fra oss. Her sier du ja eller nei.",
"moves": {
"akseptert": {
"heading": "Ja, jeg godtar tilbudet",
"description": "Vi setter i gang. Du får en innlogging på e-post, der du følger oppdraget og henter dokumentene når de er klare.",
"button": "Godta tilbudet"
},
"avslatt": {
"heading": "Nei takk",
"description": "Henvendelsen avsluttes hos oss uten oppdrag. Du kan når som helst sende en ny.",
"button": "Takk nei"
}
}
},
"questions/kontor/index.yaml": {
"question": "Hva ligger på pulten nå?",
"description": "Alle henvendelser og oppdrag, fra første melding til levert dokument.",
"confirm": "Bekreft",
"buckets": {
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Henvendelser om tilstandsrapport, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Henvendelser om prosjektering, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Henvendelser om byggesøknad, fra mottatt til levert.",
"moves": {
"open->befaring": "Befaring avtalt",
"open->avslatt": "Avslutt uten oppdrag",
"befaring->tilbud": "Tilbud sendt",
"befaring->avslatt": "Avslutt uten oppdrag",
"tilbud->avslatt": "Avslutt uten oppdrag",
"akseptert->i_arbeid": "Arbeidet er i gang",
"i_arbeid->levert": "Levert"
},
"list_title": "Byggesøknader",
"list_note": "Hver rad er én henvendelse. Knappene fører opp det du nettopp har gjort; kunden får e-post ved befaring, tilbud og levering, og sier selv ja til tilbudet."
},
"leveranser": {
"heading": "Leverte dokumenter",
"description": "Rapporter, tegninger og vedtak som er sendt til kunder.",
"moves": {
"open->arkivert": "Arkiver"
},
"list_title": "Leveranser",
"list_note": "Hver rad er ett dokument en kunde har fått. Arkiver når saken er avsluttet."
},
"ansatte": {
"heading": "Ansatte på forsiden",
"description": "Menneskene slik besøkende ser dem. Hver retter sitt eget kort; herfra kan et kort tas ned.",
"moves": {
"open->sluttet": "Tatt av forsiden"
},
"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": {
"leveranse": {
"heading": "Lever et dokument til en kunde",
"description": "Rapporten, tegningene eller vedtaket. Kunden får e-post og henter det innlogget.",
"button": "Ny leveranse"
},
"ansatt": {
"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"
}
},
"invites": {
"kontor": {
"heading": "Gi en kollega tilgang",
"description": "De får en konto og plass på kontoret, og ser de samme sakene som deg.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
},
"kunder": {
"heading": "Gi en kunde tilgang",
"description": "For en kunde som ikke kom inn gjennom et tilbud. De får en konto og ser sine egne oppdrag.",
"button": "Inviter",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"phone": "Telefon",
"email": "E-post"
}
}
},
"invited": {
"heading": "Sendte invitasjoner",
"description": "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt.",
"done": "Er inne",
"again": "Send på nytt",
"list_title": "Hvem som er invitert",
"list_note": "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."
},
"suggestions": {
"heading": "Forslag til endringer på siden",
"description": "Det de som står som ansvarlige for et spørsmål, har foreslått å endre.",
"list_title": "Forslag",
"list_note": "Hver rad er ett forslag, med siden det gjelder.",
"looking": "Vi ser på det",
"changed": "Endringen er gjort",
"declined": "Gjøres ikke"
}
},
"questions/kunder/index.yaml": {
"question": "Hvor står oppdraget ditt?",
"description": "Her følger du dine egne oppdrag hos Klingenberg Bygg og henter dokumentene vi har levert.",
"confirm": "Bekreft",
"buckets": {
"leveranser": {
"heading": "Dokumenter til deg",
"description": "Rapporter, tegninger og vedtak vi har levert. Trykk på filnavnet for å hente dokumentet.",
"moves": {},
"list_title": "Dokumenter",
"list_note": "Hver rad er ett dokument vi har levert til deg."
},
"tilstandsrapporter": {
"heading": "Tilstandsrapporter",
"description": "Dine oppdrag om tilstandsrapport, og hvor langt de har kommet.",
"moves": {},
"list_title": "Tilstandsrapporter",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"prosjekteringer": {
"heading": "Prosjekteringer",
"description": "Dine oppdrag om prosjektering, og hvor langt de har kommet.",
"moves": {},
"list_title": "Prosjekteringer",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
},
"soknader": {
"heading": "Byggesøknader",
"description": "Dine oppdrag om byggesøknad, og hvor langt de har kommet.",
"moves": {},
"list_title": "Byggesøknader",
"list_note": "Hver rad er ett av dine egne oppdrag og hvor langt det har kommet."
}
}
},
"questions/kontor/leveranse.yaml": {
"heading": "Lever et dokument til en kunde",
"description": "Last opp rapporten, tegningene eller vedtaket. Kunden får e-post og henter dokumentet innlogget.",
"button": "Lever",
"encouragement": "Kunden får beskjed med en gang.",
"icon": "send",
"road_title": "Hva skjer",
"road_description": "Dokumentet legges på kundens side, og kunden får e-post om at det ligger der.",
"road_icon": "route",
"who_title": "Hvem ser dette",
"who_description": "Kunden med den e-postadressen du oppgir, og kontoret.",
"who_icon": "users",
"form_title": "Leveransen",
"form_description": "Bruk den e-postadressen kunden logger inn med.",
"labels": {
"email": "Kundens e-post",
"hva": "Hva er levert",
"dokument": "Dokument"
},
"hints": {
"email": "kunde@eksempel.no",
"hva": "Tilstandsrapport for Storgata 12",
"dokument": ""
},
"question": "Hvilket dokument er klart til kunden?",
"page_description": "Ferdige rapporter, tegninger og vedtak leveres her."
},
"questions/kontor/leveranse-sent.yaml": {
"question": "Hva skjer nå?",
"description": "Dokumentet er levert.",
"next_heading": "Kunden har fått beskjed",
"next_description": "Kunden har fått e-post og finner dokumentet på sin side. Leveransen ligger i listen på pulten til du arkiverer den."
},
"aggregates.yaml": {
"tilstandsrapporter": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om tilstandsrapport for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på tilstandsrapport for {adresse}",
"body": "Hei {name},\n\nTilbudet på tilstandsrapport for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/tilstand?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på tilstandsrapport for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Tilstandsrapport for {adresse} er levert",
"body": "Hei {name},\n\nTilstandsrapport for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"prosjekteringer": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om prosjektering for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på prosjektering for {adresse}",
"body": "Hei {name},\n\nTilbudet på prosjektering for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/prosjekt?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på prosjektering for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Prosjektering for {adresse} er levert",
"body": "Hei {name},\n\nProsjektering for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"soknader": {
"open": {
"subject": "Vi har mottatt henvendelsen om {adresse}",
"body": "Hei {name},\n\nVi har mottatt henvendelsen din om byggesøknad for {adresse}. Fredrik tar kontakt for å avtale befaring.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"befaring": {
"subject": "Befaring avtalt for {adresse}",
"body": "Hei {name},\n\nVi har avtalt befaring for {adresse}. Etter befaringen sender vi deg et tilbud.\n\nVil du trekke henvendelsen, gjør du det her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nKlingenberg Bygg"
},
"tilbud": {
"subject": "Tilbud på byggesøknad for {adresse}",
"body": "Hei {name},\n\nTilbudet på byggesøknad for {adresse} er sendt deg i egen e-post. Si ja eller nei her:\n{site}/decide/soknad?chain={chain}&email={email}\n\nMed et ja får du en innlogging der du følger oppdraget og henter dokumentene. Svar på denne e-posten om du lurer på noe.\n\nKlingenberg Bygg"
},
"akseptert": {
"subject": "Tilbudet for {adresse} er godtatt",
"body": "{name} ({email}) har godtatt tilbudet på byggesøknad for {adresse}.\n\nOppdraget ligger på pulten: {site}/kontor"
},
"levert": {
"subject": "Byggesøknad for {adresse} er levert",
"body": "Hei {name},\n\nByggesøknad for {adresse} er levert. Dokumentene henter du innlogget:\n{site}/kunder\n\nTakk for oppdraget.\n\nKlingenberg Bygg"
}
},
"leveranser": {
"open": {
"subject": "{hva} ligger klar til deg",
"body": "Hei,\n\n{hva} er levert fra Klingenberg Bygg. Logg inn og hent dokumentet her:\n{site}/kunder\n\nSvar på denne e-posten om noe mangler.\n\nKlingenberg Bygg"
}
}
},
"site.yaml": {
"invite": {
"subject": "Din innlogging hos {site_name}",
"body": "Hei {name},\n\nDu har fått en konto hos {site_name}, med plass blant {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}."
}
},
"questions/kontor/ansatt-sent.yaml": {
"question": "Kortet ditt er lagret",
"description": "Slik står du nå på forsiden.",
"next_heading": "Vil du endre noe?",
"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": "",
"button": "Åpne"
}
},
"questions/kontor/ansatt.yaml": {
"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",
"requires_title": "",
"requires_description": "",
"requires_icon": "",
"road_title": "Når du lagrer",
"road_description": "Kortet ditt på forsiden endres med en gang.",
"road_icon": "route",
"who_title": "Hvem ser det",
"who_description": "Alle som besøker siden. Navnet ditt er det du er logget inn med.",
"who_icon": "users",
"form_title": "Kortet ditt",
"form_description": "",
"labels": {
"name": "Navn",
"rolle": "Rolle",
"erfaring": "Erfaring",
"portrett": "Portrett"
},
"hints": {
"name": "",
"rolle": "For eksempel daglig leder, rådgivende ingeniør",
"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": "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?",
"description": "Menneskene som går befaringene, skriver rapportene og står for søknadene.",
"heading": "Menneskene",
"list_description": "",
"list_title": "Menneskene",
"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": ""
}
}
+144
View File
@@ -0,0 +1,144 @@
// Pages move the way their addresses do.
//
// Deeper in the address comes in from the right and going back up
// comes in from the left: /kontor/ansatt sits to the right of /kontor.
// A page at the same depth comes from below, or from above when it
// sorts first, so the index is on top. The cards on a page leave as
// islands of their own and the next page's cards arrive one after
// another.
//
// The router swaps the page after its data has arrived, and in between
// shows nothing at all. So the click is held here, before the router
// sees it: the browser takes its picture of the page as it is, the
// click is handed on, and the transition waits until the new page has
// replaced the old one. Only transforms and opacity are animated, on
// layers the browser composites itself. Without View Transitions, or
// with reduced motion asked for, nothing here does anything and the
// router navigates as it always did.
(() => {
if (!document.startViewTransition) return;
const still = matchMedia("(prefers-reduced-motion: reduce)");
const MAX_CARDS = 8;
const WAIT_MS = 2500;
const handed = new WeakSet();
const segments = (path) => path.split("/").filter(Boolean);
// forward, back, down or up: which way the new page comes from.
function direction(from, to) {
const a = segments(from);
const b = segments(to);
if (b.length > a.length) return "forward";
if (b.length < a.length) return "back";
return to < from ? "up" : "down";
}
// Only the cards on screen become islands: each is a picture the
// browser has to take and hold, and one nobody sees costs the same.
function inView(card) {
const r = card.getBoundingClientRect();
return r.bottom > 0 && r.top < innerHeight;
}
function name(cards, prefix) {
cards.filter(inView).slice(0, MAX_CARDS).forEach((card, i) => {
card.style.viewTransitionName = `${prefix}-${i + 1}`;
});
}
function unname() {
document.querySelectorAll(".alt-card").forEach((card) => {
card.style.viewTransitionName = "";
});
}
// Resolves once the page that was showing has been replaced by the
// one at `path`, or after WAIT_MS whatever happened.
function replaced(old, path) {
return new Promise((resolve) => {
const done = () => {
observer.disconnect();
clearTimeout(timer);
resolve();
};
const check = () => {
if (location.pathname !== path) return;
if (document.querySelector("main.loading")) return;
if (old && old.isConnected) return;
if (!document.querySelector(".alternatives, main.not-found")) return;
done();
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(done, WAIT_MS);
check();
});
}
function move(from, to, handOn) {
const root = document.documentElement;
const old = document.querySelector(".alternatives");
root.dataset.motion = direction(from, to);
name([...document.querySelectorAll(".alt-card")], "pm-out");
const transition = document.startViewTransition(async () => {
unname();
handOn();
await replaced(old, to);
name([...document.querySelectorAll(".alt-card")], "pm-in");
});
transition.finished.finally(() => {
unname();
delete root.dataset.motion;
});
}
document.addEventListener(
"click",
(ev) => {
if (still.matches || handed.has(ev) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const a = ev.target.closest && ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return;
if ((a.getAttribute("rel") || "").split(/\s+/).includes("external")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin || url.pathname === location.pathname) return;
ev.preventDefault();
ev.stopImmediatePropagation();
move(location.pathname, url.pathname, () => {
const again = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 0 });
handed.add(again);
a.dispatchEvent(again);
});
},
true,
);
// Back and forward: the address has already changed when this runs,
// so the router is told a moment later, inside the transition.
let shown = location.pathname;
window.addEventListener(
"popstate",
(ev) => {
const to = location.pathname;
const from = shown;
shown = to;
if (still.matches || handed.has(ev) || from === to) return;
ev.stopImmediatePropagation();
move(from, to, () => {
const again = new PopStateEvent("popstate", { state: ev.state });
handed.add(again);
window.dispatchEvent(again);
});
},
true,
);
// Keep `shown` right after the router's own pushes.
for (const method of ["pushState", "replaceState"]) {
const original = history[method].bind(history);
history[method] = (...args) => {
const result = original(...args);
shown = location.pathname;
return result;
};
}
})();
+39
View File
@@ -51,6 +51,35 @@ pub struct AggregateSchema {
/// no answer in six weeks has to go on without one, and nothing in /// no answer in six weeks has to go on without one, and nothing in
/// a building is going to press that button. /// a building is going to press that button.
pub after_for_state: HashMap<String, AfterSpec>, pub after_for_state: HashMap<String, AfterSpec>,
/// How long a record stays in the list once it has entered a
/// state, when that is not for ever: a card taken off the front
/// page leaves the desk's list a little later, instead of standing
/// there greyed out. The event log keeps it.
pub leaves_for_state: HashMap<String, LeavesSpec>,
}
/// `leaves_after: { minutes: 10 }` on a state: how long a record that
/// has entered it stays in its bucket's list. Days, hours and minutes
/// add up; the sweep that takes it out runs every minute.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LeavesSpec {
#[serde(default, skip_serializing_if = "is_zero")]
pub days: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub hours: u32,
#[serde(default, skip_serializing_if = "is_zero")]
pub minutes: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
impl LeavesSpec {
pub fn millis(&self) -> i64 {
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
}
} }
/// A mail to send when a record enters a state. `to` is `submitter` /// A mail to send when a record enters a state. `to` is `submitter`
@@ -125,6 +154,8 @@ struct RawState {
grants: Option<String>, grants: Option<String>,
#[serde(default)] #[serde(default)]
after: Option<AfterSpec>, after: Option<AfterSpec>,
#[serde(default)]
leaves_after: Option<LeavesSpec>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
@@ -197,7 +228,14 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
let mut mail_for_state = HashMap::new(); let mut mail_for_state = HashMap::new();
let mut grants_for_state = HashMap::new(); let mut grants_for_state = HashMap::new();
let mut after_for_state = HashMap::new(); let mut after_for_state = HashMap::new();
let mut leaves_for_state = HashMap::new();
for (state, raw_state) in raw_schema.states { for (state, raw_state) in raw_schema.states {
if let Some(leaves) = raw_state.leaves_after {
if leaves.millis() == 0 {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: leaves_after needs some days, hours or minutes");
}
leaves_for_state.insert(state.clone(), leaves);
}
if let Some(group) = raw_state.grants { if let Some(group) = raw_state.grants {
if group.trim().is_empty() { 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");
@@ -245,6 +283,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
mail_for_state, mail_for_state,
grants_for_state, grants_for_state,
after_for_state, after_for_state,
leaves_for_state,
}, },
); );
} }
+67
View File
@@ -114,6 +114,73 @@ pub async fn store_answer(
Ok(()) Ok(())
} }
/// Whose a record is, on an alternative that keeps one per person
/// (`one_per_person`): the signed-in person's username, written by the
/// server into the record's fields, never taken from the form.
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> {
use futures::TryStreamExt;
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 };
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
own.push(answer);
}
}
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
own
}
/// A person keeps one card: of their records still in the bucket's
/// first state, only the newest stays in the list, and the rest leave
/// 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) {
let own = own_records(js, bucket, username).await;
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");
}
}
}
/// Fills the form of every `one_per_person` alternative with what the
/// signed-in person's own newest record says, so sending it again is
/// editing their card. A file field shows the file already there, at
/// 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>) {
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()) {
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 };
if req.kind == "file" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
} else {
req.value = Some(value.to_string());
}
}
}
}
/// One selected row's pending transition. `feature_name` is per-item /// One selected row's pending transition. `feature_name` is per-item
/// so a batch can span more than one resource feature on the same /// so a batch can span more than one resource feature on the same
/// alternative. /// alternative.
+356 -9
View File
@@ -57,6 +57,8 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// Server-side, so it can read hash.txt and link the // Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true). // content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/> <HashedStylesheet id="leptos" options=options.clone()/>
// Before the router, so it sees a link's click first.
<script src="/page-motion.js" defer=true></script>
<AutoReload options=options.clone()/> <AutoReload options=options.clone()/>
<HydrationScripts options/> <HydrationScripts options/>
<MetaTags/> <MetaTags/>
@@ -369,7 +371,7 @@ fn QuestionView(
// Swiper Element only registers its custom elements when its // Swiper Element only registers its custom elements when its
// (vendored, ~180KB) script runs - loaded once per page, and only // (vendored, ~180KB) script runs - loaded once per page, and only
// on pages where some alternative actually declares a deck. // on pages where some alternative actually declares a deck.
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1); let needs_swiper = question.slideshow.is_some() || question.alternatives.iter().any(|a| a.images.len() > 1);
// MapLibre is vendored and large, so it loads only where a field // MapLibre is vendored and large, so it loads only where a field
// actually asks for a place, and only where this instance can // actually asks for a place, and only where this instance can
// serve tiles at all. // serve tiles at all.
@@ -396,6 +398,8 @@ fn QuestionView(
landing=question_id == "/" landing=question_id == "/"
site=site.clone() site=site.clone()
current_id=question_id.clone() current_id=question_id.clone()
user=user.clone()
slideshow=question.slideshow.clone()
/> />
// A page that is about somewhere says where, under the // A page that is about somewhere says where, under the
// question: a still from this origin, and the place in words // question: a still from this origin, and the place in words
@@ -841,7 +845,15 @@ mod gesture {
} }
#[component] #[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView { fn Hero(
title: String,
description: String,
landing: bool,
site: SiteConfig,
current_id: String,
#[prop(optional_no_strip)] user: Option<User>,
#[prop(optional_no_strip)] slideshow: Option<crate::content::Slideshow>,
) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is // Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header. // actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's // WHAT the piece is belongs to the content repo: site.yaml's
@@ -940,9 +952,37 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
) )
}); });
// Who is here, and the way in or out, at the top of every page of
// a site that has sign-in at all.
let session = site.sign_in.then(|| {
let redirect = current_id.clone();
match user {
Some(user) => view! {
<nav class="session">
<span class="session-name">{user.display_name}</span>
<a href="/auth/logout" rel="external">{move || t(&lang().get(), "sign_out")}</a>
</nav>
}
.into_any(),
None => view! {
<nav class="session">
<a href=format!("/auth/login?redirect={redirect}") rel="external">{move || t(&lang().get(), "sign_in")}</a>
</nav>
}
.into_any(),
}
});
let has_slides = slideshow.is_some();
let mark = move || view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
};
view! { view! {
<header class="hero" class:hero-module=has_module> <header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
<Announcements current_id=current_id/> <Announcements current_id=current_id/>
{session}
{has_module {has_module
.then(|| { .then(|| {
view! { view! {
@@ -950,10 +990,13 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
<script type="module" inner_html=early_script.unwrap_or_default()></script> <script type="module" inner_html=early_script.unwrap_or_default()></script>
} }
})} })}
// With pictures, the wordmark is a bar of its own above
// them and the question comes under; without, the wordmark
// heads the copy as it always has.
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
<div class="hero-copy"> <div class="hero-copy">
<a class="wordmark" class:wordmark-house=house_wordmark href="/"> {(!has_slides).then(|| mark())}
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
<h1>{title}</h1> <h1>{title}</h1>
<p inner_html=render_inline_markdown(&description)></p> <p inner_html=render_inline_markdown(&description)></p>
</div> </div>
@@ -961,6 +1004,59 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
} }
} }
/// Pictures across the page (`slideshow`): the full width of the
/// screen, each picture as wide as its own shape at the strip's height,
/// the caption laid over its foot. Inert until the Swiper bundle
/// upgrades the elements; without it, a row that scrolls sideways.
#[component]
fn SlideStrip(show: crate::content::Slideshow) -> impl IntoView {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
}
/// An alternative's banner imagery: nothing, a plain image, or - for /// An alternative's banner imagery: nothing, a plain image, or - for
/// several urls - a Swiper Element card deck. The `swiper-container`/ /// several urls - a Swiper Element card deck. The `swiper-container`/
/// `swiper-slide` custom elements render inert server-side and upgrade /// `swiper-slide` custom elements render inert server-side and upgrade
@@ -1102,9 +1198,11 @@ fn AlternativeCard(
if req.kind == "file" { if req.kind == "file" {
file_refs.entry(req.name.clone()).or_default(); file_refs.entry(req.name.clone()).or_default();
} else if req.kind == "select" { } else if req.kind == "select" {
// Preset by a card of one's own (`answers::fill_own`).
let preset: Vec<String> = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect();
select_field_map select_field_map
.entry(req.name.clone()) .entry(req.name.clone())
.or_insert_with(|| RwSignal::new(Vec::new())); .or_insert_with(|| RwSignal::new(preset));
select_multi.insert(req.name.clone(), req.multiple); select_multi.insert(req.name.clone(), req.multiple);
} else { } else {
// Widget-owned fields hold JSON objects, not strings. // Widget-owned fields hold JSON objects, not strings.
@@ -1142,6 +1240,11 @@ fn AlternativeCard(
let gesture_fields_for_submit = gesture_fields.clone(); let gesture_fields_for_submit = gesture_fields.clone();
let question_id_for_transition = question_id.clone(); let question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone(); let alt_name_for_transition = alternative.name.clone();
// From the moment Send is pressed until the server has answered:
// files upload first, and a second press in that time used to send
// the form twice.
let sending = RwSignal::new(false);
let send_error: RwSignal<Option<String>> = RwSignal::new(None);
let on_submit = move |ev: leptos::ev::SubmitEvent| { let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
@@ -1170,6 +1273,8 @@ fn AlternativeCard(
let question_id_for_submit = question_id_for_submit.clone(); let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone(); let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone(); let parent_hash_for_submit = parent_hash_for_submit.clone();
sending.set(true);
send_error.set(None);
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() { for (name, sig) in field_map_for_submit.iter() {
@@ -1205,12 +1310,25 @@ fn AlternativeCard(
}; };
let Some(files) = input.files() else { continue }; let Some(files) = input.files() else { continue };
let Some(file) = files.get(0) else { continue }; let Some(file) = files.get(0) else { continue };
// Too big is known before a byte is sent.
if file.size() > MAX_UPLOAD_BYTES as f64 {
send_error.set(Some(t(&lang().get_untracked(), "upload_too_large").to_string()));
sending.set(false);
return;
}
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await { match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
Ok(key) => { Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key)); map.insert(name.clone(), serde_json::Value::String(key));
} }
// A form sent without the file it asked for is a
// record that looks complete and is not: stop, and
// say so.
Err(e) => { Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}"); leptos::logging::error!("upload of {name} failed: {e:?}");
let status = e.as_string().and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok()));
send_error.set(Some(t(&lang().get_untracked(), upload_refusal(status)).to_string()));
sending.set(false);
return;
} }
} }
} }
@@ -1223,6 +1341,7 @@ fn AlternativeCard(
parent_hash: parent_hash_for_submit, parent_hash: parent_hash_for_submit,
responses_json, responses_json,
}); });
sending.set(false);
}); });
}; };
@@ -1306,6 +1425,7 @@ fn AlternativeCard(
feature_name=feature_name.clone() feature_name=feature_name.clone()
transitions=spec.transitions.clone() transitions=spec.transitions.clone()
link=spec.link.clone() link=spec.link.clone()
bucket=spec.bucket().map(str::to_string)
empty=spec.empty.clone() empty=spec.empty.clone()
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
@@ -1415,9 +1535,26 @@ fn AlternativeCard(
if req.kind == "file" { if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new); let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
// On a card of one's own, the file already
// there (see `answers::fill_own`).
let current = req.value.clone().filter(|v| v.starts_with("/attachment/")).map(|href| {
let picture = req.accept.as_deref().is_some_and(|a| a.contains("image/"));
view! {
<span class="field-current">
<span>{move || t(&lang().get(), "current_file")}</span>
{if picture {
view! { <img class="answer-thumb" src=href.clone() alt="" /> }.into_any()
} else {
view! { <a href=href.clone() rel="external">{href.rsplit('/').next().unwrap_or_default().to_string()}</a> }.into_any()
}}
<span class="field-hint">{move || t(&lang().get(), "keep_file")}</span>
</span>
}
});
return view! { return view! {
<label class="field" for=label_for> <label class="field" for=label_for>
{label_text} {label_text}
{current}
<input <input
id=field_id id=field_id
type="file" type="file"
@@ -1729,12 +1866,24 @@ fn AlternativeCard(
class="alt-submit" class="alt-submit"
disabled=move || { disabled=move || {
(!has_requirements && pending_transitions.get().is_empty()) (!has_requirements && pending_transitions.get().is_empty())
|| sending.get()
|| submit.pending().get() || submit.pending().get()
|| transition_batch.pending().get() || transition_batch.pending().get()
} }
> >
{button_label.clone()} {button_label.clone()}
</button> </button>
// Why nothing happened, when something
// was refused: it used to say nothing.
{move || {
send_error
.get()
.or_else(|| match submit.value().get() {
Some(Err(e)) => Some(server_message(&e)),
_ => None,
})
.map(|m| view! { <p class="alt-error" role="alert">{m}</p> })
}}
</form> </form>
} }
.into_any() .into_any()
@@ -1759,6 +1908,7 @@ fn ResourceFeature(
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
link: Option<RowLink>, link: Option<RowLink>,
bucket: Option<String>,
empty: Option<String>, empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
@@ -1794,6 +1944,7 @@ fn ResourceFeature(
let feature_name = feature_name.clone(); let feature_name = feature_name.clone();
let transitions = transitions.clone(); let transitions = transitions.clone();
let link = link.clone(); let link = link.clone();
let bucket = bucket.clone();
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string()); let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
data.get() data.get()
.map(|res| match res { .map(|res| match res {
@@ -1803,6 +1954,7 @@ fn ResourceFeature(
feature_name=feature_name feature_name=feature_name
transitions=transitions transitions=transitions
link=link link=link
bucket=bucket
empty=empty empty=empty
value=value value=value
pending_transitions=pending_transitions pending_transitions=pending_transitions
@@ -1831,6 +1983,7 @@ fn ResourceValue(
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
link: Option<RowLink>, link: Option<RowLink>,
bucket: Option<String>,
empty: String, empty: String,
value: serde_json::Value, value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
@@ -1850,12 +2003,16 @@ fn ResourceValue(
} }
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) { if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers; let mut answers = answers;
// Cards are shown in the order they were put up, the way a
// team or a gallery is read; a list of cases, newest first.
let cards = transitions.is_empty() && answers.iter().all(|a| picture_of(a, bucket.as_deref()).is_some());
answers.sort_by(|a, b| { answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE; let open = |s: &str| s != crate::answers::OPEN_STATE;
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms)) let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
open(&a.state).cmp(&open(&b.state)).then(when)
}); });
return view! { return view! {
<div class="answer-list"> <div class="answer-list" class:answer-cards=cards>
<For <For
each=move || answers.clone() each=move || answers.clone()
key=|a| a.id.clone() key=|a| a.id.clone()
@@ -1863,12 +2020,14 @@ fn ResourceValue(
let feature_name = feature_name.clone(); let feature_name = feature_name.clone();
let transitions = transitions.clone(); let transitions = transitions.clone();
let link = link.clone(); let link = link.clone();
let bucket = bucket.clone();
move |answer: Answer| { move |answer: Answer| {
view! { view! {
<AnswerRow <AnswerRow
feature_name=feature_name.clone() feature_name=feature_name.clone()
transitions=transitions.clone() transitions=transitions.clone()
link=link.clone() link=link.clone()
bucket=bucket.clone()
answer=answer answer=answer
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
@@ -2123,11 +2282,62 @@ fn SelectField(
} }
} }
/// The file's own name, if a field's value is an upload's key: a
/// uuid, a dash, and the name it was sent with (`upload.rs`).
pub fn uploaded_filename(value: &str) -> Option<&str> {
let (id, rest) = value.split_at_checked(36)?;
let shaped = id.char_indices().all(|(i, c)| if matches!(i, 8 | 13 | 18 | 23) { c == '-' } else { c.is_ascii_hexdigit() });
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
/// The largest file one upload may carry. Here, not in `upload.rs`,
/// so the browser checks the same number before it sends anything.
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
/// Why an upload did not arrive, in words a person can act on, from
/// the status `POST /upload` answered with (see `upload.rs`): too big,
/// a site that takes no files, a page they may not send from, or
/// anything else.
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
fn upload_refusal(status: Option<u16>) -> &'static str {
match status {
Some(413) => "upload_too_large",
Some(503) => "upload_unavailable",
Some(403) => "upload_forbidden",
_ => "upload_failed",
}
}
/// What a refused server call says, without the wrapping a person
/// has no use for.
fn server_message(e: &ServerFnError) -> String {
let text = e.to_string();
text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text)
}
/// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase();
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"].iter().any(|ext| lower.ends_with(ext))
}
/// A record that carries a picture, in a list nobody works from, is
/// shown rather than read: the picture, then what it says, first field
/// as its heading. A team, a gallery of what has been built.
fn picture_of(answer: &Answer, bucket: Option<&str>) -> Option<String> {
let bucket = bucket?;
answer.responses.as_object()?.iter().find_map(|(key, value)| {
let name = uploaded_filename(value.as_str()?)?;
is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id))
})
}
#[component] #[component]
fn AnswerRow( fn AnswerRow(
feature_name: String, feature_name: String,
transitions: Vec<Transition>, transitions: Vec<Transition>,
link: Option<RowLink>, link: Option<RowLink>,
bucket: Option<String>,
answer: Answer, answer: Answer,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
@@ -2150,6 +2360,11 @@ fn AnswerRow(
} else { } else {
available.is_empty() available.is_empty()
}; };
if transitions.is_empty() {
if let Some(picture) = picture_of(&answer, bucket.as_deref()) {
return AnswerCard(answer, picture, link).into_any();
}
}
let responses = answer let responses = answer
.responses .responses
.as_object() .as_object()
@@ -2161,6 +2376,9 @@ fn AnswerRow(
let item_id = answer.id.clone(); let item_id = answer.id.clone();
let key = (feature_name.clone(), item_id.clone()); let key = (feature_name.clone(), item_id.clone());
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label)); let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
// Where a stored file on this record is fetched from: the record
// names it, never the key (see `upload::attachment`).
let attachment_base = bucket.map(|b| format!("/attachment/{b}/{}", answer.id));
view! { view! {
<article class="answer-row" class:decided=move || decided> <article class="answer-row" class:decided=move || decided>
@@ -2194,6 +2412,28 @@ fn AnswerRow(
.into_any(); .into_any();
} }
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string()); let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
// A stored file shows as its name, and opens.
if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) {
let href = format!("{base}/{key}");
if is_picture(filename) {
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value" href=href.clone() rel="external">
<img class="answer-thumb" src=href.clone() alt=filename.to_string() loading="lazy" />
</a>
</div>
}
.into_any();
}
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value answer-file" href=href rel="external">{filename.to_string()}</a>
</div>
}
.into_any();
}
view! { view! {
<div class="answer-field"> <div class="answer-field">
<span class="answer-field-key">{key}</span> <span class="answer-field-key">{key}</span>
@@ -2288,6 +2528,41 @@ fn AnswerRow(
</Show> </Show>
</article> </article>
} }
.into_any()
}
/// A record shown as a card: its picture, its first field as the
/// heading and the others under it, and nothing a reader has no use
/// for - no field names, no state, no time.
#[allow(non_snake_case)]
fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl IntoView {
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
let texts: Vec<String> = answer
.responses
.as_object()
.into_iter()
.flatten()
.filter_map(|(_, v)| v.as_str())
.filter(|v| uploaded_filename(v).is_none() && !v.trim().is_empty())
.map(str::to_string)
.collect();
let heading = texts.first().cloned().unwrap_or_default();
let rest: Vec<String> = texts.into_iter().skip(1).collect();
view! {
<article class="answer-row answer-card">
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
<div class="answer-card-text">
<h4 class="answer-card-heading">{heading}</h4>
// What the person wrote, as they wrote it: markdown, on
// the stricter rules for text that is not the site's.
{rest
.into_iter()
.map(|t| view! { <div class="card-field" inner_html=crate::content::render_written_markdown(&t)></div> })
.collect_view()}
</div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article>
}
} }
/// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric- /// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric-
@@ -2366,6 +2641,7 @@ pub async fn get_question(
let session: tower_sessions::Session = leptos_axum::extract().await?; let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?; let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
fill_from_user(&mut question, user.as_ref()); fill_from_user(&mut question, user.as_ref());
crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await;
let mut chain_gate = None; let mut chain_gate = None;
if let Some(target) = &question.requires_chain { if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required // The claimed lineage must verifiably end at the required
@@ -2407,6 +2683,7 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
// own configuration, and the browser has to know before it decides // own configuration, and the browser has to know before it decides
// whether to draw a map or ask for two numbers. // whether to draw a map or ask for two numbers.
site.maps = crate::maps::enabled(); site.maps = crate::maps::enabled();
site.sign_in = state.oidc.enabled();
Ok(site) Ok(site)
} }
@@ -2718,6 +2995,26 @@ pub async fn submit_answer(
let mut responses: serde_json::Value = serde_json::from_str(&responses_json) let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?; .map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// One card each: whose it is comes from the session, and a file the
// person did not choose again is the one already on their card.
let owner = if alt.one_per_person {
let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?;
let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?;
let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next();
if let Some(map) = responses.as_object_mut() {
for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") {
let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty());
if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) {
map.insert(req.name.clone(), kept.clone());
}
}
map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone()));
}
Some((bucket, user.username.clone()))
} else {
None
};
// A record that belongs to another record: check the other one is // A record that belongs to another record: check the other one is
// there before this one is written, so a reference in a stored // there before this one is written, so a reference in a stored
// record is one that resolved at least once. An optional field // record is one that resolved at least once. An optional field
@@ -2833,6 +3130,15 @@ pub async fn submit_answer(
{ {
tracing::error!("failed to store answer in {bucket}: {e}"); tracing::error!("failed to store answer in {bucket}: {e}");
} }
if let Some((own_bucket, username)) = &owner {
let initial = state
.aggregates
.load()
.get(own_bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
crate::answers::keep_newest_own(&state.jetstream, own_bucket, &initial, username).await;
}
// Arrival is a state too: what the schema says the first // Arrival is a state too: what the schema says the first
// state sends, it sends now. // state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) { if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
@@ -2933,6 +3239,14 @@ mod responsible_tests {
Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) } Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) }
} }
#[test]
fn an_uploads_key_shows_as_the_name_it_was_sent_with() {
assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-rapport 2026.pdf"), Some("rapport 2026.pdf"));
assert_eq!(super::uploaded_filename("rapport.pdf"), None);
assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-"), None);
assert_eq!(super::uploaded_filename("Storgata 1, 1850 Mysen - se vedlegg i e-post fra oss"), None);
}
#[test] #[test]
fn only_the_person_a_page_names_may_suggest_a_change_to_it() { fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None))); assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None)));
@@ -2952,3 +3266,36 @@ mod responsible_tests {
assert!(!is_responsible(None, &board)); assert!(!is_responsible(None, &board));
} }
} }
#[cfg(test)]
mod picture_tests {
use super::*;
#[test]
fn an_upload_refusal_says_which_it_was() {
assert_eq!(upload_refusal(Some(413)), "upload_too_large");
assert_eq!(upload_refusal(Some(503)), "upload_unavailable");
assert_eq!(upload_refusal(Some(403)), "upload_forbidden");
assert_eq!(upload_refusal(Some(502)), "upload_failed");
assert_eq!(upload_refusal(None), "upload_failed");
for lang in ["en", "no"] {
for status in [Some(413), Some(503), Some(403), None] {
let key = upload_refusal(status);
assert_ne!(crate::i18n::t(lang, key), key, "{lang} has words for {key}");
}
}
}
#[test]
fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
let answer: Answer = serde_json::from_value(serde_json::json!({
"id": "r1", "question_id": "kontor/ansatt", "alternative": "a", "state": "open", "submitted_ms": 1,
"responses": { "name": "Kari", "portrett": "0f8fad5b-d9cb-469f-a165-70867728950e-kari.jpg", "cv": "0f8fad5b-d9cb-469f-a165-70867728950e-cv.pdf" }
}))
.unwrap();
assert_eq!(picture_of(&answer, Some("ansatte")).as_deref(), Some("/attachment/ansatte/r1/portrett"));
// Without a bucket there is nowhere to fetch it from.
assert_eq!(picture_of(&answer, None), None);
}
}
+6 -1
View File
@@ -74,14 +74,19 @@ pub async fn site_asset_handler(
Some("svg") => "image/svg+xml", Some("svg") => "image/svg+xml",
Some("json") => "application/json", Some("json") => "application/json",
Some("png") => "image/png", Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp", Some("webp") => "image/webp",
Some("avif") => "image/avif", Some("avif") => "image/avif",
Some("ico") => "image/x-icon", Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2", Some("woff2") => "font/woff2",
_ => "application/octet-stream", _ => "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" };
( (
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")], [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
bytes, bytes,
) )
.into_response() .into_response()
+64
View File
@@ -118,3 +118,67 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
cleaned.replacen("<img", &format!("<img{attrs}"), 1) 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 ![pixel](https://tracker.example/p.gif) after"), "<p>before after</p>");
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
}
}
+75 -1
View File
@@ -95,6 +95,12 @@ pub struct Question {
/// rather than on the whole world. /// rather than on the whole world.
#[serde(default)] #[serde(default)]
pub place: Option<Place>, pub place: Option<Place>,
/// Pictures across the page, under the question: a strip the full
/// width of the screen that a visitor swipes through, each picture
/// with its caption laid over it. What the business has built,
/// shown rather than described.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slideshow: Option<Slideshow>,
} }
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe) /// A point a page is about. `zoom` is a map zoom level, 0 (the globe)
@@ -337,6 +343,14 @@ pub struct Alternative {
/// works (lysbue had the same flag). /// works (lysbue had the same flag).
#[serde(default)] #[serde(default)]
pub disabled: bool, pub disabled: bool,
/// Each signed-in person keeps one record here: their own, which
/// sending again replaces. The form opens with what their record
/// says, a file left empty keeps the one already there, and the
/// older record leaves the list. For what a person says about
/// themselves - a card on the front page, a profile. Needs
/// `record_as`, and someone signed in.
#[serde(default)]
pub one_per_person: bool,
#[serde(default)] #[serde(default)]
pub consequence: Vec<String>, pub consequence: Vec<String>,
#[serde(default)] #[serde(default)]
@@ -625,6 +639,50 @@ pub struct Requirement {
/// listing may ask about a record's own contents: one field, one /// listing may ask about a record's own contents: one field, one
/// value, compared as strings. Anything more is a query language, and /// value, compared as strings. Anything more is a query language, and
/// a jq filter is already there for shaping what comes back. /// a jq filter is already there for shaping what comes back.
/// A strip of pictures across a page (`Question.slideshow`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slideshow {
/// How tall the strip may be, in hundredths of the small viewport
/// height (`svh`): 60 is six tenths of the screen. Every picture is
/// that tall and as wide as its own shape makes it.
#[serde(default = "default_slideshow_height")]
pub max_height: u8,
pub slides: Vec<Slide>,
}
fn default_slideshow_height() -> u8 {
60
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slide {
/// A path in the content repo (`images/built/01.webp`, served from
/// this origin under `/site/`) or an `https://` address.
pub image: String,
/// Laid over the foot of the picture, and its alternative text.
#[serde(default)]
pub caption: String,
/// The picture's own size in pixels, when known: the browser then
/// holds its place in the strip before it has loaded, so pictures
/// further along can wait until they are swiped to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}
impl Slide {
/// Where the browser fetches the picture: https as given, anything
/// else as a content-repo path under `/site/`.
pub fn src(&self) -> String {
if self.image.starts_with("https://") || self.image.starts_with("/site/") {
self.image.clone()
} else {
format!("/site/{}", self.image.trim_start_matches('/'))
}
}
}
/// A link from every listed record to a page about it (`ResourceSpec.link`). /// A link from every listed record to a page about it (`ResourceSpec.link`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RowLink { pub struct RowLink {
@@ -729,8 +787,13 @@ pub struct SiteConfig {
/// the way to the browser (see `get_site`), never from content, so /// the way to the browser (see `get_site`), never from content, so
/// a `type: location` field knows whether to offer a map or two /// a `type: location` field knows whether to offer a map or two
/// number inputs. /// number inputs.
#[serde(skip)] #[serde(default)]
pub maps: bool, pub maps: bool,
/// Whether this instance has sign-in at all (a provider is
/// configured): set on the way to the browser like `maps`, so the
/// header knows whether to offer it.
#[serde(default)]
pub sign_in: bool,
/// Who this site's mail comes from. Absent means the site sends /// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and /// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`). /// skipped, never half-sent (see `mail.rs`).
@@ -1904,6 +1967,17 @@ alternatives:
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None)); assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
} }
#[test]
fn a_slide_is_fetched_from_this_origin_unless_it_is_https() {
let q: Question = serde_yaml::from_str("name: Q?\nslideshow:\n slides:\n - { image: images/built/01.webp, caption: The canteen, width: 3207, height: 1400 }\n - { image: \"https://cdn.example/a.jpg\" }\n").unwrap();
let show = q.slideshow.unwrap();
assert_eq!(show.max_height, 60);
assert_eq!(show.slides[0].src(), "/site/images/built/01.webp");
assert_eq!(show.slides[0].width, Some(3207));
assert_eq!(show.slides[1].src(), "https://cdn.example/a.jpg");
assert_eq!(show.slides[1].caption, "");
}
#[test] #[test]
fn markdown_drops_html_and_unsafe_links() { fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z"); assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
+26
View File
@@ -146,6 +146,25 @@ pub fn validate_questions(
} }
} }
// A slideshow shows something, at a height that leaves the page
// its question: between a fifth and the whole of the screen.
for question in questions.values() {
if let Some(show) = &question.slideshow {
if show.slides.is_empty() {
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);
}
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('/'));
if !ok {
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
}
}
}
}
// A mail to the record this one belongs to goes through a field of // A mail to the record this one belongs to goes through a field of
// the form: `{notice.email}` needs a `type: record` field called // the form: `{notice.email}` needs a `type: record` field called
// `notice` on every form that records into the bucket, or the mail // `notice` on every form that records into the bucket, or the mail
@@ -493,6 +512,13 @@ pub fn validate_questions(
} }
for question in questions.values() { for question in questions.values() {
for alternative in &question.alternatives { for alternative in &question.alternatives {
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",
question.id,
alternative.name
);
}
if let Some(bucket) = &alternative.record_as { if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket) let attended = read_buckets.contains(bucket)
|| aggregates || aggregates
+64
View File
@@ -37,6 +37,55 @@ pub async fn run(state: AppState) {
} }
} }
/// Records leaving their list: once a record has been in a state with
/// `leaves_after` for that long, it is taken out of the bucket. Not a
/// move - nothing is decided, no mail goes - and the event log keeps
/// the record. Every minute, over the buckets that use it and no
/// others.
pub async fn run_leaving(state: AppState) {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
if let Err(e) = sweep_leaving(&state).await {
tracing::warn!(error = %e, "leaving sweep failed");
}
}
}
/// Whether a record has stayed out its time in the state it is in.
pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> bool {
schema
.leaves_for_state
.get(&answer.state)
.is_some_and(|leaves| now_ms >= waiting_since(answer) + leaves.millis())
}
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 };
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => continue,
}
};
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 };
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(())
}
/// When a record entered the state it is in now. A record nobody has /// When a record entered the state it is in now. A record nobody has
/// moved has been waiting since it was sent. /// moved has been waiting since it was sent.
fn waiting_since(answer: &Answer) -> i64 { fn waiting_since(answer: &Answer) -> i64 {
@@ -150,6 +199,21 @@ aggregates:
} }
} }
#[test]
fn a_record_leaves_its_list_when_its_time_in_the_state_is_up() {
let s = schema(
"aggregates:\n - bucket: people\n initial: open\n states:\n open: { event: shown }\n gone: { event: gone, leaves_after: { minutes: 10 } }\n transitions:\n open: [gone]\n",
);
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 + 10 * 60 * 1000));
// A state without it keeps its records.
assert!(!has_left(&s, &waiting("open", 0), t));
assert!(crate::aggregates::parse_aggregates_yaml("aggregates:\n - bucket: p\n initial: open\n states:\n open: { event: o, leaves_after: {} }\n transitions: {}\n").is_err());
}
#[test] #[test]
fn a_deadline_comes_due_the_day_it_says_and_not_before() { fn a_deadline_comes_due_the_day_it_says_and_not_before() {
let s = schema(HEARINGS); let s = schema(HEARINGS);
+21
View File
@@ -22,7 +22,14 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "if_stuck") => " if you get stuck.", ("en", "if_stuck") => " if you get stuck.",
("en", "send") => "Send", ("en", "send") => "Send",
("en", "sign_in") => "Sign in", ("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.", ("en", "nothing_here_yet") => "Nothing here yet.",
("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", ("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start", ("en", "back_to_start") => "back to the start",
("en", "done") => "Done.", ("en", "done") => "Done.",
@@ -51,7 +58,14 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "if_stuck") => " om du står fast.", ("no", "if_stuck") => " om du står fast.",
("no", "send") => "Send", ("no", "send") => "Send",
("no", "sign_in") => "Logg inn", ("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.", ("no", "nothing_here_yet") => "Ingenting her ennå.",
("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", ("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start", ("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.", ("no", "done") => "Ferdig.",
@@ -98,7 +112,14 @@ mod tests {
"if_stuck", "if_stuck",
"send", "send",
"sign_in", "sign_in",
"sign_out",
"nothing_here_yet", "nothing_here_yet",
"upload_failed",
"upload_too_large",
"upload_unavailable",
"upload_forbidden",
"current_file",
"keep_file",
"nothing_here", "nothing_here",
"back_to_start", "back_to_start",
"done", "done",
+5 -3
View File
@@ -180,11 +180,13 @@ fn check_needs(
} }
} }
if failed == 0 { if failed == 0 {
let planned = file.needs.iter().filter(|n| n.planned).count(); let planned = file.needs.iter().filter(|n| n.planned && !n.paused).count();
let paused = file.needs.iter().filter(|n| n.paused).count();
println!( println!(
"OK: {} need(s) structurally met, {} planned, {} persona(s)", "OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
file.needs.len() - planned, file.needs.len() - planned - paused,
planned, planned,
paused,
file.personas.len() file.personas.len()
); );
} }
+9 -5
View File
@@ -78,6 +78,10 @@ pub struct Need {
/// as a warning, so the gap stays visible without failing CI. /// as a warning, so the gap stays visible without failing CI.
#[serde(default)] #[serde(default)]
pub planned: bool, pub planned: bool,
/// Open before and closed for now (full up, out of season): its
/// card is on the page with no form. Reports like `planned`.
#[serde(default)]
pub paused: bool,
/// Each state of the bucket in plain words, in the site's /// Each state of the bucket in plain words, in the site's
/// language, for the scorer: state ids are ASCII, and a Norwegian /// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model. /// `ikke_lost` reads as English to a model.
@@ -305,11 +309,11 @@ pub fn check(
let mut report = |level: Level, message: String| { let mut report = |level: Level, message: String| {
findings.push(Finding { findings.push(Finding {
need: need.id.clone(), need: need.id.clone(),
level: if need.planned { Level::Warn } else { level }, level: if need.planned || need.paused { Level::Warn } else { level },
message: if need.planned && level == Level::Fail { message: match (need.paused, need.planned, level == Level::Fail) {
format!("planned, not met yet: {message}") (true, _, true) => format!("paused, not open now: {message}"),
} else { (false, true, true) => format!("planned, not met yet: {message}"),
message _ => message,
}, },
}) })
}; };
+63 -41
View File
@@ -132,6 +132,7 @@ async fn serve() -> anyhow::Result<()> {
// windows (see announce.rs). // windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone())); tokio::spawn(portal::announce::run(state.clone()));
tokio::spawn(portal::deadlines::run(state.clone())); tokio::spawn(portal::deadlines::run(state.clone()));
tokio::spawn(portal::deadlines::run_leaving(state.clone()));
// The accounts the content mandates: the first person in, and // The accounts the content mandates: the first person in, and
// everyone a page names as responsible, at start and whenever the // everyone a page names as responsible, at start and whenever the
// content changes (seed.rs). // content changes (seed.rs).
@@ -166,30 +167,32 @@ async fn serve() -> anyhow::Result<()> {
.with_name("portal_session"); .with_name("portal_session");
// What a person does costs a little; what a script does costs a // What a person does costs a little; what a script does costs a
// lot. A burst of 30 with one refilled every two seconds is well // lot. More than a hundred requests in ten seconds is a script: a
// above anything a visitor filling in a form will reach - a page // burst of 100, refilled at ten a second. Someone reloading a page
// load fetches several resources at once - and well below what it // again and again stays well inside it, the more so since the
// takes to be a nuisance. Per caller IP, in memory, no store: this // files a page is made of (stylesheet, scripts, fonts, pictures)
// is a fence, not an accounting system, and the fence that matters // are not counted at all - see `assets` below. Per caller IP, in
// for uploads is the policy check on the route itself. // memory, no store: this is a fence, not an accounting system, and
// the fence that matters for uploads is the policy check on the
// route itself.
// //
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site; // PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
// 0 for either turns it off, for an instance behind something that // 0 for either turns it off, for an instance behind something that
// already does this. // already does this.
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS") let rate_per_second: u64 = std::env::var("PORTAL_RATE_PER_SECOND")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(2); .unwrap_or(10);
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST") let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(30); .unwrap_or(100);
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| { let governor = (rate_per_second > 0 && rate_burst > 0).then(|| {
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is // SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
// what every one of these instances is behind; it falls back to // what every one of these instances is behind; it falls back to
// the socket address when neither is set. // the socket address when neither is set.
let conf = tower_governor::governor::GovernorConfigBuilder::default() let conf = tower_governor::governor::GovernorConfigBuilder::default()
.per_second(rate_seconds) .per_millisecond((1000 / rate_per_second).max(1))
.burst_size(rate_burst) .burst_size(rate_burst)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor) .key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish() .finish()
@@ -197,8 +200,8 @@ async fn serve() -> anyhow::Result<()> {
std::sync::Arc::new(conf) std::sync::Arc::new(conf)
}); });
match &governor { match &governor {
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"), Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS 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( async fn server_fn_handler(
@@ -231,6 +234,51 @@ async fn serve() -> anyhow::Result<()> {
let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root); let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root);
let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root); let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root); let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
// The files a page is made of, from this disk: the stylesheet, the
// scripts, the fonts. Not counted by the rate limit - they cost
// next to nothing, and refusing one is what turns a reloaded page
// into an unstyled mess - and no session is loaded for them.
// Anything that makes portal ask someone else (content pictures
// from Gitea, map tiles) stays below, counted.
let assets: Router<AppState> = Router::new()
// no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
// them - a stale wasm from the previous release then talks to
// a server whose server-fn wire format has moved on and every
// page renders as its error branch. A 304 per load is the
// price of never shipping that skew again.
.nest_service(
"/pkg",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(pkg_dir)),
)
.nest_service("/fonts", ServeDir::new(fonts_dir))
.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),
)
// Same name every release, so revalidated like /pkg.
.route_service(
"/page-motion.js",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new(page_motion)),
)
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css));
let app = Router::new() let app = Router::new()
.route("/auth/login", get(oidc::login)) .route("/auth/login", get(oidc::login))
@@ -260,32 +308,6 @@ async fn serve() -> anyhow::Result<()> {
.route("/map/style.json", get(portal::maps::style)) .route("/map/style.json", get(portal::maps::style))
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile)) .route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
.route("/map/still/{place}/{size}", get(portal::maps::still)) .route("/map/still/{place}/{size}", get(portal::maps::still))
// no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
// them - a stale wasm from the previous release then talks to
// a server whose server-fn wire format has moved on and every
// page renders as its error branch. A 304 per load is the
// price of never shipping that skew again.
.nest_service(
"/pkg",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(pkg_dir)),
)
.nest_service("/fonts", ServeDir::new(fonts_dir))
.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("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
.leptos_routes_with_context( .leptos_routes_with_context(
&state, &state,
routes, routes,
@@ -307,8 +329,8 @@ async fn serve() -> anyhow::Result<()> {
let app = match governor { let app = match governor {
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)), Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
None => app, None => app,
} };
.with_state(state); let app = assets.merge(app).with_state(state);
tracing::info!("listening on http://{addr}"); tracing::info!("listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
+11 -4
View File
@@ -184,6 +184,12 @@ fn name_from_search(body: &str) -> Option<String> {
pub struct Kanidm { pub struct Kanidm {
base: String, base: String,
token: String, token: String,
/// What this site's groups are called in a Kanidm it shares with
/// other sites: `KANIDM_GROUP_PREFIX=klingenberg_` makes the
/// content's `kunder` the group `klingenberg_kunder` there. The
/// OAuth2 client's claim map takes the prefix off again, so content
/// and policy never see it. Empty for a Kanidm of the site's own.
group_prefix: String,
} }
impl Kanidm { impl Kanidm {
@@ -192,7 +198,8 @@ impl Kanidm {
pub fn from_env() -> Option<Self> { pub fn from_env() -> Option<Self> {
let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string(); 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 token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?;
Some(Kanidm { base, token }) 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)> {
@@ -262,7 +269,7 @@ impl Kanidm {
/// Who is in a group, by account name. /// Who is in a group, by account name.
pub async fn members(&self, group: &str) -> anyhow::Result<Vec<String>> { 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"), 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) { if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}"); anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
} }
@@ -273,7 +280,7 @@ impl Kanidm {
/// service account manages, like adding them was. /// service account manages, like adding them was.
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> { pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
let (status, text) = self let (status, text) = self
.call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{group}/_attr/member"), 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?; .await?;
if !(200..300).contains(&status) { if !(200..300).contains(&status) {
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}"); anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
@@ -340,7 +347,7 @@ impl Kanidm {
// tidying up after itself. // 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 let (status, text) = self
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), 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?; .await?;
if !(200..300).contains(&status) { if !(200..300).contains(&status) {
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}"); anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
+59 -8
View File
@@ -39,7 +39,7 @@ pub async fn get_resource(
.as_ref() .as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?; .ok_or_else(|| ServerFnError::new("feature has no resource"))?;
authorize_resource(&state, &question_id, &alternative, &feature_name).await?; authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
fetch_resource_value(&state, resource, &params).await fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
} }
/// The `Requirement.resource`-backed counterpart to `get_resource` - /// The `Requirement.resource`-backed counterpart to `get_resource` -
@@ -83,7 +83,7 @@ pub async fn get_requirement_options(
.as_ref() .as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?; .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, &params).await fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await
} }
/// The `Requirement.bind`-backed counterpart to /// The `Requirement.bind`-backed counterpart to
@@ -114,7 +114,7 @@ pub async fn get_requirement_binding(
.as_ref() .as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?; .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, &params).await fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await
} }
/// Scoped to the named alternative first, not flattened across all of /// Scoped to the named alternative first, not flattened across all of
@@ -211,8 +211,12 @@ fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Valu
/// stamped on it. A missing field never matches: "everything that did /// stamped on it. A missing field never matches: "everything that did
/// not say" is not something a page should be able to ask for by /// not say" is not something a page should be able to ask for by
/// accident. /// accident.
///
/// `equals: "{user.email}"` is the signed-in person's own address: a
/// list of "my cases". It is filled from the session, never from the
/// caller, and with nobody signed in it matches nothing.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
fn matches_filter( pub(crate) fn matches_filter(
resource: &crate::content::ResourceSpec, resource: &crate::content::ResourceSpec,
value: &serde_json::Value, value: &serde_json::Value,
params: &std::collections::HashMap<String, String>, params: &std::collections::HashMap<String, String>,
@@ -220,19 +224,54 @@ fn matches_filter(
let Some(filter) = &resource.filter else { let Some(filter) = &resource.filter else {
return true; return true;
}; };
let wanted = crate::content::template_action(&filter.equals, &serde_json::json!(params)) let equals = if filter.equals.contains("{user.email}") {
.unwrap_or_else(|| filter.equals.clone()); match params.get(USER_EMAIL).filter(|e| !e.is_empty()) {
Some(email) => filter.equals.replace("{user.email}", email),
None => return false,
}
} else {
filter.equals.clone()
};
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 found = value let found = value
.get("responses") .get("responses")
.and_then(|r| r.get(&filter.field)) .and_then(|r| r.get(&filter.field))
.or_else(|| value.get(&filter.field)); .or_else(|| value.get(&filter.field));
match found { match found {
Some(serde_json::Value::String(s)) => s == &wanted, Some(serde_json::Value::String(s)) => same(s),
Some(other) => other.to_string() == wanted, Some(other) => same(&other.to_string()),
None => false, None => false,
} }
} }
/// The parameter a `where` reads the signed-in person's address from.
/// Set by the server from the session; a caller's own is thrown away.
#[cfg(feature = "ssr")]
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> {
params.retain(|k, _| !k.starts_with("__"));
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> {
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(),
Err(_) => None,
};
with_person(params, user.as_ref())
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
async fn fetch_resource_value( async fn fetch_resource_value(
state: &crate::server::AppState, state: &crate::server::AppState,
@@ -559,6 +598,18 @@ mod tests {
assert!(!matches_filter(&children, &theirs, &params)); assert!(!matches_filter(&children, &theirs, &params));
// A record that did not say is not everyone's child. // A record that did not say is not everyone's child.
assert!(!matches_filter(&children, &neither, &params)); assert!(!matches_filter(&children, &neither, &params));
// "My cases": the session's address, never the caller's.
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 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));
// No filter, no narrowing: every resource written before this // No filter, no narrowing: every resource written before this
// existed still lists its whole bucket. // existed still lists its whole bucket.
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n"); let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
+5
View File
@@ -50,6 +50,11 @@ const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect"; const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc { impl Oidc {
/// Whether a provider is configured.
pub fn enabled(&self) -> bool {
self.inner.is_some()
}
/// Sign-in disabled: what a content-only instance has, and what a /// Sign-in disabled: what a content-only instance has, and what a
/// test builds an `AppState` with. /// test builds an `AppState` with.
pub fn disabled() -> Self { pub fn disabled() -> Self {
+453 -25
View File
@@ -62,6 +62,18 @@ pub struct Plan {
/// is what the page-load limit asks for. /// is what the page-load limit asks for.
#[serde(default)] #[serde(default)]
pub doors: Vec<Door>, pub doors: Vec<Door>,
/// Pictures across the front door, under the wordmark and above
/// the question: portal's `slideshow` block, verbatim (`max_height`,
/// `slides` with `image`, `caption`, `width`, `height`). What the
/// business has made, shown before anything is asked.
#[serde(default)]
pub slideshow: Option<Value>,
/// Texts the site carries word for word - bylaws, terms - each as
/// a page of its own where every section of the text is one
/// alternative: its heading the card's, its paragraphs the card's
/// words. No model touches them and no form hangs off them.
#[serde(default)]
pub documents: Vec<Document>,
/// Groups with a label the public may see: the committees a /// Groups with a label the public may see: the committees a
/// person can ask to join. A select field with `options_from: /// person can ask to join. A select field with `options_from:
/// groups` offers exactly these, so the form and the desks can /// groups` offers exactly these, so the form and the desks can
@@ -83,6 +95,13 @@ pub struct Plan {
/// from its `wants`. /// from its `wants`.
#[serde(default)] #[serde(default)]
pub does: Vec<Does>, pub does: Vec<Does>,
/// What the business stands on, in its own words: values, the
/// experience behind it. Each is a feature of the front door's
/// "who we are" card, beside the needs that `present` - the people
/// who work there. Nothing rewords the facts; the words model only
/// heads them.
#[serde(default)]
pub values: Vec<Does>,
/// Things a visitor may look at before asking for anything - the /// Things a visitor may look at before asking for anything - the
/// window shopping: a list of what has been built, releases, a /// window shopping: a list of what has been built, releases, a
/// live count - each a portal resource block, passed through as /// live count - each a portal resource block, passed through as
@@ -158,6 +177,85 @@ pub struct GroupDef {
pub invited_by: Vec<String>, pub invited_by: Vec<String>,
} }
/// A text carried word for word (`Plan.documents`).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Document {
/// The page's path: `vedtekter` is served at `/vedtekter`.
pub id: String,
/// The question the page asks, which the text answers.
pub question: String,
#[serde(default)]
pub description: String,
/// A markdown file beside the plan: every heading with text under
/// it is a section. Read into `sections` when the plan is loaded.
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub sections: Vec<Section>,
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Section {
pub heading: String,
/// The section's paragraphs, exactly as written.
pub text: Vec<String>,
}
/// A markdown text as sections: each heading with text under it, and
/// that text as paragraphs. A heading with nothing under it is a title
/// and no section; text before the first heading belongs to nobody and
/// is left out. Nothing is reworded: a paragraph's lines are joined
/// with one space, and that is all.
pub fn sections_of(markdown: &str) -> Vec<Section> {
let mut out: Vec<Section> = Vec::new();
let mut heading: Option<String> = None;
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
let close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
if let Some(h) = heading.take() {
if !paragraphs.is_empty() {
out.push(Section { heading: h, text: std::mem::take(paragraphs) });
}
}
paragraphs.clear();
};
for line in markdown.lines().chain(std::iter::once("")) {
let trimmed = line.trim();
let is_heading = trimmed.starts_with('#') && trimmed.trim_start_matches('#').starts_with(' ');
if trimmed.is_empty() || is_heading {
if !current.is_empty() {
paragraphs.push(current.join(" "));
current.clear();
}
}
if is_heading {
close(&mut heading, &mut paragraphs, &mut out);
heading = Some(trimmed.trim_start_matches('#').trim().trim_end_matches('.').to_string());
} else if !trimmed.is_empty() {
current.push(trimmed);
}
}
close(&mut heading, &mut paragraphs, &mut out);
out
}
impl Plan {
/// Reads every document's `file`, relative to `dir`, into its
/// sections. Done once, when a plan is loaded from disk.
pub fn load_documents(&mut self, dir: &Path) -> anyhow::Result<()> {
for doc in &mut self.documents {
if let Some(file) = &doc.file {
let path = dir.join(file);
let raw = std::fs::read_to_string(&path).with_context(|| format!("document {:?}: reading {}", doc.id, path.display()))?;
doc.sections = sections_of(&raw);
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Door { pub struct Door {
@@ -258,6 +356,14 @@ pub struct PlanNeed {
/// Groups that see these cases on their desk and move nothing. /// Groups that see these cases on their desk and move nothing.
#[serde(default)] #[serde(default)]
pub watched_by: Vec<String>, pub watched_by: Vec<String>,
/// Groups whose members each see *their own* cases and nobody
/// else's: the ones that carry the address they are signed in
/// with. A customer follows their assignment; a member sees what
/// they sent. The form must collect a required `email`, and the
/// group is usually one a stage `grants`, so the person who sent
/// the case is who ends up able to follow it.
#[serde(default)]
pub followed_by: Vec<String>,
/// The event name a stage is entered by, where it must not be the /// The event name a stage is entered by, where it must not be the
/// builder's default (the stage's own name; `received` for the /// builder's default (the stage's own name; `received` for the
/// first). A live site replays its records by event name, so a /// first). A live site replays its records by event name, so a
@@ -312,6 +418,34 @@ pub struct PlanNeed {
/// taken down; a register shows what was decided. /// taken down; a register shows what was decided.
#[serde(default)] #[serde(default)]
pub public_states: Vec<String>, pub public_states: Vec<String>,
/// Its records are something the business shows, not cases it
/// works: the people who work there, what it has built. With
/// `watched_by: [public]`, the front door shows every one of them
/// in the "who we are" card, and a `picture` field shows as the
/// picture.
#[serde(default)]
pub presents: bool,
/// Not taking new cases for now: full up, a season closed. The
/// card stays where it is, saying what the business does and that
/// it is paused, with no form to fill in and a button that does not
/// press; the server refuses a submission. Remove the line to open
/// again - the desk, the stages and every case under way are
/// untouched either way.
#[serde(default)]
pub paused: bool,
/// Each person in the group that enters it keeps one record: their
/// own card, which they open filled in and send again to change.
/// For what people say about themselves - who they are on the
/// front page. Needs `enters_as`, so there is someone signed in to
/// own it.
#[serde(default)]
pub one_per_person: bool,
/// How long a record stays in the lists once it reaches a stage,
/// when not for ever: `{ sluttet: { minutes: 10 } }` takes a card
/// off the desk's list ten minutes after it came off the front
/// page, instead of leaving it there greyed out.
#[serde(default)]
pub leaves_after: BTreeMap<String, crate::aggregates::LeavesSpec>,
pub collects: Vec<Field>, pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the /// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask. /// business said about them, not what the form happens to ask.
@@ -347,7 +481,7 @@ impl Field {
/// What a public listing may show: everything but contact details /// What a public listing may show: everything but contact details
/// and what the plan marks private. /// and what the plan marks private.
pub fn is_public(&self) -> bool { pub fn is_public(&self) -> bool {
!self.private && self.kind != "email" && self.kind != "tel" !self.private && self.kind != "email" && self.kind != "tel" && self.kind != "file"
} }
} }
@@ -389,7 +523,7 @@ impl Plan {
} }
} }
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"]; const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file", "picture"];
fn is_id(s: &str) -> bool { fn is_id(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
@@ -410,6 +544,17 @@ impl Plan {
bail!("contact: group {group:?} has no desk: it must handle, watch or move something"); bail!("contact: group {group:?} has no desk: it must handle, watch or move something");
} }
} }
for doc in &self.documents {
if !is_id(&doc.id) || ["status", "received", "decide"].contains(&doc.id.as_str()) || groups(self).contains(&doc.id) || self.doors.iter().any(|d| d.id == doc.id) {
bail!("document {:?}: id must be ASCII lowercase with - or _, and a path no door, group or builder page has", doc.id);
}
if doc.question.trim().is_empty() {
bail!("document {:?}: the page needs the question its text answers", doc.id);
}
if doc.sections.is_empty() {
bail!("document {:?} has no sections: give it a `file` with headings, or `sections`", doc.id);
}
}
let mut doors = BTreeSet::new(); let mut doors = BTreeSet::new();
for door in &self.doors { for door in &self.doors {
if !is_id(&door.id) || !doors.insert(&door.id) { if !is_id(&door.id) || !doors.insert(&door.id) {
@@ -519,8 +664,11 @@ impl Plan {
// small application and the firm's large one, and is one // small application and the firm's large one, and is one
// page either way. A bucket only a role ever writes to has // page either way. A bucket only a role ever writes to has
// its group's desk and nothing to publish. // its group's desk and nothing to publish.
// Unless it presents: the people are entered from inside
// precisely so that everyone can see them.
if need.watched_by.iter().any(|g| g == PUBLIC) if need.watched_by.iter().any(|g| g == PUBLIC)
&& need.enters_as.is_some() && need.enters_as.is_some()
&& !need.presents
&& !self && !self
.needs .needs
.iter() .iter()
@@ -591,6 +739,14 @@ impl Plan {
bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)")); bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)"));
} }
} }
for group in &need.followed_by {
if !is_id(group) || group == PUBLIC || group == SUBMITTER {
bail!(at(&format!("followed_by must name groups, not {group:?}")));
}
if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) {
bail!(at("followed_by shows a person the cases that carry their address, so the form must collect a required `email` of type email"));
}
}
for (stage, e) in &need.escalate { for (stage, e) in &need.escalate {
if e.days == 0 { if e.days == 0 {
bail!(at(&format!("escalate from {stage:?} must wait at least one day"))); bail!(at(&format!("escalate from {stage:?} must wait at least one day")));
@@ -613,6 +769,18 @@ impl Plan {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to))); bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
} }
} }
if let Some(bad) = need.leaves_after.keys().find(|st| !need.stages.contains(st)) {
bail!(at(&format!("leaves_after names {bad:?}, which is not one of its stages")));
}
if need.leaves_after.values().any(|l| l.millis() == 0) {
bail!(at("leaves_after needs some days, hours or minutes"));
}
if need.one_per_person && need.enters_as.is_none() {
bail!(at("one_per_person keeps a card for each signed-in person, and the need has no enters_as to sign them in"));
}
if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by"));
}
if !need.public_states.is_empty() { if !need.public_states.is_empty() {
if !need.watched_by.iter().any(|g| g == PUBLIC) { if !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by")); bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by"));
@@ -944,6 +1112,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"button": "Send", "button": "Send",
"encouragement": "", "encouragement": "",
"icon": "", "icon": "",
// Said after the description, on a paused need's card only.
"paused": if need.paused { loc(lang, "We are not taking new ones right now, and open again as soon as we can.", "Akkurat nå tar vi ikke imot nye; vi åpner igjen så snart vi kan.") } else { String::new() },
// Three sections of context the plan already knows: what // Three sections of context the plan already knows: what
// the person must have for this to work, the road a case // the person must have for this to work, the road a case
// travels, and who is at the other end. The fallbacks are // travels, and who is at the other end. The fallbacks are
@@ -995,7 +1165,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
}; };
let mut shopping: Map<String, Value> = let mut shopping: Map<String, Value> =
plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect(); plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect();
for (bucket, needs) in public_buckets(plan) { for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
shopping.insert( shopping.insert(
format!("status-{bucket}"), format!("status-{bucket}"),
json!({ json!({
@@ -1007,12 +1177,31 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
}), }),
); );
} }
if has_who_page(plan) {
words.insert(
who_page_path(plan),
json!({
"question": loc(lang, "Who are we?", "Hvem er vi?"),
"description": "",
"presents": presenting_buckets(plan).into_iter().map(|b| (b.to_string(), json!({
"heading": humanize(b), "description": "", "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."),
}))).collect::<Map<String, Value>>(),
"values": {
"heading": loc(lang, "What we stand for", "Det vi står for"),
"description": "",
"items": plan.values.iter().map(|v| (v.id.clone(), json!({ "heading": humanize(&v.id), "description": v.text, "icon": "" }))).collect::<Map<String, Value>>(),
},
}),
);
}
words.insert( words.insert(
"questions/index.yaml".to_string(), "questions/index.yaml".to_string(),
json!({ json!({
"question": "What brings you here?", "question": "What brings you here?",
"description": plan.about, "description": plan.about,
"opener": { "heading": "What we do", "description": "", "features": does }, "opener": { "heading": "What we do", "description": "", "features": does },
// The way to the page about who the business is.
"who": { "heading": loc(lang, "Who we are", "Hvem vi er"), "description": "", "button": loc(lang, "Meet us", "Bli kjent med oss") },
// Named for what it shows, never an invitation to look. // Named for what it shows, never an invitation to look.
"shopping": { "heading": format!("{} {}", loc(lang, "Latest from", "Siste fra"), plan.business), "description": "", "items": shopping }, "shopping": { "heading": format!("{} {}", loc(lang, "Latest from", "Siste fra"), plan.business), "description": "", "items": shopping },
// A door's feature says what is behind it, from the needs // A door's feature says what is behind it, from the needs
@@ -1088,7 +1277,9 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
// The list itself says what a row is and what the // The list itself says what a row is and what the
// buttons under it do; the card's own description says // buttons under it do; the card's own description says
// what the cases are. // what the cases are.
let note = if moves.is_empty() { let note = if follows_own(needs[0], &group) {
loc(lang, "Each row is one of your own cases and the stage it has reached.", "Hver rad er én av dine egne saker og hvor langt den har kommet.")
} else if moves.is_empty() {
loc(lang, "Each row is one case and the stage it has reached. Another desk moves these.", "Hver rad er én sak og hvor langt den har kommet. En annen pult flytter disse.") loc(lang, "Each row is one case and the stage it has reached. Another desk moves these.", "Hver rad er én sak og hvor langt den har kommet. En annen pult flytter disse.")
} else { } else {
loc(lang, "Each row is one case. The buttons record what you have just done with it.", "Hver rad er én sak. Knappene fører opp det du nettopp har gjort med den.") loc(lang, "Each row is one case. The buttons record what you have just done with it.", "Hver rad er én sak. Knappene fører opp det du nettopp har gjort med den.")
@@ -1384,7 +1575,16 @@ fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec<String> {
/// A group's desk shows a bucket when it moves something in it, is /// A group's desk shows a bucket when it moves something in it, is
/// answerable for it, or watches it. /// answerable for it, or watches it.
fn on_desk(need: &PlanNeed, group: &str) -> bool { fn on_desk(need: &PlanNeed, group: &str) -> bool {
group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()) group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || need.followed_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty())
}
/// A group that has the bucket only to follow its own cases: its list
/// is narrowed to the signed-in person's address.
fn follows_own(need: &PlanNeed, group: &str) -> bool {
need.followed_by.iter().any(|g| g == group)
&& need.handled_by != group
&& !need.watched_by.iter().any(|g| g == group)
&& !need.moves_by.values().flatten().any(|m| m == group)
} }
fn groups(plan: &Plan) -> BTreeSet<String> { fn groups(plan: &Plan) -> BTreeSet<String> {
@@ -1397,6 +1597,7 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
.chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned()) .chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned())
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned()) .chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
.chain(n.escalate.values().map(|e| e.to.clone())) .chain(n.escalate.values().map(|e| e.to.clone()))
.chain(n.followed_by.iter().cloned())
}) })
.collect() .collect()
} }
@@ -1459,9 +1660,13 @@ const FRONT_DOOR_LATEST: usize = 3;
/// and at most `limit` records when given. Portal filters by `states` /// and at most `limit` records when given. Portal filters by `states`
/// while listing, before `jq` runs, so the limit counts shown records. /// while listing, before `jq` runs, so the limit counts shown records.
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>, answer: &str) -> Value { fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>, answer: &str) -> Value {
let mut shown: BTreeSet<&str> = BTreeSet::new(); // In the order the form asks for them: the first is what a row, or
for n in needs { // a card, is headed by.
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str())); let mut shown: Vec<&str> = Vec::new();
for f in needs.iter().flat_map(|n| n.collects.iter()).filter(|f| f.is_public()) {
if !shown.contains(&f.name.as_str()) {
shown.push(f.name.as_str());
}
} }
let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default(); let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default();
// One record per yielded value: portal collects what the filter // One record per yielded value: portal collects what the filter
@@ -1734,6 +1939,12 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
let parent = plan.parent_of(need); let parent = plan.parent_of(need);
let mut requirements: Vec<Value> = need.collects.iter().map(|f| { let mut requirements: Vec<Value> = need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind }); let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind });
// A picture is a file the browser can show: a phone offers its
// camera roll and sends a JPEG, whatever it keeps them as.
if f.kind == "picture" {
r["type"] = json!("file");
r["accept"] = json!("image/jpeg,image/png,image/webp");
}
if f.optional { r["optional"] = json!(true); } if f.optional { r["optional"] = json!(true); }
if f.kind == "select" { r["options"] = json!(select_options(plan, f)); } if f.kind == "select" { r["options"] = json!(select_options(plan, f)); }
if f.multiple { r["multiple"] = json!(true); } if f.multiple { r["multiple"] = json!(true); }
@@ -1788,15 +1999,32 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
if !s(&w["who_description"]).trim().is_empty() { if !s(&w["who_description"]).trim().is_empty() {
features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"]))); features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
} }
features.push(feature); // A paused need asks for nothing: a form nobody can send has
// already wasted the time it took to fill in.
if !need.paused {
features.push(feature);
}
let description = if need.paused {
text_map(&w["description"], |d| format!("{} {}", d.trim(), s(&w["paused"]).trim()).trim().to_string())
} else {
text(&w["description"])
};
let mut alt = json!({ let mut alt = json!({
"name": text(&w["heading"]), "name": text(&w["heading"]),
"description": text(&w["description"]), "description": description,
"action": action, "action": action,
"consequence": button(&w["button"]), "consequence": button(&w["button"]),
"record_as": need.lands_in, "record_as": need.lands_in,
"features": features, "features": features,
}); });
if need.one_per_person {
alt["one_per_person"] = json!(true);
}
// Paused, it records nothing and says where nothing goes.
if need.paused {
alt["disabled"] = json!(true);
alt.as_object_mut().map(|a| a.remove("record_as"));
}
if !s(&w["encouragement"]).trim().is_empty() { if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = button(&w["encouragement"]); alt["encouragements"] = button(&w["encouragement"]);
} }
@@ -1853,6 +2081,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
if let Some(d) = need.deadlines.get(st) { if let Some(d) = need.deadlines.get(st) {
state["after"] = json!({ "days": d.days, "to": d.to }); state["after"] = json!({ "days": d.days, "to": d.to });
} }
if let Some(leaves) = need.leaves_after.get(st) {
state["leaves_after"] = json!(leaves);
}
(st.clone(), state) (st.clone(), state)
}).collect::<Map<_, _>>(), }).collect::<Map<_, _>>(),
"transitions": machine(need), "transitions": machine(need),
@@ -1894,7 +2125,17 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
.collect(); .collect();
// A public list is shown, not pointed at: its newest few records // A public list is shown, not pointed at: its newest few records
// inline, and a link to the whole list. // inline, and a link to the whole list.
for (bucket, needs) in public_buckets(plan) { // Who the business is has a page of its own (see `who_page`);
// the front door only leads there.
if has_who_page(plan) {
alternatives.push(json!({
"name": text(&front["who"]["heading"]),
"description": text(&front["who"]["description"]),
"action": format!("/{}", who_page_id(plan)),
"consequence": button(&front["who"]["button"]),
}));
}
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
let w = &front["shopping"]["items"][&format!("status-{bucket}")]; let w = &front["shopping"]["items"][&format!("status-{bucket}")];
let mut f = card(w); let mut f = card(w);
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() }; let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
@@ -1944,10 +2185,14 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
plan.front_door_count() plan.front_door_count()
); );
} }
files.insert( let mut front_page = json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives });
"questions/index.yaml".into(), if let Some(slideshow) = &plan.slideshow {
json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives }), front_page["slideshow"] = slideshow.clone();
); }
files.insert("questions/index.yaml".into(), front_page);
if has_who_page(plan) {
files.insert(who_page_path(plan), who_page(plan, words));
}
// A door's page: a nav page (index.yaml), public, holding the // A door's page: a nav page (index.yaml), public, holding the
// forms of the needs behind it. // forms of the needs behind it.
@@ -2058,6 +2303,10 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
if !escalated.is_empty() { if !escalated.is_empty() {
resource["states"] = json!(escalated); resource["states"] = json!(escalated);
} }
// A group that follows its own cases sees those alone.
if follows_own(needs[0], &group) {
resource["where"] = json!({ "field": "email", "equals": "{user.email}" });
}
alternatives.push(json!({ alternatives.push(json!({
"name": text(&bw["heading"]), "name": text(&bw["heading"]),
"description": text(&bw["description"]), "description": text(&bw["description"]),
@@ -2160,6 +2409,27 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
); );
} }
// Documents: a public page per text, one alternative per section,
// every word the plan's own. The first paragraph is the card's
// description; any further ones follow as unnamed features.
for doc in &plan.documents {
files.insert(
format!("questions/{}.yaml", doc.id),
json!({
"name": doc.question.trim(),
"description": doc.description.trim(),
"responsible": responsible,
"alternatives": doc.sections.iter().map(|section| {
let mut alt = json!({ "name": section.heading, "description": section.text.first().cloned().unwrap_or_default() });
if section.text.len() > 1 {
alt["features"] = json!(section.text[1..].iter().map(|p| json!({ "name": "", "description": p })).collect::<Vec<_>>());
}
alt
}).collect::<Vec<_>>(),
}),
);
}
// Public views: an ungated nav page per opened bucket, listing it // Public views: an ungated nav page per opened bucket, listing it
// read-only, with contact details and private fields projected // read-only, with contact details and private fields projected
// away. Anyone can see what was sent in and where it stands. // away. Anyone can see what was sent in and where it stands.
@@ -2241,6 +2511,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(), "stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
}); });
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); } if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
if n.paused { need["paused"] = json!(true); }
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect(); let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
if !others.is_empty() { need["also_moved_by"] = json!(others); } if !others.is_empty() { need["also_moved_by"] = json!(others); }
need need
@@ -2253,6 +2524,76 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect() files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect()
} }
const DESK_PURPOSE: &str = "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons.";
/// The page about who the business is: a question with nothing to do
/// on it, only things to read. One card per need that `presents` (the
/// people, the work), each showing every record as a card, and one for
/// the business's own statements (`values`), carried word for word.
fn who_page(plan: &Plan, words: &BTreeMap<String, Value>) -> Value {
let w = &words[&who_page_path(plan)];
let mut alternatives: Vec<Value> = Vec::new();
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)) {
let pw = &w["presents"][bucket];
alternatives.push(json!({
"name": text(&pw["heading"]),
"description": text(&pw["description"]),
"features": [{ "name": "", "description": "", "resource": public_resource(bucket, &needs, s(&pw["empty"]), None, "") }],
}));
}
if !plan.values.is_empty() {
let vw = &w["values"];
alternatives.push(json!({
"name": text(&vw["heading"]),
"description": text(&vw["description"]),
"features": plan.values.iter().map(|v| {
let iw = &vw["items"][&v.id];
let mut f = json!({ "name": text(&iw["heading"]), "description": text(&iw["description"]) });
if let Some(icon) = icon_name(s(&iw["icon"])) {
f["icon"] = json!(icon);
}
f
}).collect::<Vec<_>>(),
}));
}
json!({
"name": text(&w["question"]),
"description": text(&w["description"]),
"responsible": { "name": plan.contact.name, "contact": plan.contact.email },
"alternatives": alternatives,
})
}
fn presenting_buckets(plan: &Plan) -> Vec<&str> {
public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(b, _)| b).collect()
}
fn has_who_page(plan: &Plan) -> bool {
!plan.values.is_empty() || !presenting_buckets(plan).is_empty()
}
fn who_page_id(plan: &Plan) -> String {
loc(&plan.language, "who", "hvem")
}
fn who_page_path(plan: &Plan) -> String {
format!("questions/{}.yaml", who_page_id(plan))
}
/// The needs no persona tries, paused ones aside. A need nobody plays
/// is one whose page is only ever read for the first time by the
/// person it is for: the forms a business fills in from the inside
/// are the easy ones to forget, and where "is this my own card?" goes
/// unasked.
pub fn untried_needs(plan: &Plan) -> Vec<&str> {
plan.needs
.iter()
.filter(|n| !n.paused)
.filter(|n| !plan.personas.iter().any(|p| p.get("need").and_then(|v| v.as_str()) == Some(n.id.as_str())))
.map(|n| n.id.as_str())
.collect()
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String { fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
let voice = match tone { let voice = match tone {
Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."), Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."),
@@ -2268,26 +2609,54 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
} else { } else {
format!("a public page anyone can open, read-only, listing ONLY the cases that have reached: {shown_only}. Nothing else is shown - do not promise the reader anything waiting or turned down. `question` is what this page asks; `heading` names the list.") format!("a public page anyone can open, read-only, listing ONLY the cases that have reached: {shown_only}. Nothing else is shown - do not promise the reader anything waiting or turned down. `question` is what this page asks; `heading` names the list.")
}; };
let purpose = if path == "site.yaml" { // A desk is not always a place of work: a group that only follows
// its own cases reads a list of what it sent in, and the words
// should not hand it a job it does not have.
let desk_group = path.strip_prefix("questions/").and_then(|p| p.split('/').next()).unwrap_or_default();
let own: Vec<&str> = plan.needs.iter().filter(|n| follows_own(n, desk_group)).map(|n| n.lands_in.as_str()).collect();
let mut desk_purpose = DESK_PURPOSE.to_string();
if !own.is_empty() {
desk_purpose.push_str(&format!(" On this page, the list(s) {} show the signed-in person ONLY the cases they sent in themselves: they are following their own case, not working through other people's, so head those lists as theirs ('Your cases') and say nothing of a queue.", own.iter().map(|b| format!("`{b}`")).collect::<Vec<_>>().join(", ")));
}
// A form a role fills in from inside: its page still asks a
// question, the one the form answers, and says where the answer
// goes - a heading like "Your card on the front page" names a
// place, and the colleague reading it does not know what they are
// being asked.
let own_form = plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}.yaml", n.id)));
let mut inside_purpose = "a page only one role inside the business can open, holding one form. `question` is a real question, the one the form answers, asked of the colleague in their own terms (\"What has been delivered?\"), never a label or the name of a place; where their answer ends up - who sees it, on which page - belongs in `page_description`.".to_string();
if own_form.is_some_and(|n| n.one_per_person) {
inside_purpose.push_str(" This form is the colleague's own record, one each: it opens filled in with what they have, and saving replaces it, so ask about them (\"How do you present yourself?\"), say where it shows, and make the button save.");
}
// A form a role enters from inside is sent by a colleague at work,
// not by a visitor waiting on an answer.
let sent_purpose = match plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}-sent.yaml", n.id))) {
Some(n) => format!("a page shown right after someone in the group `{}` has sent a form from inside the business. The reader is that colleague, at work, not a visitor: nobody comes back to them. Say what has now happened to what they entered, and who it reaches.", n.enters_as.as_deref().unwrap_or_default()),
None => "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next.".to_string(),
};
let who_purpose = "the page about who the business is, with nothing to do on it, only things to read. `question` asks it as a question (\"Who are we?\"). Under `presents`, each entry heads one list shown as cards with a picture each - the people who work there, the work they have done - loosely, as a person would introduce them. `values` heads the business's own statements, which are its facts: head them, never add to them.".to_string();
let purpose = if path == who_page_path(plan) {
who_purpose.as_str()
} else if path == "site.yaml" {
"the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own." "the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own."
} else if path == "aggregates.yaml" { } else if path == "aggregates.yaml" {
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation." "the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation."
} else if path.contains("/decide/") { } else if path.contains("/decide/") {
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide." "a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
} else if path == "questions/index.yaml" { } else if path == "questions/index.yaml" {
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it." "the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. `who`, when there is one, is a card that leads to the page about who the business is: its heading asks that plainly, and `button` goes there. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
} else if path.starts_with("questions/status/") { } else if path.starts_with("questions/status/") {
status_purpose.as_str() status_purpose.as_str()
} else if plan.doors.iter().any(|d| door_path(d) == path) { } else if plan.doors.iter().any(|d| door_path(d) == path) {
"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it - or, for a need that is members' only, the card that leads to the members' page, where the form is behind sign-in: say so in its description." "a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it - or, for a need that is members' only, the card that leads to the members' page, where the form is behind sign-in: say so in its description."
} else if path.ends_with("/index.yaml") { } else if path.ends_with("/index.yaml") {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons." desk_purpose.as_str()
} else if path.ends_with("/[key].yaml") { } else if path.ends_with("/[key].yaml") {
"a page one record of a public list opens to. This is NOT where a notice is posted: the visitor has read somebody else's record on the list (a notice, an application) and is answering it. `question` asks the visitor what they have to say to it; under `forms`, each entry is one kind of answer (an objection, an appeal, an offer to lend), with the form behind it. An answer that mails the record's owner goes straight to them; nobody at a desk passes it on, so never say so." "a page one record of a public list opens to. This is NOT where a notice is posted: the visitor has read somebody else's record on the list (a notice, an application) and is answering it. `question` asks the visitor what they have to say to it; under `forms`, each entry is one kind of answer (an objection, an appeal, an offer to lend), with the form behind it. An answer that mails the record's owner goes straight to them; nobody at a desk passes it on, so never say so."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") { } else if path.contains("/received/") || path.ends_with("-sent.yaml") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next." sent_purpose.as_str()
} else { } else {
"a page only one role inside the business can open, holding one form." inside_purpose.as_str()
}; };
let mut context: Vec<String> = plan let mut context: Vec<String> = plan
.doors .doors
@@ -2297,7 +2666,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
context.extend( context.extend(
plan.needs plan.needs
.iter() .iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))), .map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, match (n.paused, n.one_per_person) { (true, _) => " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)", (_, true) => " (ONE EACH: every signed-in person keeps one record here, their own; the form opens filled in with it and sending saves the change, so it speaks to them about their own and its button saves)", _ => "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
); );
format!( format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.", "You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.",
@@ -2317,8 +2686,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--llm 'cmd'] [--lint question_lint]"; let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--llm 'cmd'] [--lint question_lint]";
let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?; let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?;
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?) let mut plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?)
.with_context(|| format!("parsing {plan_path}"))?; .with_context(|| format!("parsing {plan_path}"))?;
plan.load_documents(Path::new(&plan_path).parent().unwrap_or_else(|| Path::new(".")))?;
plan.validate()?; plan.validate()?;
for warning in plan.spectators() { for warning in plan.spectators() {
eprintln!("build: {warning}"); eprintln!("build: {warning}");
@@ -2407,6 +2777,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
// The words as used, so the next rebuild can start from them. // The words as used, so the next rebuild can start from them.
std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?; std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?;
println!("content repo written to {}", out.display()); println!("content repo written to {}", out.display());
for need in untried_needs(&plan) {
eprintln!("note: no persona tries need {need:?}; nobody will find out whether its page reads right until a person does");
}
if let Some(lint) = value("--lint") { if let Some(lint) = value("--lint") {
let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?; let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?;
@@ -2452,8 +2825,8 @@ mod tests {
lands_in: "b".into(), handled_by: "g".into(), lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![], moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false, one_per_person: false, leaves_after: BTreeMap::new(),
collects: vec![], max_fields: 3, collects: vec![], max_fields: 3,
} }
} }
@@ -2850,6 +3223,12 @@ mod tests {
assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select")); assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select"));
} }
#[test]
fn a_need_no_persona_tries_is_named() {
let plan: Plan = serde_yaml::from_str("business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: ask\n who: a customer\n wants: an answer\n lands_in: asks\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: card\n who: one of us\n wants: to be seen\n enters_as: desk\n lands_in: cards\n handled_by: desk\n stages: [open, gone]\n done_when: [gone]\n max_fields: 1\n collects:\n - { name: name }\n - id: later\n who: someone\n wants: something\n paused: true\n lands_in: later\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 1\n collects:\n - { name: email, type: email }\npersonas:\n - { id: p, need: ask, text: I need an answer }\n").unwrap();
assert_eq!(untried_needs(&plan), vec!["card"], "the inside need is untried; the paused one is not asked for");
}
#[test] #[test]
fn a_public_watcher_gets_a_read_only_page_without_contact_details() { fn a_public_watcher_gets_a_read_only_page_without_contact_details() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: traffic\n who: someone who saw a hazard\n wants: at det tas opp\n lands_in: meldinger\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n watched_by: [board, public]\n max_fields: 2\n collects:\n - { name: where }\n - { name: what, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: name, optional: true, private: true }\n"; let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: traffic\n who: someone who saw a hazard\n wants: at det tas opp\n lands_in: meldinger\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n watched_by: [board, public]\n max_fields: 2\n collects:\n - { name: where }\n - { name: what, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: name, optional: true, private: true }\n";
@@ -2863,7 +3242,7 @@ mod tests {
let resource = &view["alternatives"][0]["features"][0]["resource"]; let resource = &view["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["public"], true); assert_eq!(resource["public"], true);
assert!(resource.get("requires_group").is_none()); assert!(resource.get("requires_group").is_none());
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {what: .what, where: .where}"); assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {where: .where, what: .what}");
assert!(view.get("qualifies").is_none()); assert!(view.get("qualifies").is_none());
// The followup page points people at the list. // The followup page points people at the list.
let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap(); let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap();
@@ -3267,6 +3646,55 @@ mod invite_tests {
assert!(blank.validate().unwrap_err().to_string().contains("tones")); assert!(blank.validate().unwrap_err().to_string().contains("tones"));
} }
#[test]
fn a_group_that_follows_sees_its_own_cases_and_a_form_may_take_a_file() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: job\n who: an owner\n wants: a report\n lands_in: jobs\n handled_by: office\n followed_by: [customers]\n stages: [open, accepted, delivered]\n done_when: [delivered]\n grants: { accepted: customers }\n max_fields: 3\n collects:\n - { name: name }\n - { name: email, type: email }\n - { name: drawings, type: file, optional: true }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let mine: Value = serde_yaml::from_str(&files["questions/customers/index.yaml"]).unwrap();
let resource = &mine["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["where"], json!({ "field": "email", "equals": "{user.email}" }));
assert_eq!(resource["transitions"], json!([]));
// The office sees everything.
let office: Value = serde_yaml::from_str(&files["questions/office/index.yaml"]).unwrap();
assert!(office["alternatives"][0]["features"][0]["resource"].get("where").is_none());
// The form takes the file.
assert!(files["questions/index.yaml"].contains("type: file"));
// A group the case grants does not invite into itself.
assert!(!files["questions/customers/index.yaml"].contains("invite:"));
let no_mail: Plan = serde_yaml::from_str(&yaml.replace(" - { name: email, type: email }\n", "").replace(" grants: { accepted: customers }\n", "")).unwrap();
assert!(no_mail.validate().unwrap_err().to_string().contains("followed_by"));
}
#[test]
fn a_document_is_a_page_with_one_card_per_section_word_for_word() {
let text = "# Vedtekter\n\n### av 23.04.2018\n\n##### § 1\n\nTomter vel er upolitisk. Foreningen har som\nformål å virke for stedets velferd.\n\n##### § 4.\n\nEkstraordinært årsmøte holdes når styret krever det.\n\nGjennomføringen følger samme prosedyre.\n";
let sections = sections_of(text);
// Titles with nothing under them are not sections; a wrapped
// paragraph is one paragraph; the words are untouched.
assert_eq!(sections.len(), 2);
assert_eq!(sections[0], Section { heading: "§ 1".into(), text: vec!["Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.".into()] });
assert_eq!(sections[1].heading, "§ 4");
assert_eq!(sections[1].text.len(), 2);
let mut plan: Plan = serde_yaml::from_str(&format!("{PLAN}documents:\n - id: vedtekter\n question: Hva sier vedtektene?\n description: Vedtatt 23.04.2018.\n")).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("no sections"));
plan.documents[0].sections = sections;
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/vedtekter.yaml"]).unwrap();
assert_eq!(page["name"], "Hva sier vedtektene?");
let alts = page["alternatives"].as_array().unwrap();
assert_eq!(alts.len(), 2);
assert_eq!(alts[0]["name"], "§ 1");
assert_eq!(alts[0]["description"], "Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.");
assert!(alts[0].get("action").is_none() && alts[0].get("features").is_none());
assert_eq!(alts[1]["features"][0]["description"], "Gjennomføringen følger samme prosedyre.");
// A path something else has is refused.
plan.documents[0].id = "status".into();
assert!(plan.validate().unwrap_err().to_string().contains("document"));
}
#[test] #[test]
fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() { fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap(); let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap();
+37
View File
@@ -175,6 +175,43 @@ rest. Each is one line in the plan, and the builder does the wiring:
each brought in. Two is plenty; name them as a person would ("plain, each brought in. Two is plenty; name them as a person would ("plain,
like a good notice on the door"; "warm, as a neighbour would put like a good notice on the door"; "warm, as a neighbour would put
it"). Leave it out to tell one story. it"). Leave it out to tell one story.
- **Let a person follow their own.** `followed_by: [customers]` gives
a group a desk that lists each member's own cases and nobody else's
(the ones carrying the address they sign in with), and the files on
them. With `grants` on the stage where a customer says yes, the
person who sent the case is the one who ends up able to follow it.
A `file` field takes drawings, photos, a report.
- **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.** 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
replaces - a card on the front page they edit themselves. A picture
left unchosen stays as it was.
- **Let finished things go.** `leaves_after: { <stage>: { minutes: 10 } }`
takes a record out of every list that long after it reaches that
stage (days, hours and minutes add up). A card taken off the front
page need not stand greyed out on the desk for ever; the history
keeps it.
- **Pause what you cannot take on.** `paused: true` on a need keeps
its card on the page, saying what the business does and that it is
not taking new ones right now, with no form and a button that does
not press. Take the line out to open again; the desk and every case
under way are untouched.
- **Show the work.** `slideshow` on the plan is portal's own block,
carried onto the front door as written: a strip of pictures under
the question, each with its caption.
- **Keep the people in portal.** `people: memberships` keeps every - **Keep the people in portal.** `people: memberships` keeps every
group in portal itself: a desk invite is a place held until the group in portal itself: a desk invite is a place held until the
person signs in, a person is their phone number first, and any person signs in, a person is their phone number first, and any
+12 -7
View File
@@ -15,7 +15,7 @@ use serde::Serialize;
use crate::server::AppState; 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)] #[derive(Clone)]
pub struct Garage { pub struct Garage {
@@ -277,13 +277,15 @@ pub async fn attachment(
// cannot be used to learn which buckets, records or fields exist. // cannot be used to learn which buckets, records or fields exist.
let missing = || (StatusCode::NOT_FOUND, "not found".to_string()); let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
if !may_read_bucket(&state, &session, &bucket).await {
return Err(missing());
}
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| 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 bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| 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 key = answer let key = answer
.responses .responses
.get(&field) .get(&field)
@@ -331,20 +333,23 @@ pub async fn attachment(
/// Whether this caller may see this bucket at all: whether any feature /// Whether this caller may see this bucket at all: whether any feature
/// in the loaded content lists it and the policy lets them read that /// in the loaded content lists it and the policy lets them read that
/// feature. The desk that shows the record is what grants the file. /// feature. The desk that shows the record is what grants the file.
async fn may_read_bucket( async fn may_read_record(
state: &AppState, state: &AppState,
session: &tower_sessions::Session, session: &tower_sessions::Session,
bucket: &str, bucket: &str,
record: &serde_json::Value,
) -> bool { ) -> bool {
use crate::auth::{User, SESSION_USER_KEY}; use crate::auth::{User, SESSION_USER_KEY};
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten(); let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
let principal = crate::access::Principal::from_user(user.as_ref()); let principal = crate::access::Principal::from_user(user.as_ref());
let params = crate::resource::with_person(Default::default(), user.as_ref());
let access = state.access.load(); let access = state.access.load();
state.questions.load().values().any(|question| { state.questions.load().values().any(|question| {
question.alternatives.iter().any(|alternative| { question.alternatives.iter().any(|alternative| {
alternative.features.iter().any(|feature| { alternative.features.iter().any(|feature| {
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket) 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, &params))
&& access.allows( && access.allows(
&principal, &principal,
crate::access::Action::Read, crate::access::Action::Read,
+352 -11
View File
@@ -173,6 +173,61 @@ main.not-found {
gap: 1rem; gap: 1rem;
} }
/* 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;
font-size: 0.88rem;
}
.session a {
color: var(--ink);
text-decoration: none;
border-bottom: 0.06rem solid var(--line);
}
.session a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
.session-name {
color: var(--ink-dim);
}
/* 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 {
max-width: none;
padding: 0 0 2rem;
align-items: stretch;
gap: 0;
}
.hero-bar {
display: flex;
align-items: center;
padding: 0.9rem 1.5rem;
}
.hero-slides .slideshow {
margin-bottom: 2rem;
}
.hero-slides .hero-copy {
width: 100%;
max-width: 46rem;
margin: 0 auto;
padding: 0 1.5rem;
box-sizing: border-box;
}
.wordmark { .wordmark {
display: inline-flex; display: inline-flex;
opacity: 0.85; opacity: 0.85;
@@ -269,6 +324,69 @@ main.not-found {
/* position + z-index on these three: everything that scrolls over the /* position + z-index on these three: everything that scrolls over the
sticky canvas must be a positioned box above the hero's z-index 0, sticky canvas must be a positioned box above the hero's z-index 0,
or the (positioned) hero would paint over it. */ or the (positioned) hero would paint over it. */
/* Pictures across the page (`slideshow`): the full width of the
screen, every picture the strip's height and as wide as its own
shape, never wider than the screen. The caption floats over the
foot of its picture. */
.slideshow {
--swiper-navigation-color: #fff;
--swiper-navigation-size: 1.8rem;
position: relative;
z-index: 1;
display: block;
width: 100%;
height: var(--slideshow-height, 60svh);
max-height: var(--slideshow-height, 60svh);
margin: 0 0 3rem;
}
/* Before the Swiper bundle has upgraded the element, or without it:
a row that scrolls sideways. */
.slideshow:not(:defined) {
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
}
.slideshow swiper-slide {
flex: none;
width: auto;
height: 100%;
}
.slide {
position: relative;
height: 100%;
margin: 0;
}
.slide img {
display: block;
height: 100%;
width: auto;
/* A picture that has not loaded and says nothing of its size still
takes a place, or the whole strip would sit in view at once. */
min-width: 12rem;
max-width: 100vw;
object-fit: cover;
background: var(--line);
}
.slide figcaption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2.4rem 1rem 0.9rem;
color: #fff;
font-size: 0.95rem;
line-height: 1.35;
text-shadow: 0 0.06rem 0.2rem rgba(0, 0, 0, 0.6);
background: linear-gradient(to top, rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0));
pointer-events: none;
}
.alternatives { .alternatives {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -679,7 +797,7 @@ main.not-found {
.feature p { .feature p {
color: var(--ink-dim); color: var(--ink-dim);
font-size: 0.95rem; font-size: 0.95em;
} }
.field { .field {
@@ -690,7 +808,7 @@ main.not-found {
} }
.field-label { .field-label {
font-size: 0.85rem; font-size: 0.85em;
font-weight: 600; font-weight: 600;
color: var(--ink-dim); color: var(--ink-dim);
} }
@@ -700,17 +818,74 @@ main.not-found {
margin-left: 0.2em; margin-left: 0.2em;
} }
/* Controls take their size from the text around them (em, not rem):
a site that sets its body larger, or a card that sets itself
smaller, gets fields, pills and buttons to match, and everything
inside a control - the date picker's icon, the file picker's button
- scales with the control. */
input, input,
textarea { textarea {
font: inherit; font: inherit;
color: var(--ink); color: var(--ink);
background: var(--paper); background: var(--paper);
border: 0.06rem solid var(--line); border: 0.06rem solid var(--line);
border-radius: 0.6rem; border-radius: 0.6em;
padding: 0.65rem 0.8rem; padding: 0.65em 0.8em;
width: 100%; width: 100%;
} }
/* The calendar icon at the size of the text it sits beside, and a
target worth aiming at. */
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
min-height: 2.9em;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
width: 1.35em;
height: 1.35em;
padding: 0.2em;
margin-left: 0.4em;
cursor: pointer;
opacity: 0.75;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover,
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* The file picker's own button, in the field's colours rather than
the browser's: it follows whatever ink the field has, so it holds
on a light field and a dark one alike. */
input[type="file"] {
padding: 0.45em;
cursor: pointer;
}
input[type="file"]::file-selector-button {
font: inherit;
font-size: 0.85em;
font-weight: 600;
color: inherit;
background: color-mix(in srgb, currentColor 9%, transparent);
border: 0.06rem solid color-mix(in srgb, currentColor 28%, transparent);
border-radius: 999rem;
padding: 0.4em 1em;
margin-right: 0.8em;
cursor: pointer;
transition: border-color 120ms, background 120ms;
}
input[type="file"]::file-selector-button:hover {
background: var(--accent-soft);
border-color: var(--accent);
}
textarea { textarea {
min-height: 5rem; min-height: 5rem;
resize: vertical; resize: vertical;
@@ -971,14 +1146,14 @@ textarea:focus {
} }
.alt-submit { .alt-submit {
font-family: var(--sans); font-family: inherit;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95em;
color: var(--paper); color: var(--paper);
background: var(--accent); background: var(--accent);
border: none; border: none;
border-radius: 0.7rem; border-radius: 0.7em;
padding: 0.75rem 1.4rem; padding: 0.75em 1.4em;
cursor: pointer; cursor: pointer;
transition: filter 120ms, transform 120ms; transition: filter 120ms, transform 120ms;
/* A gateway alternative renders this as an <a> (navigation, no POST); /* A gateway alternative renders this as an <a> (navigation, no POST);
@@ -1094,6 +1269,108 @@ textarea:focus {
color: var(--accent); color: var(--accent);
} }
/* A read-only list whose records carry a picture: shown, not read. */
.answer-cards {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
align-items: start;
}
.answer-card {
padding: 0;
overflow: hidden;
gap: 0;
}
.answer-picture {
display: block;
width: 100%;
aspect-ratio: 4 / 5;
object-fit: cover;
background: var(--line);
}
.answer-card-text {
padding: 0.9rem 1.1rem 1.1rem;
display: grid;
gap: 0.35rem;
}
.answer-card-heading {
margin: 0;
font-size: 1.05rem;
}
.card-field {
font-size: 0.92rem;
color: var(--ink-dim);
}
.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;
}
.answer-card .answer-link {
margin: 0 1.1rem 1.1rem;
}
/* A picture on a desk's record: small, and it opens. */
.answer-thumb {
display: block;
max-width: 8rem;
max-height: 8rem;
border-radius: 0.4rem;
object-fit: cover;
}
/* Why a send was refused, under its button. */
.alt-error {
margin: 0.6em 0 0;
color: var(--accent);
font-size: 0.92em;
}
/* The file already on a card of one's own, above the picker. */
.field-current {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6em;
font-size: 0.9em;
color: var(--ink-dim);
}
/* A stored file on a record: its name, and it opens. */
.answer-file {
color: var(--accent);
text-decoration: none;
border-bottom: 0.06rem solid var(--accent);
}
/* The page about a listed record, when the list offers one. */ /* The page about a listed record, when the list offers one. */
.answer-link { .answer-link {
display: inline-block; display: inline-block;
@@ -1184,13 +1461,13 @@ textarea:focus {
} }
.select-option { .select-option {
font-family: var(--sans); font-family: inherit;
font-size: 0.85rem; font-size: 0.85em;
color: var(--ink); color: var(--ink);
background: var(--paper); background: var(--paper);
border: 0.06rem solid var(--line); border: 0.06rem solid var(--line);
border-radius: 999rem; border-radius: 999rem;
padding: 0.4rem 0.9rem; padding: 0.45em 1em;
cursor: pointer; cursor: pointer;
transition: border-color 120ms, color 120ms, background 120ms; transition: border-color 120ms, color 120ms, background 120ms;
} }
@@ -1338,3 +1615,67 @@ textarea:focus {
.resource-loading { .resource-loading {
min-height: 1.4em; min-height: 1.4em;
} }
/* Pages move the way their addresses do (public/page-motion.js):
deeper comes in from the right, back up from the left, a page at the
same depth from below or above. The cards leave and arrive as
islands of their own, one after another. Transforms and opacity
only. */
@media (prefers-reduced-motion: no-preference) {
:root {
--pm-duration: 340ms;
--pm-ease: cubic-bezier(0.22, 0.8, 0.24, 1);
--pm-step: 45ms;
}
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: var(--pm-duration);
animation-timing-function: var(--pm-ease);
animation-fill-mode: both;
}
/* A named card has only an old or only a new picture; its group must
not also morph. */
::view-transition-group(*) {
animation-duration: var(--pm-duration);
}
}
@keyframes pm-out-left { to { transform: translateX(-18%); opacity: 0; } }
@keyframes pm-out-right { to { transform: translateX(18%); opacity: 0; } }
@keyframes pm-out-up { to { transform: translateY(-10%); opacity: 0; } }
@keyframes pm-out-down { to { transform: translateY(10%); opacity: 0; } }
@keyframes pm-in-right { from { transform: translateX(18%); opacity: 0; } }
@keyframes pm-in-left { from { transform: translateX(-18%); opacity: 0; } }
@keyframes pm-in-below { from { transform: translateY(10%); opacity: 0; } }
@keyframes pm-in-above { from { transform: translateY(-10%); opacity: 0; } }
/* The cards, one step apart: out first, in after the page has begun. */
@media (prefers-reduced-motion: no-preference) {
:root[data-motion="forward"]::view-transition-old(*) { animation-name: pm-out-left; }
:root[data-motion="forward"]::view-transition-new(*) { animation-name: pm-in-right; }
:root[data-motion="back"]::view-transition-old(*) { animation-name: pm-out-right; }
:root[data-motion="back"]::view-transition-new(*) { animation-name: pm-in-left; }
:root[data-motion="down"]::view-transition-old(*) { animation-name: pm-out-up; }
:root[data-motion="down"]::view-transition-new(*) { animation-name: pm-in-below; }
:root[data-motion="up"]::view-transition-old(*) { animation-name: pm-out-down; }
:root[data-motion="up"]::view-transition-new(*) { animation-name: pm-in-above; }
::view-transition-old(pm-out-1) { animation-delay: calc(0 * var(--pm-step)); }
::view-transition-new(pm-in-1) { animation-delay: calc(90ms + 0 * var(--pm-step)); }
::view-transition-old(pm-out-2) { animation-delay: calc(1 * var(--pm-step)); }
::view-transition-new(pm-in-2) { animation-delay: calc(90ms + 1 * var(--pm-step)); }
::view-transition-old(pm-out-3) { animation-delay: calc(2 * var(--pm-step)); }
::view-transition-new(pm-in-3) { animation-delay: calc(90ms + 2 * var(--pm-step)); }
::view-transition-old(pm-out-4) { animation-delay: calc(3 * var(--pm-step)); }
::view-transition-new(pm-in-4) { animation-delay: calc(90ms + 3 * var(--pm-step)); }
::view-transition-old(pm-out-5) { animation-delay: calc(4 * var(--pm-step)); }
::view-transition-new(pm-in-5) { animation-delay: calc(90ms + 4 * var(--pm-step)); }
::view-transition-old(pm-out-6) { animation-delay: calc(5 * var(--pm-step)); }
::view-transition-new(pm-in-6) { animation-delay: calc(90ms + 5 * var(--pm-step)); }
::view-transition-old(pm-out-7) { animation-delay: calc(6 * var(--pm-step)); }
::view-transition-new(pm-in-7) { animation-delay: calc(90ms + 6 * var(--pm-step)); }
::view-transition-old(pm-out-8) { animation-delay: calc(7 * var(--pm-step)); }
::view-transition-new(pm-in-8) { animation-delay: calc(90ms + 7 * var(--pm-step)); }
}
+29
View File
@@ -248,3 +248,32 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
let groups = memberships::on_signed_in(&state, &kari).await; let groups = memberships::on_signed_in(&state, &kari).await;
assert!(groups.contains(&styret.to_string())); assert!(groups.contains(&styret.to_string()));
} }
#[tokio::test]
async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
use portal::answers::{keep_newest_own, own_records, OWNER_FIELD};
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
ensure_stream(&js).await.expect("event stream");
let bucket = format!("own_{}", chrono::Utc::now().timestamp_millis());
let schemas = std::collections::HashMap::new();
let now = chrono::Utc::now().timestamp_millis();
let card = |name: &str, by: &str| json!({"name": name, OWNER_FIELD: by});
// 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();
// 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);
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");
}