Compare commits
@@ -4,6 +4,31 @@ One line per change, grouped by the release that shipped it. Newest
|
||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||
content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## 0.6.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.
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.2"
|
||||
version = "0.6.7"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -267,6 +267,16 @@ whatever `public` says.
|
||||
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.
|
||||
|
||||
`states` and `where` narrow a listing. They matter most for a register
|
||||
that is public by law — public about what was decided, not about what
|
||||
was withdrawn — while the desk working the same bucket is a different
|
||||
|
||||
+16
-7
@@ -82,22 +82,31 @@ values.
|
||||
| `GARAGE_*` | uploads |
|
||||
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
|
||||
| `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
|
||||
separate unit on the same host reading the same NATS.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Every request passes a per-caller-IP limit before a session is loaded
|
||||
or a handler is entered: a burst of 30, refilled one every two
|
||||
seconds. A visitor filling in a form never reaches it (a page load
|
||||
fetches several resources at once); a script does.
|
||||
Every request that makes portal do something passes a per-caller-IP
|
||||
limit before a session is loaded or a handler is entered: a burst of
|
||||
100, refilled at ten a second. More than a hundred requests in ten
|
||||
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_SECONDS` | seconds per refilled request, default 2 |
|
||||
| `PORTAL_RATE_BURST` | how many requests at once, default 100 |
|
||||
| `PORTAL_RATE_PER_SECOND` | requests refilled per second, default 10 |
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# 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: erfaringen, i våre egne ord, og menneskene. Verdiene
|
||||
# skriver Fredrik selv; de legges inn her som en `values`-linje til.
|
||||
values:
|
||||
- id: erfaring
|
||||
text: >
|
||||
Skoler i drift, brannstasjoner, omsorgsboliger, kantina på Det
|
||||
Norske Teatret og en Munch-eiendom på Hvitsten. Vi har gått over,
|
||||
prosjektert og fulgt opp bygg for kommuner, borettslag, bedrifter
|
||||
og private, fra borprøver i gammel mur til kunstgress på
|
||||
Fredrikstad stasjon.
|
||||
|
||||
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 }
|
||||
|
||||
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." }
|
||||
|
||||
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
|
||||
@@ -0,0 +1,526 @@
|
||||
{
|
||||
"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, verdier og erfaringer.",
|
||||
"items": {
|
||||
"erfaring": {
|
||||
"heading": "Erfaringene",
|
||||
"description": "Skoler i drift, brannstasjoner, omsorgsboliger, kantina på Det Norske Teatret og en Munch-eiendom på Hvitsten. Vi har gått over, prosjektert og fulgt opp bygg for kommuner, borettslag, bedrifter og private, fra borprøver i gammel mur til kunstgress på Fredrikstad stasjon.",
|
||||
"icon": "award"
|
||||
},
|
||||
"people-ansatte": {
|
||||
"heading": "Menneskene",
|
||||
"description": "De som går befaringene, skriver rapportene og står for søknadene.",
|
||||
"empty": "Snart presenterer vi oss her.",
|
||||
"icon": "users"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": "Ditt kort på forsiden",
|
||||
"description": "Rolle, erfaring og portrett, slik besøkende ser deg.",
|
||||
"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 «Ditt kort på forsiden» igjen: det 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": "Ditt kort på forsiden",
|
||||
"description": "Slik står du blant menneskene på forsiden. Rett det du vil og lagre; du har ett kort, og det nye erstatter det gamle.",
|
||||
"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, år i faget, bygg du er stolt av",
|
||||
"portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
|
||||
},
|
||||
"question": "Ditt kort på forsiden",
|
||||
"page_description": "Det du skriver her, står under portrettet ditt på forsiden."
|
||||
},
|
||||
"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": ""
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -51,6 +51,35 @@ pub struct AggregateSchema {
|
||||
/// no answer in six weeks has to go on without one, and nothing in
|
||||
/// a building is going to press that button.
|
||||
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`
|
||||
@@ -125,6 +154,8 @@ struct RawState {
|
||||
grants: Option<String>,
|
||||
#[serde(default)]
|
||||
after: Option<AfterSpec>,
|
||||
#[serde(default)]
|
||||
leaves_after: Option<LeavesSpec>,
|
||||
}
|
||||
|
||||
#[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 grants_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 {
|
||||
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 group.trim().is_empty() {
|
||||
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,
|
||||
grants_for_state,
|
||||
after_for_state,
|
||||
leaves_for_state,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,73 @@ pub async fn store_answer(
|
||||
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
|
||||
/// so a batch can span more than one resource feature on the same
|
||||
/// alternative.
|
||||
|
||||
+172
-3
@@ -57,6 +57,8 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
// Server-side, so it can read hash.txt and link the
|
||||
// content-hashed stylesheet (hash-files = true).
|
||||
<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()/>
|
||||
<HydrationScripts options/>
|
||||
<MetaTags/>
|
||||
@@ -1196,9 +1198,11 @@ fn AlternativeCard(
|
||||
if req.kind == "file" {
|
||||
file_refs.entry(req.name.clone()).or_default();
|
||||
} 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
|
||||
.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);
|
||||
} else {
|
||||
// Widget-owned fields hold JSON objects, not strings.
|
||||
@@ -1236,6 +1240,11 @@ fn AlternativeCard(
|
||||
let gesture_fields_for_submit = gesture_fields.clone();
|
||||
let question_id_for_transition = question_id.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| {
|
||||
ev.prevent_default();
|
||||
|
||||
@@ -1264,6 +1273,8 @@ fn AlternativeCard(
|
||||
let question_id_for_submit = question_id_for_submit.clone();
|
||||
let alt_name_for_submit = alt_name_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 {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (name, sig) in field_map_for_submit.iter() {
|
||||
@@ -1303,8 +1314,14 @@ fn AlternativeCard(
|
||||
Ok(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) => {
|
||||
leptos::logging::error!("upload of {name} failed: {e:?}");
|
||||
send_error.set(Some(t(&lang().get_untracked(), "upload_failed").to_string()));
|
||||
sending.set(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1317,6 +1334,7 @@ fn AlternativeCard(
|
||||
parent_hash: parent_hash_for_submit,
|
||||
responses_json,
|
||||
});
|
||||
sending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1510,9 +1528,26 @@ fn AlternativeCard(
|
||||
|
||||
if req.kind == "file" {
|
||||
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! {
|
||||
<label class="field" for=label_for>
|
||||
{label_text}
|
||||
{current}
|
||||
<input
|
||||
id=field_id
|
||||
type="file"
|
||||
@@ -1824,12 +1859,24 @@ fn AlternativeCard(
|
||||
class="alt-submit"
|
||||
disabled=move || {
|
||||
(!has_requirements && pending_transitions.get().is_empty())
|
||||
|| sending.get()
|
||||
|| submit.pending().get()
|
||||
|| transition_batch.pending().get()
|
||||
}
|
||||
>
|
||||
{button_label.clone()}
|
||||
</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>
|
||||
}
|
||||
.into_any()
|
||||
@@ -1949,12 +1996,16 @@ fn ResourceValue(
|
||||
}
|
||||
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
|
||||
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| {
|
||||
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! {
|
||||
<div class="answer-list">
|
||||
<div class="answer-list" class:answer-cards=cards>
|
||||
<For
|
||||
each=move || answers.clone()
|
||||
key=|a| a.id.clone()
|
||||
@@ -2232,6 +2283,30 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
|
||||
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn AnswerRow(
|
||||
feature_name: String,
|
||||
@@ -2260,6 +2335,11 @@ fn AnswerRow(
|
||||
} else {
|
||||
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
|
||||
.responses
|
||||
.as_object()
|
||||
@@ -2310,6 +2390,17 @@ fn AnswerRow(
|
||||
// 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>
|
||||
@@ -2412,6 +2503,36 @@ fn AnswerRow(
|
||||
</Show>
|
||||
</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>
|
||||
{rest.into_iter().map(|t| view! { <p>{t}</p> }).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-
|
||||
@@ -2490,6 +2611,7 @@ pub async fn get_question(
|
||||
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()))?;
|
||||
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;
|
||||
if let Some(target) = &question.requires_chain {
|
||||
// The claimed lineage must verifiably end at the required
|
||||
@@ -2843,6 +2965,26 @@ pub async fn submit_answer(
|
||||
let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
|
||||
.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
|
||||
// there before this one is written, so a reference in a stored
|
||||
// record is one that resolved at least once. An optional field
|
||||
@@ -2958,6 +3100,15 @@ pub async fn submit_answer(
|
||||
{
|
||||
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
|
||||
// state sends, it sends now.
|
||||
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
|
||||
@@ -3085,3 +3236,21 @@ mod responsible_tests {
|
||||
assert!(!is_responsible(None, &board));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod picture_tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +343,14 @@ pub struct Alternative {
|
||||
/// works (lysbue had the same flag).
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub consequence: Vec<String>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -512,6 +512,13 @@ pub fn validate_questions(
|
||||
}
|
||||
for question in questions.values() {
|
||||
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 {
|
||||
let attended = read_buckets.contains(bucket)
|
||||
|| aggregates
|
||||
|
||||
@@ -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
|
||||
/// moved has been waiting since it was sent.
|
||||
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]
|
||||
fn a_deadline_comes_due_the_day_it_says_and_not_before() {
|
||||
let s = schema(HEARINGS);
|
||||
|
||||
@@ -24,6 +24,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("en", "sign_in") => "Sign in",
|
||||
("en", "sign_out") => "Sign out",
|
||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||
("en", "upload_failed") => "The file could not be sent, so nothing was. Try again, or with a smaller file.",
|
||||
("en", "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", "back_to_start") => "back to the start",
|
||||
("en", "done") => "Done.",
|
||||
@@ -54,6 +57,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("no", "sign_in") => "Logg inn",
|
||||
("no", "sign_out") => "Logg ut",
|
||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen, eller med en mindre fil.",
|
||||
("no", "current_file") => "Nå:",
|
||||
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
|
||||
("no", "nothing_here") => "Ingenting her",
|
||||
("no", "back_to_start") => "tilbake til start",
|
||||
("no", "done") => "Ferdig.",
|
||||
@@ -102,6 +108,9 @@ mod tests {
|
||||
"sign_in",
|
||||
"sign_out",
|
||||
"nothing_here_yet",
|
||||
"upload_failed",
|
||||
"current_file",
|
||||
"keep_file",
|
||||
"nothing_here",
|
||||
"back_to_start",
|
||||
"done",
|
||||
|
||||
+5
-3
@@ -180,11 +180,13 @@ fn check_needs(
|
||||
}
|
||||
}
|
||||
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!(
|
||||
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
|
||||
file.needs.len() - planned,
|
||||
"OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
|
||||
file.needs.len() - planned - paused,
|
||||
planned,
|
||||
paused,
|
||||
file.personas.len()
|
||||
);
|
||||
}
|
||||
|
||||
+9
-5
@@ -78,6 +78,10 @@ pub struct Need {
|
||||
/// as a warning, so the gap stays visible without failing CI.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// language, for the scorer: state ids are ASCII, and a Norwegian
|
||||
/// `ikke_lost` reads as English to a model.
|
||||
@@ -305,11 +309,11 @@ pub fn check(
|
||||
let mut report = |level: Level, message: String| {
|
||||
findings.push(Finding {
|
||||
need: need.id.clone(),
|
||||
level: if need.planned { Level::Warn } else { level },
|
||||
message: if need.planned && level == Level::Fail {
|
||||
format!("planned, not met yet: {message}")
|
||||
} else {
|
||||
message
|
||||
level: if need.planned || need.paused { Level::Warn } else { level },
|
||||
message: match (need.paused, need.planned, level == Level::Fail) {
|
||||
(true, _, true) => format!("paused, not open now: {message}"),
|
||||
(false, true, true) => format!("planned, not met yet: {message}"),
|
||||
_ => message,
|
||||
},
|
||||
})
|
||||
};
|
||||
|
||||
+63
-41
@@ -132,6 +132,7 @@ async fn serve() -> anyhow::Result<()> {
|
||||
// windows (see announce.rs).
|
||||
tokio::spawn(portal::announce::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
|
||||
// everyone a page names as responsible, at start and whenever the
|
||||
// content changes (seed.rs).
|
||||
@@ -166,30 +167,32 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.with_name("portal_session");
|
||||
|
||||
// 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
|
||||
// above anything a visitor filling in a form will reach - a page
|
||||
// load fetches several resources at once - and well below what it
|
||||
// takes to be a nuisance. Per caller IP, in 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.
|
||||
// lot. More than a hundred requests in ten seconds is a script: a
|
||||
// burst of 100, refilled at ten a second. Someone reloading a page
|
||||
// again and again stays well inside it, the more so since the
|
||||
// files a page is made of (stylesheet, scripts, fonts, pictures)
|
||||
// are not counted at all - see `assets` below. Per caller IP, in
|
||||
// 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;
|
||||
// 0 for either turns it off, for an instance behind something that
|
||||
// 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()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2);
|
||||
.unwrap_or(10);
|
||||
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
|
||||
.unwrap_or(100);
|
||||
let governor = (rate_per_second > 0 && rate_burst > 0).then(|| {
|
||||
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
|
||||
// what every one of these instances is behind; it falls back to
|
||||
// the socket address when neither is set.
|
||||
let conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.per_second(rate_seconds)
|
||||
.per_millisecond((1000 / rate_per_second).max(1))
|
||||
.burst_size(rate_burst)
|
||||
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
|
||||
.finish()
|
||||
@@ -197,8 +200,8 @@ async fn serve() -> anyhow::Result<()> {
|
||||
std::sync::Arc::new(conf)
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
|
||||
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
|
||||
}
|
||||
|
||||
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_css = format!("{}/maplibre-gl.css", 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()
|
||||
.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/tiles/{z}/{x}/{y}", get(portal::maps::tile))
|
||||
.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(
|
||||
&state,
|
||||
routes,
|
||||
@@ -307,8 +329,8 @@ async fn serve() -> anyhow::Result<()> {
|
||||
let app = match governor {
|
||||
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
|
||||
None => app,
|
||||
}
|
||||
.with_state(state);
|
||||
};
|
||||
let app = assets.merge(app).with_state(state);
|
||||
|
||||
tracing::info!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
+151
-19
@@ -62,6 +62,12 @@ pub struct Plan {
|
||||
/// is what the page-load limit asks for.
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -89,6 +95,13 @@ pub struct Plan {
|
||||
/// from its `wants`.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// window shopping: a list of what has been built, releases, a
|
||||
/// live count - each a portal resource block, passed through as
|
||||
@@ -200,7 +213,7 @@ pub fn sections_of(markdown: &str) -> Vec<Section> {
|
||||
let mut heading: Option<String> = None;
|
||||
let mut paragraphs: Vec<String> = Vec::new();
|
||||
let mut current: Vec<&str> = Vec::new();
|
||||
let mut close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
|
||||
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) });
|
||||
@@ -405,6 +418,34 @@ pub struct PlanNeed {
|
||||
/// taken down; a register shows what was decided.
|
||||
#[serde(default)]
|
||||
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>,
|
||||
/// How many required fields this person will tolerate: what the
|
||||
/// business said about them, not what the form happens to ask.
|
||||
@@ -482,7 +523,7 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
|
||||
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file"];
|
||||
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file", "picture"];
|
||||
|
||||
fn is_id(s: &str) -> bool {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
||||
@@ -623,8 +664,11 @@ impl Plan {
|
||||
// small application and the firm's large one, and is one
|
||||
// page either way. A bucket only a role ever writes to has
|
||||
// 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)
|
||||
&& need.enters_as.is_some()
|
||||
&& !need.presents
|
||||
&& !self
|
||||
.needs
|
||||
.iter()
|
||||
@@ -725,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)));
|
||||
}
|
||||
}
|
||||
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.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"));
|
||||
@@ -1056,6 +1112,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
"button": "Send",
|
||||
"encouragement": "",
|
||||
"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
|
||||
// the person must have for this to work, the road a case
|
||||
// travels, and who is at the other end. The fallbacks are
|
||||
@@ -1107,7 +1165,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<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();
|
||||
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(
|
||||
format!("status-{bucket}"),
|
||||
json!({
|
||||
@@ -1125,6 +1183,15 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
"question": "What brings you here?",
|
||||
"description": plan.about,
|
||||
"opener": { "heading": "What we do", "description": "", "features": does },
|
||||
"who": {
|
||||
"heading": loc(lang, "Who we are", "Hvem vi er"),
|
||||
"description": "",
|
||||
"items": plan.values.iter().map(|v| (v.id.clone(), json!({ "heading": humanize(&v.id), "description": v.text, "icon": "" })))
|
||||
.chain(public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(bucket, _)| (format!("people-{bucket}"), json!({
|
||||
"heading": humanize(bucket), "description": "", "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."), "icon": "",
|
||||
}))))
|
||||
.collect::<Map<String, Value>>(),
|
||||
},
|
||||
// Named for what it shows, never an invitation to look.
|
||||
"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
|
||||
@@ -1583,9 +1650,13 @@ const FRONT_DOOR_LATEST: usize = 3;
|
||||
/// and at most `limit` records when given. Portal filters by `states`
|
||||
/// 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 {
|
||||
let mut shown: BTreeSet<&str> = BTreeSet::new();
|
||||
for n in needs {
|
||||
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
|
||||
// In the order the form asks for them: the first is what a row, or
|
||||
// a card, is headed by.
|
||||
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();
|
||||
// One record per yielded value: portal collects what the filter
|
||||
@@ -1858,6 +1929,12 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
|
||||
let parent = plan.parent_of(need);
|
||||
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 });
|
||||
// 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.kind == "select" { r["options"] = json!(select_options(plan, f)); }
|
||||
if f.multiple { r["multiple"] = json!(true); }
|
||||
@@ -1912,15 +1989,32 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
|
||||
if !s(&w["who_description"]).trim().is_empty() {
|
||||
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!({
|
||||
"name": text(&w["heading"]),
|
||||
"description": text(&w["description"]),
|
||||
"description": description,
|
||||
"action": action,
|
||||
"consequence": button(&w["button"]),
|
||||
"record_as": need.lands_in,
|
||||
"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() {
|
||||
alt["encouragements"] = button(&w["encouragement"]);
|
||||
}
|
||||
@@ -1977,6 +2071,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
if let Some(d) = need.deadlines.get(st) {
|
||||
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)
|
||||
}).collect::<Map<_, _>>(),
|
||||
"transitions": machine(need),
|
||||
@@ -2018,7 +2115,23 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
.collect();
|
||||
// A public list is shown, not pointed at: its newest few records
|
||||
// inline, and a link to the whole list.
|
||||
for (bucket, needs) in public_buckets(plan) {
|
||||
// Who the business is: its values in its own words, and every
|
||||
// record of what it presents - the people - shown, not listed.
|
||||
let mut who: Vec<Value> = plan.values.iter().map(|v| card(&front["who"]["items"][&v.id])).collect();
|
||||
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)) {
|
||||
let w = &front["who"]["items"][&format!("people-{bucket}")];
|
||||
let mut f = card(w);
|
||||
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), None, "");
|
||||
who.push(f);
|
||||
}
|
||||
if !who.is_empty() {
|
||||
alternatives.push(json!({
|
||||
"name": text(&front["who"]["heading"]),
|
||||
"description": text(&front["who"]["description"]),
|
||||
"features": who,
|
||||
}));
|
||||
}
|
||||
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 mut f = card(w);
|
||||
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
|
||||
@@ -2068,10 +2181,11 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
plan.front_door_count()
|
||||
);
|
||||
}
|
||||
files.insert(
|
||||
"questions/index.yaml".into(),
|
||||
json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives }),
|
||||
);
|
||||
let mut front_page = json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives });
|
||||
if let Some(slideshow) = &plan.slideshow {
|
||||
front_page["slideshow"] = slideshow.clone();
|
||||
}
|
||||
files.insert("questions/index.yaml".into(), front_page);
|
||||
|
||||
// A door's page: a nav page (index.yaml), public, holding the
|
||||
// forms of the needs behind it.
|
||||
@@ -2390,6 +2504,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<_, _>>(),
|
||||
});
|
||||
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();
|
||||
if !others.is_empty() { need["also_moved_by"] = json!(others); }
|
||||
need
|
||||
@@ -2402,6 +2517,8 @@ 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()
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
|
||||
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."),
|
||||
@@ -2417,6 +2534,21 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
} 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.")
|
||||
};
|
||||
// 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 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 purpose = 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."
|
||||
} else if path == "aggregates.yaml" {
|
||||
@@ -2424,17 +2556,17 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
} 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."
|
||||
} 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 who the business is: its heading asks or names that plainly ('Who we are'), each item under it heads one of the business's own statements (values, experience) or the list of its people, and those statements are the business's facts - head them, never add to them. 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/") {
|
||||
status_purpose.as_str()
|
||||
} 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."
|
||||
} 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") {
|
||||
"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") {
|
||||
"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 {
|
||||
"a page only one role inside the business can open, holding one form."
|
||||
};
|
||||
@@ -2446,7 +2578,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
context.extend(
|
||||
plan.needs
|
||||
.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!(
|
||||
"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.",
|
||||
@@ -2603,7 +2735,7 @@ mod tests {
|
||||
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
|
||||
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(), 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,
|
||||
}
|
||||
}
|
||||
@@ -3013,7 +3145,7 @@ mod tests {
|
||||
let resource = &view["alternatives"][0]["features"][0]["resource"];
|
||||
assert_eq!(resource["public"], true);
|
||||
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());
|
||||
// The followup page points people at the list.
|
||||
let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap();
|
||||
|
||||
@@ -184,6 +184,31 @@ rest. Each is one line in the plan, and the builder does the wiring:
|
||||
- **Carry a text word for word.** `documents` puts bylaws or terms on
|
||||
a page of their own, one card per section, from a markdown file
|
||||
beside the plan. Nothing rewords them.
|
||||
- **Say who you are.** `values` are the business's own statements -
|
||||
what it stands on, the experience behind it - carried word for word
|
||||
into a "who we are" card on the front door. A need with `presents:
|
||||
true` and `watched_by: [public]` puts its records in that card, all
|
||||
of them, as cards: the people who work there, entered from inside
|
||||
(`enters_as`), each with a `picture` field that shows as their
|
||||
portrait.
|
||||
- **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
|
||||
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
|
||||
|
||||
+137
@@ -1269,6 +1269,79 @@ textarea:focus {
|
||||
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;
|
||||
}
|
||||
|
||||
.answer-card-text p {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink-dim);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.answer-card-text p: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);
|
||||
@@ -1520,3 +1593,67 @@ textarea:focus {
|
||||
.resource-loading {
|
||||
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)); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user