Skip to content

Let committees write to the backend, only to their own table (first: FOCUS edits Het Fotoboek) - #58

Open
jefryelb wants to merge 7 commits into
mainfrom
feature/fotoboek-backend
Open

jefryelb wants to merge 7 commits into
mainfrom
feature/fotoboek-backend

Conversation

@jefryelb

@jefryelb jefryelb commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

New feature: committees can write to the backend. A member with a committee tag (e.g. focus) can create, edit and delete data from that committee's own page.

Security: a committee can only write to its own table, and to nothing else. The tag unlocks no other route and no other table.

First user: FOCUS managing the photo album on /leden/fotos (Het Fotoboek).

Admins don't configure anything; they only give a member the committee's tag in /admin → Users. Who may read and write is fixed in the code.

Context

Until now, only admins could write anything to the backend. FOCUS needs to maintain Het Fotoboek, a member-only page with a card per event (cover image plus links to the photo albums). Two options existed, and neither was acceptable:

  • Make FOCUS members admin. Too much power: admin also unlocks member management, sync and user deletion.
  • Use the private store (/admin/private_kv/*). It's admin-only, and it keeps all cards in one JSON document, so every save re-sends the whole list and one bad save can wipe every card. It is meant for small admin content like hordes_join and stays that way. A save bug found there is fixed separately in Fix: saving an existing private key returned a 500 #57.

So this PR adds a proper, narrow way for committees to write.

How it works

1. A committee gets its own table

Each piece of committee data is a collection: a list of items in its own table, one row per item. Collections are registered once in code, in COLLECTIONS (data/features/committee_collections.py). This is the fotoboek's entry:

"fotos": Collection(
    table="collection_fotos",   # its own table
    read_role="member",         # who may read
    write_role="focus",         # who may write (admins always can)
    validate=validate_foto_card,
    max_item_bytes=1_000_000,
),

The table is created automatically, because the collection tables are added to DB_TABLES.

2. Three routes, checked against that registry

The same three routes serve every collection; the body says which collection.

Route Body Allowed for Does
POST /members/collections/list/ {collection} read role or admin returns all items
POST /members/collections/save/ {collection, item} write role or admin creates or replaces one item
POST /members/collections/delete/ {collection, id} write role or admin deletes one item

This flowchart shows what happens when a FOCUS member saves a fotoboek card:

flowchart TD
    A["POST /members/collections/save/<br/>{collection: 'fotos', item}"] --> B{"'fotos' in<br/>COLLECTIONS?"}
    B -->|no| X1["404 unknown collection"]
    B -->|yes| C{"session has 'focus'<br/>or 'admin'?"}
    C -->|no| X2["403 Forbidden"]
    C -->|yes| D{"card passes<br/>validate_foto_card?"}
    D -->|no| X3["400 with the problems"]
    D -->|yes| E{"under 1 MB?"}
    E -->|no| X4["413 too large"]
    E -->|yes| OK["200: write this one row<br/>in collection_fotos"]
Loading

3. The security boundary

This diagram shows everything a committee tag can reach:

flowchart LR
    F(["member with<br/>'focus' tag"]) -->|"save / delete"| T["collection_fotos<br/>(own table)"]
    F -.->|"403"| A["/admin/* routes"]
    F -.->|"403"| O["other committees'<br/>collections"]
    F -.->|"no route"| U["users, registrations,<br/>permissions, …"]
Loading
  • The registry decides. A tag only grants writing to collections that name it as write_role. FOCUS can write fotos, and nothing a different committee owns.
  • The handlers stay in their table. They read and write only the requested collection's table. There is no route that lets a committee write to any other table.
  • The backend checks every item against the collection's validator and size cap, so committee members can't store arbitrary data. For example, a fotoboek card rejects unknown fields, javascript: image URLs and non-web links.
  • Tested. A test confirms that the focus tag unlocks none of the admin routes (/admin/private_kv/set/, /admin/add_permission/, /admin/delete_user/).

4. Saving one item at a time

Every save or delete touches exactly one row. Two FOCUS members editing different cards can't overwrite each other, and a failed page load can't wipe other cards. Edits to the same card are last-write-wins.

The backend also records when each item was created (created_at). It sets it on create and keeps it on every later save, and a client can't set it. The fotoboek uses it for an automatic "Nieuw!" badge.

5. The fotoboek card

data/features/fotos.py mirrors the current FOCUS photo overview in Notion:

Field Rule
name required, at most 200 characters
date YYYY-MM-DD, the (first) day of the event
end_date optional, for events spanning several days. It can't be before date
types 1-3 different values from Wedstrijd, Gezelligheid, Training, Reis and Overig (e.g. Batavierenrace = Wedstrijd + Gezelligheid)
in_progress optional true/false: "Wordt aan gewerkt", FOCUS is still editing the photos
seizoen optional, like 25/26
image optional; an image data URL or a web URL
links at most 20 {label, url} with web URLs

Unknown fields are rejected.

6. Adding another committee later

  1. Write a validator for one item.
  2. Add one entry to COLLECTIONS.
  3. Build the page in the frontend. The frontend already has reusable hooks for this: useCollection, useSaveCollectionItem and useDeleteCollectionItem.

No new routes or admin setup are needed.

Commits

The PR is split so each step can be reviewed on its own:

  1. Storage: Collection, the empty registry, table operations, and the collection tables added to DB_TABLES.
  2. Routes: the three handlers and their route registration.
  3. Het Fotoboek: the fotos collection for FOCUS, plus the card validator in data/features/fotos.py.
  4. Tests: tests/test_committee_collections.py.
  5. Docs: a new "Committee Collections" section in spec.md, including the security boundary.
  6. created_at: the backend records when each item was created.
  7. Card fields: fotoboek cards get end_date, several types and in_progress, matching the Notion overview.

Verification

  • uv run ty check, uv run ruff format and uv run ruff check pass on every commit.
  • uv run pytest: 49 passed, 2 skipped.
  • New tests in tests/test_committee_collections.py:
    • Card validator: accepts valid cards (including a multi-day, multi-type, in-progress card) and rejects bad ones, such as an end date before the start date, no types, duplicate types or an unknown type.
    • created_at: the backend sets it, keeps it on edit, and ignores a client-sent value.
    • One card at a time: a FOCUS member creates, edits and deletes cards, and the other cards stay untouched.
    • Admins: an admin can edit without the focus tag.
    • Refusals: a plain member or anonymous request can't write (403), and an anonymous request can't read.
    • No admin routes: the focus tag unlocks no admin route.
    • Bad requests: an invalid card or id returns 400, an oversized item 413, and an unknown collection 404.
  • Manual check on a local stack (this branch plus the frontend branch feature/fotoboek-backend in dodekafrontend):
    1. An admin gives a test member the Focus tag. That was the only admin action.
    2. That member adds a card on /leden/fotos.
    3. Deleting another card left the first one untouched.
    4. A plain member sees the cards read-only. Their hand-made save and delete requests return 403.
  • Running the tests on macOS: run them as BACKEND_PRIVATE_LOCALHOST=1 FAROE_PRIVATE_HOST=127.0.0.1 uv run pytest.

Not tested here: a deployed demo or production environment.

Rollout

🤖 Generated with Claude Code

jefryelb and others added 5 commits October 17, 2026 14:26
Groundwork for letting committees write to the backend. A Collection
names its own freetser table (one row per item), the permission that
may read it, the permission that may write it, an item validator and a
size cap. COLLECTION_TABLES joins DB_TABLES so each collection's table
is created and reset like every other table.

The registry starts empty; the first collection follows separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /members/collections/{list,save,delete}/ with {"collection": ...}
in the body. Access comes from the collection's registry entry: its
read role (or admin) may list, its write role (or admin) may save or
delete one item at a time. The handlers only touch the requested
collection's own table, so a committee permission can't write anything
else. The session check (a blocking auth call) runs before
store_queue.execute, per CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collection "fotos" in table collection_fotos: every member reads the
cards, members with the focus tag (and admins) edit them. fotos.py
checks a card's shape (name, YYYY-MM-DD date, event type, optional
season, image data URL or web URL, up to 20 web links; no unknown
fields), mirroring PhotoEvent in the frontend. Items are capped at 1 MB
because cover images are stored inline after resizing in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers the card validator, a FOCUS member creating, editing and
deleting cards one at a time (the other cards stay untouched), admins
editing without the tag, plain members and anonymous requests being
refused, the focus tag unlocking no admin route, and invalid, oversized
or unknown-collection requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New "Committee Collections" section: one table per collection, the
registry, the security boundary (a committee tag only writes its own
collection), the three routes and their errors, and how to add a
collection for another committee. Adds collection_<name> to Core Tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jefryelb
jefryelb force-pushed the feature/fotoboek-backend branch from fde59cc to 754373a Compare October 17, 2026 12:27
@jefryelb jefryelb changed the title Committee collections: committees edit their own data (first: FOCUS edits Het Fotoboek) Let committees write to the backend, only to their own table (first: FOCUS edits Het Fotoboek) Oct 17, 2026
jefryelb and others added 2 commits October 17, 2026 15:02
The backend stores created_at (unix seconds) with every item: set on
create, kept when the item is replaced. It is server-owned like id, so
a created_at sent by the client is ignored. Pages can use it for things
like a "Nieuw!" badge without anyone having to maintain it by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the card in line with the current FOCUS photo overview in Notion:
- end_date (optional): for events spanning several days, not before date
- types: 1-3 different event types instead of one type
- in_progress (optional bool): "Wordt aan gewerkt", FOCUS is still
  editing the photos

The validator is split into small per-field checks. spec.md documents
the server-owned created_at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jefryelb

Copy link
Copy Markdown
Contributor Author

✅ Tested on demo (2026-10-17)

Deployed to demo.dsavdodeka.nl together with #57, through a temporary branch that has since been deleted. The demo frontend was built from dodekafrontend branch feature/fotoboek-backend. Two test members were used: Fien and Gijs.

Check Result
The admin gives Fien the Focus tag in /admin → Users. Nothing else is configured ✅ That was the only admin step
Fien sees the editor on /leden/fotos. The dev-only toggle is not in the demo build ✅
Fien adds a card with an end date and two types ✅ Saved; shows "13 – 15 maart 2026", Gezelligheid + Training
Fien adds a recent card with "Wordt aan gewerkt" ticked ✅ Shows "Nieuw!" (automatic, from created_at) and "Wordt aan gewerkt"
Fien edits that card and unticks "Wordt aan gewerkt" ✅ The label disappears; "Nieuw!" stays (created_at kept)
Fien deletes the other card ✅ Only that row is removed; the remaining card is unchanged in the backend
Gijs (only member) opens /leden/fotos ✅ Sees the card, with no add or edit buttons
Gijs sends save and delete requests by hand ✅ Both refused with 403; the cards are unchanged

Afterwards the server was restored. ~/dodeka is back on main, ~/frontend is back on enable-backend with the demo build rebuilt, and demo was restarted on main. Production was not restarted. The test accounts and one test card are still in the demo database. The card is unused until this PR is deployed to demo again.

@jefryelb
jefryelb requested a review from tiptenbrink October 17, 2026 16:54
@tiptenbrink

Copy link
Copy Markdown
Collaborator

Didn't do full review but some suggestions:

  • try to keep as much logic in the frontend vs backend, this makes it safer and easier to change. For example, as long as you validate in backend they are members of commissie or admins, maybe move more validation to frontend because we trust they wont try to call the API on their own :)
  • try to keep things generic in a way that might be used also for features added to other photo commissions. So that a later update doesnt require another backend update
  • try to make some variable names more specific (claude likes making them quite generic)

Will add some more stuff later!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants