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
MembershipAvailability and TournamentMembershipLunch exist, addressable purely by membership_id, no dependency on any specific form or field.
- 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.
- Write-through fires only for tournament-owned forms; chapter-owned forms store the same answers generically, no error, no write-through.
- Deleting a
TournamentShift referenced by any MembershipAvailability row, or by any non-archived field's option value, is rejected.
- A
lunch_{date}_{category} field is valid with either single_select_radio or multi_select_checkbox, with no external catalog to validate against.
- 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.
Background
Two reserved-key answer types need to write through into structural tables, queryable by
membership_idalone, decoupled from whichever form/field collected them:field_key = "availability") — an eventual event-assignment feature needs to check "is this person free for this shift" repeatedly, as a normal operational read.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'slunch_*-keyed fields directly.Motivation
Changes
Structural tables
MembershipAvailabilitymembership_id,tournament_shift_id— unique together,tournament_shift_idmust belong to the same tournament as the membershipTournamentMembershipLunchid,membership_id,date,category,value,label— unique on(membership_id, date, category, value), allows multiple rows per category when the field is multi-selectLunch 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. Eithersingle_select_radioormulti_select_checkboxis allowed asquestion_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
FormFieldwherefield_key LIKE 'lunch_%', joined throughForm.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_idis the option's own stable, system-generated identity — used for Edit Lifecycle's archive-by-option_iddiffing, same role it plays on every other question type's options, nothing availability-specific about it.valuedoubles as the write-through payload here instead of TD-facing display text: for a normal option type it's a shortened string version oflabel, butPlainOption/BranchingOptiontype it asstr | list[int]generically, and an entity-backed reservedfield_key(availability groupingTournamentShifts, andevent_preferencegroupingTournamentEvents the same way) sets it tolist[int]— the real ids of the underlying entities this option groups together — rather than adding a separate field just for this. Every id invaluemust 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'svalueinto a flat set of shift ids (overlapping shifts across multiple selected options naturally dedupe), diff that set against the membership's existingMembershipAvailabilityrows, insert new, delete removed.lunch_{date}_{category}: diff the submitted selection against existingTournamentMembershipLunchrows scoped to(membership_id, date, category)only — a submission to one date/category never touches another date or category's rows.membership_idis 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
TournamentShiftreferenced by anyMembershipAvailabilityrow is rejected.TournamentShiftreferenced inside any non-archived field's optionvalueis 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
question_typechange → archive+replace,FormResponsePendingUpdate) — its own separate issue, not this one.TournamentLunchOptioncatalog — intentionally not built, see lookup pattern above instead.is_active/deprecation lifecycle for shifts/lunch options — future feature, not this issue.Completion Criteria
MembershipAvailabilityandTournamentMembershipLunchexist, addressable purely bymembership_id, no dependency on any specific form or field.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.TournamentShiftreferenced by anyMembershipAvailabilityrow, or by any non-archived field's optionvalue, is rejected.lunch_{date}_{category}field is valid with eithersingle_select_radioormulti_select_checkbox, with no external catalog to validate against.MembershipAvailabilityrow per underlying shift; overlapping shifts across multiple selected options produce no duplicate rows.