Commit Graph
11 Commits
Author SHA1 Message Date
thethreemagi ea22ed53e7 fb_posts: past its publish time, a scheduled post reads as published
Facebook does not call back and the reconciliation was never built, so a
row that stayed scheduled forever was a lie of omission. Decided by Mike:
once the time passes with nothing else reported, the post is published.
status keeps what the publisher last said; state is what a reader acts
on, and the open-only list and cancel use state. Cancel on a post whose
time has passed says so.
2026-09-04 19:53:08 -04:00
thethreemagi 55e4c67b9a facebook posts: fb_posts table, ingest with hash-verified images, gated image, cancel
Open item 12, designed 2026-08-26, built today. scout-publisher reports
every post it drafts, schedules, holds or cancels by POSTing here; it
never opens the database. id is <unit>/<queue-stem>, stable across body
edits, so a redrafted post is one row and cancel is an indexed lookup on
fb_post_id. The image is copied, content-addressed at
/data/post-images/<sha256>.<ext>, and the sha256 is recomputed on arrival
- a mismatch is refused, so a row never claims a version nobody sent.
image_ref keeps the NAS path as provenance. The image route runs the same
capability check as the list on every request. Cancel goes through the
publisher's own signed per-post link stored on the row; the site never
holds the publisher's secret. fbposts:read for leaders, fbposts:ingest
for admins and scopable so the publisher's key carries exactly that.

tests/smoke_admin.py 147 -> 157.
2026-09-04 19:46:44 -04:00
thethreemagi 7f04d57665 family link: which accounts belong to a household, and GET /api/admin/family
household_people joins a roster family to the accounts of its parents; a
household can have two, a person can rarely be on two. PUT
/roster/households/{id}/people sets it (admin and above). GET /family
(account:self) returns the signed-in person's own households with scouts
and this year's checklist, and nothing else - the parent view of the
roster, read only. Inactive households drop off it.

tests/smoke_admin.py 141 -> 147.
2026-09-04 19:19:34 -04:00
thethreemagi 504538567f roster: households, scouts, a per-year checklist, lead import
Decided by Mike 2026-09-04. my.scouting stays the record of registration;
this holds what a den leader needs on a Tuesday: the families, which scout
is in which den, and a per-program-year checklist of things collected -
dues paid, health form handed in - recording THAT a thing was collected,
by whom and when, never the thing. Health forms are never stored here;
that is a policy, not a gap. A scout is a first name, last name, unit,
den and an optional BSA member ID (the recharter join key), and nothing
else: no date of birth, no address, nothing medical, and a test asserts
no such column exists.

A join lead can be imported as a household: contact copied, the children
field carried as a note to sort by hand, the lead linked and untouched.
Importing twice is 409.

roster:write for leader and above, never on a script key. Every write
lands in the action log. scout-website-backup.timer already copies the
database nightly, which was the doc's first condition for naming scouts.

tests/smoke_admin.py 122 -> 141.
2026-09-04 18:56:30 -04:00
thethreemagi 59559ad909 lead claims: outreach v1, decided by Mike
Who picked a lead up and when, so nothing sits waiting unnoticed. No
outcomes, no end states, nothing on join_leads changes: the lead stays the
record of what the family said and the claim is only who has it. One row
per claim in lead_claims; the current claim is the latest unreleased.
Taking over is release then claim, never a silent overwrite: 409 names
the holder. The holder or an owner may release. Every claim and release
lands in the action log; the summary carries an unclaimed count.

tests/smoke_admin.py 112 -> 122.
2026-09-04 18:34:01 -04:00
thethreemagi 32e1c67a6f P2: nearby units CRUD, unit meeting times, site settings - the first admin writes
Nearby rows bump verified_at on every save and deactivate rather than delete.
Unit edits are meeting fields only, gated by unit:write_own scoped to the unit
in the URL. Settings are a typed key registry falling back to code defaults;
find-a-unit now reads its source name and URL from it. link_url and contact
reject attribute-breakout characters and the nearby renderer escapes quotes.
Covered by tests/smoke_admin.py, 53 checks in-process.
2026-09-04 14:10:29 -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 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
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 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