Commit Graph
33 Commits
Author SHA1 Message Date
Mike Wichers d6e594b1b6 Fix the booth hours and season, and give the pack rhythm list its own heading 2026-09-04 10:34:52 -04:00
Mike Wichers 2a7366786e Link find-a-unit from the FAQ, the footer, and the tail of the unit pages 2026-09-04 10:29:17 -04:00
Mike Wichers f50b9d45e7 Credit and link the district unit list, and say to contact the unit first 2026-09-04 10:20:33 -04:00
Mike Wichers 29afdd453d Tone the serves chip by openness, so co-ed Exploring reads as open 2026-09-04 10:13:15 -04:00
Mike Wichers 9a98c17913 Add /find-a-unit: a courtesy directory of other Continental District units 2026-09-04 09:27:31 -04:00
Mike Wichers 706eaf7739 Add nearby_units: the courtesy directory of other Continental District units 2026-09-04 09:12:06 -04:00
claude eeb2ce6244 Copy sweep: pull the seasonal surface into named values
The hero button said 'Join us this fall' while the nav button two
inches above it said 'Join us'. That is the whole problem in one
screenshot: strings that go stale on a schedule, scattered.

Seven values at the top of app.py now hold the program year, the
meeting day, and the two meeting times. Twelve inline meeting-time
strings and four program-year labels read from them, so a time change
is one edit rather than a grep. The block carries a note that this is
not a CMS: anything with a date in it belongs in the calendar feed,
and evergreen copy stays inline where git log can answer what the
site said during recruitment.

Two strings were not stale, they were false out of season:

  'There is nothing to do between now and Tuesday' on the thanks page
  and 'See you Tuesday' on the join CTA both confidently invited
  families to a meeting that did not exist through July. Now 'we will
  be in touch with the next meeting date' and 'See you on a Tuesday'.
  The properly correct fix reads the next meeting from the calendar
  feed and this is not that, but it stops the site making a promise
  it cannot keep in August.

  The chocolate booth does not run all winter. Three places now say
  in season.

'fall grade' becomes 'current grade' on the join label and in
SHEET_HEADERS. The header is only written to an empty sheet, so the
live sheet would have kept saying 'fall grade' forever; sheet_append
now repairs cell E1 in place on the next lead, the same way it
already backfills the Comments header.

thanks_body became an f-string. It had no bare braces, so the
conversion is safe.
2026-09-01 19:35:13 -04:00
claude f0acb50190 Announcements: stick with the nav, and stop cutting the message
Two faults on a phone, both found on the live site.

The single-line clamp truncated a venue change at 'the Deep Creek
pa...', cutting the only part of the notice that mattered. Collapsed
now clamps to two lines, three under 560px. A SINGLE notice does not
clamp at all - it has no expand affordance, so clamping it loses text
with no way to get it back.

The banner scrolled away while the nav stayed pinned. That was a
deliberate call and it was wrong: a cancellation is worth more
viewport than the nav is. Banner and nav now share one sticky wrapper
so they pin together.

Expanded is exempt via :has() - an open three-notice banner pinned to
a phone viewport is the thing the original call was trying to avoid,
and it is still right about that. Open scrolls, collapsed sticks. The
40vh cap stays as the floor where :has() is unsupported.
2026-09-01 19:26:09 -04:00
claude e02125392a Announcements: a site-wide notice with a start and an end
The one string on this site where a deploy is the wrong latency is
"tonight's meeting is cancelled, the lot is flooded" at 4pm on a
Tuesday. That is a record with a lifecycle, not site copy, so it gets
a table and a write path rather than a commit.

store.py
  announcements table, created by the existing IF NOT EXISTS path so
  there is no migration. ends_at is REQUIRED: an announcement that
  never expires is site copy, and site copy belongs in the repo where
  it has a diff. Nothing is hard-deleted; taking one down early sets
  revoked_at, so what the site said and when survives.

  Ranking is urgent first, then most recent. Recency alone would let a
  routine Wednesday notice bury a Tuesday cancellation still live.

  Two caps, enforced here rather than in the route so the future panel
  inherits them: 200 characters, and 3 live at once. Both reject rather
  than truncate. Clipping a cancellation mid-sentence is worse than
  making someone shorten it, and a 4th live notice is a signal nobody
  is expiring things rather than something to render.

app.py
  announcement_bar() above the sticky nav. Native <details>, no
  JavaScript, which matters on a read-only rootfs with no build step.
  Collapsed clamps to one line with a count; expanded lists all of
  them and caps at 40vh. One notice renders with no chevron and no
  count: the common case must not look like a widget.

  FAILS OPEN. The admin API fails closed because it serves family
  phone numbers. This is the opposite case, and a broken announcement
  must never take down the public homepage.

  Colours are the existing note and rust token pairs from the brand
  standard. No new colour enters the palette.

admin_api.py
  GET/POST/DELETE on /api/admin/announcements. Leads stay read-only;
  announcements are the deliberate exception, because being mutable
  and expiring is the entire feature rather than a guess at a process.
  list returns a computed state per row (live, scheduled, expired,
  revoked, over_cap) so "why is my notice not showing" is answerable
  from the API and not from the homepage.
2026-09-01 19:22:04 -04:00
thethreemagi 985253422d harden: run unprivileged, read-only rootfs, no capabilities
The app is the only process on this box accepting unauthenticated input from
the internet and it was running as root in a writable container. Now uid 10001,
read_only with a tmpfs /tmp, cap_drop ALL and no-new-privileges. Host-side
/srv/scout-website/data and the service account key are chowned to 10001.
Verified on a throwaway container: every route, the admin API, spam rejection,
and a real submission writing to both the jsonl and SQLite.
2026-09-01 13:38:47 -04:00
thethreemagi 2d7784bcbd compose: cap container logs at 5x10MB
Bot traffic writes to this log nightly and it had no rotation configured.
2026-09-01 13:37:15 -04:00
thethreemagi 7221981389 compose: bind 8132 to localhost only
NPM proxies to scout-website:8000 over arrstack_arr_net, so the 0.0.0.0 publish
only provided a LAN path that bypassed the proxy, its exploit blocking and its
real-client-IP logging.
2026-09-01 13:35:53 -04:00
thethreemagi 77ad373885 join: honeypot field + server-side validation on POST /join
Three bot submissions landed 2026-08-30 with both selects carrying the
placeholder label "Select one..." - required= is browser-only and a direct
POST skips it. Adds a hidden honeypot (answers 303 so the bot sees success)
and server-side checks on name, email, children, interested_in and source.
Rejections are logged with the client IP and never touch any store.
2026-09-01 13:17:08 -04:00
Mike Wichers 17c4df796a Add unlisted visibility: served by link, off the index, noindex
Internal papers need a durable link before member login exists. 'unlisted'
serves a document at its slug but keeps it off /documents and sends
X-Robots-Tag: noindex so it stays out of search results.

Serving and listing are now separate questions: visible() decides whether a
document can be served at all and stays the seam login attaches to, listed()
decides whether it shows on the index. 'members' remains hidden AND
unservable, so the weaker state cannot be mistaken for the gate.

This is obscurity, not access control, and both the README and the manifest
say so. An unlisted link is forwardable.
2026-08-30 09:21:26 -04:00
Mike Wichers 1ecdb32139 Add a document shelf at /documents, served through the app
Files live outside the repo at /srv/scout-website-assets/docs (bind-mounted
read-only at /docs), the same arrangement as the photos, published by a
manifest.json beside them. Adding a document is a file drop plus a manifest
entry - the app re-reads the manifest on mtime change, so no rebuild and no
redeploy.

The manifest maps a stable slug to a filename, so next year's permission slip
can replace this year's without breaking a link already printed on a flyer.

Documents are served through /documents/{slug} rather than from a static
mount, and NOT placed under /app/static, which is public forever. That is the
point: when member login exists it plugs into documents.visible() and no
public URL moves. The visibility field already carries 'members', which today
is hidden from the index and 404s rather than 403s, since a 403 would
advertise a document we cannot yet gate.
2026-08-30 09:02:29 -04:00
Claude 27f1a2104b calendar: consume the scout-calendar feed instead of local events.json
Events now come from EVENTS_FEED_URL (the scout-calendar container,
which serves Radicale mike/site73 as JSON). The last good copy is
cached to /data/events-cache.json and served whenever the feed is down
or empty; 'Nothing on the books' remains the cold-start floor only.
The round-trip test proved the feed reproduces all 32 rows of
app/events.json byte-identically, so nothing visible changes today.

Removes app/events.json, the first-boot seed copy, and the local load
path; README calendar section rewritten; compose gains EVENTS_FEED_URL
from the Portainer stack env.
2026-08-29 11:33:43 -04:00
thethreemagi 7d2c241126 Give the join confirmation its own page instead of a banner below the fold
On a phone the /join grid collapses to one column, so the success
banner rendered below the entire pitch column and the 303 landed the
parent at the top of an apparently unchanged page. The empty form
underneath then read as a failed send.

?sent=1 now returns a dedicated confirmation body: banner first, what
happens next, and no form to resubmit.
2026-08-27 07:47:15 -04:00
Mike Wichers 96caac5648 Make a never-attempted mirror visible, and stop replay restamping the date
Two real leads on 2026-08-26 landed in the DB with no Sheet row and no mirror
row at all: the empty-env window meant the Sheet call never ran. Because
/mirrors/failed looked for state='failed', a mirror that was never attempted
read exactly like one that was never owed, and the summary reported a clean
failed_mirrors: {} while two families were missing from the sheet.

/join now writes a `pending` mirror row for every target BEFORE attempting any
of them, so the gap between "owed" and "done" is a row rather than an absence.
set_mirror_pending never downgrades an attempted mirror. failed_mirror_records
covers failed and pending, since both need replaying, and summary reports
pending_mirrors alongside failed_mirrors.

sheet_append also stamped the Sheet with datetime.now(), so replaying a lead
filed it under the day of the replay rather than the day the family submitted.
It now takes the submission timestamp off the record and only falls back to now
when there isn't one.
2026-08-26 21:39:56 -04:00
Mike Wichers 781f991fbe Replace the generic records table with a lean join_leads table
records was a leads table wearing a generic name. It carried display_name /
email / phone, which only mean anything for a lead, plus status / assigned_to /
notes, which were a guess at an outreach process that has not been designed.
Contacting a family is one-to-many, so three columns on the lead row was always
the wrong shape for it.

join_leads is one row per /join submission with one column per form field, two
timestamps, and payload holding the submission verbatim. Outreach gets its own
table when the process is actually known.

Also splits heard_from from heard_from_detail. app.py folded source_place /
source_other into a composed "Other: ..." string and threw the raw answer away,
which is the half that tells you which daycare the lead came from. The composed
value is still built for the Sheet and the ntfy push.

admin API: /records -> /leads, and PATCH is gone since a lead now has nothing
mutable on it. mirrors and meta are unchanged.
2026-08-26 20:55:58 -04:00
Mike Wichers 391e1bcb21 Drop the committed __pycache__ and add a .gitignore
app/__pycache__/app.cpython-313.pyc was committed and went stale the moment
app.py changed. It was inert because the image runs 3.12 and the bytecode was
3.13, but a stale .pyc that happened to match the interpreter would be a very
quiet way to run the wrong code.

Also ignores service_account.json, which is bind-mounted in at runtime and has
no business being committable.
2026-08-26 19:45:43 -04:00
Mike Wichers be2d81b3ab Lead pipeline: local SQLite store as source of truth
Form submissions now land in /data/scout73.db before anything leaves the
box. The Google Sheet and the ntfy push become mirrors whose per-record
outcome is recorded, so a failed sheet write is replayable instead of
surviving only as a push telling you to retype it.

The table is a generic record store keyed by 'kind', with workflow columns
and an audit trail, so RSVPs and other forms can land in the same place
later without a migration. Admin panel talks to /api/admin over HTTP and
never opens the DB file.

- app/store.py     schema, writes, reads, one-time leads.jsonl backfill
- app/admin_api.py token-gated API; fails closed when ADMIN_TOKEN is unset
- app/app.py       three hunks: imports, boot init, join_post
- compose          ADMIN_TOKEN passed through from the Portainer stack env
2026-08-26 19:38:33 -04:00
claude 5331e38d62 Calendar: replace placeholder troop events with the Band feed
The five troop entries were placeholders written to get the site up. Troop
73's real calendar lives in BAND, so those are replaced by what BAND
actually holds:

  - First Court of Honor, Klondike Winter Derby, Troop Spring Campout,
    Founder's Day, River Canoe Trip
  + Canoe Day (9/1), Gettysburg Trip (11/21), Lock In (12/12-13),
    Cabin Camping at French Creek (1/16-17)

Excluded from BAND: six events already past (the /calendar page has no
date filter), the weekly Tuesday meeting series (runs to May 2027, and
the schema has no recurrence field - it is covered in page copy), and
the 10/17 Cub Scout Campout, which is the same event as the existing
Fall Campout entry and that one carries a start time.

Pack and both entries are untouched; BAND has no pack events.
2026-08-24 20:05:00 -04:00
claude 18b0a85aca Troop meetings move to 7:30 PM
Pack runs 6:00-7:15, so the troop follows at 7:30 with a buffer. Updates
all eight troop time references across the footer, /troop and /calendar.
The two 7:00 AM references (Green Lane farmers market chocolate booth)
are deliberately unchanged.

Planned timing, not yet established practice.
2026-08-24 19:56:48 -04:00
claude 08bc249abb footer: Facebook badge icons + short labels; head: Open Graph tags and meta description 2026-08-24 19:14:45 -04:00
claude e319b24585 footer: Facebook links to new GreenLane usernames 2026-08-24 19:02:11 -04:00
claude dd04d3be72 footer: Follow column with Pack/Troop 73 Facebook links 2026-08-24 18:54:36 -04:00
claude 2eec0a12ae Copy pass: join headline, who-can-join FAQ, Eagle card, Farmers Market naming; ntfy title to 'New 73 lead' 2026-08-24 18:44:35 -04:00
claude f80243381e README: self-contained lead pipeline + decom notes 2026-08-24 13:54:53 -04:00
claude 5e3eeb9243 self-contained lead pipeline (direct sheet+ntfy, +Comments col); fix joinbtn specificity + facts flex shrink-wrap 2026-08-24 13:53:57 -04:00
claude 27fcdd3d2b fix: f-string brace crash from inline source JS 2026-08-24 13:36:34 -04:00
claude f3c483a58e join form mirrors scouting73-form (children textarea, interested_in, source w/ schools) + forwards to shared sheet pipeline; firefox joinbtn fix 2026-08-24 13:34:13 -04:00
claude 1f9e214d29 port 8132 (8131 taken by triviapoc) 2026-08-24 13:17:59 -04:00
claude 83beebf35c scout-website: greenlanescouts73.org initial deploy (from scoutpoc redesign) 2026-08-24 13:15:13 -04:00