Skip to content

feat: forms structural availability and lunch tables #57

Description

@ethnjs

Background

Two reserved-key answer types need to write through into structural tables, queryable by membership_id alone, decoupled from whichever form/field collected them:

  • Availability (field_key = "availability") — an eventual event-assignment feature needs to check "is this person free for this shift" repeatedly, as a normal operational read.
  • Lunch (field_key = "lunch_{date}_{category}") — an eventual delivery calculator needs to aggregate selections by category and day, grouped by assigned location.
    Availability reuses TournamentShift (already built for the Events feature) rather than a redundant catalog — though the responder-facing question groups shifts under TD-labeled choices (see Availability option grouping below) rather than exposing raw shifts one-to-one. Lunch does not get a dedicated catalog table — nothing else in the system needs "the tournament's lunch menu" outside of this write-through, so a purpose-built catalog isn't justified. Instead, the structural table stores whatever was actually selected, and if a canonical menu view is ever needed, it's derived by querying that tournament's lunch_*-keyed fields directly.

Motivation

  • Decouples "data that must be queryable" from "however a TD happens to build their form" — mirrors the same reasoning already applied to availability.
  • Skipping a dedicated lunch catalog avoids building and maintaining a table whose only reason to exist would be serving this one write-through — a tournament realistically has one lunch-asking field per category, not several competing ones, so the cross-field consistency a catalog would buy isn't a real problem here.

Changes

Structural tables

Table Columns
MembershipAvailability membership_id, tournament_shift_id — unique together, tournament_shift_id must belong to the same tournament as the membership
TournamentMembershipLunch id, membership_id, date, category, value, label — unique on (membership_id, date, category, value), allows multiple rows per category when the field is multi-select

Lunch field_key pattern

field_key = "lunch_{date}_{category}" (e.g. lunch_20270213_protein) — both the date and category are TD-typed, embedded directly in the key so a tournament with multiple lunch-collecting days (e.g. two confirmation days) never collides on category alone. Either single_select_radio or multi_select_checkbox is allowed as question_type — the TD's choice of type is what determines single- vs. multi-select for that category, there's no external catalog flag to keep in sync. Uniqueness per (date, category) is already covered by the existing field_key-uniqueness-per-tournament rule, since the date is part of the key itself.

"Lunch menu" lookup (no catalog)

If a canonical view of a tournament's lunch menu is ever needed (e.g. a future delivery-calculator UI), derive it by querying FormField where field_key LIKE 'lunch_%', joined through Form.tournament_id, non-archived. Worth a small shared helper function rather than every future caller re-deriving this query.

Availability option grouping

A TD types a single label (e.g. "Morning", "All Day") and links it to one or more real TournamentShifts — a responder only ever sees that label plus the combined start/end of whatever shifts are linked, never the raw shift list. Option shape:

{ "option_id": "a1b2c3d4e5", "value": [1, 2, 3], "label": "All Day" }

option_id is the option's own stable, system-generated identity — used for Edit Lifecycle's archive-by-option_id diffing, same role it plays on every other question type's options, nothing availability-specific about it. value doubles as the write-through payload here instead of TD-facing display text: for a normal option type it's a shortened string version of label, but PlainOption/BranchingOption type it as str | list[int] generically, and an entity-backed reserved field_key (availability grouping TournamentShifts, and event_preference grouping TournamentEvents the same way) sets it to list[int] — the real ids of the underlying entities this option groups together — rather than adding a separate field just for this. Every id in value must belong to the field's own tournament (same strict validation as before, just applied to a list instead of a scalar).

Write-through diff-sync

On response submission, for each answered field with a write-through-triggering field_key:

  • availability: expand each selected option's value into a flat set of shift ids (overlapping shifts across multiple selected options naturally dedupe), diff that set against the membership's existing MembershipAvailability rows, insert new, delete removed.
  • lunch_{date}_{category}: diff the submitted selection against existing TournamentMembershipLunch rows scoped to (membership_id, date, category) only — a submission to one date/category never touches another date or category's rows.
    membership_id is guaranteed to resolve at write-through time: reading/submitting any form always requires an existing membership (hardcoded, see Forms Core Model), and Onboarding's own membership-creation side effects (see the Onboarding parent issue) run before write-through would ever need one for a brand-new member.

Write-through only fires on tournament-owned forms. A chapter-owned form using either reserved key is valid and stores the answer as a normal FormAnswer — no error, no write-through.

Deletion guard

  • Deleting a TournamentShift referenced by any MembershipAvailability row is rejected.
  • Deleting a TournamentShift referenced inside any non-archived field's option value is also rejected — a shift that's actively part of a live option's grouping can't be silently pulled out from under it, even before anyone's answered yet.

What Does Not Change / Out of Scope

  • Full Edit Lifecycle (archive-not-delete, question_type change → archive+replace, FormResponsePendingUpdate) — its own separate issue, not this one.
  • A TournamentLunchOption catalog — intentionally not built, see lookup pattern above instead.
  • An is_active/deprecation lifecycle for shifts/lunch options — future feature, not this issue.
  • Whether responders must update an existing response after a published form's fields change — covered by the Edit Lifecycle issue.

Completion Criteria

  1. MembershipAvailability and TournamentMembershipLunch exist, addressable purely by membership_id, no dependency on any specific form or field.
  2. Submitting a response with an availability or lunch_{date}_{category} answer diff-syncs the corresponding structural table; only that field's own scope (its expanded shift set, or its own date+category) is touched.
  3. Write-through fires only for tournament-owned forms; chapter-owned forms store the same answers generically, no error, no write-through.
  4. Deleting a TournamentShift referenced by any MembershipAvailability row, or by any non-archived field's option value, is rejected.
  5. A lunch_{date}_{category} field is valid with either single_select_radio or multi_select_checkbox, with no external catalog to validate against.
  6. Selecting an availability option that groups multiple shifts writes one MembershipAvailability row per underlying shift; overlapping shifts across multiple selected options produce no duplicate rows.

Activity

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

Metadata

Metadata

Assignees

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions