From 44e9cdc62d33ad43e771de04acffd572e711564b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 19:00:35 -0700 Subject: [PATCH 01/92] feat(tournaments): add TournamentForm companion model and onboarding archive guard --- ...b6640_tournament_forms_and_onboarded_at.py | 63 +++++++++++++++++++ backend/app/api/routes/forms.py | 33 ++++++++++ backend/app/models/models.py | 41 ++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py diff --git a/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py b/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py new file mode 100644 index 00000000..bb6dfec1 --- /dev/null +++ b/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py @@ -0,0 +1,63 @@ +"""tournament forms and onboarded_at + +Revision ID: 8d55ec2b6640 +Revises: 7db31ae17e3c +Create Date: 2026-08-25 00:00:00.000000 + +Adds tournament_forms — a 1:1 companion row every tournament-scoped Form +gets (owner_type == "tournament"; chapter forms never get one). is_onboarding ++ order (order only meaningful for is_onboarding=True rows) drive the +onboarding step sequence for a tournament. See the TournamentForm model +docstring in app/models/models.py for the full design. + +Also adds tournament_memberships.onboarded_at, set once a member has +answered every currently-onboarding-flagged published form. + +Backfills a tournament_forms row for every existing forms row that already +has a tournament_id, so the 1:1 invariant holds for pre-existing data too. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '8d55ec2b6640' +down_revision: Union[str, None] = '7db31ae17e3c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('tournament_forms', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), + sa.Column('form_id', sa.String(length=12), nullable=False), + sa.Column('is_onboarding', sa.Boolean(), nullable=False), + sa.Column('order', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('form_id', name='uq_tournament_form_form') + ) + op.create_index(op.f('ix_tournament_forms_id'), 'tournament_forms', ['id'], unique=False) + + op.add_column('tournament_memberships', sa.Column('onboarded_at', sa.DateTime(timezone=True), nullable=True)) + + # Backfill: every pre-existing tournament-owned form needs its + # companion row too, or the 1:1 invariant is broken from day one. + op.execute( + """ + INSERT INTO tournament_forms (tournament_id, form_id, is_onboarding, created_at) + SELECT tournament_id, id, false, now() + FROM forms + WHERE tournament_id IS NOT NULL + """ + ) + + +def downgrade() -> None: + op.drop_column('tournament_memberships', 'onboarded_at') + op.drop_index(op.f('ix_tournament_forms_id'), table_name='tournament_forms') + op.drop_table('tournament_forms') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 141d9dae..3f4a710a 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -38,6 +38,7 @@ FormField, FormResponse, FormResponsePendingUpdate, + TournamentForm, TournamentMembership, User, utcnow, @@ -90,6 +91,13 @@ def create_tournament_form( created_by=current_user.id, ) db.add(form) + db.flush() # assigns form.id, needed for the companion row's FK below + + # Every tournament-scoped Form gets a TournamentForm companion row — + # see the model docstring. is_onboarding starts False; the + # onboarding-forms routes flip it. + db.add(TournamentForm(tournament_id=tournament_id, form_id=form.id)) + db.commit() db.refresh(form) return form @@ -304,6 +312,15 @@ def update_form( detail="A published form cannot be reverted to draft — archive it instead if it should stop accepting responses", ) + if payload.status == "published" and form.status == "archived": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An archived form must be unarchived to draft and reviewed before it can be republished", + ) + + if payload.status == "archived": + _reject_if_onboarding(db, form) + if payload.status == "published": try: validate_form_for_publish(db, form) @@ -324,6 +341,21 @@ def update_form( return form +# --------------------------------------------------------------------------- +# One deliberate exception to Onboarding never being referenced by Forms +# (Onboarding depends on Forms, never the reverse — see the TournamentForm +# model docstring): a form still flagged is_onboarding can't be archived out +# from under the sequence it's part of. Remove it from onboarding first. +# --------------------------------------------------------------------------- +def _reject_if_onboarding(db: Session, form: Form) -> None: + tf = db.query(TournamentForm).filter(TournamentForm.form_id == form.id).first() + if tf is not None and tf.is_onboarding: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This form is part of the onboarding sequence — remove it from onboarding before archiving", + ) + + # --------------------------------------------------------------------------- # POST /forms/{form_id}/archive/ — soft delete via status="archived". # Responses and fields are left in place. @@ -333,6 +365,7 @@ def archive_form( db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): + _reject_if_onboarding(db, form) form.status = "archived" db.commit() db.refresh(form) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index acc6afd2..d05c4fcd 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -354,6 +354,7 @@ class Tournament(Base): audit_log = relationship("AuditLogEntry", back_populates="tournament", cascade="all, delete-orphan") event_shifts = relationship("TournamentShift", back_populates="tournament", cascade="all, delete-orphan") forms = relationship("Form", back_populates="tournament", cascade="all, delete-orphan") + tournament_forms = relationship("TournamentForm", back_populates="tournament", cascade="all, delete-orphan") # Exactly one of university_id/location (XOR). Checked at flush, not @@ -395,6 +396,15 @@ class TournamentMembership(Base): # "interested" | "confirmed" status = Column(String(32), nullable=False, default="interested") + # Set once this member has answered every currently-onboarding-flagged, + # published TournamentForm for this tournament. Permanent once set — + # later removing/archiving an onboarding form never unsets it (a + # shrinking requirement can only help stragglers finish, never un-onboard + # someone already done). Adding a *new* onboarding form is the one + # requirement change allowed to null this back out — see the + # onboarding-forms POST route, which clears it tournament-wide. + onboarded_at = Column(DateTime(timezone=True), nullable=True) + notes = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), default=utcnow) @@ -697,6 +707,7 @@ class Form(Base): creator = relationship("User", back_populates="created_forms") fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan", order_by="FormField.order") responses = relationship("FormResponse", back_populates="form", cascade="all, delete-orphan") + tournament_form = relationship("TournamentForm", back_populates="form", uselist=False, cascade="all, delete-orphan") __table_args__ = ( CheckConstraint( @@ -714,6 +725,36 @@ def response_count(self) -> int: return len(self.responses) +# --------------------------------------------------------------------------- +# TournamentForm — 1:1 companion row every tournament-scoped Form gets at +# creation time (see create_tournament_form in api/routes/forms.py). Never +# deleted while the Form exists; is_onboarding just toggles what the row +# means rather than the row itself coming and going. +# +# is_onboarding + order together define the onboarding step sequence for the +# tournament (order is only meaningful — and set — for is_onboarding=True +# rows). Standard, non-onboarding tournament forms leave order null; the +# eventual prerequisite/visibility mechanism for those is a later phase, not +# built yet. +# +# A form linked here with is_onboarding=True cannot be archived — see the +# guard in api/routes/forms.py's update_form/archive_form — it must be +# removed from onboarding (is_onboarding flipped back to False) first. +# --------------------------------------------------------------------------- +class TournamentForm(Base): + __tablename__ = "tournament_forms" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False) + form_id = Column(String(12), ForeignKey("forms.id", ondelete="CASCADE"), nullable=False, unique=True) + is_onboarding = Column(Boolean, nullable=False, default=False) + order = Column(Integer, nullable=True) + created_at = Column(DateTime(timezone=True), default=utcnow) + + tournament = relationship("Tournament", back_populates="tournament_forms") + form = relationship("Form", back_populates="tournament_form") + + # --------------------------------------------------------------------------- # FormField — a single question on a Form. question_type drives how config # is shaped (see comments inline below). Removing a field with existing From 74c74ac50974eac0c1009c2632825458f54b9fff Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 19:08:42 -0700 Subject: [PATCH 02/92] feat(onboarding): add tournament onboarding-forms routes for adding, reordering, and removing onboarding steps --- .../app/api/routes/tournament/onboarding.py | 176 ++++++++++++++++++ backend/app/main.py | 2 + backend/app/schemas/tournament/onboarding.py | 34 ++++ 3 files changed, 212 insertions(+) create mode 100644 backend/app/api/routes/tournament/onboarding.py create mode 100644 backend/app/schemas/tournament/onboarding.py diff --git a/backend/app/api/routes/tournament/onboarding.py b/backend/app/api/routes/tournament/onboarding.py new file mode 100644 index 00000000..526f5616 --- /dev/null +++ b/backend/app/api/routes/tournament/onboarding.py @@ -0,0 +1,176 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.routes.forms import _to_list_read +from app.core.tournament import get_tournament, require_not_archived +from app.core.tournament.memberships import resolve_memberships_or_users +from app.core.tournament.permissions import MANAGE_FORMS, require_permission +from app.db.session import get_db +from app.models.models import TournamentForm, TournamentMembership, User +from app.schemas.tournament.onboarding import OnboardingFormAdd, OnboardingFormRead, OnboardingFormReorder + +# Routes are nested: /tournaments/{tournament_id}/onboarding-forms/... +router = APIRouter(prefix="/tournaments/{tournament_id}/onboarding-forms", tags=["tournaments"]) + + +def _read(tf: TournamentForm, creator) -> OnboardingFormRead: + base = _to_list_read(tf.form, creator) + return OnboardingFormRead(**base.model_dump(), tournament_form_id=tf.id, order=tf.order) + + +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/onboarding-forms/ — the onboarding config +# page's list, in order. manage_forms-gated, same as the standalone forms +# list — this isn't the member-facing "what do I need to fill out" view. +# --------------------------------------------------------------------------- +@router.get("/", response_model=list[OnboardingFormRead]) +def list_onboarding_forms( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_FORMS)), +): + rows = ( + db.query(TournamentForm) + .filter(TournamentForm.tournament_id == tournament_id, TournamentForm.is_onboarding == True) + .order_by(TournamentForm.order) + .all() + ) + creators = resolve_memberships_or_users(db, tournament_id, {r.form.created_by for r in rows}) + return [_read(r, creators[r.form.created_by]) for r in rows] + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/onboarding-forms/ — flips an existing +# TournamentForm row's is_onboarding to True and appends it to the order. +# Every tournament-scoped Form already has a TournamentForm row (created +# alongside the Form itself, see create_tournament_form in forms.py) — this +# never inserts a new row, only flips one. +# +# Clears onboarded_at tournament-wide: adding a new onboarding form expands +# what "onboarded" requires, so anyone already past the old bar goes back to +# pending until they clear the new one too. See TournamentMembership.onboarded_at. +# --------------------------------------------------------------------------- +@router.post("/", response_model=OnboardingFormRead, status_code=status.HTTP_201_CREATED) +def add_onboarding_form( + tournament_id: int, + payload: OnboardingFormAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_FORMS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + tf = ( + db.query(TournamentForm) + .filter(TournamentForm.form_id == payload.form_id, TournamentForm.tournament_id == tournament_id) + .first() + ) + if tf is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") + if tf.is_onboarding: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This form is already part of onboarding") + if tf.form.status == "archived": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An archived form cannot be added to onboarding") + + max_order = ( + db.query(TournamentForm.order) + .filter(TournamentForm.tournament_id == tournament_id, TournamentForm.is_onboarding == True) + .order_by(TournamentForm.order.desc()) + .first() + ) + tf.is_onboarding = True + tf.order = (max_order[0] if max_order else 0) + 1 + + db.query(TournamentMembership).filter( + TournamentMembership.tournament_id == tournament_id, + TournamentMembership.onboarded_at.isnot(None), + ).update({TournamentMembership.onboarded_at: None}) + + db.commit() + db.refresh(tf) + creators = resolve_memberships_or_users(db, tournament_id, {tf.form.created_by}) + return _read(tf, creators[tf.form.created_by]) + + +# --------------------------------------------------------------------------- +# PATCH /tournaments/{tournament_id}/onboarding-forms/reorder/ — final order +# values computed client-side (drag-and-drop preview); this validates the +# submitted set matches the current onboarding forms and applies them +# atomically. Registered before "/{form_id}/" so the literal path wins. +# Mirrors PATCH /roles/reorder-bulk/'s shape (RoleBulkReorder). +# --------------------------------------------------------------------------- +@router.patch("/reorder/", response_model=list[OnboardingFormRead]) +def reorder_onboarding_forms( + tournament_id: int, + payload: OnboardingFormReorder, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_FORMS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + rows = ( + db.query(TournamentForm) + .filter(TournamentForm.tournament_id == tournament_id, TournamentForm.is_onboarding == True) + .all() + ) + rows_by_form_id = {r.form_id: r for r in rows} + + if {item.form_id for item in payload.forms} != set(rows_by_form_id.keys()): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="forms must cover exactly the current onboarding forms, no more and no fewer", + ) + + for item in payload.forms: + rows_by_form_id[item.form_id].order = item.order + + db.commit() + creators = resolve_memberships_or_users(db, tournament_id, {r.form.created_by for r in rows}) + ordered = sorted(rows_by_form_id.values(), key=lambda r: r.order) + return [_read(r, creators[r.form.created_by]) for r in ordered] + + +# --------------------------------------------------------------------------- +# DELETE /tournaments/{tournament_id}/onboarding-forms/{form_id}/ — "remove +# from onboarding": flips is_onboarding back to False and clears order. The +# TournamentForm row itself is never deleted (see model docstring) — the +# underlying Form is untouched and can be archived separately afterward. +# +# No onboarded_at reset here — a shrinking requirement can only complete +# stragglers waiting on this form, never un-onboard someone already done. +# Remaining onboarding rows are renumbered contiguously. +# --------------------------------------------------------------------------- +@router.delete("/{form_id}/", status_code=status.HTTP_204_NO_CONTENT) +def remove_onboarding_form( + tournament_id: int, + form_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_FORMS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + tf = ( + db.query(TournamentForm) + .filter(TournamentForm.form_id == form_id, TournamentForm.tournament_id == tournament_id) + .first() + ) + if tf is None or not tf.is_onboarding: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form is not part of onboarding") + + tf.is_onboarding = False + tf.order = None + db.flush() + + remaining = ( + db.query(TournamentForm) + .filter(TournamentForm.tournament_id == tournament_id, TournamentForm.is_onboarding == True) + .order_by(TournamentForm.order) + .all() + ) + for index, row in enumerate(remaining, start=1): + row.order = index + + db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 54e75317..b89f5324 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from app.api.routes.tournament import admin as tournament_admin from app.api.routes.tournament import audit as tournament_audit from app.api.routes.tournament import setup_checklist as tournament_setup_checklist +from app.api.routes.tournament import onboarding as tournament_onboarding from app.api.routes import chapter as chapter_core from app.api.routes.chapter import admin as chapter_admin from app.api.routes.chapter import memberships as chapter_memberships @@ -104,6 +105,7 @@ def _run_archive_job(): app.include_router(tournament_admin.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_audit.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_setup_checklist.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(tournament_onboarding.router, prefix="", dependencies=[api_key_dependency]) app.include_router(sheets.router, prefix="", dependencies=[api_key_dependency]) app.include_router(users.router, prefix="", dependencies=[api_key_dependency]) app.include_router(user_experience.router, prefix="", dependencies=[api_key_dependency]) diff --git a/backend/app/schemas/tournament/onboarding.py b/backend/app/schemas/tournament/onboarding.py new file mode 100644 index 00000000..1f99ff3f --- /dev/null +++ b/backend/app/schemas/tournament/onboarding.py @@ -0,0 +1,34 @@ +from __future__ import annotations +from pydantic import BaseModel, field_validator + +from app.schemas.form import FormListRead + + +class OnboardingFormAdd(BaseModel): + form_id: str + + +class OnboardingFormReorderItem(BaseModel): + form_id: str + order: int + + @field_validator("order") + @classmethod + def validate_order(cls, v: int) -> int: + if v < 1: + raise ValueError("order must be a positive integer") + return v + + +class OnboardingFormReorder(BaseModel): + """ + Body for PATCH /onboarding-forms/reorder/ — final order values computed + client-side (drag-and-drop preview); the backend just validates the set + matches the current onboarding forms and applies them atomically. + """ + forms: list[OnboardingFormReorderItem] + + +class OnboardingFormRead(FormListRead): + tournament_form_id: int + order: int | None = None From b465b22675050d1b5d6b750d470f2cc51b88266f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 19:19:21 -0700 Subject: [PATCH 03/92] refactor(onboarding): make tournament_forms.form_id the primary key and block deleting onboarding-linked forms --- ...ec2b6640_tournament_forms_and_onboarded_at.py | 16 ++++++++-------- backend/app/api/routes/forms.py | 1 + backend/app/api/routes/tournament/onboarding.py | 2 +- backend/app/models/models.py | 16 +++++++++++----- backend/app/schemas/tournament/onboarding.py | 3 ++- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py b/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py index bb6dfec1..a49255be 100644 --- a/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py +++ b/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py @@ -10,6 +10,10 @@ onboarding step sequence for a tournament. See the TournamentForm model docstring in app/models/models.py for the full design. +form_id is the table's own primary key, not a separate surrogate id — the +relationship is strictly 1:1, so there's no "which TournamentForm" beyond +"which Form." Deleting a Form cascades away its TournamentForm row for free. + Also adds tournament_memberships.onboarded_at, set once a member has answered every currently-onboarding-flagged published form. @@ -30,18 +34,15 @@ def upgrade() -> None: op.create_table('tournament_forms', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('tournament_id', sa.Integer(), nullable=False), sa.Column('form_id', sa.String(length=12), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), sa.Column('is_onboarding', sa.Boolean(), nullable=False), sa.Column('order', sa.Integer(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('form_id', name='uq_tournament_form_form') + sa.PrimaryKeyConstraint('form_id') ) - op.create_index(op.f('ix_tournament_forms_id'), 'tournament_forms', ['id'], unique=False) op.add_column('tournament_memberships', sa.Column('onboarded_at', sa.DateTime(timezone=True), nullable=True)) @@ -49,8 +50,8 @@ def upgrade() -> None: # companion row too, or the 1:1 invariant is broken from day one. op.execute( """ - INSERT INTO tournament_forms (tournament_id, form_id, is_onboarding, created_at) - SELECT tournament_id, id, false, now() + INSERT INTO tournament_forms (form_id, tournament_id, is_onboarding, created_at) + SELECT id, tournament_id, false, now() FROM forms WHERE tournament_id IS NOT NULL """ @@ -59,5 +60,4 @@ def upgrade() -> None: def downgrade() -> None: op.drop_column('tournament_memberships', 'onboarded_at') - op.drop_index(op.f('ix_tournament_forms_id'), table_name='tournament_forms') op.drop_table('tournament_forms') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 3f4a710a..cf5e15a8 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -389,6 +389,7 @@ def delete_form( status_code=status.HTTP_409_CONFLICT, detail="Form has existing responses — archive it instead of deleting", ) + _reject_if_onboarding(db, form) db.delete(form) db.commit() diff --git a/backend/app/api/routes/tournament/onboarding.py b/backend/app/api/routes/tournament/onboarding.py index 526f5616..f8b87424 100644 --- a/backend/app/api/routes/tournament/onboarding.py +++ b/backend/app/api/routes/tournament/onboarding.py @@ -16,7 +16,7 @@ def _read(tf: TournamentForm, creator) -> OnboardingFormRead: base = _to_list_read(tf.form, creator) - return OnboardingFormRead(**base.model_dump(), tournament_form_id=tf.id, order=tf.order) + return OnboardingFormRead(**base.model_dump(), order=tf.order) # --------------------------------------------------------------------------- diff --git a/backend/app/models/models.py b/backend/app/models/models.py index d05c4fcd..56650397 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -737,16 +737,22 @@ def response_count(self) -> int: # eventual prerequisite/visibility mechanism for those is a later phase, not # built yet. # -# A form linked here with is_onboarding=True cannot be archived — see the -# guard in api/routes/forms.py's update_form/archive_form — it must be -# removed from onboarding (is_onboarding flipped back to False) first. +# A form linked here with is_onboarding=True cannot be archived or deleted — +# see the guard in api/routes/forms.py's update_form/archive_form/ +# delete_form — it must be removed from onboarding (is_onboarding flipped +# back to False) first. +# +# form_id is this row's own primary key, not a separate surrogate id — the +# relationship is strictly 1:1, so there's no "which TournamentForm" beyond +# "which Form." Deleting a Form (already blocked while responses exist, see +# Form's own delete route) cascades away its TournamentForm row for free; +# there's no independent "delete a TournamentForm" action. # --------------------------------------------------------------------------- class TournamentForm(Base): __tablename__ = "tournament_forms" - id = Column(Integer, primary_key=True, index=True) + form_id = Column(String(12), ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False) - form_id = Column(String(12), ForeignKey("forms.id", ondelete="CASCADE"), nullable=False, unique=True) is_onboarding = Column(Boolean, nullable=False, default=False) order = Column(Integer, nullable=True) created_at = Column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/schemas/tournament/onboarding.py b/backend/app/schemas/tournament/onboarding.py index 1f99ff3f..0661b6e8 100644 --- a/backend/app/schemas/tournament/onboarding.py +++ b/backend/app/schemas/tournament/onboarding.py @@ -30,5 +30,6 @@ class OnboardingFormReorder(BaseModel): class OnboardingFormRead(FormListRead): - tournament_form_id: int + # `id` (inherited from FormListRead) already is the form_id — a + # TournamentForm row's identity is its Form's identity, 1:1. order: int | None = None From c44145a8e9e7e33b7b054bd7d584cad9c4f7ad37 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 19:33:08 -0700 Subject: [PATCH 04/92] fix(onboarding): require published forms and validate reorder sequence --- .../app/api/routes/tournament/onboarding.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/tournament/onboarding.py b/backend/app/api/routes/tournament/onboarding.py index f8b87424..eea8edd4 100644 --- a/backend/app/api/routes/tournament/onboarding.py +++ b/backend/app/api/routes/tournament/onboarding.py @@ -70,8 +70,14 @@ def add_onboarding_form( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") if tf.is_onboarding: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This form is already part of onboarding") - if tf.form.status == "archived": - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An archived form cannot be added to onboarding") + # An onboarding step must be immediately answerable. In particular, + # accepting a draft here would invalidate existing completions before the + # new requirement could actually be satisfied. + if tf.form.status != "published": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Only published forms can be added to onboarding", + ) max_order = ( db.query(TournamentForm.order) @@ -117,12 +123,22 @@ def reorder_onboarding_forms( ) rows_by_form_id = {r.form_id: r for r in rows} - if {item.form_id for item in payload.forms} != set(rows_by_form_id.keys()): + submitted_ids = [item.form_id for item in payload.forms] + expected_ids = set(rows_by_form_id) + if len(submitted_ids) != len(set(submitted_ids)) or set(submitted_ids) != expected_ids: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="forms must cover exactly the current onboarding forms, no more and no fewer", ) + submitted_orders = [item.order for item in payload.forms] + expected_orders = set(range(1, len(rows) + 1)) + if set(submitted_orders) != expected_orders: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="orders must be a unique, contiguous sequence from 1 through the number of onboarding forms", + ) + for item in payload.forms: rows_by_form_id[item.form_id].order = item.order From b5969fb8659ff996357ad5f5f960de3484c68414 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 19:39:24 -0700 Subject: [PATCH 05/92] feat(onboarding): add member progress and completion endpoint --- .../app/api/routes/tournament/onboarding.py | 39 +++++++++++++- backend/app/core/tournament/onboarding.py | 54 +++++++++++++++++++ backend/app/main.py | 3 +- backend/app/schemas/tournament/onboarding.py | 8 +++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 backend/app/core/tournament/onboarding.py diff --git a/backend/app/api/routes/tournament/onboarding.py b/backend/app/api/routes/tournament/onboarding.py index eea8edd4..b46e8bae 100644 --- a/backend/app/api/routes/tournament/onboarding.py +++ b/backend/app/api/routes/tournament/onboarding.py @@ -3,15 +3,23 @@ from sqlalchemy.orm import Session from app.api.routes.forms import _to_list_read +from app.core.auth import get_current_user from app.core.tournament import get_tournament, require_not_archived -from app.core.tournament.memberships import resolve_memberships_or_users +from app.core.tournament.memberships import get_membership_by_user, resolve_memberships_or_users +from app.core.tournament.onboarding import advance_onboarding_progress from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db from app.models.models import TournamentForm, TournamentMembership, User -from app.schemas.tournament.onboarding import OnboardingFormAdd, OnboardingFormRead, OnboardingFormReorder +from app.schemas.tournament.onboarding import ( + OnboardingFormAdd, + OnboardingFormRead, + OnboardingFormReorder, + OnboardingProgressRead, +) # Routes are nested: /tournaments/{tournament_id}/onboarding-forms/... router = APIRouter(prefix="/tournaments/{tournament_id}/onboarding-forms", tags=["tournaments"]) +member_router = APIRouter(prefix="/tournaments/{tournament_id}/onboarding", tags=["tournaments"]) def _read(tf: TournamentForm, creator) -> OnboardingFormRead: @@ -19,6 +27,33 @@ def _read(tf: TournamentForm, creator) -> OnboardingFormRead: return OnboardingFormRead(**base.model_dump(), order=tf.order) +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/onboarding/progress/ — member-facing +# progression after a successful form submission. It finds the next required +# published form and snapshots onboarded_at the first time none remain. +# --------------------------------------------------------------------------- +@member_router.post("/progress/", response_model=OnboardingProgressRead) +def advance_member_onboarding( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + membership = get_membership_by_user(db, tournament_id, current_user.id) + if membership is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tournament membership not found") + + progress = advance_onboarding_progress(db, membership) + db.commit() + db.refresh(membership) + return OnboardingProgressRead( + next_form_id=progress.next_form_id, + onboarded_at=membership.onboarded_at, + ) + + # --------------------------------------------------------------------------- # GET /tournaments/{tournament_id}/onboarding-forms/ — the onboarding config # page's list, in order. manage_forms-gated, same as the standalone forms diff --git a/backend/app/core/tournament/onboarding.py b/backend/app/core/tournament/onboarding.py new file mode 100644 index 00000000..2dfdbbef --- /dev/null +++ b/backend/app/core/tournament/onboarding.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models.models import Form, FormResponse, TournamentForm, TournamentMembership, utcnow + + +@dataclass(frozen=True) +class OnboardingProgress: + """The member's next required form, if any, in the active sequence.""" + + next_form_id: str | None + + +def advance_onboarding_progress( + db: Session, + membership: TournamentMembership, +) -> OnboardingProgress: + """Find the next unanswered published onboarding form and snapshot completion. + + This intentionally lives in the tournament-onboarding layer rather than + the generic forms submission flow. A client calls the onboarding progress + endpoint after submitting a form to learn where to go next. + """ + steps = ( + db.query(TournamentForm) + .join(Form, TournamentForm.form_id == Form.id) + .filter( + TournamentForm.tournament_id == membership.tournament_id, + TournamentForm.is_onboarding == True, + Form.status == "published", + ) + .order_by(TournamentForm.order) + .all() + ) + answered_form_ids = { + form_id + for (form_id,) in ( + db.query(FormResponse.form_id) + .filter( + FormResponse.user_id == membership.user_id, + FormResponse.form_id.in_([step.form_id for step in steps]), + ) + .all() + ) + } + + next_step = next((step for step in steps if step.form_id not in answered_form_ids), None) + if next_step is None and membership.onboarded_at is None: + membership.onboarded_at = utcnow() + + return OnboardingProgress(next_form_id=next_step.form_id if next_step else None) diff --git a/backend/app/main.py b/backend/app/main.py index b89f5324..e7f4a6e9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -106,6 +106,7 @@ def _run_archive_job(): app.include_router(tournament_audit.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_setup_checklist.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_onboarding.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(tournament_onboarding.member_router, prefix="", dependencies=[api_key_dependency]) app.include_router(sheets.router, prefix="", dependencies=[api_key_dependency]) app.include_router(users.router, prefix="", dependencies=[api_key_dependency]) app.include_router(user_experience.router, prefix="", dependencies=[api_key_dependency]) @@ -127,4 +128,4 @@ async def scalar_reference(): return get_scalar_api_reference( openapi_url="/openapi.json", title="NEXUS API Reference", - ) \ No newline at end of file + ) diff --git a/backend/app/schemas/tournament/onboarding.py b/backend/app/schemas/tournament/onboarding.py index 0661b6e8..18890ef8 100644 --- a/backend/app/schemas/tournament/onboarding.py +++ b/backend/app/schemas/tournament/onboarding.py @@ -1,4 +1,5 @@ from __future__ import annotations +from datetime import datetime from pydantic import BaseModel, field_validator from app.schemas.form import FormListRead @@ -33,3 +34,10 @@ class OnboardingFormRead(FormListRead): # `id` (inherited from FormListRead) already is the form_id — a # TournamentForm row's identity is its Form's identity, 1:1. order: int | None = None + + +class OnboardingProgressRead(BaseModel): + """Member-facing result of advancing through the onboarding sequence.""" + + next_form_id: str | None = None + onboarded_at: datetime | None = None From 34743ae8f63686bf02b9bb0f943f4698f88b4987 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 19:44:52 -0700 Subject: [PATCH 06/92] test(onboarding): cover configuration and member progress flow --- .../tests/api/tournament/test_onboarding.py | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 backend/tests/api/tournament/test_onboarding.py diff --git a/backend/tests/api/tournament/test_onboarding.py b/backend/tests/api/tournament/test_onboarding.py new file mode 100644 index 00000000..5eac6367 --- /dev/null +++ b/backend/tests/api/tournament/test_onboarding.py @@ -0,0 +1,199 @@ +"""API coverage for tournament onboarding configuration and member progress.""" + +from tests.conftest import grant_role, login + +from app.models.models import Form, FormResponse, TournamentForm, TournamentMembership + + +def _make_form(db, user, tournament, *, name="Onboarding form", status="published"): + form = Form( + owner_type="tournament", + tournament_id=tournament.id, + chapter_id=None, + name=name, + title=name, + status=status, + created_by=user.id, + ) + db.add(form) + db.flush() + db.add(TournamentForm(form_id=form.id, tournament_id=tournament.id)) + db.flush() + return form + + +def _onboarding_form_ids(db, tournament_id): + return [ + row.form_id + for row in ( + db.query(TournamentForm) + .filter(TournamentForm.tournament_id == tournament_id, TournamentForm.is_onboarding == True) + .order_by(TournamentForm.order) + .all() + ) + ] + + +def _membership(db, tournament_id, user_id): + return ( + db.query(TournamentMembership) + .filter( + TournamentMembership.tournament_id == tournament_id, + TournamentMembership.user_id == user_id, + ) + .one() + ) + + +def test_add_published_form_to_onboarding_appends_and_clears_completed_members(client, db, td_user, td_tournament, other_user): + first = _make_form(db, td_user, td_tournament, name="First") + second = _make_form(db, td_user, td_tournament, name="Second") + member = grant_role(db, td_tournament, other_user, "Runner") + member.onboarded_at = td_tournament.created_at + db.commit() + + login(client, "td@test.com", "tdpass") + first_response = client.post( + f"/tournaments/{td_tournament.id}/onboarding-forms/", json={"form_id": first.id} + ) + second_response = client.post( + f"/tournaments/{td_tournament.id}/onboarding-forms/", json={"form_id": second.id} + ) + + assert first_response.status_code == 201 + assert first_response.json()["order"] == 1 + assert second_response.status_code == 201 + assert second_response.json()["order"] == 2 + assert _membership(db, td_tournament.id, other_user.id).onboarded_at is None + + +def test_add_draft_form_to_onboarding_is_rejected(client, db, td_user, td_tournament): + draft = _make_form(db, td_user, td_tournament, status="draft") + db.commit() + login(client, "td@test.com", "tdpass") + + response = client.post( + f"/tournaments/{td_tournament.id}/onboarding-forms/", json={"form_id": draft.id} + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "Only published forms can be added to onboarding" + + +def test_list_onboarding_forms_is_ordered_and_manage_forms_gated(client, db, td_user, td_tournament, other_user): + first = _make_form(db, td_user, td_tournament, name="First") + second = _make_form(db, td_user, td_tournament, name="Second") + first.tournament_form.is_onboarding = True + first.tournament_form.order = 2 + second.tournament_form.is_onboarding = True + second.tournament_form.order = 1 + grant_role(db, td_tournament, other_user, "Runner") + db.commit() + + login(client, "td@test.com", "tdpass") + response = client.get(f"/tournaments/{td_tournament.id}/onboarding-forms/") + assert response.status_code == 200 + assert [item["id"] for item in response.json()] == [second.id, first.id] + + login(client, "other@test.com", "otherpass") + assert client.get(f"/tournaments/{td_tournament.id}/onboarding-forms/").status_code == 403 + + +def test_reorder_requires_each_form_once_and_a_contiguous_sequence(client, db, td_user, td_tournament): + first = _make_form(db, td_user, td_tournament, name="First") + second = _make_form(db, td_user, td_tournament, name="Second") + for order, form in enumerate((first, second), start=1): + form.tournament_form.is_onboarding = True + form.tournament_form.order = order + db.commit() + login(client, "td@test.com", "tdpass") + + duplicate_order = client.patch( + f"/tournaments/{td_tournament.id}/onboarding-forms/reorder/", + json={"forms": [{"form_id": first.id, "order": 1}, {"form_id": second.id, "order": 1}]}, + ) + duplicate_id = client.patch( + f"/tournaments/{td_tournament.id}/onboarding-forms/reorder/", + json={"forms": [ + {"form_id": first.id, "order": 1}, + {"form_id": first.id, "order": 2}, + {"form_id": second.id, "order": 2}, + ]}, + ) + valid = client.patch( + f"/tournaments/{td_tournament.id}/onboarding-forms/reorder/", + json={"forms": [{"form_id": first.id, "order": 2}, {"form_id": second.id, "order": 1}]}, + ) + + assert duplicate_order.status_code == 422 + assert duplicate_id.status_code == 422 + assert valid.status_code == 200 + assert _onboarding_form_ids(db, td_tournament.id) == [second.id, first.id] + + +def test_remove_from_onboarding_renumbers_without_unonboarding_members(client, db, td_user, td_tournament, other_user): + first = _make_form(db, td_user, td_tournament, name="First") + second = _make_form(db, td_user, td_tournament, name="Second") + third = _make_form(db, td_user, td_tournament, name="Third") + for order, form in enumerate((first, second, third), start=1): + form.tournament_form.is_onboarding = True + form.tournament_form.order = order + member = grant_role(db, td_tournament, other_user, "Runner") + member.onboarded_at = td_tournament.created_at + db.commit() + login(client, "td@test.com", "tdpass") + + response = client.delete(f"/tournaments/{td_tournament.id}/onboarding-forms/{second.id}/") + + assert response.status_code == 204 + assert _onboarding_form_ids(db, td_tournament.id) == [first.id, third.id] + assert first.tournament_form.order == 1 + assert third.tournament_form.order == 2 + assert _membership(db, td_tournament.id, other_user.id).onboarded_at is not None + + +def test_onboarding_form_cannot_be_archived_or_deleted_until_removed(client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + form.tournament_form.is_onboarding = True + form.tournament_form.order = 1 + db.commit() + login(client, "td@test.com", "tdpass") + + archive = client.post(f"/forms/{form.id}/archive/") + delete = client.delete(f"/forms/{form.id}/") + + assert archive.status_code == 409 + assert delete.status_code == 409 + + +def test_progress_returns_next_form_then_snapshots_completion(client, db, td_user, td_tournament, other_user): + first = _make_form(db, td_user, td_tournament, name="First") + second = _make_form(db, td_user, td_tournament, name="Second") + for order, form in enumerate((first, second), start=1): + form.tournament_form.is_onboarding = True + form.tournament_form.order = order + grant_role(db, td_tournament, other_user, "Runner") + db.commit() + login(client, "other@test.com", "otherpass") + + initial = client.post(f"/tournaments/{td_tournament.id}/onboarding/progress/") + db.add(FormResponse(form_id=first.id, user_id=other_user.id)) + db.commit() + after_first = client.post(f"/tournaments/{td_tournament.id}/onboarding/progress/") + db.add(FormResponse(form_id=second.id, user_id=other_user.id)) + db.commit() + complete = client.post(f"/tournaments/{td_tournament.id}/onboarding/progress/") + + assert initial.json() == {"next_form_id": first.id, "onboarded_at": None} + assert after_first.json() == {"next_form_id": second.id, "onboarded_at": None} + assert complete.json()["next_form_id"] is None + assert complete.json()["onboarded_at"] is not None + assert _membership(db, td_tournament.id, other_user.id).onboarded_at is not None + + +def test_progress_requires_membership(client, td_user, other_tournament): + login(client, "td@test.com", "tdpass") + + response = client.post(f"/tournaments/{other_tournament.id}/onboarding/progress/") + + assert response.status_code == 404 From 6da3cca92f6b7041008a554191f6d2506dd4794c Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 20:01:33 -0700 Subject: [PATCH 07/92] feat(forms): add tournament onboarding configuration page --- .../[id]/forms/onboarding/page.tsx | 248 ++++++++++++++++++ .../dashboard/tournaments/[id]/forms/page.tsx | 3 + .../components/tournament/forms/FormsTabs.tsx | 19 ++ frontend/lib/api.ts | 20 ++ 4 files changed, 290 insertions(+) create mode 100644 frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx create mode 100644 frontend/components/tournament/forms/FormsTabs.tsx diff --git a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx new file mode 100644 index 00000000..1a7d9311 --- /dev/null +++ b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { DndContext, DragEndEvent, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; +import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { ApiError, FormListItem, OnboardingForm, formsApi, onboardingFormsApi } from "@/lib/api"; +import { useAuth } from "@/lib/useAuth"; +import { useMyMembership } from "@/lib/useMyMembership"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { Dropdown } from "@/components/ui/Dropdown"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { Spinner } from "@/components/ui/Spinner"; +import { IconEdit, IconForms, IconGripVertical, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; +import { FormsTabs } from "@/components/tournament/forms/FormsTabs"; + +function SortableOnboardingRow({ form, index, onEdit, onRemove, saving }: { + form: OnboardingForm; + index: number; + onEdit: () => void; + onRemove: () => void; + saving: boolean; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: form.id }); + + return ( +
+ + + {index + 1}. + +
+

+ {form.name} +

+ {form.description && ( +

+ {form.description} +

+ )} +
+ + {form.response_count} responses + +
+ + +
+
+ ); +} + +export default function OnboardingFormsPage() { + const params = useParams(); + const router = useRouter(); + const tournamentId = Number(params.id); + const { user: currentUser } = useAuth(); + const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); + const canManageForms = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_forms"); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + + const [allForms, setAllForms] = useState(null); + const [steps, setSteps] = useState(null); + const [selectedFormId, setSelectedFormId] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!canManageForms) return; + Promise.all([formsApi.listForTournament(tournamentId), onboardingFormsApi.list(tournamentId)]) + .then(([forms, onboarding]) => { + setAllForms(forms); + setSteps(onboarding); + }) + .catch((e) => { + setError(e instanceof ApiError ? e.message : "Failed to load onboarding forms."); + setAllForms([]); + setSteps([]); + }); + }, [canManageForms, tournamentId]); + + async function addForm() { + if (!selectedFormId) return; + setSaving(true); + setError(null); + try { + const step = await onboardingFormsApi.add(tournamentId, selectedFormId); + setSteps((current) => [...(current ?? []), step].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))); + setSelectedFormId(""); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Failed to add the form to onboarding."); + } finally { + setSaving(false); + } + } + + async function removeForm(formId: string) { + setSaving(true); + setError(null); + try { + await onboardingFormsApi.remove(tournamentId, formId); + setSteps((current) => (current ?? []).filter((form) => form.id !== formId).map((form, index) => ({ ...form, order: index + 1 }))); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Failed to remove the form from onboarding."); + } finally { + setSaving(false); + } + } + + async function reorder(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id || !steps) return; + const oldIndex = steps.findIndex((form) => form.id === active.id); + const newIndex = steps.findIndex((form) => form.id === over.id); + if (oldIndex < 0 || newIndex < 0) return; + + const previous = steps; + const next = arrayMove(steps, oldIndex, newIndex).map((form, index) => ({ ...form, order: index + 1 })); + setSteps(next); + setSaving(true); + setError(null); + try { + const saved = await onboardingFormsApi.reorder(tournamentId, next.map((form) => form.id)); + setSteps(saved); + } catch (e) { + setSteps(previous); + setError(e instanceof ApiError ? e.message : "Failed to save the new order."); + } finally { + setSaving(false); + } + } + + if (membershipLoading) { + return
; + } + + if (!canManageForms) { + return ( +
+ + + } title="No access" description="You need the manage forms permission to configure onboarding." /> + +
+ ); + } + + if (allForms === null || steps === null) { + return ( +
+ +
+
+ ); + } + + const onboardingIds = new Set(steps.map((form) => form.id)); + const availableForms = allForms.filter((form) => form.status === "published" && !onboardingIds.has(form.id)); + + return ( +
+ + + + {error &&

{error}

} + + +

Add an onboarding form

+

+ Only published forms are available. Adding a form asks previously completed members to complete the expanded sequence. +

+
+
+ ({ value: form.id, label: form.name, subtitle: form.description ?? undefined }))} + placeholder={availableForms.length ? "Choose a published form" : "No published forms available"} + locked={saving || availableForms.length === 0} + fullWidth + searchable + /> +
+ +
+
+ + {steps.length === 0 ? ( + + } title="No onboarding forms" description="Add published forms above to build the member onboarding sequence." /> + + ) : ( + +
+

Onboarding sequence

+

Drag forms to set the order members see them.

+
+ + form.id)} strategy={verticalListSortingStrategy}> + {steps.map((form, index) => ( + router.push(`/forms/${form.id}/edit`)} + onRemove={() => removeForm(form.id)} + /> + ))} + + +
+ )} +
+ ); +} diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index b2250f9e..03c46640 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -15,6 +15,7 @@ import { IconForms, IconLock, IconEdit, IconEye, IconPlus } from "@/components/u import { formatRelativeTime } from "@/lib/timeFormat"; import { CreatorHoverCard } from "@/components/tournament/CreatorHoverCard"; import { NewFormModal } from "@/components/tournament/forms/NewFormModal"; +import { FormsTabs } from "@/components/tournament/forms/FormsTabs"; // Name / Status / Creator / Responses / Updated / Actions const FORM_ROW_COLUMNS = "1.4fr 110px 0.275fr 100px 110px 76px"; @@ -180,6 +181,8 @@ export default function FormsPage() { } /> + + {loadError && (

{loadError} diff --git a/frontend/components/tournament/forms/FormsTabs.tsx b/frontend/components/tournament/forms/FormsTabs.tsx new file mode 100644 index 00000000..04bd1f5d --- /dev/null +++ b/frontend/components/tournament/forms/FormsTabs.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { TabStrip } from "@/components/ui/TabStrip"; + +type FormsTab = "forms" | "onboarding"; + +export function FormsTabs({ tournamentId, active }: { tournamentId: number; active: FormsTab }) { + const router = useRouter(); + const base = `/dashboard/tournaments/${tournamentId}/forms`; + + return ( + router.push(tab === "forms" ? base : `${base}/onboarding`)} + /> + ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 017ee9a8..c2f80c34 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1306,6 +1306,12 @@ export interface FormResponse { answers: FormAnswer[] } +// Matches OnboardingFormRead — a tournament form selected into the ordered +// member onboarding sequence. +export interface OnboardingForm extends FormListItem { + order: number | null +} + export const formsApi = { listForTournament: (tournamentId: number) => api.get(`/tournaments/${tournamentId}/forms/`), @@ -1344,3 +1350,17 @@ export const formsApi = { listResponses: (formId: string) => api.get(`/forms/${formId}/responses/`), getMyResponse: (formId: string) => api.get(`/forms/${formId}/responses/me/`), } + +export const onboardingFormsApi = { + list: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/onboarding-forms/`), + add: (tournamentId: number, formId: string) => + api.post(`/tournaments/${tournamentId}/onboarding-forms/`, { form_id: formId }), + reorder: (tournamentId: number, formIds: string[]) => + api.patch( + `/tournaments/${tournamentId}/onboarding-forms/reorder/`, + { forms: formIds.map((form_id, index) => ({ form_id, order: index + 1 })) }, + ), + remove: (tournamentId: number, formId: string) => + api.delete(`/tournaments/${tournamentId}/onboarding-forms/${formId}/`), +} From 46fda878cdda8a520e535ad4e272c54c4be15aec Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 20:06:12 -0700 Subject: [PATCH 08/92] refactor(forms): move shared header and tabs into layout --- .../tournaments/[id]/forms/layout.tsx | 21 ++++++++++ .../[id]/forms/onboarding/page.tsx | 19 ++------- .../dashboard/tournaments/[id]/forms/page.tsx | 41 +++++++------------ 3 files changed, 39 insertions(+), 42 deletions(-) create mode 100644 frontend/app/dashboard/tournaments/[id]/forms/layout.tsx diff --git a/frontend/app/dashboard/tournaments/[id]/forms/layout.tsx b/frontend/app/dashboard/tournaments/[id]/forms/layout.tsx new file mode 100644 index 00000000..24d951da --- /dev/null +++ b/frontend/app/dashboard/tournaments/[id]/forms/layout.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ReactNode } from "react"; +import { useParams, usePathname } from "next/navigation"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { FormsTabs } from "@/components/tournament/forms/FormsTabs"; + +export default function FormsLayout({ children }: { children: ReactNode }) { + const params = useParams(); + const pathname = usePathname(); + const tournamentId = Number(params.id); + const base = `/dashboard/tournaments/${tournamentId}/forms`; + + return ( + <> + + + {children} + + ); +} diff --git a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx index 1a7d9311..bd743cb9 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx @@ -8,14 +8,12 @@ import { CSS } from "@dnd-kit/utilities"; import { ApiError, FormListItem, OnboardingForm, formsApi, onboardingFormsApi } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; -import { PageHeader } from "@/components/ui/PageHeader"; import { Card } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Dropdown } from "@/components/ui/Dropdown"; import { EmptyState } from "@/components/ui/EmptyState"; import { Spinner } from "@/components/ui/Spinner"; import { IconEdit, IconForms, IconGripVertical, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; -import { FormsTabs } from "@/components/tournament/forms/FormsTabs"; function SortableOnboardingRow({ form, index, onEdit, onRemove, saving }: { form: OnboardingForm; @@ -166,21 +164,15 @@ export default function OnboardingFormsPage() { if (!canManageForms) { return ( -

- - - } title="No access" description="You need the manage forms permission to configure onboarding." /> - -
+ + } title="No access" description="You need the manage forms permission to configure onboarding." /> + ); } if (allForms === null || steps === null) { return ( -
- -
-
+
); } @@ -189,9 +181,6 @@ export default function OnboardingFormsPage() { return (
- - - {error &&

{error}

} diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index 03c46640..5641e5e3 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -5,7 +5,6 @@ import { useParams, useRouter } from "next/navigation"; import { formsApi, Form, FormListItem, FormStatus, ApiError } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; -import { PageHeader } from "@/components/ui/PageHeader"; import { Card } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; @@ -15,7 +14,6 @@ import { IconForms, IconLock, IconEdit, IconEye, IconPlus } from "@/components/u import { formatRelativeTime } from "@/lib/timeFormat"; import { CreatorHoverCard } from "@/components/tournament/CreatorHoverCard"; import { NewFormModal } from "@/components/tournament/forms/NewFormModal"; -import { FormsTabs } from "@/components/tournament/forms/FormsTabs"; // Name / Status / Creator / Responses / Updated / Actions const FORM_ROW_COLUMNS = "1.4fr 110px 0.275fr 100px 110px 76px"; @@ -140,26 +138,20 @@ export default function FormsPage() { if (!canManageForms) { return ( -
- - - } - title="No access" - description="You need the manage forms permission to view this page." - /> - -
+ + } + title="No access" + description="You need the manage forms permission to view this page." + /> + ); } if (forms === null) { return ( -
- -
- -
+
+
); } @@ -172,16 +164,11 @@ export default function FormsPage() { return (
- setCreating(true)}> - New Form - - } - /> - - +
+ +
{loadError && (

From a1688f9ac2a2f196cf62230f3be2f2a9c480bc94 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 20:15:55 -0700 Subject: [PATCH 09/92] feat(onboarding): add drag-and-drop onboarding forms config page --- .../[id]/forms/onboarding/page.tsx | 386 +++++++++++------- 1 file changed, 229 insertions(+), 157 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx index bd743cb9..d58893b7 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx @@ -1,237 +1,309 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { DndContext, DragEndEvent, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; -import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import { + DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, closestCenter, useSensor, useSensors, +} from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { ApiError, FormListItem, OnboardingForm, formsApi, onboardingFormsApi } from "@/lib/api"; +import { formsApi, onboardingFormsApi, FormListItem, OnboardingForm, FormStatus, ApiError } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; import { Card } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; -import { Dropdown } from "@/components/ui/Dropdown"; -import { EmptyState } from "@/components/ui/EmptyState"; import { Spinner } from "@/components/ui/Spinner"; -import { IconEdit, IconForms, IconGripVertical, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { Combobox } from "@/components/ui/Combobox"; +import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; +import { IconForms, IconLock, IconGripVertical, IconTrash } from "@/components/ui/Icons"; -function SortableOnboardingRow({ form, index, onEdit, onRemove, saving }: { - form: OnboardingForm; - index: number; - onEdit: () => void; - onRemove: () => void; - saving: boolean; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: form.id }); - - return ( -

- - - {index + 1}. - -
-

- {form.name} -

- {form.description && ( -

- {form.description} -

- )} -
- - {form.response_count} responses - -
- - -
-
- ); -} +const STATUS_BADGE_VARIANT: Record = { + draft: "default", + published: "confirmed", + archived: "removed", +}; export default function OnboardingFormsPage() { const params = useParams(); - const router = useRouter(); const tournamentId = Number(params.id); + const { user: currentUser } = useAuth(); const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); const canManageForms = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_forms"); - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const [allForms, setAllForms] = useState(null); - const [steps, setSteps] = useState(null); - const [selectedFormId, setSelectedFormId] = useState(""); + const [baseline, setBaseline] = useState(null); + const [draft, setDraft] = useState([]); + const [loadError, setLoadError] = useState(null); + const [addValue, setAddValue] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + const [removingId, setRemovingId] = useState(null); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); + const [saveError, setSaveError] = useState(undefined); + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); useEffect(() => { if (!canManageForms) return; Promise.all([formsApi.listForTournament(tournamentId), onboardingFormsApi.list(tournamentId)]) .then(([forms, onboarding]) => { setAllForms(forms); - setSteps(onboarding); + setBaseline(onboarding); + setDraft(onboarding); }) - .catch((e) => { - setError(e instanceof ApiError ? e.message : "Failed to load onboarding forms."); - setAllForms([]); - setSteps([]); - }); - }, [canManageForms, tournamentId]); - - async function addForm() { - if (!selectedFormId) return; - setSaving(true); - setError(null); + .catch((e) => setLoadError(e instanceof ApiError ? e.message : "Failed to load onboarding forms.")); + }, [tournamentId, canManageForms]); + + // Only a published form not already an onboarding step can be added — the + // backend rejects anything else (see add_onboarding_form's guard). + const eligibleForms = useMemo(() => { + const onboardingIds = new Set(draft.map((f) => f.id)); + return (allForms ?? []).filter((f) => f.status === "published" && !onboardingIds.has(f.id)); + }, [allForms, draft]); + + const isDirty = baseline !== null && draft.map((f) => f.id).join(",") !== baseline.map((f) => f.id).join(","); + + async function handleAdd(formId: string) { + setAdding(true); + setAddError(null); try { - const step = await onboardingFormsApi.add(tournamentId, selectedFormId); - setSteps((current) => [...(current ?? []), step].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))); - setSelectedFormId(""); + const created = await onboardingFormsApi.add(tournamentId, formId); + const next = [...draft, created]; + setBaseline(next); + setDraft(next); + setAddValue(""); } catch (e) { - setError(e instanceof ApiError ? e.message : "Failed to add the form to onboarding."); + setAddError(e instanceof ApiError ? e.message : "Failed to add form to onboarding."); } finally { - setSaving(false); + setAdding(false); } } - async function removeForm(formId: string) { - setSaving(true); - setError(null); + async function handleRemove(formId: string) { + setRemovingId(formId); try { await onboardingFormsApi.remove(tournamentId, formId); - setSteps((current) => (current ?? []).filter((form) => form.id !== formId).map((form, index) => ({ ...form, order: index + 1 }))); + const next = draft.filter((f) => f.id !== formId); + setBaseline(next); + setDraft(next); } catch (e) { - setError(e instanceof ApiError ? e.message : "Failed to remove the form from onboarding."); + setLoadError(e instanceof ApiError ? e.message : "Failed to remove form from onboarding."); } finally { - setSaving(false); + setRemovingId(null); } } - async function reorder(event: DragEndEvent) { + function handleDragStart(event: DragStartEvent) { + setActiveId(String(event.active.id)); + } + + function handleDragEnd(event: DragEndEvent) { + setActiveId(null); const { active, over } = event; - if (!over || active.id === over.id || !steps) return; - const oldIndex = steps.findIndex((form) => form.id === active.id); - const newIndex = steps.findIndex((form) => form.id === over.id); - if (oldIndex < 0 || newIndex < 0) return; - - const previous = steps; - const next = arrayMove(steps, oldIndex, newIndex).map((form, index) => ({ ...form, order: index + 1 })); - setSteps(next); + if (!over || active.id === over.id) return; + const oldIndex = draft.findIndex((f) => f.id === active.id); + const newIndex = draft.findIndex((f) => f.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + setDraft(arrayMove(draft, oldIndex, newIndex)); + } + + async function handleSave() { setSaving(true); - setError(null); + setSaveError(undefined); try { - const saved = await onboardingFormsApi.reorder(tournamentId, next.map((form) => form.id)); - setSteps(saved); + const updated = await onboardingFormsApi.reorder(tournamentId, draft.map((f) => f.id)); + setBaseline(updated); + setDraft(updated); } catch (e) { - setSteps(previous); - setError(e instanceof ApiError ? e.message : "Failed to save the new order."); + setSaveError(e instanceof ApiError ? e.message : "Failed to save the new order."); } finally { setSaving(false); } } + function handleCancel() { + if (baseline) setDraft(baseline); + setSaveError(undefined); + } + if (membershipLoading) { - return
; + return ( +
+ +
+ ); } if (!canManageForms) { return ( - } title="No access" description="You need the manage forms permission to configure onboarding." /> + } + title="No access" + description="You need the manage forms permission to view this page." + /> ); } - if (allForms === null || steps === null) { + if (baseline === null || allForms === null) { return ( -
+
+ +
); } - const onboardingIds = new Set(steps.map((form) => form.id)); - const availableForms = allForms.filter((form) => form.status === "published" && !onboardingIds.has(form.id)); - return (
- {error &&

{error}

} +

+ Members must complete these forms, in order, before they’re fully onboarded to this tournament. + Only published forms can be added. Drag to reorder. +

- -

Add an onboarding form

-

- Only published forms are available. Adding a form asks previously completed members to complete the expanded sequence. + {loadError && ( +

+ {loadError}

-
-
- ({ value: form.id, label: form.name, subtitle: form.description ?? undefined }))} - placeholder={availableForms.length ? "Choose a published form" : "No published forms available"} - locked={saving || availableForms.length === 0} - fullWidth - searchable - /> -
- -
-
+ )} - {steps.length === 0 ? ( - - } title="No onboarding forms" description="Add published forms above to build the member onboarding sequence." /> + {draft.length === 0 ? ( + + } + title="No onboarding forms yet" + description="Add a published form below to start the onboarding sequence." + /> ) : ( - -
-

Onboarding sequence

-

Drag forms to set the order members see them.

-
- - form.id)} strategy={verticalListSortingStrategy}> - {steps.map((form, index) => ( - + setActiveId(null)} + > + f.id)} strategy={verticalListSortingStrategy}> + {draft.map((form, i) => ( + router.push(`/forms/${form.id}/edit`)} - onRemove={() => removeForm(form.id)} + step={i + 1} + removing={removingId === form.id} + onRemove={() => handleRemove(form.id)} /> ))} + + {activeId && ( + f.id === activeId)!} dragging /> + )} +
)} + + + f.id} + getLabel={(f) => f.name} + value={addValue} + onChange={(text, matched) => { + if (matched) { + handleAdd(matched.id); + } else { + setAddValue(text); + } + }} + allowFreeText={false} + placeholder="Add a published form to onboarding" + maxResults={eligibleForms.length} + error={addError ?? undefined} + locked={adding} + size="md" + /> + + + +
+ ); +} + +function OnboardingFormPill({ form, dragging = false }: { form: OnboardingForm; dragging?: boolean }) { + return ( +
+ {form.name} + {form.status} +
+ ); +} + +function OnboardingFormRow({ form, step, removing, onRemove }: { + form: OnboardingForm; + step: number; + removing: boolean; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: form.id }); + const style = { + transform: isDragging ? undefined : CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0 : 1, + }; + + return ( +
+ + {step}. + + + + +
+
+ {form.name} + {form.status} + +
+
); } From e2fc96bf7055b82f549deb9516dad4e675fc71f0 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 20:29:15 -0700 Subject: [PATCH 10/92] refactor(onboarding): refine form sequence controls --- .../[id]/forms/onboarding/page.tsx | 138 +++++++++--------- 1 file changed, 67 insertions(+), 71 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx index d58893b7..8b94f5f3 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx @@ -1,32 +1,26 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { formsApi, onboardingFormsApi, FormListItem, OnboardingForm, FormStatus, ApiError } from "@/lib/api"; +import { formsApi, onboardingFormsApi, FormListItem, OnboardingForm, ApiError } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; import { Card } from "@/components/ui/Card"; -import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; -import { Combobox } from "@/components/ui/Combobox"; +import { Popover } from "@/components/ui/Popover"; import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; -import { IconForms, IconLock, IconGripVertical, IconTrash } from "@/components/ui/Icons"; - -const STATUS_BADGE_VARIANT: Record = { - draft: "default", - published: "confirmed", - archived: "removed", -}; +import { IconEdit, IconForms, IconGripVertical, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; export default function OnboardingFormsPage() { const params = useParams(); + const router = useRouter(); const tournamentId = Number(params.id); const { user: currentUser } = useAuth(); @@ -37,9 +31,6 @@ export default function OnboardingFormsPage() { const [baseline, setBaseline] = useState(null); const [draft, setDraft] = useState([]); const [loadError, setLoadError] = useState(null); - const [addValue, setAddValue] = useState(""); - const [adding, setAdding] = useState(false); - const [addError, setAddError] = useState(null); const [removingId, setRemovingId] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(undefined); @@ -68,19 +59,10 @@ export default function OnboardingFormsPage() { const isDirty = baseline !== null && draft.map((f) => f.id).join(",") !== baseline.map((f) => f.id).join(","); async function handleAdd(formId: string) { - setAdding(true); - setAddError(null); - try { - const created = await onboardingFormsApi.add(tournamentId, formId); - const next = [...draft, created]; - setBaseline(next); - setDraft(next); - setAddValue(""); - } catch (e) { - setAddError(e instanceof ApiError ? e.message : "Failed to add form to onboarding."); - } finally { - setAdding(false); - } + const created = await onboardingFormsApi.add(tournamentId, formId); + const next = [...draft, created]; + setBaseline(next); + setDraft(next); } async function handleRemove(formId: string) { @@ -160,10 +142,6 @@ export default function OnboardingFormsPage() { return (
-

- Members must complete these forms, in order, before they’re fully onboarded to this tournament. - Only published forms can be added. Drag to reorder. -

{loadError && (

@@ -171,12 +149,29 @@ export default function OnboardingFormsPage() {

)} +
+ + Add form + + } + items={eligibleForms} + getKey={(form) => form.id} + renderLabel={(form) => form.name} + onSelect={(form) => handleAdd(form.id)} + emptyMessage="No published forms are available to add." + width={300} + align="right" + /> +
+ {draft.length === 0 ? ( } title="No onboarding forms yet" - description="Add a published form below to start the onboarding sequence." + description="Add a published form above to start the onboarding sequence." /> ) : ( @@ -195,6 +190,7 @@ export default function OnboardingFormsPage() { form={form} step={i + 1} removing={removingId === form.id} + onEdit={() => router.push(`/forms/${form.id}/edit`)} onRemove={() => handleRemove(form.id)} /> ))} @@ -208,28 +204,6 @@ export default function OnboardingFormsPage() { )} - - f.id} - getLabel={(f) => f.name} - value={addValue} - onChange={(text, matched) => { - if (matched) { - handleAdd(matched.id); - } else { - setAddValue(text); - } - }} - allowFreeText={false} - placeholder="Add a published form to onboarding" - maxResults={eligibleForms.length} - error={addError ?? undefined} - locked={adding} - size="md" - /> - - {form.name} - {form.status}
); } -function OnboardingFormRow({ form, step, removing, onRemove }: { +function OnboardingFormRow({ form, step, removing, onEdit, onRemove }: { form: OnboardingForm; step: number; removing: boolean; + onEdit: () => void; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: form.id }); + const [hovered, setHovered] = useState(false); const style = { transform: isDragging ? undefined : CSS.Translate.toString(transform), transition, @@ -274,26 +249,47 @@ function OnboardingFormRow({ form, step, removing, onRemove }: { }; return ( -
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ ...style, position: "relative", display: "flex", alignItems: "center", gap: "20px", padding: "4px 10px" }} + > + {step}. - - - -
-
- {form.name} - {form.status} +
+ {form.name} +
+
) : ( diff --git a/frontend/app/tournaments/[id]/onboarding/forms/[formId]/page.tsx b/frontend/app/tournaments/[id]/onboarding/forms/[formId]/page.tsx new file mode 100644 index 00000000..315725cd --- /dev/null +++ b/frontend/app/tournaments/[id]/onboarding/forms/[formId]/page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ApiError, Form, formsApi, tournamentOnboardingApi } from "@/lib/api"; +import { FormFillFlow } from "@/components/forms/FormFillFlow"; +import { Spinner } from "@/components/ui/Spinner"; + +export default function TournamentOnboardingFormPage() { + const params = useParams(); + const router = useRouter(); + const tournamentId = Number(params.id); + const formId = String(params.formId); + const [form, setForm] = useState
(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + formsApi.get(formId) + .then(setForm) + .catch((error) => setLoadError(error instanceof ApiError ? error.message : "Failed to load form.")); + }, [formId]); + + async function submitAndAdvance(answers: Record) { + await formsApi.submitResponse( + formId, + Object.entries(answers).map(([field_id, value]) => ({ field_id, value })), + ); + const progress = await tournamentOnboardingApi.progress(tournamentId); + router.replace( + progress.next_form_id + ? `/tournaments/${tournamentId}/onboarding/forms/${progress.next_form_id}` + : `/dashboard/tournaments/${tournamentId}/overview`, + ); + } + + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + if (!form) { + return
; + } + + return ( + + ); +} diff --git a/frontend/app/tournaments/[id]/onboarding/layout.tsx b/frontend/app/tournaments/[id]/onboarding/layout.tsx new file mode 100644 index 00000000..17a18100 --- /dev/null +++ b/frontend/app/tournaments/[id]/onboarding/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { Topbar } from "@/components/layout/Topbar"; +import { UnsavedChangesProvider } from "@/lib/useUnsavedChanges"; + +export default function TournamentOnboardingLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/app/tournaments/[id]/onboarding/page.tsx b/frontend/app/tournaments/[id]/onboarding/page.tsx new file mode 100644 index 00000000..68236372 --- /dev/null +++ b/frontend/app/tournaments/[id]/onboarding/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ApiError, tournamentOnboardingApi } from "@/lib/api"; +import { Spinner } from "@/components/ui/Spinner"; + +// Resolves the member's stored onboarding state into the next form. This is +// intentionally separate from /join so returning members can resume the +// exact same flow without a join code. +export default function TournamentOnboardingPage() { + const params = useParams(); + const router = useRouter(); + const tournamentId = Number(params.id); + const [error, setError] = useState(null); + + useEffect(() => { + tournamentOnboardingApi.progress(tournamentId) + .then((progress) => { + if (progress.next_form_id) { + router.replace(`/tournaments/${tournamentId}/onboarding/forms/${progress.next_form_id}`); + } else { + router.replace(`/dashboard/tournaments/${tournamentId}/overview`); + } + }) + .catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load tournament onboarding.")); + }, [router, tournamentId]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return
; +} diff --git a/frontend/components/forms/FloatingSubmitBar.tsx b/frontend/components/forms/FloatingSubmitBar.tsx index 74cd0fdd..24b57953 100644 --- a/frontend/components/forms/FloatingSubmitBar.tsx +++ b/frontend/components/forms/FloatingSubmitBar.tsx @@ -21,11 +21,13 @@ interface FloatingSubmitBarProps { line, same idea as FloatingSaveBar's validation error line. */ invalidCount: number onSubmit: () => void + loading?: boolean + error?: string /** See FloatingSaveBar's own onHeightChange doc — same purpose. */ onHeightChange?: (px: number) => void } -export function FloatingSubmitBar({ visible, invalidCount, onSubmit, onHeightChange }: FloatingSubmitBarProps) { +export function FloatingSubmitBar({ visible, invalidCount, onSubmit, loading = false, error, onHeightChange }: FloatingSubmitBarProps) { const barRef = useRef(null) useLayoutEffect(() => { @@ -60,8 +62,13 @@ export function FloatingSubmitBar({ visible, invalidCount, onSubmit, onHeightCha {invalidCount} question{invalidCount !== 1 ? "s" : ""} need{invalidCount === 1 ? "s" : ""} your attention.
)} + {error && ( +
+ {error} +
+ )}
- +
) } diff --git a/frontend/components/forms/FormFillFlow.tsx b/frontend/components/forms/FormFillFlow.tsx index d4b6b08e..b5704fa4 100644 --- a/frontend/components/forms/FormFillFlow.tsx +++ b/frontend/components/forms/FormFillFlow.tsx @@ -91,7 +91,7 @@ interface FormFillFlowProps { /** Called once, after Submit's validation passes — a real viewer wires this to formsApi.submitResponse; omitted (the default), nothing is persisted, which is what makes this safe to use for a TD's preview. */ - onComplete?: (answers: Record) => void; + onComplete?: (answers: Record) => void | Promise; } // One question revealed at a time (Continue advances; Submit only appears @@ -118,6 +118,8 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF // last error by itself never flips this true; Submit has to actually be // clicked again once things are fixed. const [submitSucceeded, setSubmitSucceeded] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(undefined); // Field to scroll to once its Card is actually in the DOM — set alongside // the state change that reveals/flags it (revealCount, attemptedIds), so // the effect below only ever fires after that same render has committed, @@ -182,6 +184,7 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF // again rather than letting showSuccess flip true the instant the last // error happens to clear. setSubmitSucceeded(false); + setSubmitError(undefined); } function handleContinue(field: FormField) { @@ -192,7 +195,7 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF if (nextField) setPendingScrollId(nextField.id); } - function handleSubmit() { + async function handleSubmit() { setAttemptedIds((prev) => new Set([...prev, ...walk.map((f) => f.id)])); const firstInvalid = walk.find((f) => fieldErrorMessage(f, answers[f.id]) !== undefined); if (firstInvalid) { @@ -200,8 +203,17 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF setPendingScrollId(firstInvalid.id); return; } - setSubmitSucceeded(true); - onComplete?.(answers); + setSubmitting(true); + setSubmitError(undefined); + try { + await onComplete?.(answers); + setSubmitSucceeded(true); + } catch (error: unknown) { + setSubmitSucceeded(false); + setSubmitError(error instanceof Error ? error.message : "Failed to submit your response. Please try again."); + } finally { + setSubmitting(false); + } } return ( @@ -274,6 +286,8 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF visible={allContinued && !showSuccess} invalidCount={invalidCount} onSubmit={handleSubmit} + loading={submitting} + error={submitError} onHeightChange={setSubmitBarHeight} />
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index c2f80c34..da9c70a8 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1312,6 +1312,11 @@ export interface OnboardingForm extends FormListItem { order: number | null } +export interface TournamentOnboardingProgress { + next_form_id: string | null + onboarded_at: string | null +} + export const formsApi = { listForTournament: (tournamentId: number) => api.get(`/tournaments/${tournamentId}/forms/`), @@ -1364,3 +1369,8 @@ export const onboardingFormsApi = { remove: (tournamentId: number, formId: string) => api.delete(`/tournaments/${tournamentId}/onboarding-forms/${formId}/`), } + +export const tournamentOnboardingApi = { + progress: (tournamentId: number) => + api.post(`/tournaments/${tournamentId}/onboarding/progress/`, {}), +} From eec0baa21bc02d48be4ffb5f754d564a356ecb47 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 20:44:03 -0700 Subject: [PATCH 12/92] refactor(onboarding): consolidate client API methods --- .../tournaments/[id]/forms/onboarding/page.tsx | 10 +++++----- frontend/lib/api.ts | 13 +++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx index 8b94f5f3..c7bc5587 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx @@ -7,7 +7,7 @@ import { } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { formsApi, onboardingFormsApi, FormListItem, OnboardingForm, ApiError } from "@/lib/api"; +import { formsApi, tournamentOnboardingApi, FormListItem, OnboardingForm, ApiError } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; import { Card } from "@/components/ui/Card"; @@ -40,7 +40,7 @@ export default function OnboardingFormsPage() { useEffect(() => { if (!canManageForms) return; - Promise.all([formsApi.listForTournament(tournamentId), onboardingFormsApi.list(tournamentId)]) + Promise.all([formsApi.listForTournament(tournamentId), tournamentOnboardingApi.listForms(tournamentId)]) .then(([forms, onboarding]) => { setAllForms(forms); setBaseline(onboarding); @@ -59,7 +59,7 @@ export default function OnboardingFormsPage() { const isDirty = baseline !== null && draft.map((f) => f.id).join(",") !== baseline.map((f) => f.id).join(","); async function handleAdd(formId: string) { - const created = await onboardingFormsApi.add(tournamentId, formId); + const created = await tournamentOnboardingApi.addForm(tournamentId, formId); const next = [...draft, created]; setBaseline(next); setDraft(next); @@ -68,7 +68,7 @@ export default function OnboardingFormsPage() { async function handleRemove(formId: string) { setRemovingId(formId); try { - await onboardingFormsApi.remove(tournamentId, formId); + await tournamentOnboardingApi.removeForm(tournamentId, formId); const next = draft.filter((f) => f.id !== formId); setBaseline(next); setDraft(next); @@ -97,7 +97,7 @@ export default function OnboardingFormsPage() { setSaving(true); setSaveError(undefined); try { - const updated = await onboardingFormsApi.reorder(tournamentId, draft.map((f) => f.id)); + const updated = await tournamentOnboardingApi.reorderForms(tournamentId, draft.map((f) => f.id)); setBaseline(updated); setDraft(updated); } catch (e) { diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index da9c70a8..6f227d5d 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1356,21 +1356,18 @@ export const formsApi = { getMyResponse: (formId: string) => api.get(`/forms/${formId}/responses/me/`), } -export const onboardingFormsApi = { - list: (tournamentId: number) => +export const tournamentOnboardingApi = { + listForms: (tournamentId: number) => api.get(`/tournaments/${tournamentId}/onboarding-forms/`), - add: (tournamentId: number, formId: string) => + addForm: (tournamentId: number, formId: string) => api.post(`/tournaments/${tournamentId}/onboarding-forms/`, { form_id: formId }), - reorder: (tournamentId: number, formIds: string[]) => + reorderForms: (tournamentId: number, formIds: string[]) => api.patch( `/tournaments/${tournamentId}/onboarding-forms/reorder/`, { forms: formIds.map((form_id, index) => ({ form_id, order: index + 1 })) }, ), - remove: (tournamentId: number, formId: string) => + removeForm: (tournamentId: number, formId: string) => api.delete(`/tournaments/${tournamentId}/onboarding-forms/${formId}/`), -} - -export const tournamentOnboardingApi = { progress: (tournamentId: number) => api.post(`/tournaments/${tournamentId}/onboarding/progress/`, {}), } From 4129d6b1ce63af23a19d807dda1eba875a3d5e04 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 20:53:49 -0700 Subject: [PATCH 13/92] refactor(forms): add reusable respondent view route --- .../[formId] => forms/[formId]/view}/page.tsx | 29 ++++++++++--------- .../app/tournaments/[id]/onboarding/page.tsx | 3 +- 2 files changed, 18 insertions(+), 14 deletions(-) rename frontend/app/{tournaments/[id]/onboarding/forms/[formId] => forms/[formId]/view}/page.tsx (57%) diff --git a/frontend/app/tournaments/[id]/onboarding/forms/[formId]/page.tsx b/frontend/app/forms/[formId]/view/page.tsx similarity index 57% rename from frontend/app/tournaments/[id]/onboarding/forms/[formId]/page.tsx rename to frontend/app/forms/[formId]/view/page.tsx index 315725cd..4eac96f5 100644 --- a/frontend/app/tournaments/[id]/onboarding/forms/[formId]/page.tsx +++ b/frontend/app/forms/[formId]/view/page.tsx @@ -1,16 +1,24 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { ApiError, Form, formsApi, tournamentOnboardingApi } from "@/lib/api"; +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { ApiError, Form, formsApi } from "@/lib/api"; import { FormFillFlow } from "@/components/forms/FormFillFlow"; import { Spinner } from "@/components/ui/Spinner"; -export default function TournamentOnboardingFormPage() { +// Respondent-facing form renderer. `redirect` is optional so this can serve +// direct form links too; only an app-relative path is honored to avoid making +// form submissions an open-redirect vector. +function internalRedirect(value: string | null): string | null { + return value?.startsWith("/") && !value.startsWith("//") ? value : null; +} + +export default function FormViewPage() { const params = useParams(); const router = useRouter(); - const tournamentId = Number(params.id); + const searchParams = useSearchParams(); const formId = String(params.formId); + const redirect = useMemo(() => internalRedirect(searchParams.get("redirect")), [searchParams]); const [form, setForm] = useState(null); const [loadError, setLoadError] = useState(null); @@ -20,17 +28,12 @@ export default function TournamentOnboardingFormPage() { .catch((error) => setLoadError(error instanceof ApiError ? error.message : "Failed to load form.")); }, [formId]); - async function submitAndAdvance(answers: Record) { + async function submitResponse(answers: Record) { await formsApi.submitResponse( formId, Object.entries(answers).map(([field_id, value]) => ({ field_id, value })), ); - const progress = await tournamentOnboardingApi.progress(tournamentId); - router.replace( - progress.next_form_id - ? `/tournaments/${tournamentId}/onboarding/forms/${progress.next_form_id}` - : `/dashboard/tournaments/${tournamentId}/overview`, - ); + if (redirect) router.replace(redirect); } if (loadError) { @@ -49,7 +52,7 @@ export default function TournamentOnboardingFormPage() { ); } diff --git a/frontend/app/tournaments/[id]/onboarding/page.tsx b/frontend/app/tournaments/[id]/onboarding/page.tsx index 68236372..b061e689 100644 --- a/frontend/app/tournaments/[id]/onboarding/page.tsx +++ b/frontend/app/tournaments/[id]/onboarding/page.tsx @@ -18,7 +18,8 @@ export default function TournamentOnboardingPage() { tournamentOnboardingApi.progress(tournamentId) .then((progress) => { if (progress.next_form_id) { - router.replace(`/tournaments/${tournamentId}/onboarding/forms/${progress.next_form_id}`); + const redirect = encodeURIComponent(`/tournaments/${tournamentId}/onboarding`); + router.replace(`/forms/${progress.next_form_id}/view?redirect=${redirect}`); } else { router.replace(`/dashboard/tournaments/${tournamentId}/overview`); } From bbe8fbff75013060e768374b99b2f53efc90a271 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 20:58:59 -0700 Subject: [PATCH 14/92] refactor(forms): share floating action bar behavior --- .../components/forms/FloatingSubmitBar.tsx | 99 +++++++------------ frontend/components/ui/FloatingBar.tsx | 67 +++++++++++++ frontend/components/ui/FloatingSaveBar.tsx | 82 +++++---------- 3 files changed, 123 insertions(+), 125 deletions(-) create mode 100644 frontend/components/ui/FloatingBar.tsx diff --git a/frontend/components/forms/FloatingSubmitBar.tsx b/frontend/components/forms/FloatingSubmitBar.tsx index 24b57953..4170f5e1 100644 --- a/frontend/components/forms/FloatingSubmitBar.tsx +++ b/frontend/components/forms/FloatingSubmitBar.tsx @@ -1,74 +1,43 @@ -'use client' +"use client"; -import { useLayoutEffect, useRef } from "react" -import { Button } from "@/components/ui/Button" - -// Same resting-offset/height-reporting contract as FloatingSaveBar (see -// components/ui/FloatingSaveBar.tsx) — deliberately not that component -// reused with different copy: a submit flow has no "Cancel" (there's -// nothing to discard back to, answers just stay in local state) and its -// dirty/nav-block condition doesn't line up with this bar's own visibility -// the way FloatingSaveBar's do (see FormFillFlow's own useBlockNavigation -// call), so this stays a separate, purpose-built bar rather than a themed -// FloatingSaveBar variant. -const REST_OFFSET = 24 +import { Button } from "@/components/ui/Button"; +import { FloatingBar } from "@/components/ui/FloatingBar"; interface FloatingSubmitBarProps { - visible: boolean - /** Count of currently-invalid, already-attempted questions — 0 shows a - plain "Submit" bar; >0 (only possible after a failed Submit click, - since nothing is flagged invalid before that) adds an error summary - line, same idea as FloatingSaveBar's validation error line. */ - invalidCount: number - onSubmit: () => void - loading?: boolean - error?: string - /** See FloatingSaveBar's own onHeightChange doc — same purpose. */ - onHeightChange?: (px: number) => void + visible: boolean; + /** Count of currently-invalid questions that have already been attempted. */ + invalidCount: number; + onSubmit: () => void; + loading?: boolean; + error?: string; + /** Shared FloatingBar footprint; FormFillFlow reserves this as bottom padding. */ + onHeightChange?: (px: number) => void; } +// Submission intentionally remains distinct from save: it has no cancel +// action and uses FormFillFlow's own dirty-navigation guard. Only its fixed +// positioning/measurement shell is shared with FloatingSaveBar. export function FloatingSubmitBar({ visible, invalidCount, onSubmit, loading = false, error, onHeightChange }: FloatingSubmitBarProps) { - const barRef = useRef(null) - - useLayoutEffect(() => { - const el = barRef.current - if (!el || !onHeightChange) return - const measure = () => onHeightChange(visible ? el.offsetHeight + REST_OFFSET + 16 : 0) - measure() - const observer = new ResizeObserver(measure) - observer.observe(el) - return () => { observer.disconnect(); onHeightChange(0) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible]) - return ( -
-
- - Ready to submit - - {invalidCount > 0 && ( -
- {invalidCount} question{invalidCount !== 1 ? "s" : ""} need{invalidCount === 1 ? "s" : ""} your attention. -
- )} - {error && ( -
- {error} -
- )} + +
+
+ + Ready to submit + + {invalidCount > 0 && ( +
+ {invalidCount} question{invalidCount !== 1 ? "s" : ""} need{invalidCount === 1 ? "s" : ""} your attention. +
+ )} + {error && ( +
+ {error} +
+ )} +
+
- -
- ) + + ); } diff --git a/frontend/components/ui/FloatingBar.tsx b/frontend/components/ui/FloatingBar.tsx new file mode 100644 index 00000000..2c6c40f1 --- /dev/null +++ b/frontend/components/ui/FloatingBar.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { ReactNode, useLayoutEffect, useRef, useState } from "react"; + +const REST_OFFSET = 24; +const FOOTPRINT_BUFFER = 16; + +interface FloatingBarProps { + visible: boolean; + children: ReactNode; + /** Receives 0 while hidden, otherwise the measured bar footprint. */ + onHeightChange?: (px: number) => void; +} + +// Shared fixed-bottom shell for save/submit actions. It measures below the +// viewport first, so the parent reserves bottom space before the next frame +// slides the bar onscreen. This prevents the first visible frame from +// covering the page's last item. +export function FloatingBar({ visible, children, onHeightChange }: FloatingBarProps) { + const barRef = useRef(null); + const [prepared, setPrepared] = useState(false); + + useLayoutEffect(() => { + const el = barRef.current; + if (!el) return; + + let frame = 0; + if (!visible) { + onHeightChange?.(0); + frame = requestAnimationFrame(() => setPrepared(false)); + return () => cancelAnimationFrame(frame); + } + + const measure = () => onHeightChange?.(el.offsetHeight + REST_OFFSET + FOOTPRINT_BUFFER); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + frame = requestAnimationFrame(() => setPrepared(true)); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + onHeightChange?.(0); + }; + // Height handlers are often inline page functions. Re-subscribing for + // each parent render would cleanup with 0, remeasure, and create a + // feedback loop; visibility is the lifecycle boundary instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/components/ui/FloatingSaveBar.tsx b/frontend/components/ui/FloatingSaveBar.tsx index 94dd4492..bbec2005 100644 --- a/frontend/components/ui/FloatingSaveBar.tsx +++ b/frontend/components/ui/FloatingSaveBar.tsx @@ -1,14 +1,8 @@ -'use client' +"use client"; -import { useLayoutEffect, useRef } from "react" -import { Button } from "@/components/ui/Button" -import { useBlockNavigation } from "@/lib/useUnsavedChanges" - -// How far the resting bar sits above the viewport's bottom edge — also the -// gap `onHeightChange` reports on top of the bar's own measured height, so -// a caller's reserved padding actually clears it instead of running flush -// against its top edge. -const REST_OFFSET = 24 +import { Button } from "@/components/ui/Button"; +import { FloatingBar } from "@/components/ui/FloatingBar"; +import { useBlockNavigation } from "@/lib/useUnsavedChanges"; interface FloatingSaveBarProps { visible: boolean; @@ -18,13 +12,7 @@ interface FloatingSaveBarProps { onCancel: () => void; /** Pathname prefix that counts as staying put — links under it navigate freely. */ stayWithin?: string; - /** Fires with the bar's current footprint (0 when hidden; its real - rendered height + REST_OFFSET + a little breathing room when visible — - the error line can wrap and grow the bar, so this isn't a fixed - number). Callers use it as scroll-container bottom padding so the bar - never covers content sitting where it lands. Measured via - ResizeObserver rather than assumed, since the caller has no way to - know the error text's wrapped height in advance. */ + /** Current measured fixed-bar footprint; callers reserve this as bottom padding. */ onHeightChange?: (px: number) => void; } @@ -33,50 +21,24 @@ export function FloatingSaveBar({ visible, saving, error, onSave, onCancel, stay // prompt — no page-specific wiring required. useBlockNavigation(visible, stayWithin); - const barRef = useRef(null); - - useLayoutEffect(() => { - const el = barRef.current; - if (!el || !onHeightChange) return; - const measure = () => onHeightChange(visible ? el.offsetHeight + REST_OFFSET + 16 : 0); - measure(); - const observer = new ResizeObserver(measure); - observer.observe(el); - return () => { observer.disconnect(); onHeightChange(0); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible]); - return ( -
-
- - You have unsaved changes - - {error && ( -
- {error} -
- )} -
-
- - + +
+
+ + You have unsaved changes + + {error && ( +
+ {error} +
+ )} +
+
+ + +
-
+ ); } From 3398c3d5d06c3206f12f8f9c4106dd3d4d968f71 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 21:24:03 -0700 Subject: [PATCH 15/92] feat(forms): open tournament form editors in new tabs --- .../app/dashboard/tournaments/[id]/forms/onboarding/page.tsx | 5 ++--- frontend/app/dashboard/tournaments/[id]/forms/page.tsx | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx index c7bc5587..74a6b2e7 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core"; @@ -20,7 +20,6 @@ import { IconEdit, IconForms, IconGripVertical, IconLock, IconPlus, IconTrash } export default function OnboardingFormsPage() { const params = useParams(); - const router = useRouter(); const tournamentId = Number(params.id); const { user: currentUser } = useAuth(); @@ -190,7 +189,7 @@ export default function OnboardingFormsPage() { form={form} step={i + 1} removing={removingId === form.id} - onEdit={() => router.push(`/forms/${form.id}/edit`)} + onEdit={() => window.open(`/forms/${form.id}/edit`, "_blank", "noopener,noreferrer")} onRemove={() => handleRemove(form.id)} /> ))} diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index 5641e5e3..38d670b8 100644 --- a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx @@ -68,7 +68,7 @@ function FormRow({ form, isLast }: { @@ -110,7 +110,6 @@ function FormTable({ forms }: { forms: FormListItem[] }) { export default function FormsPage() { const params = useParams(); - const router = useRouter(); const tournamentId = Number(params.id); const { user: currentUser } = useAuth(); @@ -159,7 +158,7 @@ export default function FormsPage() { // Submit -> POST -> redirect straight into the builder. title/description // are set later, inside the builder — not part of this modal. function handleCreated(form: Form) { - router.push(`/forms/${form.id}/edit`); + window.open(`/forms/${form.id}/edit`, "_blank", "noopener,noreferrer"); } return ( From 841dbf717f1797bf6c276a70a4cee62987c50381 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 22:03:29 -0700 Subject: [PATCH 16/92] feat(forms): keep final form question clear of submit bar --- frontend/components/forms/FormFillFlow.tsx | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/frontend/components/forms/FormFillFlow.tsx b/frontend/components/forms/FormFillFlow.tsx index b5704fa4..f8144dce 100644 --- a/frontend/components/forms/FormFillFlow.tsx +++ b/frontend/components/forms/FormFillFlow.tsx @@ -125,6 +125,11 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF // the effect below only ever fires after that same render has committed, // never against a stale layout. const [pendingScrollId, setPendingScrollId] = useState(null); + // On the final Continue, the current card loses its button while the + // submit bar appears. Keep this separately from the ordinary next-card + // scroll: the bar's measured footprint and the card's new height both need + // to settle before we can tell whether the card is covered. + const [pendingBarClearanceId, setPendingBarClearanceId] = useState(null); // Reported live by FloatingSubmitBar (its own measured height, which // grows when the error summary line wraps) — applied as bottom padding so // the bar never covers the last question(s), same pattern as FieldList's @@ -178,6 +183,50 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF window.scrollTo({ top: window.scrollY + rect.top - safeTop - 8, behavior: "smooth" }); }, [pendingScrollId]); + useEffect(() => { + if (!pendingBarClearanceId || submitBarHeight === 0) return; + const card = document.querySelector(`[data-form-field="${pendingBarClearanceId}"]`); + if (!card) { + setPendingBarClearanceId(null); + return; + } + + // FloatingBar measures itself before it slides in, while the just-finished + // card is simultaneously dropping its Continue button. Wait through both + // the next paint and the card-height animation window before measuring. + let frame = 0; + let timer: ReturnType | undefined; + const apply = () => { + const safeTop = TOPBAR_HEIGHT + 12; + const safeBottom = window.innerHeight - submitBarHeight; + const rect = card.getBoundingClientRect(); + const visibleBand = safeBottom - safeTop; + let delta = 0; + + if (rect.height <= visibleBand) { + if (rect.top < safeTop) delta = rect.top - safeTop - 8; + else if (rect.bottom > safeBottom) delta = rect.bottom - safeBottom + 8; + } else if (rect.top < safeTop || rect.top >= safeBottom) { + // An oversized question cannot entirely fit above the bar; put its + // beginning in the readable band instead of leaving it obscured. + delta = rect.top - safeTop - 8; + } + + if (delta !== 0) window.scrollTo({ top: window.scrollY + delta, behavior: "smooth" }); + setPendingBarClearanceId(null); + }; + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + timer = window.setTimeout(apply, 250); + }); + }); + + return () => { + cancelAnimationFrame(frame); + if (timer !== undefined) clearTimeout(timer); + }; + }, [pendingBarClearanceId, submitBarHeight]); + function setAnswer(fieldId: string, value: unknown) { setAnswers((prev) => ({ ...prev, [fieldId]: value })); // Whatever Submit last validated is now stale — require clicking it @@ -193,6 +242,7 @@ export function FormFillFlow({ form, banner, successMessage, onComplete }: FormF setRevealCount((c) => c + 1); const nextField = walk[revealCount]; if (nextField) setPendingScrollId(nextField.id); + else setPendingBarClearanceId(field.id); } async function handleSubmit() { From c13243b9b6c1d6ae8cd3b8edfedee5f0875a2c6b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 23:08:51 -0700 Subject: [PATCH 17/92] feat(forms): add reversible form publishing and archiving lifecycle --- backend/app/api/routes/forms.py | 58 +++++++++++++++------ backend/tests/api/test_forms.py | 50 ++++++++++++++++++ frontend/components/forms/StatusControl.tsx | 30 +++++++++-- frontend/lib/api.ts | 1 + 4 files changed, 119 insertions(+), 20 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index cf5e15a8..847eb6e1 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -306,19 +306,19 @@ def update_form( db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): - if payload.status == "draft" and form.status == "published": + if payload.status == "published" and form.status == "archived": raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="A published form cannot be reverted to draft — archive it instead if it should stop accepting responses", + detail="An archived form must be unarchived to draft and reviewed before it can be republished", ) - if payload.status == "published" and form.status == "archived": + if payload.status == "draft" and form.status == "archived": raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="An archived form must be unarchived to draft and reviewed before it can be republished", + detail="Use the unarchive endpoint to restore an archived form to draft", ) - if payload.status == "archived": + if payload.status in {"draft", "archived"} and form.status == "published": _reject_if_onboarding(db, form) if payload.status == "published": @@ -372,6 +372,28 @@ def archive_form( return form +# --------------------------------------------------------------------------- +# POST /forms/{form_id}/unarchive/ — restores an archived form to draft. +# Restoring intentionally never publishes the form: a manager must review and +# explicitly publish it before it can accept responses again. +# --------------------------------------------------------------------------- +@router.post("/forms/{form_id}/unarchive/", response_model=FormRead) +def unarchive_form( + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + if form.status != "archived": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Only archived forms can be restored to draft", + ) + + form.status = "draft" + db.commit() + db.refresh(form) + return form + + # --------------------------------------------------------------------------- # DELETE /forms/{form_id}/ — hard delete. Blocked if any responses exist # (use the archive route above instead — hard delete would cascade away @@ -403,13 +425,13 @@ def delete_form( # `id` creates one; a currently-live field whose `id` is absent from the # payload is removed. # -# draft-status forms apply directly (hard delete/update/insert) — nothing -# on a draft form has ever been answerable, so there's no history to -# protect. published-status forms archive instead of hard-deleting/losing -# data: a removed or question_type-changed field is archived (and, for a -# type change, replaced by a new field at the same list position inheriting -# the old field_key); an option dropped from an otherwise-unchanged field's -# config is archived in place rather than removed from storage. Either way +# A never-answered draft applies directly (hard delete/update/insert). +# Published forms — and drafts restored/unpublished after receiving a response +# — archive instead of hard-deleting/losing data: a removed or +# question_type-changed field is archived (and, for a type change, replaced +# by a new field at the same list position inheriting the old field_key); an +# option dropped from an otherwise-unchanged field's config is archived in +# place rather than removed from storage. Either way # the whole batch is applied inside one transaction, flushed (so newly # created fields get real ids), validated as a whole via # collect_active_field_errors, and only committed if that validation @@ -422,7 +444,11 @@ def bulk_update_fields( db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): - is_published = form.status == "published" + # An unpublish/restore makes the form editable as a draft, but it must + # never reopen the destructive draft-edit path after answers exist. + is_history_preserving = form.status == "published" or ( + db.query(FormResponse.id).filter(FormResponse.form_id == form.id).first() is not None + ) live_fields = ( db.query(FormField) @@ -488,7 +514,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> normalized_config = _validate_config(entry.question_type, entry.config, field.field_key) type_changed = entry.question_type != field.question_type - if type_changed and is_published: + if type_changed and is_history_preserving: field.is_archived = True old_key = field.field_key # field.id is a nanoid and can contain '-', which field_key's @@ -509,7 +535,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> ) db.add(new_field) else: - if is_published: + if is_history_preserving: normalized_config, archived_option_ids = apply_option_archiving(field.config, normalized_config) if archived_option_ids: pending_flags.append((field.field_key, "option_archived", field.id, archived_option_ids)) @@ -538,7 +564,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> removed_fields = [f for fid, f in live_by_id.items() if fid not in submitted_ids] for field in removed_fields: - if is_published: + if is_history_preserving: field.is_archived = True pending_flags.append((field.field_key, "field_replaced", field.id, [])) else: diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index dd94c31d..c789edee 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -318,6 +318,40 @@ def test_archive_sets_status(self, client, db, td_user, td_tournament): assert res.status_code == 200 assert res.json()["status"] == "archived" + def test_unarchive_restores_archived_form_to_draft(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="archived") + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/unarchive/") + + assert res.status_code == 200 + assert res.json()["status"] == "draft" + + def test_unarchive_rejects_non_archived_form(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + db.commit() + login(client, "td@test.com", "tdpass") + + assert client.post(f"/forms/{form.id}/unarchive/").status_code == 409 + + def test_archived_form_must_use_unarchive_endpoint(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="archived") + db.commit() + login(client, "td@test.com", "tdpass") + + assert client.patch(f"/forms/{form.id}/", json={"status": "draft"}).status_code == 409 + + def test_unpublish_published_form_to_draft(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.patch(f"/forms/{form.id}/", json={"status": "draft"}) + + assert res.status_code == 200 + assert res.json()["status"] == "draft" + def test_delete_succeeds_with_no_responses(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) db.commit() @@ -449,6 +483,22 @@ def test_removed_field_archives_not_deletes(self, client, db, td_user, td_tourna assert field.is_archived is True assert db.query(FormField).filter(FormField.id == field.id).first() is not None + def test_unpublished_form_with_responses_preserves_removed_field(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + assert client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}).status_code == 200 + + assert client.patch(f"/forms/{form.id}/", json={"status": "draft"}).status_code == 200 + res = client.put(f"/forms/{form.id}/fields/", json={"fields": []}) + + assert res.status_code == 200 + db.refresh(field) + assert field.is_archived is True + assert db.query(FormField).filter(FormField.id == field.id).first() is not None + def test_new_entry_inserts(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) diff --git a/frontend/components/forms/StatusControl.tsx b/frontend/components/forms/StatusControl.tsx index d23ffd02..e4f0b1fb 100644 --- a/frontend/components/forms/StatusControl.tsx +++ b/frontend/components/forms/StatusControl.tsx @@ -7,8 +7,8 @@ import { IconArchive, IconTrash } from "@/components/ui/Icons"; const PRIMARY_LABEL: Record = { draft: "Publish", - published: "Published", - archived: "Archived", + published: "Unpublish", + archived: "Restore to draft", }; export function StatusControl({ form, onUpdated, onDeleted }: { @@ -39,6 +39,28 @@ export function StatusControl({ form, onUpdated, onDeleted }: { } } + async function unpublish() { + setBusy(true); setError(undefined); + try { + onUpdated(await formsApi.update(form.id, { status: "draft" })); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to unpublish form."); + } finally { + setBusy(false); + } + } + + async function restore() { + setBusy(true); setError(undefined); + try { + onUpdated(await formsApi.unarchive(form.id)); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to restore form."); + } finally { + setBusy(false); + } + } + async function deleteForm() { setError(undefined); try { @@ -71,8 +93,8 @@ export function StatusControl({ form, onUpdated, onDeleted }: { variant="primary" size="md" loading={busy} - primaryDisabled={form.status !== "draft"} - onClick={publish} + primaryDisabled={false} + onClick={form.status === "draft" ? publish : form.status === "published" ? unpublish : restore} options={options} /> {error && ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 6f227d5d..13a59c10 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1342,6 +1342,7 @@ export const formsApi = { getForEdit: (formId: string) => api.get(`/forms/${formId}/?raw=true`), update: (formId: string, body: FormUpdateInput) => api.patch(`/forms/${formId}/`, body), archive: (formId: string) => api.post(`/forms/${formId}/archive/`, {}), + unarchive: (formId: string) => api.post(`/forms/${formId}/unarchive/`, {}), // 409s if the form has any responses — check response_count client-side first. delete: (formId: string) => api.delete(`/forms/${formId}/`), // Full ordered target field list — see FormFieldInput and the Edit From 5151b35fc7659d568f294175cec3eed8de69dc98 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 23:13:14 -0700 Subject: [PATCH 18/92] refactor(forms): consolidate lifecycle transitions in patch route --- backend/app/api/routes/forms.py | 46 +------------------ backend/tests/api/test_forms.py | 24 ++-------- .../tests/api/tournament/test_onboarding.py | 2 +- frontend/components/forms/StatusControl.tsx | 8 ++-- frontend/lib/api.ts | 2 - 5 files changed, 12 insertions(+), 70 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 847eb6e1..ef30191e 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -312,13 +312,7 @@ def update_form( detail="An archived form must be unarchived to draft and reviewed before it can be republished", ) - if payload.status == "draft" and form.status == "archived": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Use the unarchive endpoint to restore an archived form to draft", - ) - - if payload.status in {"draft", "archived"} and form.status == "published": + if payload.status in {"draft", "archived"}: _reject_if_onboarding(db, form) if payload.status == "published": @@ -356,44 +350,6 @@ def _reject_if_onboarding(db: Session, form: Form) -> None: ) -# --------------------------------------------------------------------------- -# POST /forms/{form_id}/archive/ — soft delete via status="archived". -# Responses and fields are left in place. -# --------------------------------------------------------------------------- -@router.post("/forms/{form_id}/archive/", response_model=FormRead) -def archive_form( - db: Session = Depends(get_db), - form: Form = Depends(require_form_manage_access), -): - _reject_if_onboarding(db, form) - form.status = "archived" - db.commit() - db.refresh(form) - return form - - -# --------------------------------------------------------------------------- -# POST /forms/{form_id}/unarchive/ — restores an archived form to draft. -# Restoring intentionally never publishes the form: a manager must review and -# explicitly publish it before it can accept responses again. -# --------------------------------------------------------------------------- -@router.post("/forms/{form_id}/unarchive/", response_model=FormRead) -def unarchive_form( - db: Session = Depends(get_db), - form: Form = Depends(require_form_manage_access), -): - if form.status != "archived": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Only archived forms can be restored to draft", - ) - - form.status = "draft" - db.commit() - db.refresh(form) - return form - - # --------------------------------------------------------------------------- # DELETE /forms/{form_id}/ — hard delete. Blocked if any responses exist # (use the archive route above instead — hard delete would cascade away diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index c789edee..4df960e5 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -296,7 +296,7 @@ def test_includes_active_fields_ordered(self, client, db, td_user, td_tournament # --------------------------------------------------------------------------- -# PATCH / archive / delete /forms/{form_id}/ +# PATCH / delete /forms/{form_id}/ # --------------------------------------------------------------------------- class TestUpdateArchiveDeleteForm: @@ -310,38 +310,24 @@ def test_patch_updates_fields(self, client, db, td_user, td_tournament): assert res.json()["name"] == "Renamed" assert res.json()["status"] == "published" - def test_archive_sets_status(self, client, db, td_user, td_tournament): + def test_patch_archives_form(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) db.commit() login(client, "td@test.com", "tdpass") - res = client.post(f"/forms/{form.id}/archive/") + res = client.patch(f"/forms/{form.id}/", json={"status": "archived"}) assert res.status_code == 200 assert res.json()["status"] == "archived" - def test_unarchive_restores_archived_form_to_draft(self, client, db, td_user, td_tournament): + def test_patch_restores_archived_form_to_draft(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="archived") db.commit() login(client, "td@test.com", "tdpass") - res = client.post(f"/forms/{form.id}/unarchive/") + res = client.patch(f"/forms/{form.id}/", json={"status": "draft"}) assert res.status_code == 200 assert res.json()["status"] == "draft" - def test_unarchive_rejects_non_archived_form(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - db.commit() - login(client, "td@test.com", "tdpass") - - assert client.post(f"/forms/{form.id}/unarchive/").status_code == 409 - - def test_archived_form_must_use_unarchive_endpoint(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament, status="archived") - db.commit() - login(client, "td@test.com", "tdpass") - - assert client.patch(f"/forms/{form.id}/", json={"status": "draft"}).status_code == 409 - def test_unpublish_published_form_to_draft(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="published") db.commit() diff --git a/backend/tests/api/tournament/test_onboarding.py b/backend/tests/api/tournament/test_onboarding.py index 5eac6367..a874e73e 100644 --- a/backend/tests/api/tournament/test_onboarding.py +++ b/backend/tests/api/tournament/test_onboarding.py @@ -159,7 +159,7 @@ def test_onboarding_form_cannot_be_archived_or_deleted_until_removed(client, db, db.commit() login(client, "td@test.com", "tdpass") - archive = client.post(f"/forms/{form.id}/archive/") + archive = client.patch(f"/forms/{form.id}/", json={"status": "archived"}) delete = client.delete(f"/forms/{form.id}/") assert archive.status_code == 409 diff --git a/frontend/components/forms/StatusControl.tsx b/frontend/components/forms/StatusControl.tsx index e4f0b1fb..8137f3bb 100644 --- a/frontend/components/forms/StatusControl.tsx +++ b/frontend/components/forms/StatusControl.tsx @@ -31,11 +31,13 @@ export function StatusControl({ form, onUpdated, onDeleted }: { } async function archive() { - setError(undefined); + setBusy(true); setError(undefined); try { - onUpdated(await formsApi.archive(form.id)); + onUpdated(await formsApi.update(form.id, { status: "archived" })); } catch (err) { setError(err instanceof ApiError ? err.message : "Failed to archive form."); + } finally { + setBusy(false); } } @@ -53,7 +55,7 @@ export function StatusControl({ form, onUpdated, onDeleted }: { async function restore() { setBusy(true); setError(undefined); try { - onUpdated(await formsApi.unarchive(form.id)); + onUpdated(await formsApi.update(form.id, { status: "draft" })); } catch (err) { setError(err instanceof ApiError ? err.message : "Failed to restore form."); } finally { diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 13a59c10..037a50df 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1341,8 +1341,6 @@ export const formsApi = { // moment an untouched entity-backed option got saved again. getForEdit: (formId: string) => api.get(`/forms/${formId}/?raw=true`), update: (formId: string, body: FormUpdateInput) => api.patch(`/forms/${formId}/`, body), - archive: (formId: string) => api.post(`/forms/${formId}/archive/`, {}), - unarchive: (formId: string) => api.post(`/forms/${formId}/unarchive/`, {}), // 409s if the form has any responses — check response_count client-side first. delete: (formId: string) => api.delete(`/forms/${formId}/`), // Full ordered target field list — see FormFieldInput and the Edit From 04435b230b02d8ee30431021c90ee7aeded1b08a Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 23:15:56 -0700 Subject: [PATCH 19/92] feat(forms): add tournament form prerequisite evaluation --- ...b1c3d_add_tournament_form_prerequisites.py | 32 +++++ .../app/core/tournament/form_prerequisites.py | 101 ++++++++++++++ backend/app/models/models.py | 5 + .../test_tournament_form_prerequisites.py | 128 ++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py create mode 100644 backend/app/core/tournament/form_prerequisites.py create mode 100644 backend/tests/core/test_tournament_form_prerequisites.py diff --git a/backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py b/backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py new file mode 100644 index 00000000..b0420171 --- /dev/null +++ b/backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py @@ -0,0 +1,32 @@ +"""add tournament form prerequisites + +Revision ID: 9f2e4a7b1c3d +Revises: 8d55ec2b6640 +Create Date: 2026-08-25 00:00:00.000000 + +Standard tournament forms can optionally require completed onboarding, one or +more roles, and/or availability for selected shifts before a member can see +or answer them. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "9f2e4a7b1c3d" +down_revision: Union[str, None] = "8d55ec2b6640" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tournament_forms", + sa.Column("prerequisites", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")), + ) + op.alter_column("tournament_forms", "prerequisites", server_default=None) + + +def downgrade() -> None: + op.drop_column("tournament_forms", "prerequisites") diff --git a/backend/app/core/tournament/form_prerequisites.py b/backend/app/core/tournament/form_prerequisites.py new file mode 100644 index 00000000..6480b961 --- /dev/null +++ b/backend/app/core/tournament/form_prerequisites.py @@ -0,0 +1,101 @@ +"""Eligibility evaluation for standard tournament forms. + +TournamentForm.prerequisites is deliberately evaluated here, rather than in a +route, so listing a member's forms and protecting direct render/submission +links cannot drift apart. +""" +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.models import ( + TournamentForm, + TournamentMembership, + TournamentMembershipAvailability, + TournamentMembershipRole, +) + + +def member_meets_form_prerequisites( + db: Session, + membership: TournamentMembership, + tournament_form: TournamentForm, +) -> bool: + """Return whether ``membership`` passes every configured requirement. + + The persisted shape is:: + + { + "onboarding_complete": true, + "roles": {"ids": [1, 2], "match": "any"}, + "availability": {"shift_ids": [3, 4], "match": "all"} + } + + An omitted or empty group does not constrain access. Manager-side schema + validation is added with the prerequisite API; this evaluator fails closed + for malformed non-empty groups so corrupt JSON cannot grant visibility. + """ + prerequisites = tournament_form.prerequisites or {} + if not isinstance(prerequisites, dict): + return False + + if prerequisites.get("onboarding_complete") is True and membership.onboarded_at is None: + return False + + roles = prerequisites.get("roles") + if not _matches_group( + roles, + _membership_role_ids(db, membership.id), + id_key="ids", + ): + return False + + availability = prerequisites.get("availability") + if not _matches_group( + availability, + _membership_shift_ids(db, membership.id), + id_key="shift_ids", + ): + return False + + return True + + +def _matches_group(group: object, actual_ids: set[int], *, id_key: str) -> bool: + """Apply an optional any/all ID group against the member's IDs.""" + if group is None: + return True + if not isinstance(group, dict): + return False + + required_ids = group.get(id_key, []) + if not isinstance(required_ids, list) or not all(isinstance(item, int) for item in required_ids): + return False + if not required_ids: + return True + + match = group.get("match", "any") + required = set(required_ids) + if match == "any": + return bool(required & actual_ids) + if match == "all": + return required <= actual_ids + return False + + +def _membership_role_ids(db: Session, membership_id: int) -> set[int]: + return { + role_id + for (role_id,) in db.query(TournamentMembershipRole.role_id) + .filter(TournamentMembershipRole.membership_id == membership_id) + .all() + } + + +def _membership_shift_ids(db: Session, membership_id: int) -> set[int]: + return { + shift_id + for (shift_id,) in db.query(TournamentMembershipAvailability.tournament_shift_id) + .filter(TournamentMembershipAvailability.membership_id == membership_id) + .all() + } diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 56650397..4326bd1e 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -755,6 +755,11 @@ class TournamentForm(Base): tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False) is_onboarding = Column(Boolean, nullable=False, default=False) order = Column(Integer, nullable=True) + # Optional member-visibility requirements for standard tournament forms. + # Shape and referenced-id validation live with the manager API; evaluation + # lives in core/tournament/form_prerequisites.py so every future caller + # (member form list, rendering, and submission) shares one rule. + prerequisites = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime(timezone=True), default=utcnow) tournament = relationship("Tournament", back_populates="tournament_forms") diff --git a/backend/tests/core/test_tournament_form_prerequisites.py b/backend/tests/core/test_tournament_form_prerequisites.py new file mode 100644 index 00000000..bd533231 --- /dev/null +++ b/backend/tests/core/test_tournament_form_prerequisites.py @@ -0,0 +1,128 @@ +from datetime import timedelta + +from app.core.tournament.form_prerequisites import member_meets_form_prerequisites +from app.models.models import ( + Form, + TournamentForm, + TournamentMembership, + TournamentMembershipAvailability, + TournamentMembershipRole, + TournamentRole, + TournamentShift, + utcnow, +) + + +def _standard_form(db, user, tournament, prerequisites=None): + form = Form( + owner_type="tournament", + tournament_id=tournament.id, + name="Conditional form", + created_by=user.id, + ) + db.add(form) + db.flush() + tournament_form = TournamentForm( + form_id=form.id, + tournament_id=tournament.id, + prerequisites=prerequisites or {}, + ) + db.add(tournament_form) + db.commit() + return tournament_form + + +def _membership(db, tournament, user): + membership = TournamentMembership(user_id=user.id, tournament_id=tournament.id, source="manual") + db.add(membership) + db.commit() + return membership + + +def _role(db, tournament, label, rank): + role = TournamentRole(tournament_id=tournament.id, label=label, permissions=[], rank=rank) + db.add(role) + db.commit() + return role + + +def _shift(db, tournament, label): + start = utcnow() + shift = TournamentShift(tournament_id=tournament.id, label=label, start=start, end=start + timedelta(hours=2)) + db.add(shift) + db.commit() + return shift + + +def test_no_prerequisites_allows_member(db, td_user, td_tournament, other_user): + membership = _membership(db, td_tournament, other_user) + tournament_form = _standard_form(db, td_user, td_tournament) + + assert member_meets_form_prerequisites(db, membership, tournament_form) is True + + +def test_onboarding_prerequisite_requires_completion(db, td_user, td_tournament, other_user): + membership = _membership(db, td_tournament, other_user) + tournament_form = _standard_form(db, td_user, td_tournament, {"onboarding_complete": True}) + + assert member_meets_form_prerequisites(db, membership, tournament_form) is False + membership.onboarded_at = utcnow() + db.commit() + assert member_meets_form_prerequisites(db, membership, tournament_form) is True + + +def test_role_prerequisite_matches_any_or_all(db, td_user, td_tournament, other_user): + membership = _membership(db, td_tournament, other_user) + test_writer = _role(db, td_tournament, "Prerequisite Role A", 20) + event_supervisor = _role(db, td_tournament, "Prerequisite Role B", 21) + db.add(TournamentMembershipRole(membership_id=membership.id, role_id=test_writer.id)) + db.commit() + + any_form = _standard_form(db, td_user, td_tournament, {"roles": {"ids": [test_writer.id, event_supervisor.id], "match": "any"}}) + all_form = _standard_form(db, td_user, td_tournament, {"roles": {"ids": [test_writer.id, event_supervisor.id], "match": "all"}}) + + assert member_meets_form_prerequisites(db, membership, any_form) is True + assert member_meets_form_prerequisites(db, membership, all_form) is False + db.add(TournamentMembershipRole(membership_id=membership.id, role_id=event_supervisor.id)) + db.commit() + assert member_meets_form_prerequisites(db, membership, all_form) is True + + +def test_availability_prerequisite_matches_any_or_all(db, td_user, td_tournament, other_user): + membership = _membership(db, td_tournament, other_user) + first_shift = _shift(db, td_tournament, "Morning") + second_shift = _shift(db, td_tournament, "Afternoon") + db.add(TournamentMembershipAvailability(membership_id=membership.id, tournament_shift_id=first_shift.id)) + db.commit() + + any_form = _standard_form(db, td_user, td_tournament, {"availability": {"shift_ids": [first_shift.id, second_shift.id], "match": "any"}}) + all_form = _standard_form(db, td_user, td_tournament, {"availability": {"shift_ids": [first_shift.id, second_shift.id], "match": "all"}}) + + assert member_meets_form_prerequisites(db, membership, any_form) is True + assert member_meets_form_prerequisites(db, membership, all_form) is False + db.add(TournamentMembershipAvailability(membership_id=membership.id, tournament_shift_id=second_shift.id)) + db.commit() + assert member_meets_form_prerequisites(db, membership, all_form) is True + + +def test_every_configured_group_must_pass(db, td_user, td_tournament, other_user): + membership = _membership(db, td_tournament, other_user) + role = _role(db, td_tournament, "Combined prerequisite role", 20) + shift = _shift(db, td_tournament, "Morning") + tournament_form = _standard_form( + db, + td_user, + td_tournament, + { + "onboarding_complete": True, + "roles": {"ids": [role.id], "match": "all"}, + "availability": {"shift_ids": [shift.id], "match": "all"}, + }, + ) + + assert member_meets_form_prerequisites(db, membership, tournament_form) is False + membership.onboarded_at = utcnow() + db.add(TournamentMembershipRole(membership_id=membership.id, role_id=role.id)) + db.add(TournamentMembershipAvailability(membership_id=membership.id, tournament_shift_id=shift.id)) + db.commit() + assert member_meets_form_prerequisites(db, membership, tournament_form) is True From 1c88b82a9fe294bb13e49042c6b36fd221972f26 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 23:18:13 -0700 Subject: [PATCH 20/92] refactor(migrations): squash tournament form prerequisites --- ...b6640_tournament_forms_and_onboarded_at.py | 8 +++-- ...b1c3d_add_tournament_form_prerequisites.py | 32 ------------------- 2 files changed, 6 insertions(+), 34 deletions(-) delete mode 100644 backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py diff --git a/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py b/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py index a49255be..c48f5c62 100644 --- a/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py +++ b/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py @@ -1,4 +1,4 @@ -"""tournament forms and onboarded_at +"""tournament forms, onboarding, and prerequisites Revision ID: 8d55ec2b6640 Revises: 7db31ae17e3c @@ -15,7 +15,9 @@ "which Form." Deleting a Form cascades away its TournamentForm row for free. Also adds tournament_memberships.onboarded_at, set once a member has -answered every currently-onboarding-flagged published form. +answered every currently-onboarding-flagged published form, plus the +TournamentForm.prerequisites JSON configuration used by standard form +visibility rules. Backfills a tournament_forms row for every existing forms row that already has a tournament_id, so the 1:1 invariant holds for pre-existing data too. @@ -38,6 +40,7 @@ def upgrade() -> None: sa.Column('tournament_id', sa.Integer(), nullable=False), sa.Column('is_onboarding', sa.Boolean(), nullable=False), sa.Column('order', sa.Integer(), nullable=True), + sa.Column('prerequisites', sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")), sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), @@ -56,6 +59,7 @@ def upgrade() -> None: WHERE tournament_id IS NOT NULL """ ) + op.alter_column('tournament_forms', 'prerequisites', server_default=None) def downgrade() -> None: diff --git a/backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py b/backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py deleted file mode 100644 index b0420171..00000000 --- a/backend/alembic/versions/9f2e4a7b1c3d_add_tournament_form_prerequisites.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add tournament form prerequisites - -Revision ID: 9f2e4a7b1c3d -Revises: 8d55ec2b6640 -Create Date: 2026-08-25 00:00:00.000000 - -Standard tournament forms can optionally require completed onboarding, one or -more roles, and/or availability for selected shifts before a member can see -or answer them. -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "9f2e4a7b1c3d" -down_revision: Union[str, None] = "8d55ec2b6640" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "tournament_forms", - sa.Column("prerequisites", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")), - ) - op.alter_column("tournament_forms", "prerequisites", server_default=None) - - -def downgrade() -> None: - op.drop_column("tournament_forms", "prerequisites") From 26dec1969f48228751d87e87ab2d5c8163e2a2dc Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Tue, 25 Aug 2026 23:24:17 -0700 Subject: [PATCH 21/92] feat(forms): add prerequisite management API --- backend/app/api/routes/forms.py | 74 +++++++++++++++++++++++++++++++ backend/app/models/models.py | 5 +++ backend/app/schemas/form.py | 52 ++++++++++++++++++++++ backend/tests/api/test_forms.py | 77 +++++++++++++++++++++++++++++++++ frontend/lib/api.ts | 22 ++++++++++ 5 files changed, 230 insertions(+) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index ef30191e..1884bfce 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -40,6 +40,8 @@ FormResponsePendingUpdate, TournamentForm, TournamentMembership, + TournamentRole, + TournamentShift, User, utcnow, ) @@ -53,6 +55,7 @@ FormResponseCreate, FormResponseRead, FormUpdate, + TournamentFormPrerequisitesUpdate, ) from app.schemas.tournament.membership import MembershipSlimResponse from app.schemas.user import UserSlimResponse @@ -255,9 +258,80 @@ def _to_list_read(form: Form, creator: MembershipSlimResponse | ChapterMemberRes created_at=form.created_at, updated_at=form.updated_at, response_count=form.response_count, + prerequisites=form.prerequisites, ) +# --------------------------------------------------------------------------- +# PATCH /tournaments/{tournament_id}/forms/{form_id}/prerequisites/ — replaces +# prerequisite configuration for one standard tournament form. This stays +# nested under the tournament so role/shift IDs are unambiguously scoped. +# --------------------------------------------------------------------------- +@router.patch( + "/tournaments/{tournament_id}/forms/{form_id}/prerequisites/", + response_model=FormRead, + tags=["tournaments"], +) +def update_tournament_form_prerequisites( + tournament_id: int, + form_id: str, + payload: TournamentFormPrerequisitesUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_FORMS)), +): + form = ( + db.query(Form) + .filter( + Form.id == form_id, + Form.owner_type == "tournament", + Form.tournament_id == tournament_id, + ) + .first() + ) + if form is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Form not found") + + tournament_form = db.query(TournamentForm).filter(TournamentForm.form_id == form_id).first() + if tournament_form is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tournament form not found") + if tournament_form.is_onboarding: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Onboarding forms cannot have standard-form prerequisites", + ) + + prerequisites = payload.model_dump(exclude_none=True) + _validate_prerequisite_ids(db, tournament_id, prerequisites) + tournament_form.prerequisites = prerequisites + db.commit() + db.refresh(form) + return form + + +def _validate_prerequisite_ids(db: Session, tournament_id: int, prerequisites: dict) -> None: + roles = (prerequisites.get("roles") or {}).get("ids", []) + shifts = (prerequisites.get("availability") or {}).get("shift_ids", []) + + def _require_tournament_ids(ids: list[int], model, label: str) -> None: + if not ids: + return + found = { + row_id + for (row_id,) in db.query(model.id) + .filter(model.tournament_id == tournament_id, model.id.in_(ids)) + .all() + } + missing = sorted(set(ids) - found) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"{label} do not belong to this tournament: {missing}", + ) + + _require_tournament_ids(roles, TournamentRole, "role IDs") + _require_tournament_ids(shifts, TournamentShift, "shift IDs") + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/ — view/render. Any member of a linked # tournament/chapter can view (not just managers) — this is what the form diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 4326bd1e..38679e9a 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -724,6 +724,11 @@ class Form(Base): def response_count(self) -> int: return len(self.responses) + @property + def prerequisites(self) -> dict | None: + """TournamentForm configuration exposed on generic form responses.""" + return self.tournament_form.prerequisites if self.tournament_form else None + # --------------------------------------------------------------------------- # TournamentForm — 1:1 companion row every tournament-scoped Form gets at diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 38b837da..cc324d82 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -189,6 +189,52 @@ class BulkFieldsUpdate(BaseModel): fields: list[BulkFieldEntry] +# --------------------------------------------------------------------------- +# Tournament form prerequisites +# --------------------------------------------------------------------------- + +class PrerequisiteIdMatch(BaseModel): + """A required set of IDs and whether the member needs any or all of it.""" + model_config = ConfigDict(extra="forbid") + + ids: list[int] + match: Literal["any", "all"] = "any" + + @field_validator("ids") + @classmethod + def _positive_unique_ids(cls, values: list[int]) -> list[int]: + if any(value <= 0 for value in values): + raise ValueError("ids must contain positive integers") + if len(values) != len(set(values)): + raise ValueError("ids must not contain duplicates") + return values + + +class AvailabilityPrerequisite(BaseModel): + model_config = ConfigDict(extra="forbid") + + shift_ids: list[int] + match: Literal["any", "all"] = "any" + + @field_validator("shift_ids") + @classmethod + def _positive_unique_shift_ids(cls, values: list[int]) -> list[int]: + if any(value <= 0 for value in values): + raise ValueError("shift_ids must contain positive integers") + if len(values) != len(set(values)): + raise ValueError("shift_ids must not contain duplicates") + return values + + +class TournamentFormPrerequisites(BaseModel): + """Optional conditions a member must all satisfy to access a standard form.""" + model_config = ConfigDict(extra="forbid") + + onboarding_complete: bool = False + roles: PrerequisiteIdMatch | None = None + availability: AvailabilityPrerequisite | None = None + + # --------------------------------------------------------------------------- # Form Schemas # --------------------------------------------------------------------------- @@ -206,6 +252,7 @@ class FormRead(BaseModel): created_at: datetime updated_at: datetime response_count: int = 0 + prerequisites: TournamentFormPrerequisites | None = None fields: list[FormFieldRead] = [] model_config = ConfigDict(from_attributes=True) @@ -232,6 +279,7 @@ class FormListRead(BaseModel): created_at: datetime updated_at: datetime response_count: int = 0 + prerequisites: TournamentFormPrerequisites | None = None model_config = ConfigDict(from_attributes=True) @@ -262,6 +310,10 @@ class FormUpdate(BaseModel): status: Literal["draft", "published", "archived"] | None = None +class TournamentFormPrerequisitesUpdate(TournamentFormPrerequisites): + """Replacement payload for a standard tournament form's prerequisites.""" + + # --------------------------------------------------------------------------- # Form Response / Answer Schemas # --------------------------------------------------------------------------- diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 4df960e5..58df5764 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -19,6 +19,8 @@ TournamentMembership, TournamentMembershipAvailability, TournamentMembershipLunch, + TournamentForm, + TournamentRole, TournamentShift, ) @@ -251,6 +253,81 @@ def test_member_without_manage_forms_forbidden(self, client, db, td_tournament, assert res.status_code == 403 +# --------------------------------------------------------------------------- +# PATCH /tournaments/{tournament_id}/forms/{form_id}/prerequisites/ +# --------------------------------------------------------------------------- + +class TestTournamentFormPrerequisites: + def _link(self, db, form, tournament, **overrides): + row = TournamentForm(form_id=form.id, tournament_id=tournament.id, **overrides) + db.add(row) + db.commit() + return row + + def test_manager_replaces_prerequisites_and_response_includes_them(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + role = db.query(TournamentRole).filter(TournamentRole.tournament_id == td_tournament.id).first() + now = datetime.now(timezone.utc) + shift = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=now, end=now + timedelta(hours=2)) + db.add(shift) + db.commit() + self._link(db, form, td_tournament) + login(client, "td@test.com", "tdpass") + + payload = { + "onboarding_complete": True, + "roles": {"ids": [role.id], "match": "all"}, + "availability": {"shift_ids": [shift.id], "match": "any"}, + } + res = client.patch(f"/tournaments/{td_tournament.id}/forms/{form.id}/prerequisites/", json=payload) + + assert res.status_code == 200 + assert res.json()["prerequisites"] == payload + listed = client.get(f"/tournaments/{td_tournament.id}/forms/") + assert listed.status_code == 200 + assert listed.json()[0]["prerequisites"] == payload + + def test_rejects_role_or_shift_from_another_tournament(self, client, db, td_user, td_tournament, other_tournament): + form = _make_form(db, td_user, td_tournament) + self._link(db, form, td_tournament) + other_role = db.query(TournamentRole).filter(TournamentRole.tournament_id == other_tournament.id).first() + now = datetime.now(timezone.utc) + other_shift = TournamentShift(tournament_id=other_tournament.id, label="Other", start=now, end=now + timedelta(hours=2)) + db.add(other_shift) + db.commit() + login(client, "td@test.com", "tdpass") + + role_res = client.patch( + f"/tournaments/{td_tournament.id}/forms/{form.id}/prerequisites/", + json={"roles": {"ids": [other_role.id], "match": "any"}}, + ) + shift_res = client.patch( + f"/tournaments/{td_tournament.id}/forms/{form.id}/prerequisites/", + json={"availability": {"shift_ids": [other_shift.id], "match": "all"}}, + ) + + assert role_res.status_code == 422 + assert shift_res.status_code == 422 + + def test_rejects_onboarding_form_and_member_without_manage_forms(self, client, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament) + self._link(db, form, td_tournament, is_onboarding=True, order=1) + login(client, "td@test.com", "tdpass") + assert client.patch( + f"/tournaments/{td_tournament.id}/forms/{form.id}/prerequisites/", + json={"onboarding_complete": True}, + ).status_code == 409 + + db.query(TournamentForm).filter(TournamentForm.form_id == form.id).update({TournamentForm.is_onboarding: False, TournamentForm.order: None}) + db.commit() + grant_role(db, td_tournament, other_user, "Runner") + login(client, "other@test.com", "otherpass") + assert client.patch( + f"/tournaments/{td_tournament.id}/forms/{form.id}/prerequisites/", + json={"onboarding_complete": True}, + ).status_code == 403 + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/ # --------------------------------------------------------------------------- diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 037a50df..162d5537 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1228,6 +1228,24 @@ export interface FormFieldInput { config?: FormFieldConfig | null } +export type PrerequisiteMatch = "any" | "all" + +export interface IdPrerequisite { + ids: number[] + match: PrerequisiteMatch +} + +export interface AvailabilityPrerequisite { + shift_ids: number[] + match: PrerequisiteMatch +} + +export interface TournamentFormPrerequisites { + onboarding_complete: boolean + roles?: IdPrerequisite | null + availability?: AvailabilityPrerequisite | null +} + export interface Form { id: string name: string @@ -1241,6 +1259,7 @@ export interface Form { created_at: string updated_at: string response_count: number + prerequisites: TournamentFormPrerequisites | null fields: FormField[] } @@ -1268,6 +1287,7 @@ export interface FormListItem { created_at: string updated_at: string response_count: number + prerequisites: TournamentFormPrerequisites | null } export interface FormCreateInput { @@ -1327,6 +1347,8 @@ export const formsApi = { // field_key Combobox shows these as disabled options. listFieldKeysForTournament: (tournamentId: number) => api.get(`/tournaments/${tournamentId}/forms/field-keys/`), + updatePrerequisites: (tournamentId: number, formId: string, prerequisites: TournamentFormPrerequisites) => + api.patch(`/tournaments/${tournamentId}/forms/${formId}/prerequisites/`, prerequisites), createForTournament: (tournamentId: number, body: { name: string; title?: string | null; description?: string | null }) => api.post(`/tournaments/${tournamentId}/forms/`, { ...body, owner_type: 'tournament', tournament_id: tournamentId }), createForChapter: (chapterId: number, body: { name: string; title?: string | null; description?: string | null }) => From 74445158ce5925e192ca4c8e2102ff6d1a41c667 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 12:07:46 -0700 Subject: [PATCH 22/92] feat(forms): surface eligible member forms --- backend/app/api/routes/forms.py | 60 ++++++++++++++++ backend/app/core/form/permissions.py | 23 +++++- backend/app/core/tournament/onboarding.py | 22 ++++-- backend/app/schemas/form.py | 14 ++++ backend/tests/api/test_forms.py | 71 ++++++++++++++++++- .../tournaments/[id]/overview/page.tsx | 63 +++++++++++++++- frontend/lib/api.ts | 13 ++++ 7 files changed, 258 insertions(+), 8 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 1884bfce..b1ba7ee3 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -28,6 +28,9 @@ validate_reserved_field_key, ) from app.core.form.write_through import parse_lunch_field_key, sync_availability, sync_lunch +from app.core.tournament.form_prerequisites import member_meets_form_prerequisites +from app.core.tournament.memberships import get_membership_by_user +from app.core.tournament.onboarding import next_required_onboarding_form_id from app.core.tournament.memberships import resolve_memberships_or_users from app.core.tournament.permissions import MANAGE_FORMS, require_permission from app.db.session import get_db @@ -51,6 +54,7 @@ FormCreate, FormFieldRead, FormListRead, + MemberFormRead, FormRead, FormResponseCreate, FormResponseRead, @@ -169,6 +173,62 @@ def list_tournament_forms( return [_to_list_read(f, creators[f.created_by]) for f in forms] +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/forms/me/ — a member's form history and +# current work: every completed form plus every form they may take now. +# --------------------------------------------------------------------------- +@router.get( + "/tournaments/{tournament_id}/forms/me/", + response_model=list[MemberFormRead], + tags=["tournaments"], +) +def list_my_tournament_forms( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + membership = get_membership_by_user(db, tournament_id, current_user.id) + if membership is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tournament membership not found") + + rows = ( + db.query(TournamentForm) + .join(Form, Form.id == TournamentForm.form_id) + .filter(TournamentForm.tournament_id == tournament_id) + .order_by(TournamentForm.is_onboarding.desc(), TournamentForm.order, Form.updated_at.desc()) + .all() + ) + form_ids = [row.form_id for row in rows] + completed_ids = { + form_id + for (form_id,) in db.query(FormResponse.form_id) + .filter(FormResponse.user_id == current_user.id, FormResponse.form_id.in_(form_ids)) + .all() + } + next_onboarding_form_id = next_required_onboarding_form_id(db, membership) + + result = [] + for tournament_form in rows: + form = tournament_form.form + completed = form.id in completed_ids + if tournament_form.is_onboarding: + eligible = form.status == "published" and form.id == next_onboarding_form_id + else: + eligible = form.status == "published" and member_meets_form_prerequisites(db, membership, tournament_form) + if completed or eligible: + result.append(MemberFormRead( + id=form.id, + name=form.name, + title=form.title, + description=form.description, + status=form.status, + is_onboarding=tournament_form.is_onboarding, + completed=completed, + eligible=eligible, + )) + return result + + # --------------------------------------------------------------------------- # GET /tournaments/{tournament_id}/forms/field-keys/ — every field_key # already in use across this tournament's forms (archived fields included — diff --git a/backend/app/core/form/permissions.py b/backend/app/core/form/permissions.py index 684d675d..4a03ba70 100644 --- a/backend/app/core/form/permissions.py +++ b/backend/app/core/form/permissions.py @@ -3,7 +3,9 @@ from app.core.auth import get_current_user from app.core.chapters import require_officer_or_lead -from app.core.tournament.memberships import has_any_membership +from app.core.tournament.form_prerequisites import member_meets_form_prerequisites +from app.core.tournament.memberships import get_membership_by_user, has_any_membership +from app.core.tournament.onboarding import next_required_onboarding_form_id from app.core.tournament.permissions import MANAGE_FORMS, has_permission from app.db.session import get_db from app.models.models import ChapterMembership, Form, User @@ -57,6 +59,25 @@ def require_form_view_access( if form.owner_type == "tournament": if not has_any_membership(current_user, form.tournament_id, db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + if has_permission(current_user, form.tournament_id, MANAGE_FORMS, db): + return form + + membership = get_membership_by_user(db, form.tournament_id, current_user.id) + # Only a site admin can pass has_any_membership without a row; admins + # already pass MANAGE_FORMS above, so this remains a defensive guard. + if membership is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + if form.status != "published": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This form is not currently available") + + tournament_form = form.tournament_form + if tournament_form is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This form is not currently available") + if tournament_form.is_onboarding: + if next_required_onboarding_form_id(db, membership) != form.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This onboarding form is not currently available") + elif not member_meets_form_prerequisites(db, membership, tournament_form): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not meet this form's prerequisites") else: if current_user.role != "admin" and not db.query(ChapterMembership).filter( ChapterMembership.user_id == current_user.id, diff --git a/backend/app/core/tournament/onboarding.py b/backend/app/core/tournament/onboarding.py index 2dfdbbef..762ecbca 100644 --- a/backend/app/core/tournament/onboarding.py +++ b/backend/app/core/tournament/onboarding.py @@ -24,6 +24,23 @@ def advance_onboarding_progress( the generic forms submission flow. A client calls the onboarding progress endpoint after submitting a form to learn where to go next. """ + next_form_id = next_required_onboarding_form_id(db, membership) + if next_form_id is None and membership.onboarded_at is None: + membership.onboarded_at = utcnow() + + return OnboardingProgress(next_form_id=next_form_id) + + +def next_required_onboarding_form_id( + db: Session, + membership: TournamentMembership, +) -> str | None: + """Return the next unanswered published onboarding form without mutating state. + + Form access and the member overview need the exact same ordered answer as + the progression endpoint, but merely reading either must not mark someone + onboarded. ``advance_onboarding_progress`` owns that one write. + """ steps = ( db.query(TournamentForm) .join(Form, TournamentForm.form_id == Form.id) @@ -48,7 +65,4 @@ def advance_onboarding_progress( } next_step = next((step for step in steps if step.form_id not in answered_form_ids), None) - if next_step is None and membership.onboarded_at is None: - membership.onboarded_at = utcnow() - - return OnboardingProgress(next_form_id=next_step.form_id if next_step else None) + return next_step.form_id if next_step else None diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index cc324d82..fd8dd286 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -284,6 +284,20 @@ class FormListRead(BaseModel): model_config = ConfigDict(from_attributes=True) +class MemberFormRead(BaseModel): + """A member's completed or currently available tournament form.""" + id: str + name: str + title: str | None = None + description: str | None = None + status: Literal["draft", "published", "archived"] + is_onboarding: bool + completed: bool + eligible: bool + + model_config = ConfigDict(from_attributes=True) + + class FormCreate(BaseModel): name: str title: str | None = None diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 58df5764..cd1122aa 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -22,6 +22,7 @@ TournamentForm, TournamentRole, TournamentShift, + utcnow, ) @@ -328,6 +329,56 @@ def test_rejects_onboarding_form_and_member_without_manage_forms(self, client, d ).status_code == 403 +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/forms/me/ +# --------------------------------------------------------------------------- + +class TestMyTournamentForms: + def _form(self, db, user, tournament, *, status="published", is_onboarding=False, order=None, prerequisites=None): + form = _make_form(db, user, tournament, status=status) + db.add(TournamentForm( + form_id=form.id, + tournament_id=tournament.id, + is_onboarding=is_onboarding, + order=order, + prerequisites=prerequisites or {}, + )) + db.commit() + return form + + def test_lists_completed_history_and_currently_eligible_forms(self, client, db, td_user, td_tournament, other_user): + membership = grant_role(db, td_tournament, other_user, "Runner") + completed_archived = self._form(db, td_user, td_tournament, status="archived") + eligible_standard = self._form(db, td_user, td_tournament) + blocked_standard = self._form(db, td_user, td_tournament, prerequisites={"onboarding_complete": True}) + completed_onboarding = self._form(db, td_user, td_tournament, is_onboarding=True, order=1) + next_onboarding = self._form(db, td_user, td_tournament, is_onboarding=True, order=2) + db.add_all([ + FormResponse(form_id=completed_archived.id, user_id=other_user.id), + FormResponse(form_id=completed_onboarding.id, user_id=other_user.id), + ]) + db.commit() + login(client, "other@test.com", "otherpass") + + res = client.get(f"/tournaments/{td_tournament.id}/forms/me/") + + assert res.status_code == 200 + rows = {row["id"]: row for row in res.json()} + assert set(rows) == {completed_archived.id, eligible_standard.id, completed_onboarding.id, next_onboarding.id} + assert rows[completed_archived.id]["completed"] is True + assert rows[completed_archived.id]["eligible"] is False + assert rows[eligible_standard.id]["eligible"] is True + assert rows[completed_onboarding.id]["is_onboarding"] is True + assert rows[next_onboarding.id]["eligible"] is True + assert blocked_standard.id not in rows + assert membership.onboarded_at is None + + def test_requires_a_tournament_membership(self, client, td_user, td_tournament, other_user): + login(client, "other@test.com", "otherpass") + + assert client.get(f"/tournaments/{td_tournament.id}/forms/me/").status_code == 404 + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/ # --------------------------------------------------------------------------- @@ -343,7 +394,8 @@ def test_manager_can_view(self, client, td_user, td_tournament, db): def test_plain_member_can_view(self, client, db, td_user, td_tournament, other_user): grant_role(db, td_tournament, other_user, "Runner") - form = _make_form(db, td_user, td_tournament) + form = _make_form(db, td_user, td_tournament, status="published") + db.add(TournamentForm(form_id=form.id, tournament_id=td_tournament.id)) db.commit() login(client, "other@test.com", "otherpass") res = client.get(f"/forms/{form.id}/") @@ -395,6 +447,23 @@ def test_patch_archives_form(self, client, db, td_user, td_tournament): assert res.status_code == 200 assert res.json()["status"] == "archived" + def test_member_cannot_view_standard_form_without_prerequisites(self, client, db, td_user, td_tournament, other_user): + membership = grant_role(db, td_tournament, other_user, "Runner") + form = _make_form(db, td_user, td_tournament, status="published") + db.add(TournamentForm( + form_id=form.id, + tournament_id=td_tournament.id, + prerequisites={"onboarding_complete": True}, + )) + db.commit() + login(client, "other@test.com", "otherpass") + + assert client.get(f"/forms/{form.id}/").status_code == 403 + assert client.post(f"/forms/{form.id}/responses/", json={"answers": []}).status_code == 403 + membership.onboarded_at = utcnow() + db.commit() + assert client.get(f"/forms/{form.id}/").status_code == 200 + def test_patch_restores_archived_form_to_draft(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="archived") db.commit() diff --git a/frontend/app/dashboard/tournaments/[id]/overview/page.tsx b/frontend/app/dashboard/tournaments/[id]/overview/page.tsx index a372edce..b1e0d666 100644 --- a/frontend/app/dashboard/tournaments/[id]/overview/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/overview/page.tsx @@ -1,17 +1,31 @@ "use client"; -import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; import { useTournament } from "@/lib/useTournament"; import { parseLocalDate } from "@/lib/date"; +import { ApiError, formsApi, MemberForm } from "@/lib/api"; import { SetupChecklistWidget } from "@/components/tournament/setup/SetupChecklistWidget"; import { PageHeader } from "@/components/ui/PageHeader"; import { Badge } from "@/components/ui/Badge"; -import { IconCalendar, IconLocation } from "@/components/ui/Icons"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; +import { IconCalendar, IconCheckCircle, IconForms, IconLocation } from "@/components/ui/Icons"; export default function OverviewPage() { const params = useParams(); + const router = useRouter(); const tournamentId = params.id as string; const { selectedTournament } = useTournament(); + const [forms, setForms] = useState(null); + const [formsError, setFormsError] = useState(null); + + useEffect(() => { + formsApi.listMineForTournament(Number(tournamentId)) + .then(setForms) + .catch((error) => setFormsError(error instanceof ApiError ? error.message : "Failed to load forms.")); + }, [tournamentId]); const fmt = (d: string) => parseLocalDate(d).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }); @@ -56,6 +70,51 @@ export default function OverviewPage() {
+ {formsError && ( +

+ {formsError} +

+ )} + {forms === null ? ( +
+ ) : forms.length > 0 ? ( + +
+ + Forms +
+
+ {forms.map((form) => ( +
+ {form.completed ? : } +
+
+ {form.name} +
+
+ {form.completed ? "Completed" : form.is_onboarding ? "Onboarding" : "To do"} +
+
+ {form.eligible && !form.completed && ( + + )} +
+ ))} +
+
+ ) : null}
); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 162d5537..bd918e0e 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1246,6 +1246,17 @@ export interface TournamentFormPrerequisites { availability?: AvailabilityPrerequisite | null } +export interface MemberForm { + id: string + name: string + title: string | null + description: string | null + status: FormStatus + is_onboarding: boolean + completed: boolean + eligible: boolean +} + export interface Form { id: string name: string @@ -1340,6 +1351,8 @@ export interface TournamentOnboardingProgress { export const formsApi = { listForTournament: (tournamentId: number) => api.get(`/tournaments/${tournamentId}/forms/`), + listMineForTournament: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/forms/me/`), listForChapter: (chapterId: number) => api.get(`/chapters/${chapterId}/forms/`), // Every field_key already in use across this tournament's forms (archived From ed93ff31f270dd0f41edfd273b43bbe10b1a7639 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 12:17:45 -0700 Subject: [PATCH 23/92] style(forms): refine overview form card --- .../tournaments/[id]/overview/page.tsx | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/overview/page.tsx b/frontend/app/dashboard/tournaments/[id]/overview/page.tsx index b1e0d666..b2782dee 100644 --- a/frontend/app/dashboard/tournaments/[id]/overview/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/overview/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { useTournament } from "@/lib/useTournament"; import { parseLocalDate } from "@/lib/date"; import { ApiError, formsApi, MemberForm } from "@/lib/api"; @@ -11,15 +11,15 @@ import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { Spinner } from "@/components/ui/Spinner"; -import { IconCalendar, IconCheckCircle, IconForms, IconLocation } from "@/components/ui/Icons"; +import { IconCalendar, IconLocation } from "@/components/ui/Icons"; export default function OverviewPage() { const params = useParams(); - const router = useRouter(); const tournamentId = params.id as string; const { selectedTournament } = useTournament(); const [forms, setForms] = useState(null); const [formsError, setFormsError] = useState(null); + const [hoveredFormId, setHoveredFormId] = useState(null); useEffect(() => { formsApi.listMineForTournament(Number(tournamentId)) @@ -80,31 +80,38 @@ export default function OverviewPage() { ) : forms.length > 0 ? (
- Forms
- {forms.map((form) => ( -
- {form.completed ? : } + {forms.map((form, index) => ( +
setHoveredFormId(form.id)} + onMouseLeave={() => setHoveredFormId(null)} + style={{ + display: "flex", alignItems: "center", gap: "10px", padding: "8px 4px", + borderBottom: index === forms.length - 1 ? "none" : "1px solid var(--color-border)", + background: hoveredFormId === form.id ? "var(--color-bg)" : "transparent", + transition: "background 100ms ease", + }} + >
{form.name}
-
- {form.completed ? "Completed" : form.is_onboarding ? "Onboarding" : "To do"} -
+ + {form.completed ? "Completed" : "To do"} + {form.eligible && !form.completed && ( +
+ + {error &&

{error}

} + + {tracks?.length === 0 ? ( + } title="No tracks yet" description="Add the participation tracks members can select on your forms." /> + ) : ( +
+ {tracks?.map((track) => ( + setTracks((current) => current?.map((item) => item.id === next.id ? next : item) ?? current)} onDelete={() => setDeleteTarget(track)} /> + ))} +
+ )} + + + {deleteTarget && ( + setDeleteTarget(null)} + onDeleted={() => { + setTracks((current) => current?.filter((track) => track.id !== deleteTarget.id) ?? current); + setDeleteTarget(null); + }} + /> + )} +
+ ); +} + +function TrackRow({ tournamentId, track, onChange, onDelete }: { tournamentId: number; track: TournamentTrack; onChange: (track: TournamentTrack) => void; onDelete: () => void }) { + const [name, setName] = useState(track.name); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const dirty = name.trim() !== track.name; + + useEffect(() => setName(track.name), [track.name]); + + async function saveName() { + if (!dirty || !name.trim()) return; + setSaving(true); + setError(undefined); + try { + onChange(await tournamentTracksApi.update(tournamentId, track.id, { name: name.trim() })); + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Failed to rename track."); + } finally { + setSaving(false); + } + } + + async function setArchived(is_archived: boolean) { + setSaving(true); + setError(undefined); + try { + onChange(await tournamentTracksApi.update(tournamentId, track.id, { is_archived })); + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Failed to update track."); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+ setName(event.target.value)} disabled={track.is_archived} fullWidth /> +
+ {track.is_archived && Archived} + {dirty && } + + +
+ {error &&

{error}

} +
+ ); +} + +function DeleteTrackModal({ tournamentId, track, onClose, onDeleted }: { tournamentId: number; track: TournamentTrack; onClose: () => void; onDeleted: () => void }) { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(); + + async function deleteTrack() { + setDeleting(true); + setError(undefined); + try { + await tournamentTracksApi.delete(tournamentId, track.id); + onDeleted(); + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Failed to delete track."); + setDeleting(false); + } + } + + return ( + +
+

+ Delete {track.name}? This is only available while the track is not referenced by a form field. +

+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index afb4b3b1..6c298f91 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -31,6 +31,7 @@ const SETTINGS_SUBITEMS = [ { segment: "general", label: "General" }, { segment: "roles", label: "Roles" }, { segment: "invites", label: "Invites" }, + { segment: "tracks", label: "Tracks" }, { segment: "audit-log", label: "Audit Log" }, ]; @@ -59,6 +60,7 @@ export function Sidebar({ onExpandedChange, tournamentId }: SidebarProps) { ({ segment }) => (segment !== "roles" || canManageRoles) && (segment !== "invites" || canManageInvites) && + (segment !== "tracks" || canManageTournament) && (segment !== "audit-log" || canManageTournament) ); const navItems = NAV_ITEMS.filter( @@ -297,4 +299,4 @@ export function Sidebar({ onExpandedChange, tournamentId }: SidebarProps) { ); -} \ No newline at end of file +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index bd918e0e..1130ec49 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1246,6 +1246,15 @@ export interface TournamentFormPrerequisites { availability?: AvailabilityPrerequisite | null } +export interface TournamentTrack { + id: number + tournament_id: number + name: string + is_archived: boolean + created_at: string + updated_at: string +} + export interface MemberForm { id: string name: string @@ -1405,3 +1414,14 @@ export const tournamentOnboardingApi = { progress: (tournamentId: number) => api.post(`/tournaments/${tournamentId}/onboarding/progress/`, {}), } + +export const tournamentTracksApi = { + list: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/tracks/`), + create: (tournamentId: number, name: string) => + api.post(`/tournaments/${tournamentId}/tracks/`, { name }), + update: (tournamentId: number, trackId: number, body: { name?: string; is_archived?: boolean }) => + api.patch(`/tournaments/${tournamentId}/tracks/${trackId}/`, body), + delete: (tournamentId: number, trackId: number) => + api.delete(`/tournaments/${tournamentId}/tracks/${trackId}/`), +} From 1be509296244260f2c1265e132049740a68111bf Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 13:20:42 -0700 Subject: [PATCH 27/92] style(tracks): match track settings page to invites layout and add popover creation form --- .../tournaments/[id]/settings/tracks/page.tsx | 216 +++++++++++++----- 1 file changed, 164 insertions(+), 52 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx index 9ad5adc4..b37671bb 100644 --- a/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { useAuth } from "@/lib/useAuth"; import { useMyMembership } from "@/lib/useMyMembership"; @@ -11,10 +11,14 @@ import { Card } from "@/components/ui/Card"; import { EmptyState } from "@/components/ui/EmptyState"; import { Input } from "@/components/ui/Input"; import { Modal } from "@/components/ui/Modal"; +import { FormPopover } from "@/components/ui/FormPopover"; import { Spinner } from "@/components/ui/Spinner"; import { Badge } from "@/components/ui/Badge"; import { IconLock, IconPlus, IconTrash, IconVolunteers } from "@/components/ui/Icons"; +// Name / Status / Save / Archive / Delete +const TRACK_ROW_COLUMNS = "1fr 100px 88px 96px 40px"; + export default function TracksSettingsPage() { const params = useParams(); const tournamentId = Number(params.id); @@ -22,8 +26,6 @@ export default function TracksSettingsPage() { const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); const canManageTracks = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_tournament"); const [tracks, setTracks] = useState(null); - const [newName, setNewName] = useState(""); - const [creating, setCreating] = useState(false); const [error, setError] = useState(); const [deleteTarget, setDeleteTarget] = useState(null); @@ -40,20 +42,8 @@ export default function TracksSettingsPage() { if (canManageTracks) loadTracks(); }, [canManageTracks, loadTracks]); - async function createTrack() { - const name = newName.trim(); - if (!name || creating) return; - setCreating(true); - setError(undefined); - try { - const track = await tournamentTracksApi.create(tournamentId, name); - setTracks((current) => [...(current ?? []), track]); - setNewName(""); - } catch (err: unknown) { - setError(err instanceof ApiError ? err.message : "Failed to create track."); - } finally { - setCreating(false); - } + function handleCreated(track: TournamentTrack) { + setTracks((current) => [...(current ?? []), track]); } if (membershipLoading || (canManageTracks && tracks === null)) { @@ -73,37 +63,73 @@ export default function TracksSettingsPage() { return (
- -

- Tracks describe the ways members can participate, such as test writing or day 1. Archive a track when it should no longer be offered; historical form fields remain intact. -

- - -
- setNewName(event.target.value)} - onKeyDown={(event) => { if (event.key === "Enter") createTrack(); }} - placeholder="e.g. Test Writing" - fullWidth + track.name) ?? []} + onCreated={handleCreated} + trigger={ + + } /> - -
+ }/> - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} - {tracks?.length === 0 ? ( - } title="No tracks yet" description="Add the participation tracks members can select on your forms." /> - ) : ( -
- {tracks?.map((track) => ( - setTracks((current) => current?.map((item) => item.id === next.id ? next : item) ?? current)} onDelete={() => setDeleteTarget(track)} /> - ))} + {tracks?.length === 0 ? ( + + } + title="No tracks yet" + description="Add the participation tracks members can select on your forms." + action={ + track.name) ?? []} + onCreated={handleCreated} + trigger={ + + } + /> + } + /> + + ) : ( + +
+ Tracks — {tracks?.length} + Status + + +
- )} -
+ + {tracks?.map((track, i) => ( + setTracks((current) => current?.map((item) => item.id === next.id ? next : item) ?? current)} + onDelete={() => setDeleteTarget(track)} + /> + ))} + + )} {deleteTarget && ( void; onDelete: () => void }) { +function AddTrackPopover({ tournamentId, existingNames, onCreated, trigger }: { + tournamentId: number; + existingNames: string[]; + onCreated: (track: TournamentTrack) => void; + trigger: ReactNode; +}) { + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(); + + async function submit(close: () => void) { + const trimmed = name.trim(); + if (!trimmed || creating) return; + setCreating(true); + setError(undefined); + try { + const track = await tournamentTracksApi.create(tournamentId, trimmed); + onCreated(track); + setName(""); + close(); + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Failed to create track."); + } finally { + setCreating(false); + } + } + + return ( + { + if (!open) { setName(""); setError(undefined); } + }} + > + {(close) => ( +
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submit(close); } }} + error={error ?? (existingNames.some((n) => n.toLowerCase() === name.trim().toLowerCase()) ? "A track with this name already exists." : undefined)} + size="sm" + font="sans" + fullWidth + autoFocus + /> +
+ +
+
+ )} +
+ ); +} + +function TrackRow({ tournamentId, track, isLast, onChange, onDelete }: { tournamentId: number; track: TournamentTrack; isLast: boolean; onChange: (track: TournamentTrack) => void; onDelete: () => void }) { const [name, setName] = useState(track.name); const [saving, setSaving] = useState(false); const [error, setError] = useState(); + const [hovered, setHovered] = useState(false); const dirty = name.trim() !== track.name; useEffect(() => setName(track.name), [track.name]); @@ -154,21 +241,46 @@ function TrackRow({ tournamentId, track, onChange, onDelete }: { tournamentId: n } return ( -
-
-
- setName(event.target.value)} disabled={track.is_archived} fullWidth /> -
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: "grid", gridTemplateColumns: TRACK_ROW_COLUMNS, alignItems: "center", + gap: "8px", padding: "10px 12px", + borderBottom: isLast ? "none" : "1px solid var(--color-border)", + background: hovered ? "var(--color-bg)" : "transparent", + transition: "background 100ms ease", + }} + > + setName(event.target.value)} disabled={track.is_archived} size="sm" font="sans" fullWidth /> +
{track.is_archived && Archived} +
+
{dirty && } +
+
-
+
+
- {error &&

{error}

} + {error && ( +

+ {error} +

+ )}
); } From e2fafc414e5510307798c3401ae81c4fd163ee78 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 13:37:32 -0700 Subject: [PATCH 28/92] refactor(tracks): use shared EditableText, status badge, and icon-only archive action in track rows --- .../tournaments/[id]/settings/tracks/page.tsx | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx index b37671bb..c0038528 100644 --- a/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx @@ -12,12 +12,13 @@ import { EmptyState } from "@/components/ui/EmptyState"; import { Input } from "@/components/ui/Input"; import { Modal } from "@/components/ui/Modal"; import { FormPopover } from "@/components/ui/FormPopover"; +import { EditableText } from "@/components/ui/EditableText"; import { Spinner } from "@/components/ui/Spinner"; import { Badge } from "@/components/ui/Badge"; -import { IconLock, IconPlus, IconTrash, IconVolunteers } from "@/components/ui/Icons"; +import { IconArchive, IconLock, IconPlus, IconTrash, IconVolunteers } from "@/components/ui/Icons"; -// Name / Status / Save / Archive / Delete -const TRACK_ROW_COLUMNS = "1fr 100px 88px 96px 40px"; +// Name / Status / Actions +const TRACK_ROW_COLUMNS = "1fr 100px 68px"; export default function TracksSettingsPage() { const params = useParams(); @@ -114,8 +115,6 @@ export default function TracksSettingsPage() { Tracks — {tracks?.length} Status - -
{tracks?.map((track, i) => ( @@ -207,26 +206,9 @@ function AddTrackPopover({ tournamentId, existingNames, onCreated, trigger }: { } function TrackRow({ tournamentId, track, isLast, onChange, onDelete }: { tournamentId: number; track: TournamentTrack; isLast: boolean; onChange: (track: TournamentTrack) => void; onDelete: () => void }) { - const [name, setName] = useState(track.name); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [hovered, setHovered] = useState(false); - const dirty = name.trim() !== track.name; - - useEffect(() => setName(track.name), [track.name]); - - async function saveName() { - if (!dirty || !name.trim()) return; - setSaving(true); - setError(undefined); - try { - onChange(await tournamentTracksApi.update(tournamentId, track.id, { name: name.trim() })); - } catch (err: unknown) { - setError(err instanceof ApiError ? err.message : "Failed to rename track."); - } finally { - setSaving(false); - } - } async function setArchived(is_archived: boolean) { setSaving(true); @@ -252,19 +234,32 @@ function TrackRow({ tournamentId, track, isLast, onChange, onDelete }: { tournam transition: "background 100ms ease", }} > - setName(event.target.value)} disabled={track.is_archived} size="sm" font="sans" fullWidth /> -
- {track.is_archived && Archived} -
+ {track.is_archived ? ( + + {track.name} + + ) : ( + onChange(await tournamentTracksApi.update(tournamentId, track.id, { name }))} + title="Click to edit name" + /> + )}
- {dirty && } + + {track.is_archived ? "Archived" : "Active"} +
-
- -
-
} - width={320} + width={250} side="right" open={open} onOpenChange={(next) => { onOpenChange(next); if (next) onOpen?.(); }} @@ -121,10 +122,10 @@ export function PresetPopover({ }}> Preset - applyPresetKind(v === presetKind ? null : (v as PresetKind))} + onChange={(value) => applyPresetKind(value ? value as PresetKind : null)} size="sm" fullWidth /> diff --git a/frontend/components/ui/Dropdown.tsx b/frontend/components/ui/Dropdown.tsx index 4a327c71..abe3ba7c 100644 --- a/frontend/components/ui/Dropdown.tsx +++ b/frontend/components/ui/Dropdown.tsx @@ -90,8 +90,8 @@ const PANEL_GAP = 4 // px between trigger edge and panel // Heights match Button/Input's scale — same size name, same height everywhere. const SIZE_MAP: Record<'sm' | 'md', { height: number; triggerFontSize: string; optionPadding: string; optionFontSize: string }> = { - sm: { height: 28, triggerFontSize: '11px', optionPadding: '5px 8px', optionFontSize: '11px' }, - md: { height: 36, triggerFontSize: '14px', optionPadding: '7px 10px', optionFontSize: '13px' }, + sm: { height: 28, triggerFontSize: '13px', optionPadding: '5px 8px', optionFontSize: '13px' }, + md: { height: 36, triggerFontSize: '14px', optionPadding: '7px 10px', optionFontSize: '14px' }, } const BACKGROUND_MAP: Record<'primary' | 'secondary', string> = { diff --git a/frontend/lib/forms/fieldKeyPresets.ts b/frontend/lib/forms/fieldKeyPresets.ts index ff5d9d35..b2601638 100644 --- a/frontend/lib/forms/fieldKeyPresets.ts +++ b/frontend/lib/forms/fieldKeyPresets.ts @@ -54,7 +54,7 @@ const LUNCH_FIELD_KEY_PATTERN = /^lunch_(\d{4})(\d{2})(\d{2})_([a-z0-9_]+)$/; // "availability_"/"event_preference_"/"lunch_" sentinel the instant a // preset is picked, before its date/suffix/category is filled in, and that // in-progress state must still read as "this preset is active" (matching -// the ButtonGroup selection, the QuestionEditBody body it renders, etc.) — +// the preset dropdown selection, the QuestionEditBody body it renders, etc.) — // otherwise picking a preset would appear to silently do nothing until // every parameter was filled in. Save-time validation is what actually // enforces the fully-parameterized shape (matches From 0e659b498973eeb00d0c5c42bbe12e48d77f1c68 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 15:37:57 -0700 Subject: [PATCH 31/92] feat(forms): validate track outcome fields --- backend/app/api/routes/forms.py | 4 + backend/app/core/form/__init__.py | 15 +-- backend/app/core/form/validation.py | 102 ++++++++++++++++++- backend/app/schemas/form.py | 29 ++++++ backend/form-question-types-reference.md | 5 +- backend/tests/api/tournament/test_tracks.py | 10 +- backend/tests/core/test_form_validation.py | 103 +++++++++++++++++++- 7 files changed, 254 insertions(+), 14 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index b1ba7ee3..fb80713a 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -26,6 +26,8 @@ validate_field_config, validate_form_for_publish, validate_reserved_field_key, + validate_tournament_preset, + validate_track_status_options, ) from app.core.form.write_through import parse_lunch_field_key, sync_availability, sync_lunch from app.core.tournament.form_prerequisites import member_meets_form_prerequisites @@ -588,8 +590,10 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> try: normalized = validate_field_config(question_type, config) validate_reserved_field_key(field_key, question_type) + validate_tournament_preset(field_key, form.tournament_id) if AVAILABILITY_FIELD_KEY_PATTERN.match(field_key): validate_availability_options(db, form.tournament_id, normalized) + validate_track_status_options(db, form.tournament_id, field_key, question_type, normalized) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) return normalized diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index fdbb0544..b3fc2a57 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -109,10 +109,10 @@ def field_key_taken_in_tournament(db: Session, tournament_id: int, field_key: st def track_referenced_by_form_field(db: Session, tournament_id: int, track_id: int) -> bool: """True when any field in the tournament is bound to ``track_id``. - The track preset added later stores the durable catalog ID in config; the - field-key check covers the reserved ``track_{id}`` shape as well. Archived - fields are intentionally included: they are historical form structure and - deleting the catalog entry would leave them unresolved. + Track outcomes store durable catalog IDs in each option's + ``track_statuses`` list. Archived fields are intentionally included: they + are historical form structure and deleting the catalog entry would leave + them unresolved. """ fields = ( db.query(FormField) @@ -120,9 +120,12 @@ def track_referenced_by_form_field(db: Session, tournament_id: int, track_id: in .filter(Form.tournament_id == tournament_id) .all() ) - track_key = f"track_{track_id}" return any( - field.field_key == track_key or (field.config or {}).get("track_id") == track_id + any( + assignment.get("track_id") == track_id + for option in (field.config or {}).get("options") or [] + for assignment in option.get("track_statuses") or [] + ) for field in fields ) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index e247e052..610c54c1 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -14,7 +14,7 @@ from pydantic import ValidationError from sqlalchemy.orm import Session -from app.models.models import Form, FormField, TournamentShift +from app.models.models import Form, FormField, TournamentShift, TournamentTrack from app.schemas.form import QUESTION_TYPE_CONFIG_SCHEMAS BRANCHING_QUESTION_TYPES = {"single_select_radio", "single_select_dropdown"} @@ -40,12 +40,26 @@ LUNCH_FIELD_KEY_PATTERN = re.compile(r"^lunch_(\d{8})_([a-z0-9_]+)$") LUNCH_QUESTION_TYPES = {"single_select_radio", "multi_select_checkbox"} +# track_status_{suffix}, e.g. "track_status_volunteer_interest". The suffix +# makes the question's stable field key independent of a catalog track, so +# one answer can affect several tracks. +TRACK_STATUS_FIELD_KEY_PATTERN = re.compile(r"^track_status_([a-z0-9_]+)$") +TRACK_STATUS_QUESTION_TYPES = {"single_select_radio", "multi_select_checkbox"} + # Reserved field_key patterns (lunch excluded — it's checked separately # since it also needs to strip the date/category before matching), each # paired with the question_types it may be combined with. RESERVED_FIELD_KEY_PATTERNS = ( (AVAILABILITY_FIELD_KEY_PATTERN, AVAILABILITY_QUESTION_TYPES), (EVENT_PREFERENCE_FIELD_KEY_PATTERN, EVENT_PREFERENCE_QUESTION_TYPES), + (TRACK_STATUS_FIELD_KEY_PATTERN, TRACK_STATUS_QUESTION_TYPES), +) + +TOURNAMENT_PRESET_FIELD_KEY_PATTERNS = ( + AVAILABILITY_FIELD_KEY_PATTERN, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, + LUNCH_FIELD_KEY_PATTERN, + TRACK_STATUS_FIELD_KEY_PATTERN, ) @@ -71,11 +85,22 @@ def validate_field_config(question_type: str, config: dict | None) -> dict: except ValidationError as e: raise FormFieldValidationError(str(e)) - return parsed.model_dump() + normalized = parsed.model_dump() + # These fields were added for track outcomes after forms already existed. + # Keep an omitted opt-in/mapping omitted instead of rewriting every + # unrelated form field the next time its form is saved. + source = config or {} + if "track_status_enabled" not in source: + normalized.pop("track_status_enabled", None) + for original, option in zip(source.get("options") or [], normalized.get("options") or []): + if "track_statuses" not in original: + option.pop("track_statuses", None) + return normalized def validate_reserved_field_key(field_key: str, question_type: str) -> None: - """Reserved field_keys (availability_*, event_preference_*, lunch_*) reuse + """Reserved field_keys (availability_*, event_preference_*, lunch_*, + track_status_*) reuse an existing structural question_type rather than introducing their own — reject a reserved key paired with a question_type it doesn't allow. A bare "availability"/"event_preference" (no date/suffix) is not a valid @@ -98,6 +123,73 @@ def validate_reserved_field_key(field_key: str, question_type: str) -> None: return +def validate_tournament_preset(field_key: str, tournament_id: int | None) -> None: + """Reserved presets are currently available only to tournament forms.""" + if any(pattern.match(field_key) for pattern in TOURNAMENT_PRESET_FIELD_KEY_PATTERNS): + _require(tournament_id is not None, f"field_key '{field_key}' requires a tournament-owned form") + + +def track_status_enabled(field_key: str, config: dict) -> bool: + """Whether this field is allowed to carry per-option track outcomes.""" + return bool(TRACK_STATUS_FIELD_KEY_PATTERN.match(field_key)) or ( + bool(AVAILABILITY_FIELD_KEY_PATTERN.match(field_key)) + and bool(config.get("track_status_enabled")) + ) + + +def _track_status_assignments(config: dict) -> list[dict]: + return [ + assignment + for option in config.get("options") or [] + for assignment in option.get("track_statuses") or [] + ] + + +def validate_track_status_options( + db: Session, + tournament_id: int | None, + field_key: str, + question_type: str, + config: dict, +) -> None: + """Validate option-level track outcomes for Track Status and opted-in + Availability fields. Track mappings are tournament-only and catalog IDs + remain valid after archival so historical fields can still be read.""" + assignments = _track_status_assignments(config) + enabled = track_status_enabled(field_key, config) + + _require( + enabled or (not assignments and not config.get("track_status_enabled")), + "track_statuses are only allowed on a track_status_* field or an opted-in availability field", + ) + if not enabled: + return + + _require(tournament_id is not None, "track status fields require a tournament-owned form") + if TRACK_STATUS_FIELD_KEY_PATTERN.match(field_key): + _require(config.get("required") is True, "track status fields must be required") + + track_ids = {assignment["track_id"] for assignment in assignments} + valid_ids = { + track_id + for (track_id,) in db.query(TournamentTrack.id) + .filter(TournamentTrack.tournament_id == tournament_id, TournamentTrack.id.in_(track_ids)) + .all() + } if track_ids else set() + missing_ids = track_ids - valid_ids + _require(not missing_ids, f"track id(s) do not belong to this tournament: {sorted(missing_ids)}") + + if question_type == "multi_select_checkbox": + statuses_by_track: dict[int, set[str]] = {} + for assignment in assignments: + statuses_by_track.setdefault(assignment["track_id"], set()).add(assignment["status"]) + conflicting = sorted(track_id for track_id, statuses in statuses_by_track.items() if len(statuses) > 1) + _require( + not conflicting, + f"checkbox options assign conflicting statuses for track id(s): {conflicting}", + ) + + def validate_branching_options( db: Session, form_id: str, @@ -174,6 +266,10 @@ def collect_active_field_errors(db: Session, form: Form) -> list[str]: for check in ( lambda: validate_reserved_field_key(field.field_key, field.question_type), + lambda: validate_tournament_preset(field.field_key, form.tournament_id), + lambda: validate_track_status_options( + db, form.tournament_id, field.field_key, field.question_type, normalized_config + ), lambda: validate_branching_options( db, form.id, field.question_type, normalized_config, field_id=field.id ), diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index fd8dd286..94ab7798 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -37,6 +37,21 @@ def _unique_option_fields(options: list) -> list: return options +class TrackStatusAssignment(BaseModel): + """One track outcome attached to a selectable option.""" + model_config = ConfigDict(extra="forbid") + + track_id: int = Field(gt=0) + status: Literal["interested", "confirmed", "declined"] + + +def _unique_track_statuses(assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: + track_ids = [assignment.track_id for assignment in assignments] + if len(track_ids) != len(set(track_ids)): + raise ValueError("duplicate track_id in track_statuses") + return assignments + + class PlainOption(BaseModel): """An option with no branching — multi_select_checkbox, ranked_choice. extra='forbid' rejects a stray next_field_id/action on these types. @@ -50,6 +65,12 @@ class PlainOption(BaseModel): value: str | list[int] = Field(min_length=1) label: str = Field(min_length=1) is_archived: bool = False + track_statuses: list[TrackStatusAssignment] = Field(default_factory=list) + + @field_validator("track_statuses") + @classmethod + def _unique_track_statuses(cls, assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: + return _unique_track_statuses(assignments) class BranchingOption(BaseModel): @@ -60,9 +81,15 @@ class BranchingOption(BaseModel): value: str | list[int] = Field(min_length=1) label: str = Field(min_length=1) is_archived: bool = False + track_statuses: list[TrackStatusAssignment] = Field(default_factory=list) next_field_id: str | None = None action: Literal["submit_form"] | None = None + @field_validator("track_statuses") + @classmethod + def _unique_track_statuses(cls, assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: + return _unique_track_statuses(assignments) + @model_validator(mode="after") def _mutually_exclusive(self): if self.next_field_id is not None and self.action is not None: @@ -84,6 +111,7 @@ class SingleSelectRadioConfig(BaseModel): # Dropdown has no equivalent (it's always a closed Dropdown control, not # a style choice), so this doesn't exist on that config. display_style: Literal["buttons", "list"] = "list" + track_status_enabled: bool = False options: list[BranchingOption] @field_validator("options") @@ -107,6 +135,7 @@ class MultiSelectCheckboxConfig(BaseModel): model_config = ConfigDict(extra="forbid") required: bool display_style: Literal["buttons", "list"] = "list" + track_status_enabled: bool = False options: list[PlainOption] @field_validator("options") diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index 58be800e..56f7948f 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -20,7 +20,7 @@ Every `FormField` shares the same outer shape: **`field_key` is required on every field, no exceptions.** The TD types a normal-language label for how they want the question to show up on their dashboard (e.g. "Test Writing Interest") and it's slugified into `field_key` (lowercase, alphanumeric + underscores, e.g. `test_writing_interest`) — this is what the TD sees when scanning/filtering responses later, not just an internal id. Must be unique **per tournament** — across every `Form` that tournament owns, not just within one form — so creating a field checks existing `field_key`s across all of that tournament's forms, including archived fields (an archived key isn't freed for reuse, to keep historical dashboard references unambiguous). -**Line between `question_type` and `field_key`:** `question_type` is purely structural — how the question is rendered and answered. `field_key` is semantic — when it's a reserved key (`availability_{date}`, `event_preference_{suffix}`, `lunch_{custom}`), it changes how a *structurally normal* field's options/answers get parsed and, for tournament forms, written through to a structural table. Reserved keys don't get their own `question_type` — they reuse the existing structural types and layer extra validation on top. When a TD picks a reserved-key preset/template, `field_key` should be locked to the reserved value rather than freely typed — otherwise a stray typo (`availibility`) silently breaks write-through with no error. Flagging this as the intended behavior, not yet confirmed. +**Line between `question_type` and `field_key`:** `question_type` is purely structural — how the question is rendered and answered. `field_key` is semantic — when it's a reserved key (`availability_{date}`, `event_preference_{suffix}`, `lunch_{custom}`, `track_status_{suffix}`), it changes how a *structurally normal* field's options/answers get parsed and, for tournament forms, written through to a structural table. Reserved keys don't get their own `question_type` — they reuse the existing structural types and layer extra validation on top. When a TD picks a reserved-key preset/template, `field_key` should be locked to the reserved value rather than freely typed — otherwise a stray typo (`availibility`) silently breaks write-through with no error. Flagging this as the intended behavior, not yet confirmed. A tournament may have **multiple** fields under the same reserved prefix — `availability_20260315`, `availability_20260316` for two dates, `event_preference_morning`, `event_preference_afternoon` for two independently-ranked axes. `availability_*` fields are the one case where multiple questions share a single pool of storage (see below) — every other reserved key, including `event_preference_*`, keeps each suffix's answers separate simply because each field has its own `field_id`/`FormAnswer` row; there's no merging step needed for that. @@ -143,9 +143,10 @@ Only `single_select_radio` and `single_select_dropdown` options may carry branch | `availability_{date}` — e.g. `availability_20260315` (`^availability_\d{8}$`), one per date; a bare `availability` (no date) is **not** a valid reserved key | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipAvailability` (tournament-owned forms only); selected option_id(s) across **every** active `availability_*` field on the response are expanded into their grouped `TournamentShift` ids, unioned, and diffed as one set — every date's question feeds the same centralized "shifts this member is available for" pool, not a per-date table | | `lunch_{date}_{category}` — e.g. `lunch_20270213_protein` (`^lunch_\d{8}_[a-z0-9_]+$`), one per (date, category) pair | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipLunch` (tournament-owned forms only); selected option_id(s) resolve to their stored `value`/`label`, no catalog table — stores whatever option was selected, keyed by category string | | `event_preference_{suffix}` — e.g. `event_preference_morning` (`^event_preference_[a-z0-9_]+$`), one per independently-ranked axis; a bare `event_preference` (no suffix) is **not** a valid reserved key | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer`, same as any custom question (option `value` may be `list[int]` of real `TournamentEvent` ids, resolved on render; not yet strictly validated against real events). Unlike `availability`, different suffixes are **not** merged into one pool — each suffix is read as its own axis by querying `FormAnswer` directly wherever event preferences are needed downstream, rather than being synced into a dedicated structural table. `TournamentMembership.event_preference` is an unrelated, already-deprecated manual-entry JSON column (along with `role_preference`, `availability`, `lunch_order`, `extra_data` on that model) — not read or written by this write-through. | +| `track_status_{suffix}` — e.g. `track_status_volunteer_interest` (`^track_status_[a-z0-9_]+$`), one per independently named status question | required `single_select_radio` or `multi_select_checkbox` | pending membership-track status write-through; each option carries `track_statuses: [{track_id, status}]`, where `status` is `interested`, `confirmed`, or `declined`. An `availability_*` field may carry the same option metadata only with `track_status_enabled: true`. Checkbox options may repeat a track only when they assign the same status. | | any TD-typed slug | any type | none — generic `FormAnswer` | -Reserved keys are valid on both tournament- and chapter-owned forms — the key itself doesn't require tournament ownership. Only the write-through step is tournament-only; on a chapter-owned form these fields behave exactly like a normal custom question. +Reserved keys are currently valid only on tournament-owned forms. `track_status_*` also requires tracks from that tournament's catalog. --- diff --git a/backend/tests/api/tournament/test_tracks.py b/backend/tests/api/tournament/test_tracks.py index 5f24e094..afd9c528 100644 --- a/backend/tests/api/tournament/test_tracks.py +++ b/backend/tests/api/tournament/test_tracks.py @@ -82,10 +82,16 @@ def test_unused_track_can_be_deleted_but_referenced_track_cannot(client, db, td_ db.add(FormField( form_id=form.id, order=1, - field_key=f"track_{referenced['id']}", + field_key="track_status_interest", label="Track status", question_type="single_select_radio", - config={"required": True, "options": [], "track_id": referenced["id"]}, + config={ + "required": True, + "options": [{ + "option_id": "interested", "value": "interested", "label": "Interested", + "track_statuses": [{"track_id": referenced["id"], "status": "interested"}], + }], + }, )) db.commit() diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py index 19825116..5104b798 100644 --- a/backend/tests/core/test_form_validation.py +++ b/backend/tests/core/test_form_validation.py @@ -16,8 +16,10 @@ validate_field_config, validate_form_for_publish, validate_reserved_field_key, + validate_track_status_options, + validate_tournament_preset, ) -from app.models.models import Form, FormField, TournamentShift +from app.models.models import Form, FormField, TournamentShift, TournamentTrack # --------------------------------------------------------------------------- @@ -230,6 +232,29 @@ def test_bare_event_preference_key_no_longer_reserved(self): def test_non_reserved_key_any_type_allowed(self): validate_reserved_field_key("favorite_color", "acknowledgment") # no raise + @pytest.mark.parametrize("question_type", ["single_select_radio", "multi_select_checkbox"]) + def test_track_status_allowed_types_pass(self, question_type): + validate_reserved_field_key("track_status_volunteer_interest", question_type) # no raise + + def test_track_status_disallowed_type_rejected(self): + with pytest.raises(FormFieldValidationError): + validate_reserved_field_key("track_status_volunteer_interest", "single_select_dropdown") + + +class TestValidateTournamentPreset: + @pytest.mark.parametrize("field_key", [ + "availability_20260315", + "event_preference_morning", + "lunch_20260315_protein", + "track_status_interest", + ]) + def test_presets_reject_chapter_owned_forms(self, field_key): + with pytest.raises(FormFieldValidationError, match="tournament-owned"): + validate_tournament_preset(field_key, None) + + def test_custom_field_is_valid_without_tournament(self): + validate_tournament_preset("favorite_color", None) + # --------------------------------------------------------------------------- # validate_branching_options @@ -316,6 +341,82 @@ def test_empty_list_value_rejected(self, db, td_user, td_tournament): validate_availability_options(db, td_tournament.id, config) +# --------------------------------------------------------------------------- +# validate_track_status_options +# --------------------------------------------------------------------------- + +class TestValidateTrackStatusOptions: + def _track(self, db, tournament): + track = TournamentTrack(tournament_id=tournament.id, name="Test Writing") + db.add(track) + db.flush() + return track + + def _config(self, track_id, **overrides): + config = { + "required": True, + "options": [{ + "option_id": "yes", "value": "yes", "label": "Yes", + "track_statuses": [{"track_id": track_id, "status": "interested"}], + }], + } + config.update(overrides) + return config + + def test_track_status_accepts_tournament_track(self, db, td_user, td_tournament): + track = self._track(db, td_tournament) + config = self._config(track.id) + validate_track_status_options(db, td_tournament.id, "track_status_interest", "single_select_radio", config) + + def test_track_status_requires_required_question(self, db, td_user, td_tournament): + track = self._track(db, td_tournament) + with pytest.raises(FormFieldValidationError, match="must be required"): + validate_track_status_options( + db, td_tournament.id, "track_status_interest", "single_select_radio", + self._config(track.id, required=False), + ) + + def test_option_track_statuses_require_explicit_known_status(self): + with pytest.raises(FormFieldValidationError): + validate_field_config( + "single_select_radio", + { + "required": True, + "options": [{ + "option_id": "opt_1", "value": "a", "label": "A", + "track_statuses": [{"track_id": 1, "status": "maybe"}], + }], + }, + ) + + def test_track_status_rejects_foreign_track(self, db, td_user, td_tournament, other_tournament): + foreign = self._track(db, other_tournament) + with pytest.raises(FormFieldValidationError, match="do not belong"): + validate_track_status_options( + db, td_tournament.id, "track_status_interest", "single_select_radio", self._config(foreign.id) + ) + + def test_availability_requires_opt_in_for_track_outcomes(self, db, td_user, td_tournament): + track = self._track(db, td_tournament) + config = self._config(track.id) + with pytest.raises(FormFieldValidationError, match="only allowed"): + validate_track_status_options( + db, td_tournament.id, "availability_20260315", "single_select_radio", config + ) + + config["track_status_enabled"] = True + validate_track_status_options(db, td_tournament.id, "availability_20260315", "single_select_radio", config) + + def test_checkbox_rejects_conflicting_track_statuses(self, db, td_user, td_tournament): + track = self._track(db, td_tournament) + config = self._config(track.id, options=[ + {"option_id": "one", "value": "one", "label": "One", "track_statuses": [{"track_id": track.id, "status": "interested"}]}, + {"option_id": "two", "value": "two", "label": "Two", "track_statuses": [{"track_id": track.id, "status": "declined"}]}, + ]) + with pytest.raises(FormFieldValidationError, match="conflicting statuses"): + validate_track_status_options(db, td_tournament.id, "track_status_interest", "multi_select_checkbox", config) + + # --------------------------------------------------------------------------- # validate_form_for_publish — aggregate pass # --------------------------------------------------------------------------- From 2e456ccca71d84c0bfb26ee259d3c0b074117542 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 16:23:41 -0700 Subject: [PATCH 32/92] refactor(forms): store track outcomes in option values --- backend/app/core/form/__init__.py | 4 ++-- backend/app/core/form/validation.py | 20 ++++++++++---------- backend/app/schemas/form.py | 19 ++++--------------- 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index b3fc2a57..b589ede6 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -122,9 +122,9 @@ def track_referenced_by_form_field(db: Session, tournament_id: int, track_id: in ) return any( any( - assignment.get("track_id") == track_id + assignment.get("id") == track_id for option in (field.config or {}).get("options") or [] - for assignment in option.get("track_statuses") or [] + for assignment in (option.get("value") if isinstance(option.get("value"), list) else (option.get("value") or {}).get("track_statuses", [])) ) for field in fields ) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 610c54c1..b58387e4 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -92,9 +92,6 @@ def validate_field_config(question_type: str, config: dict | None) -> dict: source = config or {} if "track_status_enabled" not in source: normalized.pop("track_status_enabled", None) - for original, option in zip(source.get("options") or [], normalized.get("options") or []): - if "track_statuses" not in original: - option.pop("track_statuses", None) return normalized @@ -138,11 +135,14 @@ def track_status_enabled(field_key: str, config: dict) -> bool: def _track_status_assignments(config: dict) -> list[dict]: - return [ - assignment - for option in config.get("options") or [] - for assignment in option.get("track_statuses") or [] - ] + assignments = [] + for option in config.get("options") or []: + value = option.get("value") + if isinstance(value, list): + assignments.extend(value) + elif isinstance(value, dict): + assignments.extend(value.get("track_statuses") or []) + return assignments def validate_track_status_options( @@ -169,7 +169,7 @@ def validate_track_status_options( if TRACK_STATUS_FIELD_KEY_PATTERN.match(field_key): _require(config.get("required") is True, "track status fields must be required") - track_ids = {assignment["track_id"] for assignment in assignments} + track_ids = {assignment["id"] for assignment in assignments} valid_ids = { track_id for (track_id,) in db.query(TournamentTrack.id) @@ -182,7 +182,7 @@ def validate_track_status_options( if question_type == "multi_select_checkbox": statuses_by_track: dict[int, set[str]] = {} for assignment in assignments: - statuses_by_track.setdefault(assignment["track_id"], set()).add(assignment["status"]) + statuses_by_track.setdefault(assignment["id"], set()).add(assignment["status"]) conflicting = sorted(track_id for track_id, statuses in statuses_by_track.items() if len(statuses) > 1) _require( not conflicting, diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 94ab7798..8392c98d 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -41,12 +41,12 @@ class TrackStatusAssignment(BaseModel): """One track outcome attached to a selectable option.""" model_config = ConfigDict(extra="forbid") - track_id: int = Field(gt=0) + id: int = Field(gt=0) status: Literal["interested", "confirmed", "declined"] def _unique_track_statuses(assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: - track_ids = [assignment.track_id for assignment in assignments] + track_ids = [assignment.id for assignment in assignments] if len(track_ids) != len(set(track_ids)): raise ValueError("duplicate track_id in track_statuses") return assignments @@ -62,15 +62,9 @@ class PlainOption(BaseModel): expect based on the field's field_key.""" model_config = ConfigDict(extra="forbid") option_id: str = Field(min_length=1) - value: str | list[int] = Field(min_length=1) + value: str | list[int] | list[TrackStatusAssignment] | dict = Field(min_length=1) label: str = Field(min_length=1) is_archived: bool = False - track_statuses: list[TrackStatusAssignment] = Field(default_factory=list) - - @field_validator("track_statuses") - @classmethod - def _unique_track_statuses(cls, assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: - return _unique_track_statuses(assignments) class BranchingOption(BaseModel): @@ -78,17 +72,12 @@ class BranchingOption(BaseModel): See PlainOption for value's dual str/list[int] shape.""" model_config = ConfigDict(extra="forbid") option_id: str = Field(min_length=1) - value: str | list[int] = Field(min_length=1) + value: str | list[int] | list[TrackStatusAssignment] | dict = Field(min_length=1) label: str = Field(min_length=1) is_archived: bool = False - track_statuses: list[TrackStatusAssignment] = Field(default_factory=list) next_field_id: str | None = None action: Literal["submit_form"] | None = None - @field_validator("track_statuses") - @classmethod - def _unique_track_statuses(cls, assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: - return _unique_track_statuses(assignments) @model_validator(mode="after") def _mutually_exclusive(self): From eaaeb398793dfa092268cae24d705f9358b100b4 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 16:30:29 -0700 Subject: [PATCH 33/92] feat(forms): inject track name on standard form GET --- backend/app/core/form/__init__.py | 63 +++++++++++++++++++++++++++---- backend/tests/core/test_forms.py | 61 ++++++++++++++++++++++++++++++ frontend/lib/api.ts | 12 +++++- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index b589ede6..44aec8bd 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,6 +1,18 @@ from sqlalchemy.orm import Session -from app.core.form.validation import AVAILABILITY_FIELD_KEY_PATTERN, EVENT_PREFERENCE_FIELD_KEY_PATTERN -from app.models.models import Form, FormAnswer, FormField, FormResponsePendingUpdate, TournamentEvent, TournamentShift +from app.core.form.validation import ( + AVAILABILITY_FIELD_KEY_PATTERN, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, + TRACK_STATUS_FIELD_KEY_PATTERN, +) +from app.models.models import ( + Form, + FormAnswer, + FormField, + FormResponsePendingUpdate, + TournamentEvent, + TournamentShift, + TournamentTrack, +) import re import secrets @@ -232,6 +244,27 @@ def flag_pending_updates_for_archived_options(db: Session, field: FormField, arc _upsert_pending_update(db, answer.response_id, field.field_key, "option_archived") +def _resolve_track_statuses(db: Session, assignments: list[dict]) -> list[dict]: + """Hydrate stored track ids into responder-facing track names. + + Track ids remain the durable builder representation; the normal form + read includes names so a renderer never needs to make a separate catalog + request. Preserve the configured mapping order rather than database order. + """ + track_ids = [assignment.get("id") for assignment in assignments if isinstance(assignment, dict)] + names_by_id = { + track_id: name + for track_id, name in db.query(TournamentTrack.id, TournamentTrack.name) + .filter(TournamentTrack.id.in_(track_ids)) + .all() + } if track_ids else {} + return [ + {"id": assignment["id"], "name": names_by_id[assignment["id"]], "status": assignment["status"]} + for assignment in assignments + if isinstance(assignment, dict) and assignment.get("id") in names_by_id + ] + + def _resolve_availability_option(db: Session, option: dict) -> dict: """Responder-facing view of one availability option: `value` (normally the raw list[int] of grouped TournamentShift ids) is resolved in place @@ -242,20 +275,27 @@ def _resolve_availability_option(db: Session, option: dict) -> dict: `option_id` is what's actually submitted back on answer (see write-through, which resolves it server-side against the field's stored config, not this rendering).""" - shift_ids = option.get("value") or [] + raw_value = option.get("value") or [] + has_track_statuses = isinstance(raw_value, dict) + shift_ids = (raw_value.get("shift_ids") or []) if has_track_statuses else raw_value rows = ( db.query(TournamentShift.id, TournamentShift.label, TournamentShift.start, TournamentShift.end) .filter(TournamentShift.id.in_(shift_ids)) .order_by(TournamentShift.start) .all() ) + resolved_shifts = [ + {"id": shift_id, "label": label, "start": start, "end": end} + for shift_id, label, start, end in rows + ] + resolved_value = { + "shifts": resolved_shifts, + "track_statuses": _resolve_track_statuses(db, raw_value.get("track_statuses") or []), + } if has_track_statuses else resolved_shifts return { "option_id": option["option_id"], "label": option["label"], - "value": [ - {"id": shift_id, "label": label, "start": start, "end": end} - for shift_id, label, start, end in rows - ], + "value": resolved_value, } @@ -309,5 +349,14 @@ def resolve_field_options(db: Session, field: FormField) -> list[dict]: return [_resolve_availability_option(db, o) for o in options] if EVENT_PREFERENCE_FIELD_KEY_PATTERN.match(field.field_key): return [_resolve_event_preference_option(db, o) for o in options] + if TRACK_STATUS_FIELD_KEY_PATTERN.match(field.field_key): + return [ + { + "option_id": option["option_id"], + "label": option["label"], + "value": _resolve_track_statuses(db, option.get("value") or []), + } + for option in options + ] return options diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index a6ee5b9d..369e1f55 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -273,6 +273,35 @@ def test_archived_option_excluded(self, db, td_user, td_tournament): assert resolve_field_options(db, field) == [] + def test_track_outcomes_resolve_alongside_grouped_shifts(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + shift = _make_shift( + db, td_tournament, "Saturday", + datetime(2027, 2, 13, 7, 0, tzinfo=timezone.utc), datetime(2027, 2, 13, 16, 0, tzinfo=timezone.utc), + ) + from app.models.models import TournamentTrack + track = TournamentTrack(tournament_id=td_tournament.id, name="Day 1") + db.add(track) + db.flush() + field = _make_field( + db, form, field_key="availability_20260315", question_type="single_select_radio", + config={"options": [{ + "option_id": "opt_1", + "value": {"shift_ids": [shift.id], "track_statuses": [{"id": track.id, "status": "interested"}]}, + "label": "Saturday", + "is_archived": False, + }]}, + ) + db.commit() + + assert resolve_field_options(db, field) == [{ + "option_id": "opt_1", "label": "Saturday", + "value": { + "shifts": [{"id": shift.id, "label": "Saturday", "start": shift.start, "end": shift.end}], + "track_statuses": [{"id": track.id, "name": "Day 1", "status": "interested"}], + }, + }] + def test_non_availability_field_returns_raw_options(self, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) field = _make_field(db, form) # default config, field_key="favorite_color" @@ -286,6 +315,38 @@ def test_non_availability_field_returns_raw_options(self, db, td_user, td_tourna # resolve_field_options — event_preference resolved entities # --------------------------------------------------------------------------- +class TestResolveTrackStatusOptions: + def test_track_statuses_resolve_to_id_name_and_status(self, db, td_user, td_tournament): + from app.models.models import TournamentTrack + + form = _make_form(db, td_user, td_tournament) + day_one = TournamentTrack(tournament_id=td_tournament.id, name="Day 1") + test_writing = TournamentTrack(tournament_id=td_tournament.id, name="Test Writing") + db.add_all([day_one, test_writing]) + db.flush() + field = _make_field( + db, form, field_key="track_status_interest", question_type="single_select_radio", + config={"options": [{ + "option_id": "opt_1", + "value": [ + {"id": test_writing.id, "status": "confirmed"}, + {"id": day_one.id, "status": "interested"}, + ], + "label": "Yes", + "is_archived": False, + }]}, + ) + db.commit() + + assert resolve_field_options(db, field) == [{ + "option_id": "opt_1", "label": "Yes", + "value": [ + {"id": test_writing.id, "name": "Test Writing", "status": "confirmed"}, + {"id": day_one.id, "name": "Day 1", "status": "interested"}, + ], + }] + + class TestResolveEventPreferenceOptions: def test_grouped_events_resolve_to_id_name_and_division(self, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 1130ec49..21721ec9 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1164,7 +1164,7 @@ export type FormOwnerType = 'tournament' | 'chapter' // backend/app/core/form/__init__.py. export interface FormFieldOption { option_id: string - value: string | number[] | ResolvedShiftOption[] | ResolvedEventOption[] + value: string | number[] | TrackStatusAssignment[] | AvailabilityTrackStatusValue | ResolvedTrackStatusAssignment[] | ResolvedShiftOption[] | ResolvedEventOption[] label: string is_archived?: boolean // single_select_radio/dropdown only — mutually exclusive with each other. @@ -1172,6 +1172,15 @@ export interface FormFieldOption { action?: 'submit_form' | null } +export type TrackStatus = "interested" | "confirmed" | "declined" +export interface TrackStatusAssignment { id: number; status: TrackStatus } +export interface ResolvedTrackStatusAssignment extends TrackStatusAssignment { name: string } +export interface AvailabilityTrackStatusValue { + shift_ids?: number[] + shifts?: ResolvedShiftOption[] + track_statuses: TrackStatusAssignment[] | ResolvedTrackStatusAssignment[] +} + // value shape after GET-time resolution for availability/event_preference — // one entry per grouped entity, kept separate rather than collapsed. export interface ResolvedShiftOption { @@ -1199,6 +1208,7 @@ export interface FormFieldConfig { // plain radio/checkbox list. single_select_dropdown has no equivalent // (always a closed Dropdown control, not a style choice). display_style?: "buttons" | "list" + track_status_enabled?: boolean } export interface FormField { From d98cbef8aa2803bd30e46d927a4c20837f5d80a1 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 16:39:57 -0700 Subject: [PATCH 34/92] feat(forms): add track outcome controls to the form editor --- .../components/forms/EntityOptionsEditor.tsx | 294 +++++++++--------- frontend/components/forms/FieldCard.tsx | 1 + frontend/components/forms/FieldList.tsx | 1 + frontend/components/forms/FieldToolbar.tsx | 7 +- frontend/components/forms/PresetPopover.tsx | 46 ++- .../components/forms/QuestionRenderer.tsx | 10 +- frontend/components/ui/ChipInput.tsx | 4 + frontend/lib/forms/fieldKeyPresets.ts | 20 +- frontend/lib/forms/fieldTypes.ts | 4 +- frontend/lib/forms/useFormValidation.tsx | 15 +- 10 files changed, 239 insertions(+), 163 deletions(-) diff --git a/frontend/components/forms/EntityOptionsEditor.tsx b/frontend/components/forms/EntityOptionsEditor.tsx index 00264ffd..b9ec4f43 100644 --- a/frontend/components/forms/EntityOptionsEditor.tsx +++ b/frontend/components/forms/EntityOptionsEditor.tsx @@ -2,75 +2,62 @@ import { useEffect, useMemo, useState } from 'react' import { - tournamentShiftsApi, tournamentEventsApi, TournamentShift, TournamentEvent, Tournament, FormQuestionType, ApiError, + tournamentShiftsApi, tournamentEventsApi, tournamentTracksApi, TournamentShift, + TournamentEvent, TournamentTrack, Tournament, FormQuestionType, ApiError, + TrackStatus, TrackStatusAssignment, } from '@/lib/api' import { eventNameWithDivision } from '@/lib/eventDisplay' import { formatDayLabel, formatTime, toDateInput } from '@/lib/timeFormat' import { Button } from '@/components/ui/Button' import { ChipInput } from '@/components/ui/ChipInput' +import { Dropdown } from '@/components/ui/Dropdown' import { Popover } from '@/components/ui/Popover' import { IconPlus, IconSearch } from '@/components/ui/Icons' import { BranchTarget, EditableOption, newEntityOption, OptionsEditor } from '@/components/forms/OptionsEditor' import { EventOptionsPickerModal } from '@/components/forms/EventOptionsPickerModal' -type EntityFieldKey = 'availability' | 'event_preference' +type EntityFieldKey = 'availability' | 'event_preference' | 'track_status' type Entity = TournamentShift | TournamentEvent interface EntityOptionsEditorProps { fieldKey: EntityFieldKey - /** id sources shifts/events; is_multi_day decides whether availability's - shift chips/picker show a day alongside the label (see entityLabel/ - entityTooltip) or fall back to the original label+time-range display — - a single-day tournament has no day worth disambiguating. */ tournament: Tournament questionType: FormQuestionType options: EditableOption[] onChange: (options: EditableOption[]) => void - /** single_select_radio/multi_select_checkbox only — same ButtonGroup-style - row toggle OptionsEditor offers for freeform options. */ displayStyle?: 'list' | 'buttons' - /** single_select_radio/single_select_dropdown only — same per-option - "where does this lead" dropdown OptionsEditor offers for freeform - options; entity-backed options are still real, addressable rows, so - there's no reason branching should be freeform-only. */ branchTargets?: BranchTarget[] - /** Forwarded straight to OptionsEditor — see its own doc. */ errors?: string[] + trackStatusEnabled?: boolean } -// On a multi-day tournament, a shift's label + short day/date is enough to -// tell same-named shifts on different days apart (the ambiguous case) — the -// exact time range is secondary once you've picked one, so it's dropped from -// the visible text and surfaces via entityTooltip instead, on both the chip -// and the picker row, rather than crowding every shift with a full time -// range it usually doesn't need. A single-day tournament has no day worth -// disambiguating, so this falls back to the original label+time-range -// display with no tooltip needed. Events have no day/time of their own here -// (event_preference options aren't day-scoped), so they're unaffected either way. -function entityLabel(fieldKey: EntityFieldKey, entity: Entity, isMultiDay: boolean): string { +const STATUS_OPTIONS = [ + { value: '', label: 'Choose status' }, + { value: 'interested', label: 'Interested' }, + { value: 'confirmed', label: 'Confirmed' }, + { value: 'declined', label: 'Declined' }, +] + +function entityLabel(fieldKey: Exclude, entity: Entity, isMultiDay: boolean): string { if (fieldKey === 'availability') { const shift = entity as TournamentShift return isMultiDay ? `${shift.label} (${formatDayLabel(toDateInput(shift.start))})` - : `${shift.label} (${formatTime(shift.start)}–${formatTime(shift.end)})` + : `${shift.label} (${formatTime(shift.start)}-${formatTime(shift.end)})` } return eventNameWithDivision(entity as TournamentEvent) } -function entityTooltip(fieldKey: EntityFieldKey, entity: Entity, isMultiDay: boolean): string | undefined { +function entityTooltip(fieldKey: Exclude, entity: Entity, isMultiDay: boolean): string | undefined { if (fieldKey !== 'availability' || !isMultiDay) return undefined const shift = entity as TournamentShift - return `${formatTime(shift.start)}–${formatTime(shift.end)}` + return `${formatTime(shift.start)}-${formatTime(shift.end)}` } -// The picker row gets everything inline instead of a tooltip — there's -// plenty of horizontal room in a 280px-wide panel, unlike the chip's own -// tight footprint. Always shows the time range; the day only joins it on a -// multi-day tournament, same disambiguation rule as entityLabel/entityTooltip. -function entityPickerLabel(fieldKey: EntityFieldKey, entity: Entity, isMultiDay: boolean): string { +function entityPickerLabel(fieldKey: Exclude, entity: Entity, isMultiDay: boolean): string { if (fieldKey === 'availability') { const shift = entity as TournamentShift - const time = `${formatTime(shift.start)}–${formatTime(shift.end)}` + const time = `${formatTime(shift.start)}-${formatTime(shift.end)}` return isMultiDay ? `${shift.label} (${formatDayLabel(toDateInput(shift.start))}, ${time})` : `${shift.label} (${time})` @@ -78,169 +65,174 @@ function entityPickerLabel(fieldKey: EntityFieldKey, entity: Entity, isMultiDay: return eventNameWithDivision(entity as TournamentEvent) } -// availability/event_preference variant of OptionsEditor — each option -// groups one or more real tournament entities (TournamentShift or -// TournamentEvent) under a single TD-labeled choice (e.g. "All Day" -> -// [shift 1, shift 2]), stored raw as option.value: number[], rather than -// freeform text. Built directly on OptionsEditor's row shell (grip/bullet/ -// label/delete/DnD/displayStyle/branch dropdown) via renderExtra, rather -// than a parallel implementation — the only thing actually different here is -// an *additional* block below the row: a Badge list + Popover checklist -// (reusing the same pattern EventPanel uses for shift attach/detach) sitting -// alongside whatever OptionsEditor already renders for that row. -export function EntityOptionsEditor({ fieldKey, tournament, questionType, options, onChange, displayStyle, branchTargets, errors }: EntityOptionsEditorProps) { - const [entities, setEntities] = useState(null) +function assignmentsFor(option: EditableOption, availability: boolean): TrackStatusAssignment[] { + if (availability) { + return typeof option.value === 'object' && !Array.isArray(option.value) + ? option.value.track_statuses as TrackStatusAssignment[] + : [] + } + return Array.isArray(option.value) ? option.value as TrackStatusAssignment[] : [] +} + +function shiftIdsFor(option: EditableOption): number[] { + if (typeof option.value === 'object' && !Array.isArray(option.value)) { + return option.value.shift_ids ?? [] + } + return Array.isArray(option.value) ? option.value as number[] : [] +} + +// Shared options editor for entity-backed presets and Track Status. The only +// difference is whether the row also has a shift/event picker; track outcome +// chips live here for both Track Status and opted-in Availability fields. +export function EntityOptionsEditor({ fieldKey, tournament, questionType, options, onChange, displayStyle, branchTargets, errors, trackStatusEnabled = false }: EntityOptionsEditorProps) { + const isEntity = fieldKey !== 'track_status' + const hasTrackOutcomes = fieldKey === 'track_status' || trackStatusEnabled + const [entities, setEntities] = useState(isEntity ? null : []) const [loadError, setLoadError] = useState(null) - // event_preference-only bulk entry point (see EventOptionsPickerModal) — - // the per-row picker above stays as the way to fine-tune one option - // afterward, this is just a faster way to create several at once. const [showPicker, setShowPicker] = useState(false) const existingEventIds = useMemo( - () => new Set(options.flatMap((o) => (Array.isArray(o.value) ? (o.value as number[]) : []))), - [options] + () => new Set(options.flatMap((option) => fieldKey === 'event_preference' ? shiftIdsFor(option) : [])), + [fieldKey, options], ) useEffect(() => { - setEntities(null); - setLoadError(null); + if (!isEntity) return const list = fieldKey === 'availability' ? tournamentShiftsApi.list(tournament.id) : tournamentEventsApi.list(tournament.id) list .then(setEntities) - .catch((e) => setLoadError(e instanceof ApiError ? e.message : `Failed to load ${fieldKey === 'availability' ? 'shifts' : 'events'}.`)) - }, [fieldKey, tournament.id]) + .catch((error) => setLoadError(error instanceof ApiError ? error.message : `Failed to load ${fieldKey === 'availability' ? 'shifts' : 'events'}.`)) + }, [fieldKey, isEntity, tournament.id]) function toggleEntity(clientKey: string, entityId: number) { - onChange(options.map((o) => { - if (o.clientKey !== clientKey) return o - const ids = Array.isArray(o.value) ? (o.value as number[]) : [] - const next = ids.includes(entityId) ? ids.filter((id) => id !== entityId) : [...ids, entityId] - return { ...o, value: next } + onChange(options.map((option) => { + if (option.clientKey !== clientKey) return option + const ids = shiftIdsFor(option) + const nextIds = ids.includes(entityId) ? ids.filter((id) => id !== entityId) : [...ids, entityId] + if (fieldKey === 'availability' && trackStatusEnabled) { + return { + ...option, + value: { shift_ids: nextIds, track_statuses: assignmentsFor(option, true) }, + } + } + return { ...option, value: nextIds } })) } - // The option rows don't depend on this fetch — their labels live on the - // field itself. Only the chips and the picker's checklist need the entity - // list, so the editor renders immediately and those fill in when it lands; - // blocking the whole body on it made the rows unclickable (and unfocusable - // — see FieldCard's FocusIntent) for as long as the request took. const loading = entities === null const loaded = entities ?? [] const noun = fieldKey === 'availability' ? 'shifts' : 'events' - const emptyMessage = loading - ? `Loading ${noun}…` + ? `Loading ${noun}...` : fieldKey === 'availability' - ? 'No shifts on this tournament yet — add some under Events > Shifts.' - : 'No events on this tournament yet — add some under Events.' + ? 'No shifts on this tournament yet - add some under Events > Shifts.' + : 'No events on this tournament yet - add some under Events.' return ( <> - {loadError && ( -

- {loadError} -

- )} + {loadError &&

{loadError}

} ( - toggleEntity(option.clientKey, id)} - /> + <> + {isEntity && } + isMultiDay={tournament.is_multi_day} + emptyMessage={emptyMessage} + onToggle={(id) => toggleEntity(option.clientKey, id)} + />} + {hasTrackOutcomes && onChange(options.map((item) => item.clientKey === next.clientKey ? next : item))} + />} + )} /> - {fieldKey === 'event_preference' && loaded.length > 0 && ( - - )} - {showPicker && ( - setShowPicker(false)} - onConfirm={(newOptions) => { - // A brand-new field starts with one placeholder option (empty - // label, no entities picked yet) — bulk-adding real options from - // the modal should replace that placeholder, not sit next to it - // as an extra unlabeled option the TD has to notice and delete. - const [first, ...rest] = options - const firstIsEmptyPlaceholder = first && !first.label.trim() && (!Array.isArray(first.value) || first.value.length === 0) - const base = firstIsEmptyPlaceholder ? rest : options - onChange([...base, ...newOptions]) - }} - /> - )} + {fieldKey === 'event_preference' && loaded.length > 0 && } + {showPicker && setShowPicker(false)} + onConfirm={(newOptions) => { + const [first, ...rest] = options + const firstIsEmptyPlaceholder = first && !first.label.trim() && shiftIdsFor(first).length === 0 + onChange([...(firstIsEmptyPlaceholder ? rest : options), ...newOptions]) + }} + />} ) } -function EntityPicker({ option, entities, fieldKey, isMultiDay, emptyMessage, onToggle }: { - option: EditableOption +function EntityPicker({ selectedIds, entities, fieldKey, isMultiDay, emptyMessage, onToggle }: { + selectedIds: number[] entities: Entity[] - fieldKey: EntityFieldKey + fieldKey: Exclude isMultiDay: boolean emptyMessage: string onToggle: (id: number) => void }) { - const selectedIds = Array.isArray(option.value) ? (option.value as number[]) : [] - const selectedEntities = entities.filter((e) => selectedIds.includes(e.id)) - - // disableInput: chips here only ever come from the Popover checklist (the - // addButton), never typed/pasted — ChipInput's own "x" is still live - // though, so removing a chip needs mapping back to the entity it came from - // rather than a free-text diff. Only one chip is ever removed per click - // (typing is disabled), so the first entity missing from the new chip list - // is unambiguously the one that was removed. + const selectedEntities = entities.filter((entity) => selectedIds.includes(entity.id)) function handleChipsChange(chips: string[]) { - const removed = selectedEntities.find((e) => !chips.includes(entityLabel(fieldKey, e, isMultiDay))) + const removed = selectedEntities.find((entity) => !chips.includes(entityLabel(fieldKey, entity, isMultiDay))) if (removed) onToggle(removed.id) } + return entityLabel(fieldKey, entity, isMultiDay))} + onChange={handleChipsChange} + disableInput variant="transparent" size="sm" fullWidth + getChipTooltip={(chip) => { + const entity = selectedEntities.find((item) => entityLabel(fieldKey, item, isMultiDay) === chip) + return entity ? entityTooltip(fieldKey, entity, isMultiDay) : undefined + }} + addButton={ {fieldKey === 'availability' ? 'Shifts' : 'Events'}} items={entities} getKey={(entity) => entity.id} renderLabel={(entity) => entityPickerLabel(fieldKey, entity, isMultiDay)} onSelect={(entity) => onToggle(entity.id)} checklist isSelected={(entity) => selectedIds.includes(entity.id)} emptyMessage={emptyMessage} width={400} />} + /> +} - return ( - entityLabel(fieldKey, e, isMultiDay))} - onChange={handleChipsChange} - disableInput - variant="transparent" - size="sm" - fullWidth - getChipTooltip={(chip) => { - const entity = selectedEntities.find((e) => entityLabel(fieldKey, e, isMultiDay) === chip) - return entity ? entityTooltip(fieldKey, entity, isMultiDay) : undefined - }} - addButton={ - - {fieldKey === 'availability' ? 'Shifts' : 'Events'} - - } - items={entities} - getKey={(e) => e.id} - renderLabel={(e) => entityPickerLabel(fieldKey, e, isMultiDay)} - onSelect={(e) => onToggle(e.id)} - checklist - isSelected={(e) => selectedIds.includes(e.id)} - emptyMessage={emptyMessage} - width={400} - /> - } - /> - ) +function TrackOutcomePicker({ tournament, option, availability, onChange }: { tournament: Tournament; option: EditableOption; availability: boolean; onChange: (option: EditableOption) => void }) { + const [tracks, setTracks] = useState([]) + useEffect(() => { tournamentTracksApi.list(tournament.id).then(setTracks).catch(() => {}) }, [tournament.id]) + const assignments = assignmentsFor(option, availability) + const selected = tracks.filter((track) => assignments.some((assignment) => assignment.id === track.id)) + const byName = new Map(selected.map((track) => [track.name, track])) + + function replaceAssignments(nextAssignments: TrackStatusAssignment[]) { + onChange({ + ...option, + value: availability + ? { shift_ids: shiftIdsFor(option), track_statuses: nextAssignments } + : nextAssignments, + }) + } + function toggle(track: TournamentTrack) { + replaceAssignments(assignments.some((item) => item.id === track.id) + ? assignments.filter((item) => item.id !== track.id) + : [...assignments, { id: track.id, status: '' as TrackStatus }]) + } + function setStatus(trackId: number, status: string) { + replaceAssignments(assignments.map((item) => item.id === trackId ? { ...item, status: status as TrackStatus } : item)) + } + + return track.name)} + onChange={(names) => selected.filter((track) => !names.includes(track.name)).forEach(toggle)} + disableInput variant="transparent" size="sm" fullWidth + getChipStatus={(name) => assignments.find((item) => item.id === byName.get(name)?.id)?.status ? 'default' : 'error'} + renderChipTrailing={(name) => { + const track = byName.get(name) + const assignment = assignments.find((item) => item.id === track?.id) + return track && assignment ? setStatus(track.id, status)} options={STATUS_OPTIONS} size="sm" width={132} /> : null + }} + addButton={ Tracks} items={tracks.filter((track) => !track.is_archived)} getKey={(track) => track.id} renderLabel={(track) => track.name} onSelect={toggle} checklist isSelected={(track) => assignments.some((item) => item.id === track.id)} emptyMessage="No active tracks." width={300} />} + /> } diff --git a/frontend/components/forms/FieldCard.tsx b/frontend/components/forms/FieldCard.tsx index 59cc3f3a..2ea41bcd 100644 --- a/frontend/components/forms/FieldCard.tsx +++ b/frontend/components/forms/FieldCard.tsx @@ -327,6 +327,7 @@ export function FieldCard({ onFieldChange({ config: { ...field.config, required: checked } })} + locked={presetKind === "track_status"} />
diff --git a/frontend/components/forms/FieldList.tsx b/frontend/components/forms/FieldList.tsx index 8735574c..3a690024 100644 --- a/frontend/components/forms/FieldList.tsx +++ b/frontend/components/forms/FieldList.tsx @@ -512,6 +512,7 @@ export function FieldList({ form }: { form: Form }) { usedFieldKeys={usedFieldKeys} allFields={fields} tournamentDates={tournamentDates} + presetsEnabled={form.tournament_id != null} onOpenPresets={loadTournament} errors={validation.errorsFor(expandedField.clientKey)} saveAttempt={saveAttempt} diff --git a/frontend/components/forms/FieldToolbar.tsx b/frontend/components/forms/FieldToolbar.tsx index b68de6d2..e9ef8fb1 100644 --- a/frontend/components/forms/FieldToolbar.tsx +++ b/frontend/components/forms/FieldToolbar.tsx @@ -21,7 +21,7 @@ type ActivePopover = "key" | "preset" | null; // boxRef — imperatively, since re-rendering on every observed resize frame // would be waste. export function FieldToolbar({ - boxRef, field, onFieldChange, usedFieldKeys, allFields, errors, saveAttempt, tournamentDates, onOpenPresets, + boxRef, field, onFieldChange, usedFieldKeys, allFields, errors, saveAttempt, tournamentDates, onOpenPresets, presetsEnabled, showDescription, onAddFieldBelow, onToggleDescription, displayStyle, onToggleDisplayStyle, }: { boxRef: React.RefObject; @@ -36,6 +36,7 @@ export function FieldToolbar({ tournamentDates: string[]; /** Fires when the presets panel opens — see PresetPopover's onOpen. */ onOpenPresets?: () => void; + presetsEnabled: boolean; showDescription: boolean; onAddFieldBelow: () => void; onToggleDescription: () => void; @@ -104,7 +105,7 @@ export function FieldToolbar({ open={activePopover === "key"} onOpenChange={(open) => setActivePopover(open ? "key" : null)} /> - setActivePopover(open ? "preset" : null)} - /> + />} {displayStyle && (
{presetKind === "availability" && ( - + <> + +
+ Also update track status + onFieldChange({ + config: { + ...field.config, + track_status_enabled: checked, + options: field.config?.options?.map((option) => { + if (checked && Array.isArray(option.value)) { + return { ...option, value: { shift_ids: option.value, track_statuses: [] } }; + } + if (!checked && typeof option.value === "object" && !Array.isArray(option.value)) { + return { ...option, value: option.value.shift_ids ?? [] }; + } + return option; + }), + }, + })} + /> +
+ )} {presetKind === "event_preference" && ( @@ -139,12 +166,27 @@ export function PresetPopover({ {presetKind === "lunch" && ( )} + {presetKind === "track_status" && ( + + )}
)} ); } +function TrackStatusParams({ field, onFieldChange, showErrors }: { + field: EditableField; onFieldChange: (updates: Partial) => void; showErrors: boolean; +}) { + const { suffix: parsedSuffix } = parseTrackStatusFieldKey(field.field_key); + const [suffix, setSuffix] = useState(parsedSuffix); + function handleChange(value: string) { + setSuffix(value); + onFieldChange({ field_key: buildTrackStatusFieldKey(value) }); + } + return handleChange(e.target.value)} size="sm" fullWidth error={showErrors && !parsedSuffix ? "Suffix is required." : undefined} />; +} + function DayPicker({ label, date, tournamentDates, onChange, error }: { label: string; date: string; diff --git a/frontend/components/forms/QuestionRenderer.tsx b/frontend/components/forms/QuestionRenderer.tsx index 7216b007..6fe268f4 100644 --- a/frontend/components/forms/QuestionRenderer.tsx +++ b/frontend/components/forms/QuestionRenderer.tsx @@ -359,6 +359,7 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra const presetKind = activePresetKind(field.field_key ?? '') const supportsBranching = BRANCHING_TYPES.includes(field.question_type) const isEntityBackedKind = isEntityBackedPreset(presetKind) + const hasTrackOutcomes = presetKind === 'track_status' || (presetKind === 'availability' && !!field.config?.track_status_enabled) // tournament null means the entity-backed editor has no scope to fetch // shifts/events from — falls through to the read-only preview at the // bottom instead (see the tournament prop doc on QuestionRenderer), never @@ -375,12 +376,14 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra return } - if (isEntity || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { + const usesTrackOutcomeEditor = hasTrackOutcomes && !!tournament + + if (isEntity || usesTrackOutcomeEditor || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { return ( <> - {isEntity ? ( + {isEntity || usesTrackOutcomeEditor ? ( ) : ( ReactNode; } const STATUS_STYLES: Record = { @@ -91,6 +93,7 @@ function ChipRemoveButton({ onClick }: { onClick: () => void }) { export function ChipInput({ value, onChange, label, error, placeholder, fullWidth, getChipStatus, disableInput, locked, chipLockReason, getChipTooltip, variant = "primary", size = "md", addButton, + renderChipTrailing, }: ChipInputProps) { const [draft, setDraft] = useState(""); const sizing = SIZE_MAP[size]; @@ -172,6 +175,7 @@ export function ChipInput({ {chip} ) : chip} + {renderChipTrailing?.(chip)} {!locked && lockReason && ( = { allowedQuestionTypes: ["single_select_radio", "multi_select_checkbox"], defaultQuestionType: "single_select_radio", }, + track_status: { + kind: "track_status", label: "Track Status", + allowedQuestionTypes: ["single_select_radio", "multi_select_checkbox"], + defaultQuestionType: "single_select_radio", + }, }; const AVAILABILITY_FIELD_KEY_PATTERN = /^availability_(\d{4})(\d{2})(\d{2})$/; const EVENT_PREFERENCE_FIELD_KEY_PATTERN = /^event_preference_([a-z0-9_]+)$/; const LUNCH_FIELD_KEY_PATTERN = /^lunch_(\d{4})(\d{2})(\d{2})_([a-z0-9_]+)$/; +const TRACK_STATUS_FIELD_KEY_PATTERN = /^track_status_([a-z0-9_]+)$/; // The preset currently active on a field_key, if any — prefix-based, not // the strict fully-parameterized pattern: PresetPopover sets a bare @@ -63,6 +69,7 @@ export function activePresetKind(fieldKey: string): PresetKind | null { if (fieldKey.startsWith("availability_")) return "availability"; if (fieldKey.startsWith("event_preference_")) return "event_preference"; if (fieldKey.startsWith("lunch_")) return "lunch"; + if (fieldKey.startsWith("track_status_")) return "track_status"; return null; } @@ -106,6 +113,16 @@ export function buildLunchFieldKey(date: string, category: string): string { return `lunch_${date.replaceAll("-", "")}_${slug}`; } +export function parseTrackStatusFieldKey(fieldKey: string): { suffix: string } { + const match = TRACK_STATUS_FIELD_KEY_PATTERN.exec(fieldKey); + return { suffix: match ? match[1] : "" }; +} + +export function buildTrackStatusFieldKey(suffix: string): string { + const slug = slugifyFieldKeyPart(suffix); + return slug ? `track_status_${slug}` : "track_status_"; +} + // Whether a useFormValidation issue message is about field_key specifically // (vs. label/options/etc.) — matches both "Field key is required." and // "This field key is already used by another question...". Shared so @@ -126,6 +143,7 @@ const PRESET_INCOMPLETE_REQUIREMENT: Record = { availability: "pick a date", event_preference: "enter a suffix", lunch: "pick a date and category", + track_status: "enter a suffix", }; export function presetIncompleteMessage(kind: PresetKind): string { diff --git a/frontend/lib/forms/fieldTypes.ts b/frontend/lib/forms/fieldTypes.ts index c1710108..9532c34b 100644 --- a/frontend/lib/forms/fieldTypes.ts +++ b/frontend/lib/forms/fieldTypes.ts @@ -42,9 +42,9 @@ const CONFIG_KEYS_BY_TYPE: Record = short_text: ["required", "max_length"], long_text: ["required", "max_length"], acknowledgment: ["required", "confirm_label"], - single_select_radio: ["required", "display_style", "options"], + single_select_radio: ["required", "display_style", "track_status_enabled", "options"], single_select_dropdown: ["required", "options"], - multi_select_checkbox: ["required", "display_style", "options"], + multi_select_checkbox: ["required", "display_style", "track_status_enabled", "options"], ranked_choice: ["required", "ranks", "allow_duplicates", "options"], }; diff --git a/frontend/lib/forms/useFormValidation.tsx b/frontend/lib/forms/useFormValidation.tsx index 1447d9a4..f4bd9601 100644 --- a/frontend/lib/forms/useFormValidation.tsx +++ b/frontend/lib/forms/useFormValidation.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { ApiError, FormFieldConfig, FormQuestionType } from "@/lib/api"; +import { ApiError, FormFieldConfig, FormQuestionType, TrackStatusAssignment } from "@/lib/api"; import { Banner } from "@/components/ui/Banner"; import { activePresetKind, effectiveFieldKey, presetIncompleteMessage } from "@/lib/forms/fieldKeyPresets"; @@ -71,6 +71,19 @@ export function issuesFor(field: ValidatableField): string[] { const labels = options.map((o) => o.label.trim().toLowerCase()); if (new Set(labels).size !== labels.length) issues.push("Option labels must be unique."); } + const hasTrackOutcomes = presetKind === "track_status" || (presetKind === "availability" && !!config.track_status_enabled); + const trackAssignmentsFor = (option: typeof options[number]): TrackStatusAssignment[] => { + const onlyAssignments = (items: unknown[]): TrackStatusAssignment[] => items.filter( + (item): item is TrackStatusAssignment => typeof item === "object" && item !== null && "id" in item && "status" in item, + ); + if (presetKind === "availability" && typeof option.value === "object" && !Array.isArray(option.value)) { + return onlyAssignments(option.value.track_statuses ?? []); + } + return presetKind === "track_status" && Array.isArray(option.value) ? onlyAssignments(option.value) : []; + }; + if (hasTrackOutcomes && options.some((option) => trackAssignmentsFor(option).some((assignment) => !assignment.status))) { + issues.push("Choose a status for every track outcome."); + } if (field.question_type === "ranked_choice" && (config.ranks ?? 1) > options.length) { issues.push("Ranks can't exceed the number of options."); } From 080f97e2b8b1519b1e2832080cd8e8f26ff7fc9e Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 16:41:15 -0700 Subject: [PATCH 35/92] feat(forms): allow availability forms to store track outcomes --- backend/app/core/form/validation.py | 6 ++++++ backend/app/schemas/form.py | 30 ++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index b58387e4..d82828ad 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -327,6 +327,12 @@ def validate_availability_options(db: Session, tournament_id: int | None, config shift_ids: set[int] = set() for option in options: value = option.get("value") + if config.get("track_status_enabled"): + _require( + isinstance(value, dict) and isinstance(value.get("shift_ids"), list), + f"availability option value '{value}' must contain shift_ids and track_statuses when track status is enabled", + ) + value = value["shift_ids"] _require( isinstance(value, list) and len(value) > 0 and all(isinstance(v, int) for v in value), f"availability option value '{value}' must be a non-empty list of TournamentShift ids", diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 8392c98d..9c49a465 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -30,13 +30,24 @@ def _unique_option_fields(options: list) -> list: if option.option_id in seen_ids: raise ValueError(f"duplicate option_id '{option.option_id}'") seen_ids.add(option.option_id) - value_key = tuple(option.value) if isinstance(option.value, list) else option.value + value_key = _option_value_key(option.value) if value_key in seen_values: raise ValueError(f"duplicate option value '{option.value}'") seen_values.add(value_key) return options +def _option_value_key(value: Any): + """Make the supported JSON option values comparable for uniqueness.""" + if isinstance(value, BaseModel): + return _option_value_key(value.model_dump()) + if isinstance(value, list): + return tuple(_option_value_key(item) for item in value) + if isinstance(value, dict): + return tuple(sorted((key, _option_value_key(item)) for key, item in value.items())) + return value + + class TrackStatusAssignment(BaseModel): """One track outcome attached to a selectable option.""" model_config = ConfigDict(extra="forbid") @@ -52,6 +63,19 @@ def _unique_track_statuses(assignments: list[TrackStatusAssignment]) -> list[Tra return assignments +class AvailabilityTrackStatusValue(BaseModel): + """Raw builder value for an availability option that updates tracks.""" + model_config = ConfigDict(extra="forbid") + + shift_ids: list[int] = Field(min_length=1) + track_statuses: list[TrackStatusAssignment] = Field(default_factory=list) + + @field_validator("track_statuses") + @classmethod + def _unique_tracks(cls, assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: + return _unique_track_statuses(assignments) + + class PlainOption(BaseModel): """An option with no branching — multi_select_checkbox, ranked_choice. extra='forbid' rejects a stray next_field_id/action on these types. @@ -62,7 +86,7 @@ class PlainOption(BaseModel): expect based on the field's field_key.""" model_config = ConfigDict(extra="forbid") option_id: str = Field(min_length=1) - value: str | list[int] | list[TrackStatusAssignment] | dict = Field(min_length=1) + value: str | list[int] | list[TrackStatusAssignment] | AvailabilityTrackStatusValue label: str = Field(min_length=1) is_archived: bool = False @@ -72,7 +96,7 @@ class BranchingOption(BaseModel): See PlainOption for value's dual str/list[int] shape.""" model_config = ConfigDict(extra="forbid") option_id: str = Field(min_length=1) - value: str | list[int] | list[TrackStatusAssignment] | dict = Field(min_length=1) + value: str | list[int] | list[TrackStatusAssignment] | AvailabilityTrackStatusValue label: str = Field(min_length=1) is_archived: bool = False next_field_id: str | None = None From d7ef6774d5b3cefea7b5e1570a4e188f6950b040 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 16:51:31 -0700 Subject: [PATCH 36/92] feat(forms): preserve form options when changing presets --- backend/app/core/form/__init__.py | 2 +- backend/app/core/form/validation.py | 6 ++--- backend/app/schemas/form.py | 2 +- .../components/forms/EntityOptionsEditor.tsx | 11 +++++----- frontend/components/forms/PresetPopover.tsx | 22 +++++++++++++++++-- frontend/lib/forms/useFormValidation.tsx | 2 +- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 44aec8bd..6c43a8c2 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -121,7 +121,7 @@ def field_key_taken_in_tournament(db: Session, tournament_id: int, field_key: st def track_referenced_by_form_field(db: Session, tournament_id: int, track_id: int) -> bool: """True when any field in the tournament is bound to ``track_id``. - Track outcomes store durable catalog IDs in each option's + Track statuses store durable catalog IDs in each option's ``track_statuses`` list. Archived fields are intentionally included: they are historical form structure and deleting the catalog entry would leave them unresolved. diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index d82828ad..cd3a023d 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -86,7 +86,7 @@ def validate_field_config(question_type: str, config: dict | None) -> dict: raise FormFieldValidationError(str(e)) normalized = parsed.model_dump() - # These fields were added for track outcomes after forms already existed. + # These fields were added for track statuses after forms already existed. # Keep an omitted opt-in/mapping omitted instead of rewriting every # unrelated form field the next time its form is saved. source = config or {} @@ -127,7 +127,7 @@ def validate_tournament_preset(field_key: str, tournament_id: int | None) -> Non def track_status_enabled(field_key: str, config: dict) -> bool: - """Whether this field is allowed to carry per-option track outcomes.""" + """Whether this field is allowed to carry per-option track statuses.""" return bool(TRACK_STATUS_FIELD_KEY_PATTERN.match(field_key)) or ( bool(AVAILABILITY_FIELD_KEY_PATTERN.match(field_key)) and bool(config.get("track_status_enabled")) @@ -152,7 +152,7 @@ def validate_track_status_options( question_type: str, config: dict, ) -> None: - """Validate option-level track outcomes for Track Status and opted-in + """Validate option-level track statuses for Track Status and opted-in Availability fields. Track mappings are tournament-only and catalog IDs remain valid after archival so historical fields can still be read.""" assignments = _track_status_assignments(config) diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 9c49a465..f9d8833d 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -49,7 +49,7 @@ def _option_value_key(value: Any): class TrackStatusAssignment(BaseModel): - """One track outcome attached to a selectable option.""" + """One track status attached to a selectable option.""" model_config = ConfigDict(extra="forbid") id: int = Field(gt=0) diff --git a/frontend/components/forms/EntityOptionsEditor.tsx b/frontend/components/forms/EntityOptionsEditor.tsx index b9ec4f43..d00bb1c3 100644 --- a/frontend/components/forms/EntityOptionsEditor.tsx +++ b/frontend/components/forms/EntityOptionsEditor.tsx @@ -82,11 +82,11 @@ function shiftIdsFor(option: EditableOption): number[] { } // Shared options editor for entity-backed presets and Track Status. The only -// difference is whether the row also has a shift/event picker; track outcome -// chips live here for both Track Status and opted-in Availability fields. +// difference is whether the row also has a shift/event picker; track chips +// live here for both Track Status and opted-in Availability fields. export function EntityOptionsEditor({ fieldKey, tournament, questionType, options, onChange, displayStyle, branchTargets, errors, trackStatusEnabled = false }: EntityOptionsEditorProps) { const isEntity = fieldKey !== 'track_status' - const hasTrackOutcomes = fieldKey === 'track_status' || trackStatusEnabled + const hasTracks = fieldKey === 'track_status' || trackStatusEnabled const [entities, setEntities] = useState(isEntity ? null : []) const [loadError, setLoadError] = useState(null) const [showPicker, setShowPicker] = useState(false) @@ -149,7 +149,7 @@ export function EntityOptionsEditor({ fieldKey, tournament, questionType, option emptyMessage={emptyMessage} onToggle={(id) => toggleEntity(option.clientKey, id)} />} - {hasTrackOutcomes && } -function TrackOutcomePicker({ tournament, option, availability, onChange }: { tournament: Tournament; option: EditableOption; availability: boolean; onChange: (option: EditableOption) => void }) { +function TrackPicker({ tournament, option, availability, onChange }: { tournament: Tournament; option: EditableOption; availability: boolean; onChange: (option: EditableOption) => void }) { const [tracks, setTracks] = useState([]) useEffect(() => { tournamentTracksApi.list(tournament.id).then(setTracks).catch(() => {}) }, [tournament.id]) const assignments = assignmentsFor(option, availability) @@ -223,7 +223,6 @@ function TrackOutcomePicker({ tournament, option, availability, onChange }: { to } return track.name)} onChange={(names) => selected.filter((track) => !names.includes(track.name)).forEach(toggle)} disableInput variant="transparent" size="sm" fullWidth diff --git a/frontend/components/forms/PresetPopover.tsx b/frontend/components/forms/PresetPopover.tsx index 52ca4b9f..05d7ae4c 100644 --- a/frontend/components/forms/PresetPopover.tsx +++ b/frontend/components/forms/PresetPopover.tsx @@ -94,10 +94,25 @@ export function PresetPopover({ // plain freeform for lunch — rather than leaving the TD looking at an // empty list with nothing to click but "Add option." const starterOption = isEntityBackedPreset(kind) ? newEntityOption() : newOption(); + const supportsBranching = questionType === "single_select_radio" || questionType === "single_select_dropdown"; + // Keep existing option rows (including their branch targets) when a + // preset changes. Only the value changes shape to fit the new preset. + const existingOptions = field.config?.options ?? []; + const options = existingOptions.length > 0 + ? existingOptions.map((option) => ({ + ...option, + ...(supportsBranching ? {} : { next_field_id: null, action: null }), + value: isEntityBackedPreset(kind) + ? (Array.isArray(option.value) && option.value.every((value) => typeof value === "number") ? option.value : []) + : kind === "track_status" + ? (Array.isArray(option.value) && option.value.every((value) => typeof value === "object" && value !== null && "id" in value && "status" in value) ? option.value : []) + : typeof option.value === "string" ? option.value : option.label, + })) + : [starterOption]; onFieldChange({ field_key: fieldKey, question_type: questionType, - config: { ...sanitizeConfigForType(field.config, questionType), required: kind === "track_status" ? true : field.config?.required ?? false, options: [starterOption] }, + config: { ...sanitizeConfigForType(field.config, questionType), required: kind === "track_status" ? true : field.config?.required ?? false, options }, }); } @@ -147,7 +162,10 @@ export function PresetPopover({ track_status_enabled: checked, options: field.config?.options?.map((option) => { if (checked && Array.isArray(option.value)) { - return { ...option, value: { shift_ids: option.value, track_statuses: [] } }; + const shiftIds = option.value.every((value) => typeof value === "number") + ? option.value as number[] + : []; + return { ...option, value: { shift_ids: shiftIds, track_statuses: [] } }; } if (!checked && typeof option.value === "object" && !Array.isArray(option.value)) { return { ...option, value: option.value.shift_ids ?? [] }; diff --git a/frontend/lib/forms/useFormValidation.tsx b/frontend/lib/forms/useFormValidation.tsx index f4bd9601..3b83c326 100644 --- a/frontend/lib/forms/useFormValidation.tsx +++ b/frontend/lib/forms/useFormValidation.tsx @@ -82,7 +82,7 @@ export function issuesFor(field: ValidatableField): string[] { return presetKind === "track_status" && Array.isArray(option.value) ? onlyAssignments(option.value) : []; }; if (hasTrackOutcomes && options.some((option) => trackAssignmentsFor(option).some((assignment) => !assignment.status))) { - issues.push("Choose a status for every track outcome."); + issues.push("Choose a status for every track."); } if (field.question_type === "ranked_choice" && (config.ranks ?? 1) > options.length) { issues.push("Ranks can't exceed the number of options."); From 32531487fc0693897e55f91b22b529a82e15699f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 16:55:52 -0700 Subject: [PATCH 37/92] feat(forms): default preset dropdown to empty with a clear button instead of a 'No preset' option --- frontend/components/forms/PresetPopover.tsx | 34 ++++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/components/forms/PresetPopover.tsx b/frontend/components/forms/PresetPopover.tsx index 05d7ae4c..3e7e235f 100644 --- a/frontend/components/forms/PresetPopover.tsx +++ b/frontend/components/forms/PresetPopover.tsx @@ -6,7 +6,7 @@ import { Dropdown } from "@/components/ui/Dropdown"; import { FormPopover } from "@/components/ui/FormPopover"; import { Input } from "@/components/ui/Input"; import { Toggle } from "@/components/ui/Toggle"; -import { IconPresets } from "@/components/ui/Icons"; +import { IconPresets, IconX } from "@/components/ui/Icons"; import { TournamentDayPicker } from "@/components/tournament/TournamentDayPicker"; import { newEntityOption, newOption } from "@/components/forms/OptionsEditor"; import { EditableField } from "@/lib/forms/editableField"; @@ -19,12 +19,11 @@ import { } from "@/lib/forms/fieldKeyPresets"; import { OPTION_BEARING_TYPES, sanitizeConfigForType } from "@/lib/forms/fieldTypes"; -const KIND_OPTIONS: { value: PresetKind | ""; label: string }[] = [ - { value: "", label: "No preset" }, +const KIND_OPTIONS: { value: PresetKind; label: string }[] = [ { value: "availability", label: "Availability" }, { value: "event_preference", label: "Event" }, { value: "lunch", label: "Lunch" }, - { value: "track_status", label: "Track Status" }, + { value: "track_status", label: "Track" }, ]; // Reserved-key presets (availability_{date}, event_preference_{suffix}, @@ -141,13 +140,26 @@ export function PresetPopover({ }}> Preset - applyPresetKind(value ? value as PresetKind : null)} - size="sm" - fullWidth - /> +
+ applyPresetKind(value as PresetKind)} + placeholder="No preset" + size="sm" + fullWidth + /> + {presetKind && ( + + )} +
{presetKind === "availability" && ( <> From e1fe17be1e8282ee3e024e40d7c74ae1a167bccd Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 16:57:41 -0700 Subject: [PATCH 38/92] style(forms): move option row delete button to the far right edge --- frontend/components/forms/OptionsEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/forms/OptionsEditor.tsx b/frontend/components/forms/OptionsEditor.tsx index 1d067230..02648232 100644 --- a/frontend/components/forms/OptionsEditor.tsx +++ b/frontend/components/forms/OptionsEditor.tsx @@ -422,12 +422,12 @@ function OptionRow({ option, bulletType, number, displayStyle, trailing, extra, size="md" fullWidth /> + {trailing} {canRemove && ( )} - {trailing}
{/* Indented to match the Input's left edge above, not the row's own — otherwise it lines up with the bullet instead (EntityOptionsEditor's From b94f67c30292a3453cecf904ff100298afd3d9ca Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 17:10:08 -0700 Subject: [PATCH 39/92] feat(forms): render track chip status as a colored pill menu instead of an embedded dropdown --- .../components/forms/EntityOptionsEditor.tsx | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/frontend/components/forms/EntityOptionsEditor.tsx b/frontend/components/forms/EntityOptionsEditor.tsx index d00bb1c3..17b7954d 100644 --- a/frontend/components/forms/EntityOptionsEditor.tsx +++ b/frontend/components/forms/EntityOptionsEditor.tsx @@ -10,9 +10,8 @@ import { eventNameWithDivision } from '@/lib/eventDisplay' import { formatDayLabel, formatTime, toDateInput } from '@/lib/timeFormat' import { Button } from '@/components/ui/Button' import { ChipInput } from '@/components/ui/ChipInput' -import { Dropdown } from '@/components/ui/Dropdown' import { Popover } from '@/components/ui/Popover' -import { IconPlus, IconSearch } from '@/components/ui/Icons' +import { IconChevronDown, IconPlus, IconSearch } from '@/components/ui/Icons' import { BranchTarget, EditableOption, newEntityOption, OptionsEditor } from '@/components/forms/OptionsEditor' import { EventOptionsPickerModal } from '@/components/forms/EventOptionsPickerModal' @@ -31,12 +30,17 @@ interface EntityOptionsEditorProps { trackStatusEnabled?: boolean } -const STATUS_OPTIONS = [ - { value: '', label: 'Choose status' }, +const STATUS_OPTIONS: { value: TrackStatus; label: string }[] = [ { value: 'interested', label: 'Interested' }, { value: 'confirmed', label: 'Confirmed' }, { value: 'declined', label: 'Declined' }, ] +const STATUS_LABEL: Record = { + '': 'Set status', + interested: 'Interested', + confirmed: 'Confirmed', + declined: 'Declined', +} function entityLabel(fieldKey: Exclude, entity: Entity, isMultiDay: boolean): string { if (fieldKey === 'availability') { @@ -230,8 +234,49 @@ function TrackPicker({ tournament, option, availability, onChange }: { tournamen renderChipTrailing={(name) => { const track = byName.get(name) const assignment = assignments.find((item) => item.id === track?.id) - return track && assignment ? setStatus(track.id, status)} options={STATUS_OPTIONS} size="sm" width={132} /> : null + return track && assignment ? setStatus(track.id, status)} /> : null }} addButton={ Tracks} items={tracks.filter((track) => !track.is_archived)} getKey={(track) => track.id} renderLabel={(track) => track.name} onSelect={toggle} checklist isSelected={(track) => assignments.some((item) => item.id === track.id)} emptyMessage="No active tracks." width={300} />} /> } + +const STATUS_PILL_STYLE: Record = { + '': { background: 'var(--color-bg)', color: 'var(--color-text-tertiary)', border: 'var(--color-border-strong)' }, + interested: { background: 'transparent', color: 'var(--color-text-secondary)', border: 'var(--color-border-strong)' }, + confirmed: { background: 'var(--color-success-subtle)', color: 'var(--color-success)', border: 'var(--color-success)' }, + declined: { background: 'var(--color-danger-subtle)', color: 'var(--color-danger)', border: 'var(--color-danger)' }, +} + +// Right-hand segment of the track chip — a plain clickable pill + chevron +// (not a bordered Dropdown) so it reads as part of the chip itself, not a +// boxed control embedded inside one. Keeps the chip a single fixed height +// matching its neighbors (the Tracks add button) instead of growing to fit +// a full-size Dropdown's own chrome. Colored per status so the chip reads +// at a glance, same palette as Badge's interested/confirmed/declined variants. +function TrackStatusMenu({ status, onChange }: { status: TrackStatus | ''; onChange: (status: TrackStatus) => void }) { + const [open, setOpen] = useState(false) + const pill = STATUS_PILL_STYLE[status] + return ( + + {STATUS_LABEL[status]} + + + } + items={STATUS_OPTIONS} + getKey={(opt) => opt.value} + renderLabel={(opt) => opt.label} + onSelect={(opt) => onChange(opt.value)} + onOpenChange={setOpen} + width={140} + align="left" + /> + ) +} From 6e47d76d5da42d4f003a82f8bd4a774566cb74ae Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 17:17:22 -0700 Subject: [PATCH 40/92] chore(forms): update field key info hint --- frontend/components/forms/FieldKeyPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/forms/FieldKeyPopover.tsx b/frontend/components/forms/FieldKeyPopover.tsx index 92b18b90..12b75fee 100644 --- a/frontend/components/forms/FieldKeyPopover.tsx +++ b/frontend/components/forms/FieldKeyPopover.tsx @@ -143,7 +143,7 @@ function KeyInfoHint() { fontFamily: "var(--font-sans)", fontSize: "12px", color: "var(--color-text-primary)", pointerEvents: "none", }}> - How this question shows up when scanning or filtering responses on the dashboard — not shown to respondents. Follows the question text until you edit it here. Must be unique across every form this tournament owns. + How this question shows up when scanning or filtering responses on the dashboard — not shown to respondents. Must be unique across every form this tournament owns. )} From 9e2f011800d37fdf0fa68b005c454c1cd82496a4 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 17:55:15 -0700 Subject: [PATCH 41/92] fix(forms): honor submitted field_key on edit and don't read entity ids as track statuses --- backend/app/api/routes/forms.py | 21 +++++++-- backend/app/core/form/validation.py | 15 ++++++- backend/tests/api/test_forms.py | 45 +++++++++++++++++++ backend/tests/core/test_form_validation.py | 44 +++++++++++++++--- .../components/forms/QuestionRenderer.tsx | 10 ++--- 5 files changed, 118 insertions(+), 17 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index fb80713a..1649c862 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -605,9 +605,23 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> for entry in payload.fields: if entry.id is not None: field = live_by_id[entry.id] - normalized_config = _validate_config(entry.question_type, entry.config, field.field_key) type_changed = entry.question_type != field.question_type - + # entry.field_key is None/blank when the caller isn't renaming + # this field at all (the common case — most edits touch label/ + # config, not the key) — that means "leave it alone", not "set it + # to slugify('')", which the model's snake_case validator rejects. + new_field_key = slugify(entry.field_key) if entry.field_key else field.field_key + if new_field_key != field.field_key: + _check_field_key_available(new_field_key) + normalized_config = _validate_config(entry.question_type, entry.config, new_field_key) + + # A type change that must preserve history archives this field and + # creates its replacement at the same list position. The + # replacement normally inherits the old field_key (see the Edit + # Lifecycle doc — deliberate continuity), but a submitted key + # still wins: applying a preset changes the key and the + # question_type in the same save, and silently keeping the old key + # would validate the new preset config against the wrong key. if type_changed and is_history_preserving: field.is_archived = True old_key = field.field_key @@ -623,7 +637,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> label=entry.label, description=entry.description, question_type=entry.question_type, - field_key=old_key, + field_key=new_field_key, config=normalized_config, is_archived=False, ) @@ -637,6 +651,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> field.label = entry.label field.description = entry.description field.question_type = entry.question_type + field.field_key = new_field_key field.config = normalized_config flag_modified(field, "config") else: diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index cd3a023d..1615bc96 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -134,14 +134,25 @@ def track_status_enabled(field_key: str, config: dict) -> bool: ) +def _is_assignment(item: object) -> bool: + """An option's `value` can legally be a list of two different things — + entity ids (list[int], for availability/event_preference) or track + assignments (list[TrackStatusAssignment]). Discriminate on the element, + not the container: treating every list as assignments makes a plain + event_preference field look like it carries track statuses.""" + return isinstance(item, dict) and "id" in item and "status" in item + + def _track_status_assignments(config: dict) -> list[dict]: assignments = [] for option in config.get("options") or []: value = option.get("value") if isinstance(value, list): - assignments.extend(value) + assignments.extend(item for item in value if _is_assignment(item)) elif isinstance(value, dict): - assignments.extend(value.get("track_statuses") or []) + assignments.extend( + item for item in (value.get("track_statuses") or []) if _is_assignment(item) + ) return assignments diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index cd1122aa..3136c7a2 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -22,6 +22,7 @@ TournamentForm, TournamentRole, TournamentShift, + TournamentTrack, utcnow, ) @@ -600,6 +601,50 @@ def test_question_type_change_archives_and_replaces_same_key(self, client, db, t assert field.is_archived is True assert field.field_key != "color" + def test_submitted_field_key_wins_over_inheritance_on_replacement(self, client, db, td_user, td_tournament): + """Applying a preset renames the field_key *and* changes the + question_type in one save. The replacement normally inherits the old + key, but here that would validate the new track_status config against + the pre-preset key and 422.""" + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, order=1, field_key="interest", question_type="short_text", config={"required": False, "max_length": 50}) + track = TournamentTrack(tournament_id=td_tournament.id, name="Test Writing") + db.add(track) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{ + "id": field.id, + "field_key": "track_status_volunteer_interest", + "label": "Interested?", + "question_type": "single_select_radio", + "config": {"required": True, "options": [ + {"option_id": "yes", "label": "Yes", "value": [{"id": track.id, "status": "interested"}]}, + {"option_id": "no", "label": "No", "value": []}, + ]}, + }]}, + ) + assert res.status_code == 200, res.json() + assert res.json()[0]["field_key"] == "track_status_volunteer_interest" + + def test_field_key_rename_applies_in_place(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": field.id, "field_key": "favorite_color", "label": "Color", "question_type": "short_text", "config": {"required": False, "max_length": 50}}]}, + ) + assert res.status_code == 200, res.json() + assert res.json()[0]["id"] == field.id + assert res.json()[0]["field_key"] == "favorite_color" + def test_removed_field_archives_not_deletes(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py index 5104b798..901ea3e0 100644 --- a/backend/tests/core/test_form_validation.py +++ b/backend/tests/core/test_form_validation.py @@ -352,12 +352,28 @@ def _track(self, db, tournament): db.flush() return track + # A track_status_* option carries its assignments *as* its value + # (list[TrackStatusAssignment]); an opted-in availability option nests + # them under a dict alongside shift_ids. Both shapes must match + # schemas/form.py exactly — extra='forbid' rejects anything else, so a + # test config that invents its own shape validates nothing. def _config(self, track_id, **overrides): config = { "required": True, "options": [{ - "option_id": "yes", "value": "yes", "label": "Yes", - "track_statuses": [{"track_id": track_id, "status": "interested"}], + "option_id": "yes", "label": "Yes", + "value": [{"id": track_id, "status": "interested"}], + }], + } + config.update(overrides) + return config + + def _availability_config(self, track_id, **overrides): + config = { + "required": True, + "options": [{ + "option_id": "yes", "label": "Yes", + "value": {"shift_ids": [1], "track_statuses": [{"id": track_id, "status": "interested"}]}, }], } config.update(overrides) @@ -383,8 +399,8 @@ def test_option_track_statuses_require_explicit_known_status(self): { "required": True, "options": [{ - "option_id": "opt_1", "value": "a", "label": "A", - "track_statuses": [{"track_id": 1, "status": "maybe"}], + "option_id": "opt_1", "label": "A", + "value": [{"id": 1, "status": "maybe"}], }], }, ) @@ -398,7 +414,7 @@ def test_track_status_rejects_foreign_track(self, db, td_user, td_tournament, ot def test_availability_requires_opt_in_for_track_outcomes(self, db, td_user, td_tournament): track = self._track(db, td_tournament) - config = self._config(track.id) + config = self._availability_config(track.id) with pytest.raises(FormFieldValidationError, match="only allowed"): validate_track_status_options( db, td_tournament.id, "availability_20260315", "single_select_radio", config @@ -407,11 +423,25 @@ def test_availability_requires_opt_in_for_track_outcomes(self, db, td_user, td_t config["track_status_enabled"] = True validate_track_status_options(db, td_tournament.id, "availability_20260315", "single_select_radio", config) + def test_entity_ids_are_not_mistaken_for_track_assignments(self, db, td_user, td_tournament): + """A list-valued option on a non-track field holds entity ids, not + assignments — the "only allowed" guard must not fire on those.""" + config = { + "required": True, + "options": [{"option_id": "one", "label": "One", "value": [1, 2, 3]}], + } + validate_track_status_options( + db, td_tournament.id, "event_preference_labs", "multi_select_checkbox", config + ) + validate_track_status_options( + db, td_tournament.id, "availability_20260315", "multi_select_checkbox", config + ) + def test_checkbox_rejects_conflicting_track_statuses(self, db, td_user, td_tournament): track = self._track(db, td_tournament) config = self._config(track.id, options=[ - {"option_id": "one", "value": "one", "label": "One", "track_statuses": [{"track_id": track.id, "status": "interested"}]}, - {"option_id": "two", "value": "two", "label": "Two", "track_statuses": [{"track_id": track.id, "status": "declined"}]}, + {"option_id": "one", "label": "One", "value": [{"id": track.id, "status": "interested"}]}, + {"option_id": "two", "label": "Two", "value": [{"id": track.id, "status": "declined"}]}, ]) with pytest.raises(FormFieldValidationError, match="conflicting statuses"): validate_track_status_options(db, td_tournament.id, "track_status_interest", "multi_select_checkbox", config) diff --git a/frontend/components/forms/QuestionRenderer.tsx b/frontend/components/forms/QuestionRenderer.tsx index 6fe268f4..1b799997 100644 --- a/frontend/components/forms/QuestionRenderer.tsx +++ b/frontend/components/forms/QuestionRenderer.tsx @@ -359,7 +359,7 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra const presetKind = activePresetKind(field.field_key ?? '') const supportsBranching = BRANCHING_TYPES.includes(field.question_type) const isEntityBackedKind = isEntityBackedPreset(presetKind) - const hasTrackOutcomes = presetKind === 'track_status' || (presetKind === 'availability' && !!field.config?.track_status_enabled) + const hasTracks = presetKind === 'track_status' || (presetKind === 'availability' && !!field.config?.track_status_enabled) // tournament null means the entity-backed editor has no scope to fetch // shifts/events from — falls through to the read-only preview at the // bottom instead (see the tournament prop doc on QuestionRenderer), never @@ -376,12 +376,12 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra return } - const usesTrackOutcomeEditor = hasTrackOutcomes && !!tournament + const usesTrackEditor = hasTracks && !!tournament - if (isEntity || usesTrackOutcomeEditor || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { + if (isEntity || usesTrackEditor || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { return ( <> - {isEntity || usesTrackOutcomeEditor ? ( + {isEntity || usesTrackEditor ? ( ) : ( Date: Wed, 26 Aug 2026 18:02:31 -0700 Subject: [PATCH 42/92] fix(forms): keep option rows and branching when clearing a preset --- frontend/components/forms/PresetPopover.tsx | 76 ++++++++++++++------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/frontend/components/forms/PresetPopover.tsx b/frontend/components/forms/PresetPopover.tsx index 3e7e235f..a10f645c 100644 --- a/frontend/components/forms/PresetPopover.tsx +++ b/frontend/components/forms/PresetPopover.tsx @@ -9,6 +9,7 @@ import { Toggle } from "@/components/ui/Toggle"; import { IconPresets, IconX } from "@/components/ui/Icons"; import { TournamentDayPicker } from "@/components/tournament/TournamentDayPicker"; import { newEntityOption, newOption } from "@/components/forms/OptionsEditor"; +import { FormQuestionType } from "@/lib/api"; import { EditableField } from "@/lib/forms/editableField"; import { PresetKind, PRESETS, activePresetKind, isEntityBackedPreset, slugifyFieldKey, isPresetError, @@ -19,6 +20,35 @@ import { } from "@/lib/forms/fieldKeyPresets"; import { OPTION_BEARING_TYPES, sanitizeConfigForType } from "@/lib/forms/fieldTypes"; +type EditableOption = NonNullable["options"]>[number]; + +function isAssignmentList(value: unknown): boolean { + return Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null && "id" in item && "status" in item); +} + +// The option rows a field keeps when its preset changes — including changing +// to "no preset". Rows, labels and branch targets are the TD's work and +// always survive; only each `value` is rewritten, since the three shapes +// (freeform string, entity ids, track assignments) aren't interchangeable. A +// value that can't carry into the new shape falls back to the row's own +// label (freeform) or an empty selection (entity/track), never to a dropped +// row. Returns a single starter row only when there was nothing to keep. +function carryOptions(field: EditableField, kind: PresetKind | null, questionType: FormQuestionType): EditableOption[] { + if (!OPTION_BEARING_TYPES.includes(questionType)) return []; + const supportsBranching = questionType === "single_select_radio" || questionType === "single_select_dropdown"; + const existing = field.config?.options ?? []; + if (existing.length === 0) return [isEntityBackedPreset(kind) ? newEntityOption() : newOption()]; + return existing.map((option) => ({ + ...option, + ...(supportsBranching ? {} : { next_field_id: null, action: null }), + value: isEntityBackedPreset(kind) + ? (Array.isArray(option.value) && option.value.every((value) => typeof value === "number") ? option.value : []) + : kind === "track_status" + ? (isAssignmentList(option.value) ? option.value : []) + : typeof option.value === "string" ? option.value : option.label, + })); +} + const KIND_OPTIONS: { value: PresetKind; label: string }[] = [ { value: "availability", label: "Availability" }, { value: "event_preference", label: "Event" }, @@ -31,10 +61,8 @@ const KIND_OPTIONS: { value: PresetKind; label: string }[] = [ // FieldKeyPopover's plain free-text key, since a preset now needs its own // parameter input(s) (a date, a suffix, a date+category pair) rather than // being a single fixed field_key a TD picks off a list. Choosing/changing a -// preset here always resets `options` — an entity-backed picker's -// `value: number[]` and a freeform row's `value: string` aren't -// interchangeable, so switching kinds (including back to "no preset") -// can't safely keep whatever was there before. +// preset here keeps the TD's option rows and their branch targets — only each +// value's *shape* is rewritten to fit the new kind (see reshapeOptions). export function PresetPopover({ field, onFieldChange, tournamentDates, onOpen, errors, saveAttempt, open, onOpenChange, }: { @@ -69,10 +97,19 @@ export function PresetPopover({ function applyPresetKind(kind: PresetKind | null) { if (kind === null) { - const options = OPTION_BEARING_TYPES.includes(field.question_type) ? [newOption()] : []; + // Clearing is just another kind change: keep the rows, drop back to + // freeform values, and only seed a starter row if there was nothing + // to keep. track_status_enabled goes with the preset — it's an + // availability-only opt-in, and the backend rejects the flag on any + // other key. + const options = carryOptions(field, null, field.question_type); onFieldChange({ field_key: slugifyFieldKey(field.label), - config: { ...sanitizeConfigForType(field.config, field.question_type), options }, + config: { + ...sanitizeConfigForType(field.config, field.question_type), + track_status_enabled: undefined, + options, + }, }); return; } @@ -92,26 +129,19 @@ export function PresetPopover({ // id array to fill in via the picker) for availability/event_preference, // plain freeform for lunch — rather than leaving the TD looking at an // empty list with nothing to click but "Add option." - const starterOption = isEntityBackedPreset(kind) ? newEntityOption() : newOption(); - const supportsBranching = questionType === "single_select_radio" || questionType === "single_select_dropdown"; - // Keep existing option rows (including their branch targets) when a - // preset changes. Only the value changes shape to fit the new preset. - const existingOptions = field.config?.options ?? []; - const options = existingOptions.length > 0 - ? existingOptions.map((option) => ({ - ...option, - ...(supportsBranching ? {} : { next_field_id: null, action: null }), - value: isEntityBackedPreset(kind) - ? (Array.isArray(option.value) && option.value.every((value) => typeof value === "number") ? option.value : []) - : kind === "track_status" - ? (Array.isArray(option.value) && option.value.every((value) => typeof value === "object" && value !== null && "id" in value && "status" in value) ? option.value : []) - : typeof option.value === "string" ? option.value : option.label, - })) - : [starterOption]; + const options = carryOptions(field, kind, questionType); onFieldChange({ field_key: fieldKey, question_type: questionType, - config: { ...sanitizeConfigForType(field.config, questionType), required: kind === "track_status" ? true : field.config?.required ?? false, options }, + config: { + ...sanitizeConfigForType(field.config, questionType), + required: kind === "track_status" ? true : field.config?.required ?? false, + // Only availability opts into track statuses via this flag; every + // other kind (track_status included — its key alone enables them) + // must not carry it over. + track_status_enabled: kind === "availability" ? field.config?.track_status_enabled : undefined, + options, + }, }); } From 57c52ac4a5c2c24063c7929fac785733aceafc75 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 18:08:50 -0700 Subject: [PATCH 43/92] fix(forms): share one track status assignment extractor to fix 500 on track delete --- backend/app/core/form/__init__.py | 4 ++-- backend/app/core/form/validation.py | 7 +++++-- backend/tests/api/tournament/test_tracks.py | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 6c43a8c2..5db0ec98 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -3,6 +3,7 @@ AVAILABILITY_FIELD_KEY_PATTERN, EVENT_PREFERENCE_FIELD_KEY_PATTERN, TRACK_STATUS_FIELD_KEY_PATTERN, + track_status_assignments, ) from app.models.models import ( Form, @@ -135,8 +136,7 @@ def track_referenced_by_form_field(db: Session, tournament_id: int, track_id: in return any( any( assignment.get("id") == track_id - for option in (field.config or {}).get("options") or [] - for assignment in (option.get("value") if isinstance(option.get("value"), list) else (option.get("value") or {}).get("track_statuses", [])) + for assignment in track_status_assignments(field.config or {}) ) for field in fields ) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 1615bc96..1de6830c 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -143,7 +143,10 @@ def _is_assignment(item: object) -> bool: return isinstance(item, dict) and "id" in item and "status" in item -def _track_status_assignments(config: dict) -> list[dict]: +def track_status_assignments(config: dict) -> list[dict]: + """Every track assignment carried by a field's options, whichever shape + holds them. The single place that knows how to dig them out — callers + that hand-roll it get the value shapes wrong (see _is_assignment).""" assignments = [] for option in config.get("options") or []: value = option.get("value") @@ -166,7 +169,7 @@ def validate_track_status_options( """Validate option-level track statuses for Track Status and opted-in Availability fields. Track mappings are tournament-only and catalog IDs remain valid after archival so historical fields can still be read.""" - assignments = _track_status_assignments(config) + assignments = track_status_assignments(config) enabled = track_status_enabled(field_key, config) _require( diff --git a/backend/tests/api/tournament/test_tracks.py b/backend/tests/api/tournament/test_tracks.py index afd9c528..2037dec0 100644 --- a/backend/tests/api/tournament/test_tracks.py +++ b/backend/tests/api/tournament/test_tracks.py @@ -88,8 +88,8 @@ def test_unused_track_can_be_deleted_but_referenced_track_cannot(client, db, td_ config={ "required": True, "options": [{ - "option_id": "interested", "value": "interested", "label": "Interested", - "track_statuses": [{"track_id": referenced["id"], "status": "interested"}], + "option_id": "interested", "label": "Interested", + "value": [{"id": referenced["id"], "status": "interested"}], }], }, )) From 865a805b01559c2fad48ffef90c7595a76ed9628 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 18:12:49 -0700 Subject: [PATCH 44/92] fix(forms): hide archived options from the editor and respondent view --- .../components/forms/QuestionRenderer.tsx | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/frontend/components/forms/QuestionRenderer.tsx b/frontend/components/forms/QuestionRenderer.tsx index 1b799997..5f2ee247 100644 --- a/frontend/components/forms/QuestionRenderer.tsx +++ b/frontend/components/forms/QuestionRenderer.tsx @@ -190,6 +190,12 @@ function QuestionBody({ field, interactive, value, onChange, error, shifts }: { shifts?: TournamentShift[] | null }) { const config = field.config ?? {} + // An archived option stays in `config.options` forever so old answers + // referencing its option_id keep resolving (see apply_option_archiving on + // the backend) — it is storage, never a choice to present. Respondents + // whose answer pointed at one are asked to re-answer via the + // "option_archived" pending-update flow instead. + const liveOptions: FormFieldOption[] = (config.options ?? []).filter((opt) => !opt.is_archived) switch (field.question_type) { case 'short_text': @@ -231,7 +237,7 @@ function QuestionBody({ field, interactive, value, onChange, error, shifts }: { ) case 'single_select_radio': { - const options: FormFieldOption[] = config.options ?? [] + const options = liveOptions const displayOptions = options.map((opt) => ({ value: opt.option_id, label: optionDisplayLabel(opt, shifts) })) const selected = interactive ? (value as string | undefined) ?? '' : '' @@ -261,7 +267,7 @@ function QuestionBody({ field, interactive, value, onChange, error, shifts }: { } case 'single_select_dropdown': { - const options: FormFieldOption[] = config.options ?? [] + const options = liveOptions return ( ({ value: opt.option_id, label: optionDisplayLabel(opt, shifts) })) const selected = interactive ? ((value as string[] | undefined) ?? []) : [] @@ -310,7 +316,7 @@ function QuestionBody({ field, interactive, value, onChange, error, shifts }: { } case 'ranked_choice': { - const options: FormFieldOption[] = config.options ?? [] + const options = liveOptions const ranks = config.ranks ?? options.length return ( !option.is_archived) + const archivedOptions = allOptions.filter((option) => option.is_archived) + const setOptions = (options: EditableOption[]) => + onFieldChange({ config: { ...field.config, options: [...options, ...archivedOptions] } }) + if (isEntity || usesTrackEditor || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { return ( <> @@ -386,8 +403,8 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra fieldKey={presetKind as 'availability' | 'event_preference' | 'track_status'} tournament={tournament!} questionType={field.question_type} - options={(field.config?.options as EditableOption[] | undefined) ?? []} - onChange={(options) => onFieldChange({ config: { ...field.config, options } })} + options={liveOptions} + onChange={setOptions} displayStyle={field.config?.display_style} branchTargets={supportsBranching && branchingEnabled ? branchTargets : undefined} errors={errors} @@ -395,8 +412,8 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra /> ) : ( onFieldChange({ config: { ...field.config, options } })} + options={liveOptions} + onChange={setOptions} questionType={field.question_type} displayStyle={field.config?.display_style} branchTargets={supportsBranching && branchingEnabled ? branchTargets : undefined} @@ -409,7 +426,7 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra rank mechanics are a property of the question type, not of where the option rows come from. */} {field.question_type === 'ranked_choice' && (() => { - const options = (field.config?.options as EditableOption[] | undefined) ?? [] + const options = liveOptions const ranks = field.config?.ranks ?? 1 // Same live-data gate as confirmError above — re-check against the // current option count rather than trusting the errors snapshot is From bff76e62a80b6bab292fc71c8da24e2646423c49 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 20:37:14 -0700 Subject: [PATCH 45/92] docs(forms): add form edit lifecycle spec and preset config shapes --- backend/form-edit-lifecycle.md | 268 +++++++++++++++++++++++ backend/form-question-types-reference.md | 110 ++++++++-- 2 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 backend/form-edit-lifecycle.md diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md new file mode 100644 index 00000000..83efc47b --- /dev/null +++ b/backend/form-edit-lifecycle.md @@ -0,0 +1,268 @@ +# Form Edit Lifecycle + +**Target-state specification.** Describes how editing a form that already has +responses should behave. Companion to `form-question-types-reference.md`, +which covers config/option shapes. + +## Principles + +1. **Edits mutate in place.** A field keeps its `id` across every edit — + `question_type`, `field_key`, options, all of it. Archiving is for + retirement, not for editing. +2. **Answers are self-describing.** A stored answer records the shape it was + answered under, so reading history never depends on the field's current + configuration. +3. **Stored answers are never rewritten.** No migration, no replay, no + recompute. See Write-through. +4. **Intent is declared, not inferred.** A diff cannot distinguish "we ran out + of shirts" from "this option was never valid." The TD says which. +5. **`field_key` is a name, not an identity.** It's a display and + write-through label. Only `field_id` identifies a question. + +## Identity + +| Identifier | Mutable | Role | +|---|---|---| +| `FormField.id` | never | The question. What answers and pending updates reference. | +| `FormField.field_key` | freely | Display name + write-through semantics. Unique among **live** fields per tournament; an archived field's key is released. | +| Option `option_id` | never | The choice. What answers reference. | + +Because identity lives on `field_id`, renaming a `field_key` requires no +bookkeeping — nothing else keys off it. + +## Answer storage + +`FormAnswer` stores `field_id`, the selected `option_id`(s), a +`{option_id, value, label}` snapshot taken at submit time, and the +`question_type` **and** `field_key` it was answered under. + +Answer shape is a function of `(question_type, field_key)` — a preset key +stores entity ids where a standard key stores text. Recording both is what +makes every in-place change safe to read back: a pre-change answer is still +interpreted by the rules that were in force when it was given. + +Two read paths, deliberately different: + +- **Responses view** (form submissions) — render the snapshot verbatim. Shows + what the respondent actually saw. +- **Member profile** (current truth) — resolve `option_id` against the field's + *current* config and use today's `value`/`label`. Fall back to the snapshot + only when the option no longer exists. + +## When a pending update is raised + +`FormResponsePendingUpdate` asks a previous responder to look at a question +again. + +**Mandatory — always raised, TD cannot suppress:** + +| Change | Who is flagged | +|---|---| +| `question_type` changes shape class (below) | everyone who answered | +| Option added or reopened | everyone who answered | +| Option invalidated | only those who selected it | + +An added option flags everyone because a previous responder may have settled +for a lesser choice when their real answer wasn't offered. Reopening a closed +option is identical in effect, so it's treated the same. + +**Never raised:** + +| Change | +|---| +| `question_type` changes within its shape class | +| Option `value` edited (TD-facing text only) | +| Option closed | +| Field retired | +| Field order, `display_style`, branching targets | + +**TD's choice:** + +| Change | Default | Why it's a judgment call | +|---|---|---| +| `field_key` moves between preset and standard | **on** | Nothing changed for the respondent — the labels can be identical, and their answer is still correct. But write-through is forward-only, so their data won't reach `MembershipAvailability` / `TournamentMembershipLunch` / track statuses unless they resubmit. The TD is deciding whether they need that data for people who already answered. | +| Question label | off | Rewording may or may not change what's being asked. | +| Question description | off | Same. | +| Option label (respondent-facing text) | off | Same. | + +The preset toggle defaults **on** because silently leaving existing responders +out of write-through is the more surprising outcome. The confirmation modal +should say so, not just show a switch. + +### Shape classes + +A `question_type` change matters only when the stored answer shape changes. + +| Class | Types | Stored shape | +|---|---|---| +| text | `short_text`, `long_text` | string | +| single-select | `single_select_radio`, `single_select_dropdown` | one snapshot | +| multi | `multi_select_checkbox` | list of snapshots | +| ranked | `ranked_choice` | `{rank: snapshot}` | +| bool | `acknowledgment` | boolean | + +Within a class → presentational, no pending update. Across classes → +mandatory. + +## Save-time confirmation + +Shown only when the form is history-preserving, and only when the save +contains at least one change that could raise a pending update. + +A modal lists every edited question with a per-question toggle: + +- **Mandatory** changes appear with the toggle on and locked, so the TD sees + the full blast radius before committing. +- **TD's choice** changes appear editable, at the default for that change + type (see the table above — not all default off). +- Where a default carries a non-obvious consequence, the row states it. A + preset key change that's toggled off should read as "existing responses + won't be written through," not as a bare switch. +- Questions whose edits never raise a pending update aren't listed. + +This is the last chance to reconsider before responders are asked to redo +work. + +## Option lifecycle + +Four verbs. The TD picks; the system never guesses. + +| Verb | Meaning | Storage | Pending update | +|---|---|---|---| +| **Add** | new choice available | appended | everyone | +| **Close** | ran out; existing answers still valid | `is_archived: true` | nobody | +| **Reopen** | a closed option is available again | `is_archived: false` | everyone | +| **Invalidate** | never valid; existing answers are wrong | removed | only those who selected it | + +All four keep the same `field_id` — the question didn't change, its choices +did. An invalidated option's past answers still render from their snapshot; +they're flagged as stale, not corrupted. + +Closed options are never shown to respondents and never appear as editable +rows in the builder. They live in storage only. + +## Field lifecycle + +| Action | Effect | Answers | Pending update | +|---|---|---|---| +| **Edit** | mutate in place; `id` preserved | untouched | per the tiers above | +| **Retire** | `is_archived: true`; key released | kept as history | none | +| **Restore** | `is_archived: false` | re-link automatically via `field_id` | none | +| **Invalidate** | `is_archived: true` | **purged**, with write-through cleanup | none — there's nothing left to review | + +**Restore** works because editing never changes `field_id`, so `FormAnswer` +rows still point at the field. On restore, re-validate: `next_field_id` may +point at something since retired, and the `field_key` may have been claimed by +a live field while it was gone. + +**Invalidate** is the only destructive action. It's for a question that should +never have been asked — the answers are not history worth keeping. Requires +explicit confirmation. + +Archived fields appear in the builder in a collapsed **Archived questions** +section, never inline — they must not participate in `order` or be selectable +as branching targets. Restoring appends to the end of `order`. + +## Response routes + +| Route | Access | Behavior | +|---|---|---| +| `POST /forms/{id}/responses/` | view | **Create only.** `409` if this user already has a response. Validates every required field; writes through every answer. | +| `PATCH /forms/{id}/responses/me/` | view | **Gated edit.** Body carries `{field_id, value}` for one or more fields. | +| `GET /forms/{id}/responses/` | manage | all responses | +| `GET /forms/{id}/responses/me/` | view | own response | + +`PATCH` rejects with `403` any `field_id` that does not have an open pending +update for this response. That is the whole gate: a respondent can only touch +what the TD asked them to revisit, enforced server-side rather than by the UI. + +- Only the patched fields' answers are replaced. Everything else is untouched. +- Required-field validation applies to the patched fields only — the rest + already satisfied it at creation. +- Write-through runs for the patched fields only. +- Each patched field's pending update is cleared. + +## Clearing a pending update + +A pending update clears when its field is patched — explicitly, one at a time. +If a TD flags three questions and the respondent answers one, the other two +stay open. + +`created_at` is retained for display and ordering ("flagged 3 days ago"), not +for clearing. + +## The respondent's update flow + +A submitted response is **not freely editable.** A respondent may only change +questions that carry a pending update — enforced by `PATCH`, not by the UI. + +The form reopens with full context, but only the flagged questions are live: + +- Every previously answered question renders prefilled. +- Questions with a pending update render blank, highlighted, and editable. +- Every other question renders read-only, showing its prior answer. It is + **not** resubmitted — `PATCH` carries only the flagged fields. +- The respondent is never asked to retype answers that didn't change. + +A respondent who needs to correct something that isn't flagged asks the TD, +who can raise a pending update for that question. There is no self-serve path, +because an unrestricted edit to an old response can overwrite newer state +elsewhere (see Write-through). + +## Write-through + +**Write-through is forward-only.** It runs at submission time and never +recomputes from stored answers. Replaying historical answers would apply them +out of submission order — an old form's "interested" would overwrite a newer +form's "confirmed." + +This is why answers are never rewritten when a `field_key` moves between +preset and standard: the old answers keep their original semantics, and only +new submissions write through under the new key. + +Cleanup on **Invalidate**: + +| Target | Rule | +|---|---| +| `TournamentMembershipLunch` | keyed by (membership, category) — delete the field's rows | +| `MembershipAvailability` | **never deleted.** Rows are a union across every active `availability_*` field; per-field deletion is undefined. | +| Track statuses | **never deleted.** A track's state may have been set by a later form; removing this field's contribution can't be done without replay. | + +A blanket "reset all availability" is a separate, explicit TD action, not a +side effect of editing one field. It should stay rare — re-collecting form +responses is expensive in practice. + +## Schema requirements + +- `FormAnswer.question_type` and `FormAnswer.field_key` — the semantics the + answer was recorded under. +- `FormResponsePendingUpdate.field_id` replaces `field_key` — identity, not + name. `created_at` already exists and becomes the clearing mechanism. +- `field_key` uniqueness narrows to live fields per tournament; archived keys + are released. + +No lineage column is needed. In-place mutation keeps `field_id` stable, which +is what a lineage column would otherwise have to reconstruct. + +## Track status ordering + +Track status write-through is last-write-wins, and "last" is the order +write-through runs, not submission order. Left unconstrained, a respondent +editing an older form could demote a track that a newer form already set to +`confirmed`. + +Three rules narrow this to near-zero: + +1. **Forward-only write-through** — historical answers are never replayed. +2. **Locked responses** — a respondent can only edit questions the TD flagged, + so no spontaneous edits to old forms. +3. **Patch-scoped write-through** — an edit carries only the flagged fields, + so no other field's write-through re-fires. + +**Remaining exposure:** a TD raises a pending update on a track question in an +*older* form, and the respondent's new answer overwrites a newer form's status. +This requires a deliberate TD action on that specific question, so it's +visible rather than silent — but it is not prevented. + +Closing it fully needs write-through to record which response last set each +track status and reject an out-of-order write. Deferred, not solved. diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index 56f7948f..317b089e 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -25,10 +25,10 @@ Every `FormField` shares the same outer shape: A tournament may have **multiple** fields under the same reserved prefix — `availability_20260315`, `availability_20260316` for two dates, `event_preference_morning`, `event_preference_afternoon` for two independently-ranked axes. `availability_*` fields are the one case where multiple questions share a single pool of storage (see below) — every other reserved key, including `event_preference_*`, keeps each suffix's answers separate simply because each field has its own `field_id`/`FormAnswer` row; there's no merging step needed for that. **Options-storage rule:** wherever a type has an `options` array, each option is `{ "option_id": ..., "value": ..., "label": ..., "is_archived": false }`: -- `option_id` — system-generated, opaque, required, and the **sole stable identifier**: what a submitted answer actually references, what branching matches against, and what Edit Lifecycle diffs/archives by (see "Reserved `field_key`s" and the Edit Lifecycle section below). Never client-authored; a create/update request may omit it (new option) or echo back one from a prior `GET` (existing option, kept stable). +- `option_id` — system-generated, opaque, required, and the **sole stable identifier**: what a submitted answer actually references, what branching matches against, and what the edit lifecycle diffs/archives by (see "Reserved `field_key`s" and `form-edit-lifecycle.md`). Never client-authored; a create/update request may omit it (new option) or echo back one from a prior `GET` (existing option, kept stable). - `value` — normally TD-facing display text (typically a shortened version of `label`). For an entity-backed reserved `field_key` (`availability` grouping `TournamentShift`s, `event_preference` grouping `TournamentEvent`s), it's instead `list[int]` — the real ids of the underlying entities this option groups together — and the client is responsible for interpreting which shape to expect based on `field_key`. A bare `list[int]` for `event_preference` is resolved on render (see below); a legacy plain-string `value` there passes through unresolved. - `label` — responder-facing display text. -- `is_archived` — set by the server during a published-form republish (see Edit Lifecycle); an archived option is dropped from what a new respondent sees/can select, but stays in storage so a past answer referencing its `option_id` still resolves. +- `is_archived` — set by the server during a published-form republish (see `form-edit-lifecycle.md`); an archived option is dropped from what a new respondent sees/can select, but stays in storage so a past answer referencing its `option_id` still resolves. Options are stored raw and literal — a resolved snapshot at creation/edit time, not a dynamic source reference. Editors may offer an "auto-load from tournament" convenience (events, shifts) that populates `value`'s entity-id list once; after that it's just a normal static list like any other question's options, no live server-side lookup involved. @@ -143,36 +143,100 @@ Only `single_select_radio` and `single_select_dropdown` options may carry branch | `availability_{date}` — e.g. `availability_20260315` (`^availability_\d{8}$`), one per date; a bare `availability` (no date) is **not** a valid reserved key | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipAvailability` (tournament-owned forms only); selected option_id(s) across **every** active `availability_*` field on the response are expanded into their grouped `TournamentShift` ids, unioned, and diffed as one set — every date's question feeds the same centralized "shifts this member is available for" pool, not a per-date table | | `lunch_{date}_{category}` — e.g. `lunch_20270213_protein` (`^lunch_\d{8}_[a-z0-9_]+$`), one per (date, category) pair | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipLunch` (tournament-owned forms only); selected option_id(s) resolve to their stored `value`/`label`, no catalog table — stores whatever option was selected, keyed by category string | | `event_preference_{suffix}` — e.g. `event_preference_morning` (`^event_preference_[a-z0-9_]+$`), one per independently-ranked axis; a bare `event_preference` (no suffix) is **not** a valid reserved key | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer`, same as any custom question (option `value` may be `list[int]` of real `TournamentEvent` ids, resolved on render; not yet strictly validated against real events). Unlike `availability`, different suffixes are **not** merged into one pool — each suffix is read as its own axis by querying `FormAnswer` directly wherever event preferences are needed downstream, rather than being synced into a dedicated structural table. `TournamentMembership.event_preference` is an unrelated, already-deprecated manual-entry JSON column (along with `role_preference`, `availability`, `lunch_order`, `extra_data` on that model) — not read or written by this write-through. | -| `track_status_{suffix}` — e.g. `track_status_volunteer_interest` (`^track_status_[a-z0-9_]+$`), one per independently named status question | required `single_select_radio` or `multi_select_checkbox` | pending membership-track status write-through; each option carries `track_statuses: [{track_id, status}]`, where `status` is `interested`, `confirmed`, or `declined`. An `availability_*` field may carry the same option metadata only with `track_status_enabled: true`. Checkbox options may repeat a track only when they assign the same status. | +| `track_status_{suffix}` — e.g. `track_status_volunteer_interest` (`^track_status_[a-z0-9_]+$`), one per independently named status question | `single_select_radio` or `multi_select_checkbox`, and `required` **must** be `true` | pending membership-track status write-through; each option's `value` is the list of track assignments it applies (shape below). An `availability_*` field may carry assignments too, but only with `track_status_enabled: true`. Checkbox options may repeat a track only when they assign it the same status. | | any TD-typed slug | any type | none — generic `FormAnswer` | Reserved keys are currently valid only on tournament-owned forms. `track_status_*` also requires tracks from that tournament's catalog. ---- +### Preset `config` shapes -## `Form.status` +A preset never introduces its own `question_type` — it reuses a structural one +and changes what each option's `value` holds. There are **five** distinct +shapes, because `availability_*` has two depending on the track opt-in. All +option schemas are `extra="forbid"` (`app/schemas/form.py`): a key that isn't +in the shape below is rejected outright, not ignored. -`Form.status` is `"draft"` | `"published"` | `"archived"`, set/transitioned via `PATCH /forms/{form_id}/`: -- **Only a `published` form accepts responses.** `POST /forms/{form_id}/responses/` rejects with `409` on a `draft` or `archived` form, regardless of the requester's access level. -- **A `published` form can't be reverted to `draft`.** `PATCH .../status: "draft"` on a currently-`published` form is rejected with `409` — archive it instead if it should stop accepting responses. This exists because `draft`-status editing is a hard-delete/direct-apply path (see Edit Lifecycle below); allowing published → draft would let a TD silently destroy already-answered fields/options through a path that was never meant to touch live data. -- Publishing (`draft` → `published`, or an explicit republish while already `published`) runs a whole-form validation pass (`validate_form_for_publish`): the form must have at least one active field, and every field's `config`/branching/`next_field_id` resolution must be valid in aggregate — not just individually — before the transition/republish is allowed. +**1. `availability_{date}` — plain.** `value` is the `TournamentShift` ids this +option groups. + +```json +{ "required": true, "display_style": "list", "options": [ + { "option_id": "a1b2c3d4e5", "label": "All Day", "value": [3, 2, 5] } +] } +``` + +**2. `availability_{date}` — with track statuses.** Set by the builder's "Also +update track status" toggle. `config.track_status_enabled: true` is what +*permits* assignments here; the flag is availability-only and rejected on any +other reserved key. `value` becomes an object — `shift_ids` is required and +non-empty. + +```json +{ "required": true, "track_status_enabled": true, "options": [ + { "option_id": "a1b2c3d4e5", "label": "All Day", "value": { + "shift_ids": [3, 2, 5], + "track_statuses": [{ "id": 7, "status": "confirmed" }] + } } +] } +``` + +**3. `event_preference_{suffix}`.** `value` is the `TournamentEvent` ids +grouped under one label. Not yet strictly validated against real events. + +```json +{ "required": true, "ranks": 3, "allow_duplicates": false, "options": [ + { "option_id": "a1b2c3d4e5", "label": "Life Science", "value": [5, 9] } +] } +``` + +**4. `lunch_{date}_{category}`.** Looks like a preset but its options are +ordinary TD-typed text — `value` is a plain string, same as any custom +question. The reserved key only drives write-through. + +```json +{ "required": true, "display_style": "list", "options": [ + { "option_id": "a1b2c3d4e5", "label": "Vegetarian", "value": "vegetarian" } +] } +``` -## Edit Lifecycle +**5. `track_status_{suffix}`.** `value` **is** the assignment list — there is +no separate `track_statuses` key on the option. `required` must be `true`. -Once a form is `published`, someone may have already answered it, so editing its fields doesn't work the way it does on a `draft` form. There's no server-side draft/staging table — the client holds an in-progress edit locally and sends the complete target field list in one request, which the server treats as "go live now." +```json +{ "required": true, "display_style": "list", "options": [ + { "option_id": "a1b2c3d4e5", "label": "Yes", "value": [ + { "id": 7, "status": "interested" } + ] }, + { "option_id": "f6e5d4c3b2", "label": "No", "value": [] } +] } +``` -**`PUT /forms/{id}/fields/`** replaces the old per-field `POST`/`PATCH`/`DELETE` routes entirely. Body is the full ordered target list of fields: -- Entry with an existing field `id` → update. -- Entry with no `id` → create. -- A currently-live, non-archived field whose `id` is missing from the list → removal. +**Assignment shape**, shared by 2 and 5: `{ "id": , +"status": "interested" | "confirmed" | "declined" }`. Both keys are required +— `id` is the track's catalog id (**not** `track_id`), and `status` has no +default. Track ids must belong to the field's own tournament; archived tracks +stay valid so historical fields still resolve. On `multi_select_checkbox`, two +options may only name the same track if they assign it the same status. + +Duplicate track ids *within a single option* are rejected on shape 2 only — +`_unique_track_statuses` is wired into `AvailabilityTrackStatusValue` but not +into a bare `list[TrackStatusAssignment]`, so shape 5 currently accepts +`[{"id": 7, "status": "interested"}, {"id": 7, "status": "declined"}]`. +Asymmetry, not intent. + +**Why `value` and not a dedicated key:** the option schemas union +`str | list[int] | list[TrackStatusAssignment] | AvailabilityTrackStatusValue` +on `value` rather than adding per-preset fields, so switching presets rewrites +one field instead of migrating between key sets. The cost is that `value`'s +shape is only interpretable alongside `field_key` — code that reads options +must discriminate on the *element*, not just `isinstance(value, list)`, or it +will read grouped entity ids as track assignments. -**`draft`-status forms:** applied directly — hard delete removed fields, update changed ones (including `question_type` changes, in place), insert new ones. No archiving, since nothing on a form that's never been published has ever been answerable. +--- -**`published`-status forms:** the server diffs the submitted list against current live fields, then validates the whole proposed end-state (config shape, options, branching `next_field_id` resolution) before anything commits — a dangling branch reference, including one that would point at a field this same request removes, rejects the whole batch atomically. If valid: -- Label/description/config-only changes → update in place. -- `question_type` change → archive the old field, create a replacement at the same list position, inheriting the same `field_key` (an explicit exception to "archived keys stay reserved forever" — this is the same logical question continuing, not a new one). -- Missing from the submitted list → archive, not delete. -- No `id` → insert as new. -- Within an updated field, options are diffed by `option_id` the same way — one missing from the submitted config gets `is_archived: true` added rather than being dropped from storage. +## `Form.status` -**`FormResponsePendingUpdate`** (`response_id`, `field_key`, `reason`: `"field_replaced"` | `"option_archived"`, unique on `(response_id, field_key)`) is generated whenever a republish archives a field or option that a response had already answered — this is how a TD or respondent finds out an existing answer needs another look. Keyed by `field_key` (not a field id) so it always resolves to whichever field currently holds that key, regardless of further edits. `reason` only ever escalates `option_archived` → `field_replaced`, never the reverse. Cleared when the response next submits a fresh answer to whichever field currently holds that `field_key`. +`Form.status` is `"draft"` | `"published"` | `"archived"`, set/transitioned via `PATCH /forms/{form_id}/`: +- **Only a `published` form accepts responses.** `POST /forms/{form_id}/responses/` rejects with `409` on a `draft` or `archived` form, regardless of the requester's access level. +- **A `published` form can't be reverted to `draft`.** `PATCH .../status: "draft"` on a currently-`published` form is rejected with `409` — archive it instead if it should stop accepting responses. This exists because `draft`-status editing is a hard-delete/direct-apply path (see `form-edit-lifecycle.md`); allowing published → draft would let a TD silently destroy already-answered fields/options through a path that was never meant to touch live data. +- Publishing (`draft` → `published`, or an explicit republish while already `published`) runs a whole-form validation pass (`validate_form_for_publish`): the form must have at least one active field, and every field's `config`/branching/`next_field_id` resolution must be valid in aggregate — not just individually — before the transition/republish is allowed. From 27e0f22dd449825283a8d5bc4f808de23bd5c38e Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 20:55:52 -0700 Subject: [PATCH 46/92] docs(forms): spec gated response patching and pending update lifecycle --- backend/form-edit-lifecycle.md | 42 +++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md index 83efc47b..f76a3187 100644 --- a/backend/form-edit-lifecycle.md +++ b/backend/form-edit-lifecycle.md @@ -61,6 +61,7 @@ again. | `question_type` changes shape class (below) | everyone who answered | | Option added or reopened | everyone who answered | | Option invalidated | only those who selected it | +| Field becomes required | only those who left it blank | An added option flags everyone because a previous responder may have settled for a lesser choice when their real answer wasn't offered. Reopening a closed @@ -146,9 +147,14 @@ rows in the builder. They live in storage only. | Action | Effect | Answers | Pending update | |---|---|---|---| | **Edit** | mutate in place; `id` preserved | untouched | per the tiers above | -| **Retire** | `is_archived: true`; key released | kept as history | none | +| **Retire** | `is_archived: true`; key released | kept as history | **open ones deleted** | | **Restore** | `is_archived: false` | re-link automatically via `field_id` | none | -| **Invalidate** | `is_archived: true` | **purged**, with write-through cleanup | none — there's nothing left to review | +| **Invalidate** | `is_archived: true` | **purged**, with write-through cleanup | **open ones deleted** | + +Retiring or invalidating a field **deletes its open pending updates.** A flag +on a field the respondent can no longer answer is unclearable by construction +— `PATCH` would reject the field, and the question isn't rendered. This is not +optional cleanup; skipping it strands respondents permanently. **Restore** works because editing never changes `field_id`, so `FormAnswer` rows still point at the field. On restore, re-validate: `next_field_id` may @@ -220,6 +226,13 @@ This is why answers are never rewritten when a `field_key` moves between preset and standard: the old answers keep their original semantics, and only new submissions write through under the new key. +**Rows already written stay, by design.** Moving a question away from a preset +does not remove what it previously wrote to `MembershipAvailability` or track +statuses. Those tables are shared — multiple questions, across multiple forms, +contribute to the same rows, so no single field owns any of them and none can +be safely withdrawn. Lunch is the exception: keyed by (membership, category), +it has a single owner and can be deleted. + Cleanup on **Invalidate**: | Target | Rule | @@ -232,17 +245,15 @@ A blanket "reset all availability" is a separate, explicit TD action, not a side effect of editing one field. It should stay rare — re-collecting form responses is expensive in practice. -## Schema requirements - -- `FormAnswer.question_type` and `FormAnswer.field_key` — the semantics the - answer was recorded under. -- `FormResponsePendingUpdate.field_id` replaces `field_key` — identity, not - name. `created_at` already exists and becomes the clearing mechanism. -- `field_key` uniqueness narrows to live fields per tournament; archived keys - are released. +## Storage -No lineage column is needed. In-place mutation keeps `field_id` stable, which -is what a lineage column would otherwise have to reconstruct. +- `FormAnswer` records `field_id`, the selected `option_id`(s), the + `{option_id, value, label}` snapshot, and the `question_type` / `field_key` + the answer was given under. +- `FormResponsePendingUpdate` is keyed on `field_id`, with `created_at` for + display and ordering. +- `field_key` is unique among **live** fields within a tournament. Archived + fields do not reserve their keys, and may share a key with a live field. ## Track status ordering @@ -265,4 +276,9 @@ This requires a deliberate TD action on that specific question, so it's visible rather than silent — but it is not prevented. Closing it fully needs write-through to record which response last set each -track status and reject an out-of-order write. Deferred, not solved. +track status and reject an out-of-order write. + +## Out of scope + +A TD editing another user's response. Responses are locked to flagged fields +and there is no TD override. From e697f9803d90d536e0b8a479a2b49ff5169f5b8c Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 21:14:14 -0700 Subject: [PATCH 47/92] docs(forms): document pending update reasons and storage model --- backend/form-edit-lifecycle.md | 50 +++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md index f76a3187..c39ad5b4 100644 --- a/backend/form-edit-lifecycle.md +++ b/backend/form-edit-lifecycle.md @@ -51,17 +51,19 @@ Two read paths, deliberately different: ## When a pending update is raised -`FormResponsePendingUpdate` asks a previous responder to look at a question -again. +A pending update asks a previous responder to look at a question again. One +row per (response, field), carrying the set of `reasons` that opened it — +several can apply to the same field in one save, so they union rather than +override. **Mandatory — always raised, TD cannot suppress:** -| Change | Who is flagged | -|---|---| -| `question_type` changes shape class (below) | everyone who answered | -| Option added or reopened | everyone who answered | -| Option invalidated | only those who selected it | -| Field becomes required | only those who left it blank | +| Change | `reason` | Who is flagged | +|---|---|---| +| `question_type` changes shape class (below) | `question_type_changed` | everyone who answered | +| Option added or reopened | `option_added` | everyone who answered | +| Option invalidated | `option_invalidated` | only those who selected it | +| Field becomes required | `now_required` | only those who left it blank | An added option flags everyone because a previous responder may have settled for a lesser choice when their real answer wasn't offered. Reopening a closed @@ -79,12 +81,12 @@ option is identical in effect, so it's treated the same. **TD's choice:** -| Change | Default | Why it's a judgment call | -|---|---|---| -| `field_key` moves between preset and standard | **on** | Nothing changed for the respondent — the labels can be identical, and their answer is still correct. But write-through is forward-only, so their data won't reach `MembershipAvailability` / `TournamentMembershipLunch` / track statuses unless they resubmit. The TD is deciding whether they need that data for people who already answered. | -| Question label | off | Rewording may or may not change what's being asked. | -| Question description | off | Same. | -| Option label (respondent-facing text) | off | Same. | +| Change | `reason` | Default | Why it's a judgment call | +|---|---|---|---| +| `field_key` moves between preset and standard | `key_changed` | **on** | Nothing changed for the respondent — the labels can be identical, and their answer is still correct. But write-through is forward-only, so their data won't reach `MembershipAvailability` / `TournamentMembershipLunch` / track statuses unless they resubmit. The TD is deciding whether they need that data for people who already answered. | +| Question label | `text_changed` | off | Rewording may or may not change what's being asked. | +| Question description | `text_changed` | off | Same. | +| Option label (respondent-facing text) | `text_changed` | off | Same. | The preset toggle defaults **on** because silently leaving existing responders out of write-through is the more surprising outcome. The confirmation modal @@ -250,11 +252,27 @@ responses is expensive in practice. - `FormAnswer` records `field_id`, the selected `option_id`(s), the `{option_id, value, label}` snapshot, and the `question_type` / `field_key` the answer was given under. -- `FormResponsePendingUpdate` is keyed on `field_id`, with `created_at` for - display and ordering. - `field_key` is unique among **live** fields within a tournament. Archived fields do not reserve their keys, and may share a key with a live field. +### `FormResponsePendingUpdate` + +| Column | Notes | +|---|---| +| `response_id` | the flagged response | +| `field_id` | the field to answer to clear it. Never `field_key` — a key is a TD-editable name, so keying history on it strands the flag the moment the question is renamed. | +| `reasons` | set of the `reason` values above; unioned when several apply | +| `created_at` | display and ordering ("flagged 3 days ago") | + +Unique on `(response_id, field_id)`. + +`field_id` points at the field the respondent can *act on*, which is not +always the field they originally answered — where a question is replaced +rather than edited, the flag follows the successor. + +A row is cleared when that field is patched, and deleted outright when the +field is retired or invalidated. + ## Track status ordering Track status write-through is last-write-wins, and "last" is the order From 9037ca0c1cc3063b9bb0d95f8bbb48255f02a33a Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 21:23:10 -0700 Subject: [PATCH 48/92] feat(forms): record answer semantics and key pending updates on field_id --- ...dd_answer_semantics_and_pending_update_.py | 102 ++++++++++++++++++ backend/app/api/routes/forms.py | 47 +++++--- backend/app/core/form/__init__.py | 37 ++++--- backend/app/models/models.py | 32 ++++-- backend/tests/api/test_forms.py | 13 ++- 5 files changed, 191 insertions(+), 40 deletions(-) create mode 100644 backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py diff --git a/backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py b/backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py new file mode 100644 index 00000000..64340a4f --- /dev/null +++ b/backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py @@ -0,0 +1,102 @@ +"""add answer semantics and pivot pending updates to field_id + +Phase 1 of the form edit lifecycle work (see backend/form-edit-lifecycle.md). + +Two changes: + * FormAnswer records the question_type/field_key it was answered under, so a + stored answer stays readable after its field is edited. + * FormResponsePendingUpdate keys on field_id instead of field_key. A key is + a TD-editable display name; keying history on it strands the flag the + moment the question is renamed. + +Revision ID: 00bf7c99a668 +Revises: 8d55ec2b6640 +Create Date: 2026-08-26 21:00:55.083705 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '00bf7c99a668' +down_revision: Union[str, None] = '8d55ec2b6640' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('form_answers', sa.Column('question_type', sa.String(length=32), nullable=True)) + op.add_column('form_answers', sa.Column('field_key', sa.String(length=64), nullable=True)) + + # Backfill from each answer's field as it looks *now*. Correct for any + # field untouched since the answer was given, approximate otherwise — + # there's no record of the field's past shape, which is the gap these + # columns close going forward. + op.execute(""" + UPDATE form_answers a + SET question_type = f.question_type, + field_key = f.field_key + FROM form_fields f + WHERE f.id = a.field_id + """) + + op.add_column('form_response_pending_updates', sa.Column('field_id', sa.String(length=12), nullable=True)) + + # Resolve each flag's field_key to a field on the same form. Prefer the + # live one: a removed field keeps its key while archived, so a key can be + # held by both an archived row and its replacement — the flag refers to + # whichever the respondent can actually answer. + op.execute(""" + UPDATE form_response_pending_updates p + SET field_id = ( + SELECT f.id + FROM form_fields f + WHERE f.form_id = ( + SELECT r.form_id FROM form_responses r WHERE r.id = p.response_id + ) + AND f.field_key = p.field_key + ORDER BY f.is_archived ASC, f.id ASC + LIMIT 1 + ) + """) + + # A flag whose key resolves to nothing points at a field that no longer + # exists, so it could never be cleared by answering anything. Drop it + # rather than block the NOT NULL below. + op.execute("DELETE FROM form_response_pending_updates WHERE field_id IS NULL") + + op.alter_column('form_response_pending_updates', 'field_id', nullable=False) + op.create_foreign_key( + 'fk_pending_update_field_id', 'form_response_pending_updates', 'form_fields', + ['field_id'], ['id'], ondelete='CASCADE', + ) + + op.drop_constraint('uq_pending_update_per_response_field', 'form_response_pending_updates', type_='unique') + op.drop_column('form_response_pending_updates', 'field_key') + op.create_unique_constraint( + 'uq_pending_update_per_response_field', 'form_response_pending_updates', ['response_id', 'field_id'] + ) + + +def downgrade() -> None: + op.add_column('form_response_pending_updates', sa.Column('field_key', sa.String(length=64), nullable=True)) + op.execute(""" + UPDATE form_response_pending_updates p + SET field_key = f.field_key + FROM form_fields f + WHERE f.id = p.field_id + """) + op.execute("DELETE FROM form_response_pending_updates WHERE field_key IS NULL") + op.alter_column('form_response_pending_updates', 'field_key', nullable=False) + + op.drop_constraint('uq_pending_update_per_response_field', 'form_response_pending_updates', type_='unique') + op.drop_constraint('fk_pending_update_field_id', 'form_response_pending_updates', type_='foreignkey') + op.drop_column('form_response_pending_updates', 'field_id') + op.create_unique_constraint( + 'uq_pending_update_per_response_field', 'form_response_pending_updates', ['response_id', 'field_key'] + ) + + op.drop_column('form_answers', 'field_key') + op.drop_column('form_answers', 'question_type') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 1649c862..db69f35f 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -598,8 +598,12 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) return normalized - pending_flags: list[tuple[str, str, str | None, list[str]]] = [] - # (field_key, reason, field_id_for_answer_lookup, archived_option_ids) + # (field_answered, field_to_flag, reason, archived_option_ids). The two + # fields differ only on the archive+replace path, where responses answered + # the now-archived row but must be pointed at its replacement — the field + # they can actually answer. Held as ORM objects because a replacement has + # no id until the flush below. + pending_flags: list[tuple[FormField, FormField, str, list[str]]] = [] order = 1 for entry in payload.fields: @@ -629,7 +633,6 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> # snake_case-alphanumeric validator rejects — swap it for # '_' so the archived key stays valid regardless of id shape. field.field_key = f"{old_key}_archived_{field.id}".replace("-", "_") - pending_flags.append((old_key, "field_replaced", field.id, [])) new_field = FormField( form_id=form.id, @@ -642,11 +645,12 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> is_archived=False, ) db.add(new_field) + pending_flags.append((field, new_field, "field_replaced", [])) else: if is_history_preserving: normalized_config, archived_option_ids = apply_option_archiving(field.config, normalized_config) if archived_option_ids: - pending_flags.append((field.field_key, "option_archived", field.id, archived_option_ids)) + pending_flags.append((field, field, "option_archived", archived_option_ids)) field.order = order field.label = entry.label field.description = entry.description @@ -675,7 +679,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> for field in removed_fields: if is_history_preserving: field.is_archived = True - pending_flags.append((field.field_key, "field_replaced", field.id, [])) + pending_flags.append((field, field, "field_replaced", [])) else: db.delete(field) @@ -686,11 +690,11 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> db.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="; ".join(errors)) - for field_key, reason, field_id, archived_option_ids in pending_flags: + for field_answered, field_to_flag, reason, archived_option_ids in pending_flags: if reason == "field_replaced": - flag_pending_updates_for_field(db, field_id, field_key, "field_replaced") + flag_pending_updates_for_field(db, field_answered.id, field_to_flag.id, "field_replaced") else: - flag_pending_updates_for_archived_options(db, live_by_id[field_id], archived_option_ids) + flag_pending_updates_for_archived_options(db, field_answered, archived_option_ids) # Editing a FormField never touches the Form row itself, so its # onupdate=utcnow wouldn't otherwise fire — bump it explicitly so @@ -764,20 +768,29 @@ def submit_form_response( field_by_id = {field.id: field for field in active_fields} for answer_in in payload.answers: - stored_value = snapshot_answer_value(field_by_id[answer_in.field_id], answer_in.value) - db.add(FormAnswer(response_id=response.id, field_id=answer_in.field_id, value=stored_value)) + field = field_by_id[answer_in.field_id] + stored_value = snapshot_answer_value(field, answer_in.value) + # question_type/field_key record the semantics this answer was given + # under — `value`'s shape is a function of both, so storing them keeps + # the answer readable after the field is edited rather than + # reinterpreting it through whatever the field looks like later. + db.add(FormAnswer( + response_id=response.id, + field_id=field.id, + value=stored_value, + question_type=field.question_type, + field_key=field.field_key, + )) # A fresh answer for a field clears any pending-update flag on it — the - # respondent has now seen and re-confirmed whatever changed. Keyed by - # field_key (not field_id) since that's what a pending-update row keys - # on and what survives an archive+replace (see FormResponsePendingUpdate). - answered_field_keys = { - field.field_key for field in active_fields if field.id in answers_by_field + # respondent has now seen and re-confirmed whatever changed. + answered_field_ids = { + field.id for field in active_fields if field.id in answers_by_field } - if answered_field_keys: + if answered_field_ids: db.query(FormResponsePendingUpdate).filter( FormResponsePendingUpdate.response_id == response.id, - FormResponsePendingUpdate.field_key.in_(answered_field_keys), + FormResponsePendingUpdate.field_id.in_(answered_field_ids), ).delete(synchronize_session=False) if form.owner_type == "tournament": diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 5db0ec98..a85ec512 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -200,33 +200,46 @@ def apply_option_archiving(old_config: dict | None, new_config: dict) -> tuple[d return merged, newly_archived_ids -def _upsert_pending_update(db: Session, response_id: str, field_key: str, reason: str) -> None: +def _upsert_pending_update(db: Session, response_id: str, field_id: str, reason: str) -> None: existing = ( db.query(FormResponsePendingUpdate) .filter( FormResponsePendingUpdate.response_id == response_id, - FormResponsePendingUpdate.field_key == field_key, + FormResponsePendingUpdate.field_id == field_id, ) .first() ) if existing is None: - db.add(FormResponsePendingUpdate(response_id=response_id, field_key=field_key, reason=reason)) + db.add(FormResponsePendingUpdate(response_id=response_id, field_id=field_id, reason=reason)) elif existing.reason == "option_archived" and reason == "field_replaced": # Escalate only in this direction — see FormResponsePendingUpdate. existing.reason = "field_replaced" -def flag_pending_updates_for_field(db: Session, field_id: str, field_key: str, reason: str) -> None: - """Upserts a pending-update row for every response that answered - `field_id` — used when that field was archived (removed, or archived - +replaced by a question_type change). Keyed on `field_key`, not - `field_id`, since field_key is what a respondent/TD recognizes and - what survives an archive+replace.""" +def flag_pending_updates_for_field( + db: Session, answered_field_id: str, target_field_id: str, reason: str +) -> None: + """Flags every response that answered `answered_field_id`, pointing the + flag at `target_field_id` — the field they must answer to clear it. The + two differ on the archive+replace path, where the question continues as a + new row and the archived one can no longer be answered.""" response_ids = { - rid for (rid,) in db.query(FormAnswer.response_id).filter(FormAnswer.field_id == field_id).all() + rid + for (rid,) in db.query(FormAnswer.response_id) + .filter(FormAnswer.field_id == answered_field_id) + .all() } for response_id in response_ids: - _upsert_pending_update(db, response_id, field_key, reason) + _upsert_pending_update(db, response_id, target_field_id, reason) + + +def delete_pending_updates_for_field(db: Session, field_id: str) -> None: + """Drops every open flag on `field_id` — for when the field stops being + answerable at all. A flag pointing at a retired field can never clear, + since clearing requires the respondent to answer it.""" + db.query(FormResponsePendingUpdate).filter( + FormResponsePendingUpdate.field_id == field_id + ).delete(synchronize_session=False) def flag_pending_updates_for_archived_options(db: Session, field: FormField, archived_option_ids: list[str]) -> None: @@ -241,7 +254,7 @@ def flag_pending_updates_for_archived_options(db: Session, field: FormField, arc answers = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).all() for answer in answers: if archived_ids & selected_option_ids(field, answer.value): - _upsert_pending_update(db, answer.response_id, field.field_key, "option_archived") + _upsert_pending_update(db, answer.response_id, field.id, "option_archived") def _resolve_track_statuses(db: Session, assignments: list[dict]) -> list[dict]: diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 839b40a3..9d6bccd9 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -879,6 +879,14 @@ class FormAnswer(Base): response_id = Column(String(12), ForeignKey("form_responses.id", ondelete="CASCADE"), nullable=False) field_id = Column(String(12), ForeignKey("form_fields.id"), nullable=False) value = Column(JSON, nullable=False) + # The semantics this answer was given under. `value`'s shape is a function + # of (question_type, field_key) — a preset key stores entity ids where a + # standard key stores text — so recording both here keeps a stored answer + # readable after the field is edited, instead of reinterpreting history + # through whatever the field looks like today. Nullable only for rows + # predating this column; always written on new answers. + question_type = Column(String(32), nullable=True) + field_key = Column(String(64), nullable=True) response = relationship("FormResponse", back_populates="answers") field = relationship("FormField", back_populates="answer") @@ -889,27 +897,33 @@ class FormAnswer(Base): # --------------------------------------------------------------------------- -# FormResponsePendingUpdate — flags that a response answered a field/option -# which a republish later archived out from under it, so a TD/respondent -# can be shown "this answer needs another look". One row per -# (response, field_key); reason only ever escalates option_archived -> -# field_replaced (never the reverse) on upsert, and the row is deleted once -# that response next submits an answer for whichever field currently holds -# that field_key — see _apply_published_field_changes in api/routes/forms.py. +# FormResponsePendingUpdate — flags that a response's answer to a field needs +# another look, because the TD changed the question in a way that may have +# invalidated it. One row per (response, field_id). +# +# Keyed on field_id, never field_key: a key is a TD-editable display name, so +# keying history on it strands the flag the moment the question is renamed. +# The field a flag points at is therefore stable across every edit. +# +# `reason` only ever escalates option_archived -> field_replaced, never the +# reverse. A row is cleared when the respondent patches that field, and is +# deleted outright if the field is retired or invalidated — a flag on a +# question that can no longer be answered is unclearable by construction. +# See backend/form-edit-lifecycle.md. # --------------------------------------------------------------------------- class FormResponsePendingUpdate(Base): __tablename__ = "form_response_pending_updates" id = Column(Integer, primary_key=True, index=True) response_id = Column(String(12), ForeignKey("form_responses.id", ondelete="CASCADE"), nullable=False) - field_key = Column(String(64), nullable=False) + field_id = Column(String(12), ForeignKey("form_fields.id", ondelete="CASCADE"), nullable=False) reason = Column(String(32), nullable=False) # "field_replaced" | "option_archived" created_at = Column(DateTime(timezone=True), default=utcnow) response = relationship("FormResponse", back_populates="pending_updates") __table_args__ = ( - UniqueConstraint("response_id", "field_key", name="uq_pending_update_per_response_field"), + UniqueConstraint("response_id", "field_id", name="uq_pending_update_per_response_field"), ) diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 3136c7a2..58937ffa 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -784,7 +784,7 @@ def test_option_removed_archives_not_dropped(self, client, db, td_user, td_tourn pending = ( db.query(FormResponsePendingUpdate) - .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_key == "color") + .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_id == field.id) .first() ) assert pending is not None @@ -841,9 +841,18 @@ def test_field_replaced_flags_pending_update_for_prior_answer(self, client, db, json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, ) + # The type change archived the old field and created a replacement; + # the flag points at the replacement, since that's the field the + # respondent can actually answer to clear it. + replacement = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.field_key == "color", FormField.is_archived == False) + .one() + ) + assert replacement.id != field.id pending = ( db.query(FormResponsePendingUpdate) - .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_key == "color") + .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_id == replacement.id) .first() ) assert pending is not None From e8da7753f8d0604834eddc36e148c4ef147dbf0f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 21:26:49 -0700 Subject: [PATCH 49/92] test(forms): fix view access fixture to use an available form --- backend/tests/core/test_forms.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index 369e1f55..c71d084d 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -24,6 +24,7 @@ FormField, FormResponse, TournamentEvent, + TournamentForm, TournamentShift, ) @@ -489,7 +490,21 @@ def test_view_access_non_member_requires_membership(self, db, td_user, td_tourna def test_view_access_plain_member_passes_without_manage_permission(self, db, td_user, td_tournament, other_user): grant_role(db, td_tournament, other_user, "Runner") - form = _make_form(db, td_user, td_tournament) + # View access needs a form a member could actually fill out: published, + # with its TournamentForm companion, and not gated behind onboarding + # order or prerequisites. A bare draft fails before permissions are + # ever considered. + form = _make_form(db, td_user, td_tournament, status="published") + db.add(TournamentForm(tournament_id=td_tournament.id, form_id=form.id)) db.commit() result = require_form_view_access(form.id, db, other_user) assert result.id == form.id + + def test_view_access_plain_member_blocked_on_draft_form(self, db, td_user, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + form = _make_form(db, td_user, td_tournament) + db.add(TournamentForm(tournament_id=td_tournament.id, form_id=form.id)) + db.commit() + with pytest.raises(HTTPException) as exc_info: + require_form_view_access(form.id, db, other_user) + assert exc_info.value.status_code == 403 From 515e0f57897fc5b24e289c73e67f72388f6051d5 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 21:40:44 -0700 Subject: [PATCH 50/92] docs(forms): note that archived field keys are now reusable --- backend/form-question-types-reference.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index 317b089e..0d178247 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -18,7 +18,9 @@ Every `FormField` shares the same outer shape: `config` is type-specific — shapes below. -**`field_key` is required on every field, no exceptions.** The TD types a normal-language label for how they want the question to show up on their dashboard (e.g. "Test Writing Interest") and it's slugified into `field_key` (lowercase, alphanumeric + underscores, e.g. `test_writing_interest`) — this is what the TD sees when scanning/filtering responses later, not just an internal id. Must be unique **per tournament** — across every `Form` that tournament owns, not just within one form — so creating a field checks existing `field_key`s across all of that tournament's forms, including archived fields (an archived key isn't freed for reuse, to keep historical dashboard references unambiguous). +**`field_key` is required on every field, no exceptions.** The TD types a normal-language label for how they want the question to show up on their dashboard (e.g. "Test Writing Interest") and it's slugified into `field_key` (lowercase, alphanumeric + underscores, e.g. `test_writing_interest`) — this is what the TD sees when scanning/filtering responses later, not just an internal id. Must be unique **per tournament** among **live** fields — across every `Form` that tournament owns, not just within one form — so creating a field checks existing `field_key`s across all of that tournament's forms. Archived fields are excluded: a key is a display name, not an identity (`field_id` is), so retiring a question frees its name for reuse. See `form-edit-lifecycle.md`. + +One consequence is unresolved: a reused key means historical dashboard references can now overlap, with the same key naming two different questions at different points in time. Answers are unambiguous — each is bound to a `field_id` and records the `field_key` it was given under — but any TD-facing view that groups or filters by key alone will merge them. How that surfaces is deliberately left for later. **Line between `question_type` and `field_key`:** `question_type` is purely structural — how the question is rendered and answered. `field_key` is semantic — when it's a reserved key (`availability_{date}`, `event_preference_{suffix}`, `lunch_{custom}`, `track_status_{suffix}`), it changes how a *structurally normal* field's options/answers get parsed and, for tournament forms, written through to a structural table. Reserved keys don't get their own `question_type` — they reuse the existing structural types and layer extra validation on top. When a TD picks a reserved-key preset/template, `field_key` should be locked to the reserved value rather than freely typed — otherwise a stray typo (`availibility`) silently breaks write-through with no error. Flagging this as the intended behavior, not yet confirmed. From 6c1d60dc25e00ee77e7610fbbafa9bf20c9625e2 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 21:49:56 -0700 Subject: [PATCH 51/92] feat(forms): edit fields in place and release archived field keys --- ...da722fb9b4_unmangle_archived_field_keys.py | 73 ++++++++++++++ backend/app/api/routes/forms.py | 98 ++++++++----------- backend/app/core/form/__init__.py | 35 +++---- backend/app/models/models.py | 12 ++- backend/tests/api/test_forms.py | 37 ++++--- backend/tests/core/test_forms.py | 14 ++- 6 files changed, 171 insertions(+), 98 deletions(-) create mode 100644 backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py diff --git a/backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py b/backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py new file mode 100644 index 00000000..2d36bd3f --- /dev/null +++ b/backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py @@ -0,0 +1,73 @@ +"""unmangle archived field keys + +Phase 2 of the form edit lifecycle work (see backend/form-edit-lifecycle.md). + +Archived fields used to have their field_key rewritten to +`{key}_archived_{id}` so the replacement created by a question_type change +could inherit the original. Fields are now edited in place — there are no +replacements — and field_key uniqueness only applies to live fields, so the +mangled names have nothing left to avoid colliding with. + +Must not run before uniqueness narrows to live fields: an archived +`interest_archived_abc123` un-mangles to `interest`, which the live row +created by its replacement already holds. + +Revision ID: 30da722fb9b4 +Revises: 00bf7c99a668 +Create Date: 2026-08-26 22:14:03.117294 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '30da722fb9b4' +down_revision: Union[str, None] = '00bf7c99a668' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# The mangle was f"{key}_archived_{id}" with '-' swapped for '_' (field_key's +# validator rejects hyphens). Matching on each row's own id rather than a +# regex keeps this exact — a TD-authored key that merely looks mangled is +# left alone. +_SUFFIX = "'_archived_' || replace(f.id, '-', '_')" + + +def upgrade() -> None: + # Order matters. uq_form_field_key covers archived rows too, so + # un-mangling under it would collide an archived field with the live one + # holding its original key. Drop first, un-mangle, then re-add scoped to + # live fields. + op.drop_constraint('uq_form_field_key', 'form_fields', type_='unique') + + op.execute(f""" + UPDATE form_fields f + SET field_key = left(f.field_key, length(f.field_key) - length({_SUFFIX})) + WHERE f.is_archived = true + AND right(f.field_key, length({_SUFFIX})) = {_SUFFIX} + AND length(f.field_key) > length({_SUFFIX}) + """) + + op.create_index( + 'uq_form_field_key', 'form_fields', ['form_id', 'field_key'], + unique=True, postgresql_where=sa.text('is_archived = false'), + ) + + +def downgrade() -> None: + op.drop_index('uq_form_field_key', table_name='form_fields') + + # Re-mangle every archived field, not just the ones this migration + # touched — the pre-Phase-2 invariant is that no archived key collides + # with a live one, and there's no record of which were originally mangled. + op.execute(f""" + UPDATE form_fields f + SET field_key = f.field_key || {_SUFFIX} + WHERE f.is_archived = true + AND right(f.field_key, length({_SUFFIX})) <> {_SUFFIX} + """) + + op.create_unique_constraint('uq_form_field_key', 'form_fields', ['form_id', 'field_key']) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index db69f35f..c1198a4e 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -232,12 +232,14 @@ def list_my_tournament_forms( # --------------------------------------------------------------------------- -# GET /tournaments/{tournament_id}/forms/field-keys/ — every field_key -# already in use across this tournament's forms (archived fields included — -# an archived key isn't released for reuse, see field_key_taken_in_tournament -# in app/core/form). Lets the builder's field_key Combobox show these as -# visible options before Save, rather than only discovering a collision via -# the 409 that PUT .../fields/ would otherwise return. +# GET /tournaments/{tournament_id}/forms/field-keys/ — every field_key in use +# by a live field across this tournament's forms. Lets the builder's field_key +# Combobox show these before Save, rather than only discovering a collision +# via the 409 that PUT .../fields/ would otherwise return. +# +# Archived fields are excluded deliberately: they don't reserve their keys +# (see field_key_taken_in_tournament), so listing them here would make the +# builder block a key the API would happily accept. # --------------------------------------------------------------------------- @router.get( "/tournaments/{tournament_id}/forms/field-keys/", @@ -252,7 +254,7 @@ def list_tournament_field_keys( rows = ( db.query(FormField.field_key) .join(Form, Form.id == FormField.form_id) - .filter(Form.tournament_id == tournament_id) + .filter(Form.tournament_id == tournament_id, FormField.is_archived == False) .distinct() .all() ) @@ -571,7 +573,11 @@ def _check_field_key_available(field_key: str) -> None: else: taken = ( db.query(FormField) - .filter(FormField.form_id == form.id, FormField.field_key == field_key) + .filter( + FormField.form_id == form.id, + FormField.field_key == field_key, + FormField.is_archived == False, + ) .first() is not None ) @@ -598,12 +604,8 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) return normalized - # (field_answered, field_to_flag, reason, archived_option_ids). The two - # fields differ only on the archive+replace path, where responses answered - # the now-archived row but must be pointed at its replacement — the field - # they can actually answer. Held as ORM objects because a replacement has - # no id until the flush below. - pending_flags: list[tuple[FormField, FormField, str, list[str]]] = [] + pending_flags: list[tuple[str, str, list[str]]] = [] + # (field_id, reason, archived_option_ids) order = 1 for entry in payload.fields: @@ -619,45 +621,27 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> _check_field_key_available(new_field_key) normalized_config = _validate_config(entry.question_type, entry.config, new_field_key) - # A type change that must preserve history archives this field and - # creates its replacement at the same list position. The - # replacement normally inherits the old field_key (see the Edit - # Lifecycle doc — deliberate continuity), but a submitted key - # still wins: applying a preset changes the key and the - # question_type in the same save, and silently keeping the old key - # would validate the new preset config against the wrong key. - if type_changed and is_history_preserving: - field.is_archived = True - old_key = field.field_key - # field.id is a nanoid and can contain '-', which field_key's - # snake_case-alphanumeric validator rejects — swap it for - # '_' so the archived key stays valid regardless of id shape. - field.field_key = f"{old_key}_archived_{field.id}".replace("-", "_") - - new_field = FormField( - form_id=form.id, - order=order, - label=entry.label, - description=entry.description, - question_type=entry.question_type, - field_key=new_field_key, - config=normalized_config, - is_archived=False, - ) - db.add(new_field) - pending_flags.append((field, new_field, "field_replaced", [])) - else: - if is_history_preserving: - normalized_config, archived_option_ids = apply_option_archiving(field.config, normalized_config) - if archived_option_ids: - pending_flags.append((field, field, "option_archived", archived_option_ids)) - field.order = order - field.label = entry.label - field.description = entry.description - field.question_type = entry.question_type - field.field_key = new_field_key - field.config = normalized_config - flag_modified(field, "config") + # Every edit applies to the field itself — a question_type change + # included. The field keeps its id, so answers and pending updates + # stay attached without any lineage bookkeeping; FormAnswer records + # the question_type/field_key each answer was given under, so past + # answers remain readable under the old semantics rather than being + # reinterpreted through the new type. See form-edit-lifecycle.md. + if is_history_preserving: + normalized_config, archived_option_ids = apply_option_archiving(field.config, normalized_config) + if archived_option_ids: + pending_flags.append((field.id, "option_archived", archived_option_ids)) + if type_changed: + # Ordered after option_archived so the upsert escalates to + # the stronger reason rather than the other way round. + pending_flags.append((field.id, "field_replaced", [])) + field.order = order + field.label = entry.label + field.description = entry.description + field.question_type = entry.question_type + field.field_key = new_field_key + field.config = normalized_config + flag_modified(field, "config") else: field_key = slugify(entry.field_key or "") _check_field_key_available(field_key) @@ -679,7 +663,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> for field in removed_fields: if is_history_preserving: field.is_archived = True - pending_flags.append((field, field, "field_replaced", [])) + pending_flags.append((field.id, "field_replaced", [])) else: db.delete(field) @@ -690,11 +674,11 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> db.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="; ".join(errors)) - for field_answered, field_to_flag, reason, archived_option_ids in pending_flags: + for field_id, reason, archived_option_ids in pending_flags: if reason == "field_replaced": - flag_pending_updates_for_field(db, field_answered.id, field_to_flag.id, "field_replaced") + flag_pending_updates_for_field(db, field_id, "field_replaced") else: - flag_pending_updates_for_archived_options(db, field_answered, archived_option_ids) + flag_pending_updates_for_archived_options(db, live_by_id[field_id], archived_option_ids) # Editing a FormField never touches the Form row itself, so its # onupdate=utcnow wouldn't otherwise fire — bump it explicitly so diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index a85ec512..c4dbdbc3 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -106,14 +106,22 @@ def assign_option_ids(config: dict | None) -> dict | None: def field_key_taken_in_tournament(db: Session, tournament_id: int, field_key: str) -> bool: - """True if `field_key` is already used by any FormField — archived - included, an archived key isn't released for reuse — belonging to any - Form owned by `tournament_id`. field_key is the TD-visible dashboard - lookup key, so it's unique tournament-wide, not just per form.""" + """True if `field_key` is in use by a **live** FormField on any Form owned + by `tournament_id`. field_key is the TD-visible dashboard lookup key, so + it's unique tournament-wide, not just per form. + + Archived fields don't reserve their keys: a key is a display name, not an + identity (that's field_id), so retiring a question releases its name for + reuse — including by the question a TD adds back after deleting one by + mistake. An archived field may therefore share a key with a live one.""" return ( db.query(FormField) .join(Form, Form.id == FormField.form_id) - .filter(Form.tournament_id == tournament_id, FormField.field_key == field_key) + .filter( + Form.tournament_id == tournament_id, + FormField.field_key == field_key, + FormField.is_archived == False, + ) .first() is not None ) @@ -216,21 +224,14 @@ def _upsert_pending_update(db: Session, response_id: str, field_id: str, reason: existing.reason = "field_replaced" -def flag_pending_updates_for_field( - db: Session, answered_field_id: str, target_field_id: str, reason: str -) -> None: - """Flags every response that answered `answered_field_id`, pointing the - flag at `target_field_id` — the field they must answer to clear it. The - two differ on the archive+replace path, where the question continues as a - new row and the archived one can no longer be answered.""" +def flag_pending_updates_for_field(db: Session, field_id: str, reason: str) -> None: + """Flags every response that answered `field_id`. A field is edited in + place, so the field they answered is the field they'll answer again.""" response_ids = { - rid - for (rid,) in db.query(FormAnswer.response_id) - .filter(FormAnswer.field_id == answered_field_id) - .all() + rid for (rid,) in db.query(FormAnswer.response_id).filter(FormAnswer.field_id == field_id).all() } for response_id in response_ids: - _upsert_pending_update(db, response_id, target_field_id, reason) + _upsert_pending_update(db, response_id, field_id, reason) def delete_pending_updates_for_field(db: Session, field_id: str) -> None: diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 9d6bccd9..8738b68c 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -9,7 +9,7 @@ from nanoid import generate as generate_nanoid from sqlalchemy import ( Integer, String, Text, Boolean, Date, DateTime, JSON, - ForeignKey, UniqueConstraint, CheckConstraint, Column, event, Index, + ForeignKey, UniqueConstraint, CheckConstraint, Column, event, Index, text, ) from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship, validates @@ -834,7 +834,15 @@ class FormField(Base): answer = relationship("FormAnswer", back_populates="field") __table_args__ = ( - UniqueConstraint("form_id", "field_key", name="uq_form_field_key"), + # Live fields only. An archived field doesn't reserve its key — see + # field_key_taken_in_tournament — so a retired question and the one + # replacing it can share a name. A plain UniqueConstraint here would + # block that at the DB even though the application allows it. + Index( + "uq_form_field_key", "form_id", "field_key", + unique=True, + postgresql_where=text("is_archived = false"), + ), ) @validates("field_key") diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 58937ffa..2ff15b25 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -224,7 +224,9 @@ def test_chapter_plain_member_forbidden(self, client, db, chapter): # --------------------------------------------------------------------------- class TestListTournamentFieldKeys: - def test_lists_distinct_keys_across_forms_including_archived(self, client, db, td_user, td_tournament): + def test_lists_distinct_live_keys_across_forms(self, client, db, td_user, td_tournament): + """Archived keys are excluded — they're reusable, so listing them + would make the builder block a key the API accepts.""" form_a = _make_form(db, td_user, td_tournament, name="A") form_b = _make_form(db, td_user, td_tournament, name="B") _make_field(db, form_a, field_key="favorite_color") @@ -235,7 +237,7 @@ def test_lists_distinct_keys_across_forms_including_archived(self, client, db, t login(client, "td@test.com", "tdpass") res = client.get(f"/tournaments/{td_tournament.id}/forms/field-keys/") assert res.status_code == 200 - assert set(res.json()) == {"favorite_color", "shirt_size", "archived_key"} + assert set(res.json()) == {"favorite_color", "shirt_size"} def test_excludes_chapter_forms(self, client, db, td_user, td_tournament, chapter): form_a = _make_form(db, td_user, td_tournament, name="A") @@ -579,7 +581,10 @@ def test_label_only_edit_applies_in_place(self, client, db, td_user, td_tourname assert res.json()[0]["id"] == field.id assert res.json()[0]["label"] == "New label" - def test_question_type_change_archives_and_replaces_same_key(self, client, db, td_user, td_tournament): + def test_question_type_change_applies_in_place(self, client, db, td_user, td_tournament): + """A type change edits the field rather than archiving and replacing + it, so the id survives and answers stay attached without any lineage + bookkeeping.""" form = _make_form(db, td_user, td_tournament) field = _make_field(db, form, order=1, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) db.commit() @@ -593,19 +598,19 @@ def test_question_type_change_archives_and_replaces_same_key(self, client, db, t assert res.status_code == 200 data = res.json() assert len(data) == 1 - assert data[0]["id"] != field.id + assert data[0]["id"] == field.id assert data[0]["field_key"] == "color" assert data[0]["question_type"] == "long_text" db.refresh(field) - assert field.is_archived is True - assert field.field_key != "color" + assert field.is_archived is False + assert field.field_key == "color" + assert db.query(FormField).filter(FormField.form_id == form.id).count() == 1 - def test_submitted_field_key_wins_over_inheritance_on_replacement(self, client, db, td_user, td_tournament): + def test_preset_applied_to_existing_field_uses_submitted_key(self, client, db, td_user, td_tournament): """Applying a preset renames the field_key *and* changes the - question_type in one save. The replacement normally inherits the old - key, but here that would validate the new track_status config against - the pre-preset key and 422.""" + question_type in one save. The new config must be validated against + the submitted key, not the pre-preset one, or it 422s.""" form = _make_form(db, td_user, td_tournament) field = _make_field(db, form, order=1, field_key="interest", question_type="short_text", config={"required": False, "max_length": 50}) track = TournamentTrack(tournament_id=td_tournament.id, name="Test Writing") @@ -841,18 +846,10 @@ def test_field_replaced_flags_pending_update_for_prior_answer(self, client, db, json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, ) - # The type change archived the old field and created a replacement; - # the flag points at the replacement, since that's the field the - # respondent can actually answer to clear it. - replacement = ( - db.query(FormField) - .filter(FormField.form_id == form.id, FormField.field_key == "color", FormField.is_archived == False) - .one() - ) - assert replacement.id != field.id + # The field was edited in place, so the flag points at it directly. pending = ( db.query(FormResponsePendingUpdate) - .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_id == replacement.id) + .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_id == field.id) .first() ) assert pending is not None diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index c71d084d..4bf72e04 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -430,12 +430,22 @@ def test_field_key_taken_false_for_different_tournament(self, db, td_user, td_to assert field_key_taken_in_tournament(db, other_tournament.id, "only_here") is False - def test_field_key_taken_true_when_archived(self, db, td_user, td_tournament): + def test_field_key_released_when_archived(self, db, td_user, td_tournament): + """A key is a display name, not an identity — retiring a question + frees its name, so a TD who deletes one by mistake can add it back.""" form = _make_form(db, td_user, td_tournament) _make_field(db, form, field_key="was_used", is_archived=True) db.commit() - assert field_key_taken_in_tournament(db, td_tournament.id, "was_used") is True + assert field_key_taken_in_tournament(db, td_tournament.id, "was_used") is False + + def test_field_key_taken_when_live_field_shares_key_with_archived(self, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + _make_field(db, form, field_key="reused", is_archived=True) + _make_field(db, form, order=2, field_key="reused") + db.commit() + + assert field_key_taken_in_tournament(db, td_tournament.id, "reused") is True # --------------------------------------------------------------------------- From 4a0585f8b86b342312a86d57b8dbc3d762e00b80 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 23:16:28 -0700 Subject: [PATCH 52/92] feat(forms): classify field edits and flag only the responders each change affects --- ...c0973e134e52_pending_update_reasons_set.py | 57 +++++ backend/app/api/routes/forms.py | 38 ++-- backend/app/core/form/__init__.py | 78 ++++--- backend/app/core/form/changes.py | 139 ++++++++++++ backend/app/models/models.py | 14 +- backend/app/schemas/form.py | 12 +- backend/tests/api/test_forms.py | 84 +++++++- backend/tests/core/test_form_changes.py | 201 ++++++++++++++++++ 8 files changed, 563 insertions(+), 60 deletions(-) create mode 100644 backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py create mode 100644 backend/app/core/form/changes.py create mode 100644 backend/tests/core/test_form_changes.py diff --git a/backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py b/backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py new file mode 100644 index 00000000..6febe1f2 --- /dev/null +++ b/backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py @@ -0,0 +1,57 @@ +"""pending update reasons set + +Phase 3 of the form edit lifecycle work (see backend/form-edit-lifecycle.md). + +`reason` held one of two values and escalated one-way. Change classification +produces six, and several can apply to the same field in one save, so it +becomes a set that unions instead. + +Revision ID: c0973e134e52 +Revises: 30da722fb9b4 +Create Date: 2026-08-27 00:12:44.882910 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'c0973e134e52' +down_revision: Union[str, None] = '30da722fb9b4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('form_response_pending_updates', sa.Column('reasons', sa.JSON(), nullable=True)) + + # Map the old pair onto the new vocabulary. field_replaced only ever came + # from a question_type change or a removal; removals no longer flag at + # all, so every surviving row of that kind is a type change. + op.execute(""" + UPDATE form_response_pending_updates + SET reasons = CASE reason + WHEN 'option_archived' THEN '["option_invalidated"]'::json + ELSE '["question_type_changed"]'::json + END + """) + + op.alter_column('form_response_pending_updates', 'reasons', nullable=False) + op.drop_column('form_response_pending_updates', 'reason') + + +def downgrade() -> None: + op.add_column('form_response_pending_updates', sa.Column('reason', sa.String(length=32), nullable=True)) + # Collapse back to the single stronger value; the extra reasons the new + # vocabulary carries have no pre-Phase-3 equivalent and are dropped. + op.execute(""" + UPDATE form_response_pending_updates + SET reason = CASE + WHEN reasons::jsonb ? 'option_invalidated' + AND jsonb_array_length(reasons::jsonb) = 1 THEN 'option_archived' + ELSE 'field_replaced' + END + """) + op.alter_column('form_response_pending_updates', 'reason', nullable=False) + op.drop_column('form_response_pending_updates', 'reasons') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index c1198a4e..eed3e82c 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -9,12 +9,13 @@ apply_option_archiving, assign_option_ids, field_key_taken_in_tournament, - flag_pending_updates_for_archived_options, - flag_pending_updates_for_field, + delete_pending_updates_for_field, + flag_pending_updates, resolve_field_options, slugify, snapshot_answer_value, ) +from app.core.form import changes from app.core.form.branching import missing_required_field_keys from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( @@ -604,8 +605,9 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) return normalized - pending_flags: list[tuple[str, str, list[str]]] = [] - # (field_id, reason, archived_option_ids) + # (field, reasons, removed_option_ids) — resolved into rows after the + # flush, so a rolled-back batch leaves no flags behind. + pending_flags: list[tuple[FormField, set[str], list[str]]] = [] order = 1 for entry in payload.fields: @@ -628,13 +630,18 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> # answers remain readable under the old semantics rather than being # reinterpreted through the new type. See form-edit-lifecycle.md. if is_history_preserving: + reasons = changes.classify_field_change( + field, + new_question_type=entry.question_type, + new_field_key=new_field_key, + new_config=normalized_config, + new_label=entry.label, + new_description=entry.description, + ) + reasons = changes.resolve_reasons(reasons, entry.notify_responders) normalized_config, archived_option_ids = apply_option_archiving(field.config, normalized_config) - if archived_option_ids: - pending_flags.append((field.id, "option_archived", archived_option_ids)) - if type_changed: - # Ordered after option_archived so the upsert escalates to - # the stronger reason rather than the other way round. - pending_flags.append((field.id, "field_replaced", [])) + if reasons: + pending_flags.append((field, reasons, archived_option_ids)) field.order = order field.label = entry.label field.description = entry.description @@ -662,8 +669,10 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> removed_fields = [f for fid, f in live_by_id.items() if fid not in submitted_ids] for field in removed_fields: if is_history_preserving: + # Retiring a question raises nothing: a flag on a field that can + # no longer be answered could never clear. Any open ones go too. field.is_archived = True - pending_flags.append((field.id, "field_replaced", [])) + delete_pending_updates_for_field(db, field.id) else: db.delete(field) @@ -674,11 +683,8 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> db.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="; ".join(errors)) - for field_id, reason, archived_option_ids in pending_flags: - if reason == "field_replaced": - flag_pending_updates_for_field(db, field_id, "field_replaced") - else: - flag_pending_updates_for_archived_options(db, live_by_id[field_id], archived_option_ids) + for field, reasons, removed_option_ids in pending_flags: + flag_pending_updates(db, field, reasons, removed_option_ids) # Editing a FormField never touches the Form row itself, so its # onupdate=utcnow wouldn't otherwise fire — bump it explicitly so diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index c4dbdbc3..81164e37 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,4 +1,5 @@ from sqlalchemy.orm import Session +from app.core.form import changes from app.core.form.validation import ( AVAILABILITY_FIELD_KEY_PATTERN, EVENT_PREFERENCE_FIELD_KEY_PATTERN, @@ -9,6 +10,7 @@ Form, FormAnswer, FormField, + FormResponse, FormResponsePendingUpdate, TournamentEvent, TournamentShift, @@ -208,7 +210,9 @@ def apply_option_archiving(old_config: dict | None, new_config: dict) -> tuple[d return merged, newly_archived_ids -def _upsert_pending_update(db: Session, response_id: str, field_id: str, reason: str) -> None: +def _upsert_pending_update(db: Session, response_id: str, field_id: str, reasons: set[str]) -> None: + """One row per (response, field); repeat calls union their reasons rather + than overwriting, since several can apply to the same field in one save.""" existing = ( db.query(FormResponsePendingUpdate) .filter( @@ -218,20 +222,57 @@ def _upsert_pending_update(db: Session, response_id: str, field_id: str, reason: .first() ) if existing is None: - db.add(FormResponsePendingUpdate(response_id=response_id, field_id=field_id, reason=reason)) - elif existing.reason == "option_archived" and reason == "field_replaced": - # Escalate only in this direction — see FormResponsePendingUpdate. - existing.reason = "field_replaced" + db.add(FormResponsePendingUpdate(response_id=response_id, field_id=field_id, reasons=sorted(reasons))) + else: + existing.reasons = sorted(set(existing.reasons or []) | reasons) -def flag_pending_updates_for_field(db: Session, field_id: str, reason: str) -> None: - """Flags every response that answered `field_id`. A field is edited in - place, so the field they answered is the field they'll answer again.""" +def _responses_leaving_field_blank(db: Session, field: FormField) -> set[str]: + """Responses to this field's form that didn't actually answer it — no + answer row at all, or one holding an empty value.""" response_ids = { - rid for (rid,) in db.query(FormAnswer.response_id).filter(FormAnswer.field_id == field_id).all() + rid for (rid,) in db.query(FormResponse.id).filter(FormResponse.form_id == field.form_id).all() } - for response_id in response_ids: - _upsert_pending_update(db, response_id, field_id, reason) + for answer in db.query(FormAnswer).filter(FormAnswer.field_id == field.id).all(): + if answer.value not in (None, "", [], {}): + response_ids.discard(answer.response_id) + return response_ids + + +def flag_pending_updates( + db: Session, field: FormField, reasons: set[str], removed_option_ids: list[str] | None = None +) -> None: + """Raise (or extend) flags on `field` for whoever each reason affects. + + Audience is per-reason, not per-field: losing an option only concerns the + people who picked it, and a field turning required only concerns the ones + who skipped it. Everything else concerns everyone who answered.""" + if not reasons: + return + + by_response: dict[str, set[str]] = {} + + def _add(response_id: str, reason_set: set[str]) -> None: + if reason_set: + by_response.setdefault(response_id, set()).update(reason_set) + + broad = reasons - {changes.OPTION_INVALIDATED, changes.NOW_REQUIRED} + if broad: + for (response_id,) in db.query(FormAnswer.response_id).filter(FormAnswer.field_id == field.id).all(): + _add(response_id, broad) + + if changes.OPTION_INVALIDATED in reasons and removed_option_ids: + removed = set(removed_option_ids) + for answer in db.query(FormAnswer).filter(FormAnswer.field_id == field.id).all(): + if removed & selected_option_ids(field, answer.value): + _add(answer.response_id, {changes.OPTION_INVALIDATED}) + + if changes.NOW_REQUIRED in reasons: + for response_id in _responses_leaving_field_blank(db, field): + _add(response_id, {changes.NOW_REQUIRED}) + + for response_id, reason_set in by_response.items(): + _upsert_pending_update(db, response_id, field.id, reason_set) def delete_pending_updates_for_field(db: Session, field_id: str) -> None: @@ -243,21 +284,6 @@ def delete_pending_updates_for_field(db: Session, field_id: str) -> None: ).delete(synchronize_session=False) -def flag_pending_updates_for_archived_options(db: Session, field: FormField, archived_option_ids: list[str]) -> None: - """Upserts option_archived for every response whose stored answer on - `field` (still live, unchanged type) selected one of `archived_option_ids`. - FormAnswer.value is a plain JSON column (not JSONB), so this is a - Python-side scan rather than a DB-side containment query — same - reasoning as the TournamentShift deletion guard's scan.""" - if not archived_option_ids: - return - archived_ids = set(archived_option_ids) - answers = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).all() - for answer in answers: - if archived_ids & selected_option_ids(field, answer.value): - _upsert_pending_update(db, answer.response_id, field.id, "option_archived") - - def _resolve_track_statuses(db: Session, assignments: list[dict]) -> list[dict]: """Hydrate stored track ids into responder-facing track names. diff --git a/backend/app/core/form/changes.py b/backend/app/core/form/changes.py new file mode 100644 index 00000000..c9cb66c6 --- /dev/null +++ b/backend/app/core/form/changes.py @@ -0,0 +1,139 @@ +"""Classifying what a field edit means for people who already answered it. + +The TD sends a target field list; this works out, per field, whether anyone +must be asked to look at their answer again and why. See +backend/form-edit-lifecycle.md for the rules these implement. + +Two tiers: + * MANDATORY_REASONS always apply — the change invalidates or outdates an + existing answer, and the TD can't suppress the prompt. + * OPTIONAL_REASONS are judgment calls the TD makes per field at save time. + Each carries a default for when the caller doesn't say. +""" +from app.core.form.validation import ( + AVAILABILITY_FIELD_KEY_PATTERN, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, + LUNCH_FIELD_KEY_PATTERN, + TRACK_STATUS_FIELD_KEY_PATTERN, +) + +# Answer storage shape per question_type. A type change only matters when it +# moves between classes — radio -> dropdown is a rendering choice and leaves +# every stored answer valid, while radio -> checkbox turns one snapshot into a +# list of them. +SHAPE_CLASSES: dict[str, str] = { + "short_text": "text", + "long_text": "text", + "single_select_radio": "single_select", + "single_select_dropdown": "single_select", + "multi_select_checkbox": "multi", + "ranked_choice": "ranked", + "acknowledgment": "bool", +} + +QUESTION_TYPE_CHANGED = "question_type_changed" +OPTION_ADDED = "option_added" +OPTION_INVALIDATED = "option_invalidated" +NOW_REQUIRED = "now_required" +KEY_CHANGED = "key_changed" +TEXT_CHANGED = "text_changed" + +MANDATORY_REASONS = frozenset({QUESTION_TYPE_CHANGED, OPTION_ADDED, OPTION_INVALIDATED, NOW_REQUIRED}) + +# Default for each judgment call when the caller doesn't send one. key_changed +# defaults on because the consequence of skipping it is invisible: those +# responders simply never reach the write-through tables, with no error and no +# empty state to notice. +OPTIONAL_REASON_DEFAULTS: dict[str, bool] = { + KEY_CHANGED: True, + TEXT_CHANGED: False, +} + +_PRESET_PATTERNS = ( + AVAILABILITY_FIELD_KEY_PATTERN, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, + LUNCH_FIELD_KEY_PATTERN, + TRACK_STATUS_FIELD_KEY_PATTERN, +) + + +def is_preset_key(field_key: str) -> bool: + return any(pattern.match(field_key) for pattern in _PRESET_PATTERNS) + + +def shape_class(question_type: str) -> str | None: + return SHAPE_CLASSES.get(question_type) + + +def _option_ids(config: dict | None) -> set[str]: + """Live option ids only — an archived option isn't offered to anyone, so + it can't be what a respondent 'gained' or 'lost'.""" + return { + option["option_id"] + for option in (config or {}).get("options") or [] + if not option.get("is_archived") + } + + +def _labels_by_option_id(config: dict | None) -> dict[str, str]: + return { + option["option_id"]: option.get("label") + for option in (config or {}).get("options") or [] + } + + +def classify_field_change( + old_field, + new_question_type: str, + new_field_key: str, + new_config: dict | None, + new_label: str, + new_description: str | None, +) -> set[str]: + """Every reason this edit raises, mandatory and optional together. The + caller decides which optional ones survive — see resolve_reasons.""" + reasons: set[str] = set() + old_config = old_field.config or {} + + shape_changed = shape_class(new_question_type) != shape_class(old_field.question_type) + if shape_changed: + reasons.add(QUESTION_TYPE_CHANGED) + + # Options are only comparable within a shape class — a text field has none + # to diff against, and across classes the whole answer is invalid anyway. + # Reporting "an option was added" alongside the type change would be noise + # describing a consequence of it, not a separate thing to review. + if not shape_changed: + old_ids, new_ids = _option_ids(old_config), _option_ids(new_config) + if new_ids - old_ids: + reasons.add(OPTION_ADDED) + if old_ids - new_ids: + reasons.add(OPTION_INVALIDATED) + + if new_config and new_config.get("required") and not old_config.get("required"): + reasons.add(NOW_REQUIRED) + + if is_preset_key(new_field_key) != is_preset_key(old_field.field_key): + reasons.add(KEY_CHANGED) + + old_labels = _labels_by_option_id(old_config) + new_labels = _labels_by_option_id(new_config) + label_changed = any( + option_id in old_labels and old_labels[option_id] != label + for option_id, label in new_labels.items() + ) + if new_label != old_field.label or new_description != old_field.description or label_changed: + reasons.add(TEXT_CHANGED) + + return reasons + + +def resolve_reasons(reasons: set[str], notify: bool | None) -> set[str]: + """Drop the optional reasons the TD declined. `notify` is their answer for + this field: None means they didn't say, so each optional reason falls back + to its own default. Mandatory reasons are never affected.""" + kept = {reason for reason in reasons if reason in MANDATORY_REASONS} + for reason in reasons - MANDATORY_REASONS: + if notify if notify is not None else OPTIONAL_REASON_DEFAULTS.get(reason, False): + kept.add(reason) + return kept diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 8738b68c..3fb2e0e8 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -913,11 +913,12 @@ class FormAnswer(Base): # keying history on it strands the flag the moment the question is renamed. # The field a flag points at is therefore stable across every edit. # -# `reason` only ever escalates option_archived -> field_replaced, never the -# reverse. A row is cleared when the respondent patches that field, and is -# deleted outright if the field is retired or invalidated — a flag on a -# question that can no longer be answered is unclearable by construction. -# See backend/form-edit-lifecycle.md. +# `reasons` is a set, not a single value: one save can legitimately trigger +# several on the same field (an option added *and* the wording changed), so +# they union rather than override. A row is cleared when the respondent +# patches that field, and is deleted outright if the field is retired or +# invalidated — a flag on a question that can no longer be answered is +# unclearable by construction. See backend/form-edit-lifecycle.md. # --------------------------------------------------------------------------- class FormResponsePendingUpdate(Base): __tablename__ = "form_response_pending_updates" @@ -925,7 +926,8 @@ class FormResponsePendingUpdate(Base): id = Column(Integer, primary_key=True, index=True) response_id = Column(String(12), ForeignKey("form_responses.id", ondelete="CASCADE"), nullable=False) field_id = Column(String(12), ForeignKey("form_fields.id", ondelete="CASCADE"), nullable=False) - reason = Column(String(32), nullable=False) # "field_replaced" | "option_archived" + # See app/core/form/changes.py for the values and what raises each. + reasons = Column(JSON, nullable=False, default=list) created_at = Column(DateTime(timezone=True), default=utcnow) response = relationship("FormResponse", back_populates="pending_updates") diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index f9d8833d..00121f83 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -216,15 +216,21 @@ class FormFieldRead(BaseModel): class BulkFieldEntry(BaseModel): """One entry in a PUT /forms/{form_id}/fields/ payload. `id` absent means "create"; `id` present must match a currently-live field on this - form. `field_key` is only meaningful (and required) on create — on an - update it's server-controlled (immutable, or carried over onto a - question_type-change replacement) and any value sent here is ignored.""" + form. `field_key` is required on create; on an update, omitting it leaves + the existing key alone while sending one renames the field.""" id: str | None = None field_key: str | None = None label: str description: str | None = None question_type: str config: dict[str, Any] | None = None + # The TD's answer, for this field, to "ask previous responders to review + # this?" — it only governs the judgment-call changes (wording, and moving + # between a preset and a standard key). Changes that actually invalidate + # an answer prompt regardless. None means the caller didn't decide, so + # each such change falls back to its own default; see + # app/core/form/changes.py. + notify_responders: bool | None = None class BulkFieldsUpdate(BaseModel): diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 2ff15b25..733d0236 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -793,7 +793,7 @@ def test_option_removed_archives_not_dropped(self, client, db, td_user, td_tourn .first() ) assert pending is not None - assert pending.reason == "option_archived" + assert pending.reasons == ["option_invalidated"] def test_pending_update_cleared_on_fresh_submission(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) @@ -831,7 +831,47 @@ def test_pending_update_cleared_on_fresh_submission(self, client, db, td_user, t assert res.status_code == 200 assert db.query(FormResponsePendingUpdate).filter(FormResponsePendingUpdate.response_id == response_id).count() == 0 - def test_field_replaced_flags_pending_update_for_prior_answer(self, client, db, td_user, td_tournament): + def _pending(self, db, response_id, field_id): + return ( + db.query(FormResponsePendingUpdate) + .filter( + FormResponsePendingUpdate.response_id == response_id, + FormResponsePendingUpdate.field_id == field_id, + ) + .first() + ) + + def test_cross_shape_type_change_flags_pending_update(self, client, db, td_user, td_tournament): + """short_text -> single_select_radio turns a plain string answer into + an option reference, so the stored answer no longer means anything.""" + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}) + response_id = res.json()["id"] + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{ + "id": field.id, "label": "Color", "question_type": "single_select_radio", + "config": {"required": False, "options": [ + {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, + ]}, + }]}, + ) + assert res.status_code == 200, res.json() + + # Edited in place, so the flag points at the field directly. + pending = self._pending(db, response_id, field.id) + assert pending is not None + assert pending.reasons == ["question_type_changed"] + + def test_within_shape_type_change_flags_nobody(self, client, db, td_user, td_tournament): + """short_text -> long_text is a rendering choice; both store a plain + string, so no previous answer was invalidated.""" form = _make_form(db, td_user, td_tournament) field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) db.commit() @@ -845,15 +885,41 @@ def test_field_replaced_flags_pending_update_for_prior_answer(self, client, db, f"/forms/{form.id}/fields/", json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, ) + assert self._pending(db, response_id, field.id) is None - # The field was edited in place, so the flag points at it directly. - pending = ( - db.query(FormResponsePendingUpdate) - .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_id == field.id) - .first() + def test_retiring_a_field_deletes_its_open_flags(self, client, db, td_user, td_tournament): + """A flag on a question nobody can answer any more could never clear, + so retirement takes them with it.""" + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + keep = _make_field(db, form, order=2, field_key="name", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": field.id, "value": "blue"}, + {"field_id": keep.id, "value": "sam"}, + ]}) + response_id = res.json()["id"] + + # Flag it via a cross-shape type change, then retire it. + client.put( + f"/forms/{form.id}/fields/", + json={"fields": [ + {"id": field.id, "label": "Color", "question_type": "single_select_radio", + "config": {"required": False, "options": [{"option_id": "opt_blue", "value": "blue", "label": "Blue"}]}}, + {"id": keep.id, "label": "Name", "question_type": "short_text", "config": {"required": False, "max_length": 50}}, + ]}, ) - assert pending is not None - assert pending.reason == "field_replaced" + assert self._pending(db, response_id, field.id) is not None + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": keep.id, "label": "Name", "question_type": "short_text", "config": {"required": False, "max_length": 50}}]}, + ) + assert res.status_code == 200, res.json() + assert self._pending(db, response_id, field.id) is None def test_option_without_option_id_gets_one_generated(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) diff --git a/backend/tests/core/test_form_changes.py b/backend/tests/core/test_form_changes.py new file mode 100644 index 00000000..4a8d03fa --- /dev/null +++ b/backend/tests/core/test_form_changes.py @@ -0,0 +1,201 @@ +"""Tests for app/core/form/changes.py — what a field edit means for people +who already answered it. Pure functions over a field and its proposed next +state; no DB, no HTTP. See backend/form-edit-lifecycle.md for the rules.""" +import pytest + +from app.core.form import changes +from app.models.models import FormField + + +def _field(**overrides): + """A live field to edit. Not persisted — classify_field_change only reads + attributes.""" + defaults = dict( + form_id="form1", + order=1, + label="Favorite color", + description=None, + question_type="single_select_radio", + field_key="favorite_color", + config={ + "required": False, + "options": [ + {"option_id": "opt_red", "value": "red", "label": "Red"}, + {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, + ], + }, + is_archived=False, + ) + defaults.update(overrides) + return FormField(**defaults) + + +def _classify(field, **overrides): + """Re-submit `field` unchanged except for the given overrides — so each + test isolates one edit rather than restating the whole entry.""" + args = dict( + new_question_type=field.question_type, + new_field_key=field.field_key, + new_config=field.config, + new_label=field.label, + new_description=field.description, + ) + args.update(overrides) + return changes.classify_field_change(field, **args) + + +class TestNoChange: + def test_resubmitting_unchanged_field_raises_nothing(self): + assert _classify(_field()) == set() + + +class TestQuestionType: + @pytest.mark.parametrize("old,new", [ + ("short_text", "long_text"), + ("long_text", "short_text"), + ("single_select_radio", "single_select_dropdown"), + ("single_select_dropdown", "single_select_radio"), + ]) + def test_within_shape_class_is_presentational(self, old, new): + """Both store the same answer shape, so every stored answer stays + valid — nobody needs to re-answer.""" + field = _field(question_type=old) + assert changes.QUESTION_TYPE_CHANGED not in _classify(field, new_question_type=new) + + @pytest.mark.parametrize("old,new", [ + ("single_select_radio", "multi_select_checkbox"), + ("multi_select_checkbox", "ranked_choice"), + ("short_text", "single_select_radio"), + ("acknowledgment", "short_text"), + ]) + def test_across_shape_classes_flags(self, old, new): + field = _field(question_type=old) + assert changes.QUESTION_TYPE_CHANGED in _classify(field, new_question_type=new) + + +class TestOptions: + def test_added_option_flags(self): + field = _field() + config = {**field.config, "options": [ + *field.config["options"], + {"option_id": "opt_green", "value": "green", "label": "Green"}, + ]} + assert changes.OPTION_ADDED in _classify(field, new_config=config) + + def test_removed_option_flags_as_invalidated(self): + field = _field() + config = {**field.config, "options": field.config["options"][:1]} + reasons = _classify(field, new_config=config) + assert changes.OPTION_INVALIDATED in reasons + assert changes.OPTION_ADDED not in reasons + + def test_archived_option_is_not_a_live_option(self): + """An option archived by a previous save is already hidden from + respondents, so its continued presence in storage isn't an addition.""" + field = _field(config={"required": False, "options": [ + {"option_id": "opt_red", "value": "red", "label": "Red"}, + {"option_id": "opt_old", "value": "old", "label": "Old", "is_archived": True}, + ]}) + assert _classify(field) == set() + + def test_reordering_options_raises_nothing(self): + field = _field() + config = {**field.config, "options": list(reversed(field.config["options"]))} + assert _classify(field, new_config=config) == set() + + def test_option_value_edit_is_not_respondent_facing(self): + """`value` is TD-facing text; `label` is what the respondent read.""" + field = _field() + options = [{**field.config["options"][0], "value": "crimson"}, field.config["options"][1]] + assert _classify(field, new_config={**field.config, "options": options}) == set() + + def test_option_label_edit_is_text_changed(self): + field = _field() + options = [{**field.config["options"][0], "label": "Crimson"}, field.config["options"][1]] + assert _classify(field, new_config={**field.config, "options": options}) == {changes.TEXT_CHANGED} + + +class TestRequired: + def test_becoming_required_flags(self): + field = _field() + assert changes.NOW_REQUIRED in _classify(field, new_config={**field.config, "required": True}) + + def test_becoming_optional_does_not_flag(self): + """A previously required answer is still a valid answer.""" + field = _field(config={"required": True, "options": []}) + assert _classify(field, new_config={"required": False, "options": []}) == set() + + +class TestFieldKey: + def test_standard_to_preset_flags(self): + field = _field(field_key="availability_question") + assert changes.KEY_CHANGED in _classify(field, new_field_key="availability_20260315") + + def test_preset_to_standard_flags(self): + field = _field(field_key="lunch_20270213_protein") + assert changes.KEY_CHANGED in _classify(field, new_field_key="lunch_choice") + + def test_standard_rename_is_not_a_key_change(self): + """A plain key is a display name — renaming it changes nothing about + what was asked or where the answer goes.""" + field = _field(field_key="favorite_color") + assert _classify(field, new_field_key="preferred_color") == set() + + def test_preset_to_different_preset_is_not_a_key_change(self): + field = _field(field_key="availability_20260315") + assert _classify(field, new_field_key="availability_20260316") == set() + + +class TestText: + def test_label_edit_flags(self): + assert _classify(_field(), new_label="What colour do you like?") == {changes.TEXT_CHANGED} + + def test_description_edit_flags(self): + assert _classify(_field(), new_description="Pick one") == {changes.TEXT_CHANGED} + + +class TestResolveReasons: + def test_mandatory_survives_an_explicit_no(self): + reasons = {changes.QUESTION_TYPE_CHANGED, changes.OPTION_ADDED} + assert changes.resolve_reasons(reasons, notify=False) == reasons + + def test_optional_dropped_when_declined(self): + reasons = {changes.TEXT_CHANGED, changes.KEY_CHANGED} + assert changes.resolve_reasons(reasons, notify=False) == set() + + def test_optional_kept_when_accepted(self): + reasons = {changes.TEXT_CHANGED, changes.KEY_CHANGED} + assert changes.resolve_reasons(reasons, notify=True) == reasons + + def test_defaults_apply_when_caller_is_silent(self): + """key_changed defaults on — skipping it silently leaves those + responders out of write-through, with nothing to notice. Wording + changes default off.""" + reasons = {changes.TEXT_CHANGED, changes.KEY_CHANGED} + assert changes.resolve_reasons(reasons, notify=None) == {changes.KEY_CHANGED} + + def test_mixed_keeps_mandatory_and_drops_declined_optional(self): + reasons = {changes.NOW_REQUIRED, changes.TEXT_CHANGED} + assert changes.resolve_reasons(reasons, notify=False) == {changes.NOW_REQUIRED} + + +class TestShapeChangeSubsumesOptionDiffs: + def test_gaining_options_with_a_type_change_reports_only_the_type_change(self): + """A text field has no options to diff against — "an option was + added" would describe a consequence of the type change, not a + separate thing for a respondent to review.""" + field = _field(question_type="short_text", config={"required": False, "max_length": 50}) + config = {"required": False, "options": [ + {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, + ]} + reasons = _classify(field, new_question_type="single_select_radio", new_config=config) + assert reasons == {changes.QUESTION_TYPE_CHANGED} + + def test_option_diffs_still_report_within_a_shape_class(self): + field = _field(question_type="single_select_radio") + config = {**field.config, "options": [ + *field.config["options"], + {"option_id": "opt_green", "value": "green", "label": "Green"}, + ]} + reasons = _classify(field, new_question_type="single_select_dropdown", new_config=config) + assert reasons == {changes.OPTION_ADDED} From eefe60fdaa6c0dd1eb13dca3051667905270de46 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Wed, 26 Aug 2026 23:16:50 -0700 Subject: [PATCH 53/92] refactor(forms): squash edit lifecycle migrations into one revision --- ...dd_answer_semantics_and_pending_update_.py | 102 ----------- ...da722fb9b4_unmangle_archived_field_keys.py | 73 -------- ...c0973e134e52_pending_update_reasons_set.py | 57 ------ .../d8c1e4c9b52d_form_edit_lifecycle.py | 167 ++++++++++++++++++ 4 files changed, 167 insertions(+), 232 deletions(-) delete mode 100644 backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py delete mode 100644 backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py delete mode 100644 backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py create mode 100644 backend/alembic/versions/d8c1e4c9b52d_form_edit_lifecycle.py diff --git a/backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py b/backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py deleted file mode 100644 index 64340a4f..00000000 --- a/backend/alembic/versions/00bf7c99a668_add_answer_semantics_and_pending_update_.py +++ /dev/null @@ -1,102 +0,0 @@ -"""add answer semantics and pivot pending updates to field_id - -Phase 1 of the form edit lifecycle work (see backend/form-edit-lifecycle.md). - -Two changes: - * FormAnswer records the question_type/field_key it was answered under, so a - stored answer stays readable after its field is edited. - * FormResponsePendingUpdate keys on field_id instead of field_key. A key is - a TD-editable display name; keying history on it strands the flag the - moment the question is renamed. - -Revision ID: 00bf7c99a668 -Revises: 8d55ec2b6640 -Create Date: 2026-08-26 21:00:55.083705 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = '00bf7c99a668' -down_revision: Union[str, None] = '8d55ec2b6640' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column('form_answers', sa.Column('question_type', sa.String(length=32), nullable=True)) - op.add_column('form_answers', sa.Column('field_key', sa.String(length=64), nullable=True)) - - # Backfill from each answer's field as it looks *now*. Correct for any - # field untouched since the answer was given, approximate otherwise — - # there's no record of the field's past shape, which is the gap these - # columns close going forward. - op.execute(""" - UPDATE form_answers a - SET question_type = f.question_type, - field_key = f.field_key - FROM form_fields f - WHERE f.id = a.field_id - """) - - op.add_column('form_response_pending_updates', sa.Column('field_id', sa.String(length=12), nullable=True)) - - # Resolve each flag's field_key to a field on the same form. Prefer the - # live one: a removed field keeps its key while archived, so a key can be - # held by both an archived row and its replacement — the flag refers to - # whichever the respondent can actually answer. - op.execute(""" - UPDATE form_response_pending_updates p - SET field_id = ( - SELECT f.id - FROM form_fields f - WHERE f.form_id = ( - SELECT r.form_id FROM form_responses r WHERE r.id = p.response_id - ) - AND f.field_key = p.field_key - ORDER BY f.is_archived ASC, f.id ASC - LIMIT 1 - ) - """) - - # A flag whose key resolves to nothing points at a field that no longer - # exists, so it could never be cleared by answering anything. Drop it - # rather than block the NOT NULL below. - op.execute("DELETE FROM form_response_pending_updates WHERE field_id IS NULL") - - op.alter_column('form_response_pending_updates', 'field_id', nullable=False) - op.create_foreign_key( - 'fk_pending_update_field_id', 'form_response_pending_updates', 'form_fields', - ['field_id'], ['id'], ondelete='CASCADE', - ) - - op.drop_constraint('uq_pending_update_per_response_field', 'form_response_pending_updates', type_='unique') - op.drop_column('form_response_pending_updates', 'field_key') - op.create_unique_constraint( - 'uq_pending_update_per_response_field', 'form_response_pending_updates', ['response_id', 'field_id'] - ) - - -def downgrade() -> None: - op.add_column('form_response_pending_updates', sa.Column('field_key', sa.String(length=64), nullable=True)) - op.execute(""" - UPDATE form_response_pending_updates p - SET field_key = f.field_key - FROM form_fields f - WHERE f.id = p.field_id - """) - op.execute("DELETE FROM form_response_pending_updates WHERE field_key IS NULL") - op.alter_column('form_response_pending_updates', 'field_key', nullable=False) - - op.drop_constraint('uq_pending_update_per_response_field', 'form_response_pending_updates', type_='unique') - op.drop_constraint('fk_pending_update_field_id', 'form_response_pending_updates', type_='foreignkey') - op.drop_column('form_response_pending_updates', 'field_id') - op.create_unique_constraint( - 'uq_pending_update_per_response_field', 'form_response_pending_updates', ['response_id', 'field_key'] - ) - - op.drop_column('form_answers', 'field_key') - op.drop_column('form_answers', 'question_type') diff --git a/backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py b/backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py deleted file mode 100644 index 2d36bd3f..00000000 --- a/backend/alembic/versions/30da722fb9b4_unmangle_archived_field_keys.py +++ /dev/null @@ -1,73 +0,0 @@ -"""unmangle archived field keys - -Phase 2 of the form edit lifecycle work (see backend/form-edit-lifecycle.md). - -Archived fields used to have their field_key rewritten to -`{key}_archived_{id}` so the replacement created by a question_type change -could inherit the original. Fields are now edited in place — there are no -replacements — and field_key uniqueness only applies to live fields, so the -mangled names have nothing left to avoid colliding with. - -Must not run before uniqueness narrows to live fields: an archived -`interest_archived_abc123` un-mangles to `interest`, which the live row -created by its replacement already holds. - -Revision ID: 30da722fb9b4 -Revises: 00bf7c99a668 -Create Date: 2026-08-26 22:14:03.117294 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = '30da722fb9b4' -down_revision: Union[str, None] = '00bf7c99a668' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -# The mangle was f"{key}_archived_{id}" with '-' swapped for '_' (field_key's -# validator rejects hyphens). Matching on each row's own id rather than a -# regex keeps this exact — a TD-authored key that merely looks mangled is -# left alone. -_SUFFIX = "'_archived_' || replace(f.id, '-', '_')" - - -def upgrade() -> None: - # Order matters. uq_form_field_key covers archived rows too, so - # un-mangling under it would collide an archived field with the live one - # holding its original key. Drop first, un-mangle, then re-add scoped to - # live fields. - op.drop_constraint('uq_form_field_key', 'form_fields', type_='unique') - - op.execute(f""" - UPDATE form_fields f - SET field_key = left(f.field_key, length(f.field_key) - length({_SUFFIX})) - WHERE f.is_archived = true - AND right(f.field_key, length({_SUFFIX})) = {_SUFFIX} - AND length(f.field_key) > length({_SUFFIX}) - """) - - op.create_index( - 'uq_form_field_key', 'form_fields', ['form_id', 'field_key'], - unique=True, postgresql_where=sa.text('is_archived = false'), - ) - - -def downgrade() -> None: - op.drop_index('uq_form_field_key', table_name='form_fields') - - # Re-mangle every archived field, not just the ones this migration - # touched — the pre-Phase-2 invariant is that no archived key collides - # with a live one, and there's no record of which were originally mangled. - op.execute(f""" - UPDATE form_fields f - SET field_key = f.field_key || {_SUFFIX} - WHERE f.is_archived = true - AND right(f.field_key, length({_SUFFIX})) <> {_SUFFIX} - """) - - op.create_unique_constraint('uq_form_field_key', 'form_fields', ['form_id', 'field_key']) diff --git a/backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py b/backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py deleted file mode 100644 index 6febe1f2..00000000 --- a/backend/alembic/versions/c0973e134e52_pending_update_reasons_set.py +++ /dev/null @@ -1,57 +0,0 @@ -"""pending update reasons set - -Phase 3 of the form edit lifecycle work (see backend/form-edit-lifecycle.md). - -`reason` held one of two values and escalated one-way. Change classification -produces six, and several can apply to the same field in one save, so it -becomes a set that unions instead. - -Revision ID: c0973e134e52 -Revises: 30da722fb9b4 -Create Date: 2026-08-27 00:12:44.882910 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = 'c0973e134e52' -down_revision: Union[str, None] = '30da722fb9b4' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column('form_response_pending_updates', sa.Column('reasons', sa.JSON(), nullable=True)) - - # Map the old pair onto the new vocabulary. field_replaced only ever came - # from a question_type change or a removal; removals no longer flag at - # all, so every surviving row of that kind is a type change. - op.execute(""" - UPDATE form_response_pending_updates - SET reasons = CASE reason - WHEN 'option_archived' THEN '["option_invalidated"]'::json - ELSE '["question_type_changed"]'::json - END - """) - - op.alter_column('form_response_pending_updates', 'reasons', nullable=False) - op.drop_column('form_response_pending_updates', 'reason') - - -def downgrade() -> None: - op.add_column('form_response_pending_updates', sa.Column('reason', sa.String(length=32), nullable=True)) - # Collapse back to the single stronger value; the extra reasons the new - # vocabulary carries have no pre-Phase-3 equivalent and are dropped. - op.execute(""" - UPDATE form_response_pending_updates - SET reason = CASE - WHEN reasons::jsonb ? 'option_invalidated' - AND jsonb_array_length(reasons::jsonb) = 1 THEN 'option_archived' - ELSE 'field_replaced' - END - """) - op.alter_column('form_response_pending_updates', 'reason', nullable=False) - op.drop_column('form_response_pending_updates', 'reasons') diff --git a/backend/alembic/versions/d8c1e4c9b52d_form_edit_lifecycle.py b/backend/alembic/versions/d8c1e4c9b52d_form_edit_lifecycle.py new file mode 100644 index 00000000..8a419e38 --- /dev/null +++ b/backend/alembic/versions/d8c1e4c9b52d_form_edit_lifecycle.py @@ -0,0 +1,167 @@ +"""form edit lifecycle + +Schema for backend/form-edit-lifecycle.md — fields are edited in place rather +than archived and replaced, so identity moves off field_key onto field_id. + + * FormAnswer records the question_type/field_key it was answered under. + `value`'s shape is a function of both, so an answer stays readable after + its field is edited instead of being reinterpreted through the new shape. + * FormResponsePendingUpdate keys on field_id, not field_key — a key is a + TD-editable display name, and keying history on it strands the flag the + moment a question is renamed. `reason` becomes a `reasons` set, since one + save can raise several on the same field. + * field_key uniqueness narrows to live fields, and the `_archived_` name + mangling that existed to free a key for a replacement is undone. + +Revision ID: d8c1e4c9b52d +Revises: 8d55ec2b6640 +Create Date: 2026-08-27 01:03:18.442071 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'd8c1e4c9b52d' +down_revision: Union[str, None] = '8d55ec2b6640' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# The old mangle was f"{key}_archived_{id}" with '-' swapped for '_' (field_key +# rejects hyphens). Matching each row against its own id keeps this exact — a +# TD-authored key that merely looks mangled is left alone. +_SUFFIX = "'_archived_' || replace(f.id, '-', '_')" + + +def upgrade() -> None: + # --- FormAnswer: record the semantics each answer was given under ------ + op.add_column('form_answers', sa.Column('question_type', sa.String(length=32), nullable=True)) + op.add_column('form_answers', sa.Column('field_key', sa.String(length=64), nullable=True)) + + # Backfilled from each answer's field as it looks *now*: correct for any + # field untouched since the answer was given, approximate otherwise — + # there's no record of the field's past shape, which is the gap these + # columns close going forward. + op.execute(""" + UPDATE form_answers a + SET question_type = f.question_type, + field_key = f.field_key + FROM form_fields f + WHERE f.id = a.field_id + """) + + # --- FormResponsePendingUpdate: field_key -> field_id, reason -> reasons + op.add_column('form_response_pending_updates', sa.Column('field_id', sa.String(length=12), nullable=True)) + op.add_column('form_response_pending_updates', sa.Column('reasons', sa.JSON(), nullable=True)) + + # Resolved before the un-mangling below, so each key still matches exactly + # one field: an archived row is still carrying its mangled name here, and + # only the live field holds the key the flag refers to. + op.execute(""" + UPDATE form_response_pending_updates p + SET field_id = ( + SELECT f.id + FROM form_fields f + WHERE f.form_id = ( + SELECT r.form_id FROM form_responses r WHERE r.id = p.response_id + ) + AND f.field_key = p.field_key + ORDER BY f.is_archived ASC, f.id ASC + LIMIT 1 + ) + """) + + # Map the old pair onto the new vocabulary. field_replaced came from a + # question_type change or a removal; removals no longer flag at all, so + # every surviving row of that kind is a type change. + op.execute(""" + UPDATE form_response_pending_updates + SET reasons = CASE reason + WHEN 'option_archived' THEN '["option_invalidated"]'::json + ELSE '["question_type_changed"]'::json + END + """) + + # A flag whose key resolves to nothing points at a field that no longer + # exists, so it could never be cleared by answering anything. + op.execute("DELETE FROM form_response_pending_updates WHERE field_id IS NULL") + + op.alter_column('form_response_pending_updates', 'field_id', nullable=False) + op.alter_column('form_response_pending_updates', 'reasons', nullable=False) + op.create_foreign_key( + 'fk_pending_update_field_id', 'form_response_pending_updates', 'form_fields', + ['field_id'], ['id'], ondelete='CASCADE', + ) + op.drop_constraint('uq_pending_update_per_response_field', 'form_response_pending_updates', type_='unique') + op.drop_column('form_response_pending_updates', 'field_key') + op.drop_column('form_response_pending_updates', 'reason') + op.create_unique_constraint( + 'uq_pending_update_per_response_field', 'form_response_pending_updates', ['response_id', 'field_id'] + ) + + # --- FormField: release archived keys --------------------------------- + # Order matters. uq_form_field_key covers archived rows too, so un-mangling + # under it would collide an archived field with the live one holding its + # original key. + op.drop_constraint('uq_form_field_key', 'form_fields', type_='unique') + op.execute(f""" + UPDATE form_fields f + SET field_key = left(f.field_key, length(f.field_key) - length({_SUFFIX})) + WHERE f.is_archived = true + AND right(f.field_key, length({_SUFFIX})) = {_SUFFIX} + AND length(f.field_key) > length({_SUFFIX}) + """) + op.create_index( + 'uq_form_field_key', 'form_fields', ['form_id', 'field_key'], + unique=True, postgresql_where=sa.text('is_archived = false'), + ) + + +def downgrade() -> None: + op.drop_index('uq_form_field_key', table_name='form_fields') + # Re-mangle every archived field, not just the ones upgrade() touched — + # the old invariant is that no archived key collides with a live one, and + # there's no record of which were originally mangled. + op.execute(f""" + UPDATE form_fields f + SET field_key = f.field_key || {_SUFFIX} + WHERE f.is_archived = true + AND right(f.field_key, length({_SUFFIX})) <> {_SUFFIX} + """) + op.create_unique_constraint('uq_form_field_key', 'form_fields', ['form_id', 'field_key']) + + op.add_column('form_response_pending_updates', sa.Column('field_key', sa.String(length=64), nullable=True)) + op.add_column('form_response_pending_updates', sa.Column('reason', sa.String(length=32), nullable=True)) + op.execute(""" + UPDATE form_response_pending_updates p + SET field_key = f.field_key + FROM form_fields f + WHERE f.id = p.field_id + """) + # Collapse to the single stronger value; reasons with no pre-lifecycle + # equivalent are dropped. + op.execute(""" + UPDATE form_response_pending_updates + SET reason = CASE + WHEN reasons::jsonb ? 'option_invalidated' + AND jsonb_array_length(reasons::jsonb) = 1 THEN 'option_archived' + ELSE 'field_replaced' + END + """) + op.execute("DELETE FROM form_response_pending_updates WHERE field_key IS NULL") + op.alter_column('form_response_pending_updates', 'field_key', nullable=False) + op.alter_column('form_response_pending_updates', 'reason', nullable=False) + + op.drop_constraint('uq_pending_update_per_response_field', 'form_response_pending_updates', type_='unique') + op.drop_constraint('fk_pending_update_field_id', 'form_response_pending_updates', type_='foreignkey') + op.drop_column('form_response_pending_updates', 'reasons') + op.drop_column('form_response_pending_updates', 'field_id') + op.create_unique_constraint( + 'uq_pending_update_per_response_field', 'form_response_pending_updates', ['response_id', 'field_key'] + ) + + op.drop_column('form_answers', 'field_key') + op.drop_column('form_answers', 'question_type') From b93d2ebe1abd3aa1a7e9c35d2dbaccfdd9d4d72b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 20:54:55 -0700 Subject: [PATCH 54/92] feat(forms): restrict response edits to flagged questions via a gated patch route --- backend/app/api/routes/forms.py | 212 +++++++++++++++++++++++++------- backend/tests/api/test_forms.py | 149 +++++++++++++++++++--- 2 files changed, 300 insertions(+), 61 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index eed3e82c..6181d7f3 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -12,6 +12,7 @@ delete_pending_updates_for_field, flag_pending_updates, resolve_field_options, + selected_option_ids, slugify, snapshot_answer_value, ) @@ -701,11 +702,62 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> ) +def _require_published(form: Form) -> None: + if form.status != "published": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Form is '{form.status}', not published — responses aren't accepted", + ) + + +def _active_fields(db: Session, form: Form) -> list[FormField]: + return ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == False) + .all() + ) + + +def _stored_answer_option_ids(db: Session, response: FormResponse, active_fields: list[FormField]) -> dict: + """The response's answers as option_id lists, in the shape write-through + expects. A submitted payload carries bare option_ids, but most stored + answers hold {option_id, value, label} snapshots instead — so replaying + from storage has to unwrap them first.""" + stored = { + answer.field_id: answer.value + for answer in db.query(FormAnswer).filter(FormAnswer.response_id == response.id).all() + } + return { + field.id: sorted(selected_option_ids(field, stored[field.id])) + for field in active_fields + if field.id in stored + } + + +def _store_answers(db: Session, response: FormResponse, fields_by_id: dict, answers: list) -> None: + for answer_in in answers: + field = fields_by_id[answer_in.field_id] + # question_type/field_key record the semantics this answer was given + # under — `value`'s shape is a function of both, so storing them keeps + # the answer readable after the field is edited rather than + # reinterpreting it through whatever the field looks like later. + db.add(FormAnswer( + response_id=response.id, + field_id=field.id, + value=snapshot_answer_value(field, answer_in.value), + question_type=field.question_type, + field_key=field.field_key, + )) + + # --------------------------------------------------------------------------- -# POST /forms/{form_id}/responses/ — submit or resubmit. One row per -# (form, user); resubmitting replaces all of that user's answers in place -# (no submission history). View access, not manage — this is what the -# person filling the form out calls. +# POST /forms/{form_id}/responses/ — first submission only. One row per +# (form, user); a second POST is a 409, not a resubmit. Editing an existing +# response goes through PATCH below, which only accepts the questions a TD +# flagged — an unrestricted rewrite of an old response re-fires write-through +# for fields the respondent never touched, overwriting state a newer form may +# have set (see form-edit-lifecycle.md). View access, not manage — this is +# what the person filling the form out calls. # --------------------------------------------------------------------------- @router.post("/forms/{form_id}/responses/", response_model=FormResponseRead) def submit_form_response( @@ -714,21 +766,21 @@ def submit_form_response( form: Form = Depends(require_form_view_access), current_user: User = Depends(get_current_user), ): - if form.status != "published": + _require_published(form) + + existing = ( + db.query(FormResponse) + .filter(FormResponse.form_id == form.id, FormResponse.user_id == current_user.id) + .first() + ) + if existing is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"Form is '{form.status}', not published — responses aren't accepted", + detail="You have already responded to this form — use PATCH to update flagged questions", ) - active_fields = ( - db.query(FormField) - .filter(FormField.form_id == form.id, FormField.is_archived == False) - .all() - ) - valid_field_ids = {field.id for field in active_fields} - - field_ids = [answer_in.field_id for answer_in in payload.answers] - invalid_field_ids = set(field_ids) - valid_field_ids + active_fields = _active_fields(db, form) + invalid_field_ids = {a.field_id for a in payload.answers} - {f.id for f in active_fields} if invalid_field_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -743,48 +795,114 @@ def submit_form_response( detail=f"Missing required field(s): {sorted(missing_required)}", ) + response = FormResponse(form_id=form.id, user_id=current_user.id) + db.add(response) + db.flush() + + _store_answers(db, response, {f.id: f for f in active_fields}, payload.answers) + + if form.owner_type == "tournament": + _write_through_reserved_fields(db, form, active_fields, answers_by_field, current_user) + + db.commit() + db.refresh(response) + return response + + +# --------------------------------------------------------------------------- +# PATCH /forms/{form_id}/responses/me/ — edit a submitted response, limited to +# the questions carrying a pending update. A respondent can't freely revise an +# old response: replaying answers that didn't change can overwrite state a +# newer form already set (see the track status ordering note in +# form-edit-lifecycle.md). The gate is enforced here, not in the UI. +# +# Only the patched fields are replaced, validated, written through, and +# cleared; the rest of the response is untouched. +# --------------------------------------------------------------------------- +@router.patch("/forms/{form_id}/responses/me/", response_model=FormResponseRead) +def patch_form_response( + payload: FormResponseCreate, + db: Session = Depends(get_db), + form: Form = Depends(require_form_view_access), + current_user: User = Depends(get_current_user), +): + _require_published(form) + response = ( db.query(FormResponse) .filter(FormResponse.form_id == form.id, FormResponse.user_id == current_user.id) .first() ) if response is None: - response = FormResponse(form_id=form.id, user_id=current_user.id) - db.add(response) - db.flush() - else: - db.query(FormAnswer).filter(FormAnswer.response_id == response.id).delete() - response.updated_at = utcnow() - - field_by_id = {field.id: field for field in active_fields} - for answer_in in payload.answers: - field = field_by_id[answer_in.field_id] - stored_value = snapshot_answer_value(field, answer_in.value) - # question_type/field_key record the semantics this answer was given - # under — `value`'s shape is a function of both, so storing them keeps - # the answer readable after the field is edited rather than - # reinterpreting it through whatever the field looks like later. - db.add(FormAnswer( - response_id=response.id, - field_id=field.id, - value=stored_value, - question_type=field.question_type, - field_key=field.field_key, - )) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No response to update — submit the form first", + ) - # A fresh answer for a field clears any pending-update flag on it — the - # respondent has now seen and re-confirmed whatever changed. - answered_field_ids = { - field.id for field in active_fields if field.id in answers_by_field + patched_ids = {answer_in.field_id for answer_in in payload.answers} + if not patched_ids: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No answers to update") + + flagged_ids = { + field_id + for (field_id,) in db.query(FormResponsePendingUpdate.field_id).filter( + FormResponsePendingUpdate.response_id == response.id + ) } - if answered_field_ids: - db.query(FormResponsePendingUpdate).filter( - FormResponsePendingUpdate.response_id == response.id, - FormResponsePendingUpdate.field_id.in_(answered_field_ids), - ).delete(synchronize_session=False) + ungated = patched_ids - flagged_ids + if ungated: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"These questions aren't open for editing: {sorted(ungated)}", + ) + + fields_by_id = {f.id: f for f in _active_fields(db, form) if f.id in patched_ids} + # A flag should only ever point at a live field; anything missing here + # means one was retired without its flags being cleaned up. + missing = patched_ids - set(fields_by_id) + if missing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid field_id(s) for this form: {sorted(missing)}", + ) + + answers_by_field = {answer_in.field_id: answer_in.value for answer_in in payload.answers} + # Only over what's being patched — the rest of the response already + # satisfied required validation when it was submitted. + missing_required = missing_required_field_keys(list(fields_by_id.values()), answers_by_field) + if missing_required: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Missing required field(s): {sorted(missing_required)}", + ) + + db.query(FormAnswer).filter( + FormAnswer.response_id == response.id, FormAnswer.field_id.in_(patched_ids) + ).delete(synchronize_session=False) + _store_answers(db, response, fields_by_id, payload.answers) + + db.query(FormResponsePendingUpdate).filter( + FormResponsePendingUpdate.response_id == response.id, + FormResponsePendingUpdate.field_id.in_(patched_ids), + ).delete(synchronize_session=False) + + response.updated_at = utcnow() if form.owner_type == "tournament": - _write_through_reserved_fields(db, form, active_fields, answers_by_field, current_user) + # Deliberately *not* scoped to the patched fields. Availability is a + # union across every availability_* field on the form, diffed against + # the membership's whole set — handing it a subset would delete the + # shifts the unpatched fields contribute. Recomputing from the full + # response is safe because that diff is idempotent: unpatched fields + # resolve to the same ids they already produced. + # + # A last-write-wins target (track status, when it lands) would *not* + # be safe this way and will need real per-field scoping. + db.flush() + active_fields = _active_fields(db, form) + _write_through_reserved_fields( + db, form, active_fields, _stored_answer_option_ids(db, response, active_fields), current_user + ) db.commit() db.refresh(response) diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 733d0236..36da71a0 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -827,8 +827,10 @@ def test_pending_update_cleared_on_fresh_submission(self, client, db, td_user, t ) assert db.query(FormResponsePendingUpdate).filter(FormResponsePendingUpdate.response_id == response_id).count() == 1 - res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_blue"]}]}) - assert res.status_code == 200 + # Answering the flagged question clears it. That goes through PATCH — + # POST no longer resubmits. + res = client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_blue"]}]}) + assert res.status_code == 200, res.json() assert db.query(FormResponsePendingUpdate).filter(FormResponsePendingUpdate.response_id == response_id).count() == 0 def _pending(self, db, response_id, field_id): @@ -1008,6 +1010,106 @@ def test_duplicate_option_id_within_field_rejected(self, client, db, td_user, td # POST /forms/{form_id}/responses/ — submission and resubmission # --------------------------------------------------------------------------- +class TestPatchResponse: + """PATCH /forms/{id}/responses/me/ — the only way to change a submitted + answer, and only for questions the TD flagged.""" + + def _submit(self, client, db, form, field, value): + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": value}]}) + assert res.status_code == 200, res.json() + return res.json()["id"] + + def _flag(self, db, response_id, field, reasons=("text_changed",)): + db.add(FormResponsePendingUpdate( + response_id=response_id, field_id=field.id, reasons=list(reasons) + )) + db.commit() + + def test_flagged_field_can_be_patched_and_clears_the_flag(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + response_id = self._submit(client, db, form, field, ["opt_1"]) + self._flag(db, response_id, field) + + res = client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_2"]}]}) + assert res.status_code == 200, res.json() + + answer = db.query(FormAnswer).filter(FormAnswer.response_id == response_id, FormAnswer.field_id == field.id).one() + assert answer.value == ["opt_2"] + assert db.query(FormResponsePendingUpdate).filter( + FormResponsePendingUpdate.response_id == response_id + ).count() == 0 + + def test_unflagged_field_is_rejected(self, client, db, td_user, td_tournament): + """The lock is server-side: a respondent can't revise an answer just + because the UI let them see it.""" + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + response_id = self._submit(client, db, form, field, ["opt_1"]) + + res = client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_2"]}]}) + assert res.status_code == 403 + + answer = db.query(FormAnswer).filter(FormAnswer.response_id == response_id).one() + assert answer.value == ["opt_1"] + + def test_patching_only_one_of_several_flagged_leaves_the_rest_open(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color") + other = _make_field(db, form, order=2, field_key="shirt") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": field.id, "value": ["opt_1"]}, + {"field_id": other.id, "value": ["opt_1"]}, + ]}) + response_id = res.json()["id"] + self._flag(db, response_id, field) + self._flag(db, response_id, other) + + client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_2"]}]}) + + remaining = db.query(FormResponsePendingUpdate).filter( + FormResponsePendingUpdate.response_id == response_id + ).all() + assert [row.field_id for row in remaining] == [other.id] + + def test_unpatched_answers_are_untouched(self, client, db, td_user, td_tournament): + """A patch carries only the flagged fields — it isn't a full replace, + so nothing else on the response may be disturbed.""" + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color") + other = _make_field(db, form, order=2, field_key="shirt") + db.commit() + login(client, "td@test.com", "tdpass") + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": field.id, "value": ["opt_1"]}, + {"field_id": other.id, "value": ["opt_2"]}, + ]}) + response_id = res.json()["id"] + self._flag(db, response_id, field) + + client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_2"]}]}) + + untouched = db.query(FormAnswer).filter( + FormAnswer.response_id == response_id, FormAnswer.field_id == other.id + ).one() + assert untouched.value == ["opt_2"] + + def test_patch_without_a_response_is_404(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color") + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_1"]}]}) + assert res.status_code == 404 + + class TestSubmitResponse: def test_first_submission_creates_response(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="published") @@ -1025,7 +1127,9 @@ def test_first_submission_creates_response(self, client, db, td_user, td_tournam assert len(data["answers"]) == 1 assert data["answers"][0]["value"] == ["opt_1"] - def test_resubmission_overwrites_in_place(self, client, db, td_user, td_tournament): + def test_second_submission_rejected(self, client, db, td_user, td_tournament): + """POST creates; it no longer resubmits. Editing goes through PATCH, + which only accepts flagged questions.""" form = _make_form(db, td_user, td_tournament, status="published") field = _make_field(db, form, field_key="color") db.commit() @@ -1034,10 +1138,10 @@ def test_resubmission_overwrites_in_place(self, client, db, td_user, td_tourname client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_1"]}]}) res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_2"]}]}) - assert res.status_code == 200 - assert len(res.json()["answers"]) == 1 - assert res.json()["answers"][0]["value"] == ["opt_2"] + assert res.status_code == 409 assert db.query(FormResponse).filter(FormResponse.form_id == form.id, FormResponse.user_id == td_user.id).count() == 1 + answer = db.query(FormAnswer).join(FormResponse).filter(FormResponse.form_id == form.id).one() + assert answer.value == ["opt_1"] def test_invalid_field_id_rejected(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="published") @@ -1236,6 +1340,19 @@ def test_overlapping_selected_options_dedupe_shared_shift(self, client, db, td_u membership_id = self._membership_id(db, td_user, td_tournament) assert self._shift_ids(db, membership_id) == {morning.id, afternoon.id} + def _flag(self, db, form, user, field): + """Open a pending update on `field` so PATCH will accept it. Which + reason doesn't matter here — the gate only checks that one exists.""" + response = ( + db.query(FormResponse) + .filter(FormResponse.form_id == form.id, FormResponse.user_id == user.id) + .one() + ) + db.add(FormResponsePendingUpdate( + response_id=response.id, field_id=field.id, reasons=["option_invalidated"] + )) + db.commit() + def test_deselecting_option_keeps_shift_still_covered_by_another(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="published") morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) @@ -1257,8 +1374,11 @@ def test_deselecting_option_keeps_shift_still_covered_by_another(self, client, d client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_morning", "opt_all_day"]}]}) # Deselect "All Day" — "Morning" alone still covers the morning shift. - res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_morning"]}]}) - assert res.status_code == 200 + # Changing a submitted answer means PATCH, which needs the question + # flagged first. + self._flag(db, form, td_user, field) + res = client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_morning"]}]}) + assert res.status_code == 200, res.json() membership_id = self._membership_id(db, td_user, td_tournament) assert self._shift_ids(db, membership_id) == {morning.id} @@ -1347,15 +1467,16 @@ def test_blanking_one_of_two_availability_fields_only_clears_its_own_shifts(self {"field_id": field_sun.id, "value": ["opt_sun"]}, ]}, ) - # Resubmit with the Saturday field blanked — Sunday's shift should - # survive untouched. - res = client.post( - f"/forms/{form.id}/responses/", + # Blank the Saturday field — Sunday's shift should survive untouched, + # even though write-through recomputes the union across both fields. + self._flag(db, form, td_user, field_sat) + res = client.patch( + f"/forms/{form.id}/responses/me/", json={"answers": [ - {"field_id": field_sun.id, "value": ["opt_sun"]}, + {"field_id": field_sat.id, "value": []}, ]}, ) - assert res.status_code == 200 + assert res.status_code == 200, res.json() membership_id = self._membership_id(db, td_user, td_tournament) assert self._shift_ids(db, membership_id) == {sunday.id} From ce7f349b86f2f12a9bc27ad7a4e87a08898808b6 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 21:05:46 -0700 Subject: [PATCH 55/92] fix(forms): scope availability write-through to the days a submission covers --- backend/app/api/routes/forms.py | 50 ++++++++++---- backend/app/core/form/write_through.py | 52 ++++++++++++--- backend/tests/api/test_forms.py | 91 ++++++++++++++++++++++---- 3 files changed, 158 insertions(+), 35 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 6181d7f3..92d219cc 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -1,3 +1,5 @@ +from datetime import date + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session @@ -31,7 +33,13 @@ validate_tournament_preset, validate_track_status_options, ) -from app.core.form.write_through import parse_lunch_field_key, sync_availability, sync_lunch +from app.core.form.write_through import ( + parse_availability_field_key, + parse_lunch_field_key, + shift_ids_on_dates, + sync_availability, + sync_lunch, +) from app.core.tournament.form_prerequisites import member_meets_form_prerequisites from app.core.tournament.memberships import get_membership_by_user from app.core.tournament.onboarding import next_required_onboarding_form_id @@ -919,17 +927,21 @@ def _write_through_reserved_fields( """Syncs `availability_{date}`/`lunch_{date}_{category}` answers into their structural tables — tournament-owned forms only (see form-question-types-reference.md). Runs over every active field, not - just answered ones, so a reserved field left blank on resubmit clears - any previously-synced rows rather than leaving them stale. - - A tournament can have multiple `availability_*` fields (one per date), - but they all write into the same centralized - TournamentMembershipAvailability pool for this membership — so their - selected shift ids are unioned across every matching field first, and - `sync_availability` (which diffs against *all* of the membership's - existing rows, not per-field) is called exactly once. Calling it once - per field instead would have each call's diff wipe out the shift ids - contributed by the previous field's call.""" + just answered ones, so a reserved field left blank clears any + previously-synced rows rather than leaving them stale. + + Availability write-through is scoped by *day*, not by form or field. Every + `availability_*` field across every form feeds one centralized + TournamentMembershipAvailability pool, so a submission may only touch the + shifts belonging to the days it actually asked about — otherwise answering + a Sunday form would wipe the Saturday availability a different form + collected. The days covered here are unioned and handed to + sync_availability as the boundary of what it may change; everything + outside is left alone. + + Fields are unioned before that single call rather than synced one at a + time: two questions covering the same day would otherwise have the second + call's removal undo the first call's addition.""" membership = ( db.query(TournamentMembership) .filter( @@ -945,6 +957,7 @@ def _write_through_reserved_fields( ) availability_shift_ids: set[int] = set() + availability_dates: set[date] = set() for field in active_fields: value = answers_by_field.get(field.id) @@ -960,6 +973,10 @@ def _write_through_reserved_fields( options_by_id = {opt["option_id"]: opt for opt in (field.config or {}).get("options", [])} for option_id in selected: availability_shift_ids.update(options_by_id.get(option_id, {}).get("value") or []) + # The day is what this question governs, independent of which + # shifts its options currently name — so regrouping an option + # can't strand a shift the member should have lost. + availability_dates.add(parse_availability_field_key(field.field_key)) continue if LUNCH_FIELD_KEY_PATTERN.match(field.field_key): @@ -975,8 +992,13 @@ def _write_through_reserved_fields( ] sync_lunch(db, membership.id, lunch_date, category, values) - if any(AVAILABILITY_FIELD_KEY_PATTERN.match(field.field_key) for field in active_fields): - sync_availability(db, membership.id, list(availability_shift_ids)) + if availability_dates: + sync_availability( + db, + membership.id, + availability_shift_ids, + shift_ids_on_dates(db, form.tournament_id, availability_dates), + ) # --------------------------------------------------------------------------- diff --git a/backend/app/core/form/write_through.py b/backend/app/core/form/write_through.py index b5bc8ff5..84907601 100644 --- a/backend/app/core/form/write_through.py +++ b/backend/app/core/form/write_through.py @@ -12,8 +12,12 @@ from sqlalchemy.orm import Session -from app.core.form.validation import LUNCH_FIELD_KEY_PATTERN -from app.models.models import TournamentMembershipAvailability, TournamentMembershipLunch +from app.core.form.validation import AVAILABILITY_FIELD_KEY_PATTERN, LUNCH_FIELD_KEY_PATTERN +from app.models.models import ( + TournamentMembershipAvailability, + TournamentMembershipLunch, + TournamentShift, +) def parse_lunch_field_key(field_key: str) -> tuple[date_type, str]: @@ -24,25 +28,57 @@ def parse_lunch_field_key(field_key: str) -> tuple[date_type, str]: return datetime.strptime(date_str, "%Y%m%d").date(), category -def sync_availability(db: Session, membership_id: int, tournament_shift_ids: list[int]) -> None: - """Diffs `tournament_shift_ids` against this membership's existing - TournamentMembershipAvailability rows and applies only the delta.""" +def parse_availability_field_key(field_key: str) -> date_type: + """The date an `availability_{YYYYMMDD}` field covers (already known to + match AVAILABILITY_FIELD_KEY_PATTERN).""" + match = AVAILABILITY_FIELD_KEY_PATTERN.match(field_key) + return datetime.strptime(match.group(1), "%Y%m%d").date() + + +def shift_ids_on_dates(db: Session, tournament_id: int, dates: set[date_type]) -> set[int]: + """Every shift the given tournament days contain — the set an + availability question for those days is answering about, whether or not + its options currently reference each one.""" + if not dates: + return set() + return { + shift_id + for shift_id, start in db.query(TournamentShift.id, TournamentShift.start) + .filter(TournamentShift.tournament_id == tournament_id) + .all() + if start.date() in dates + } + + +def sync_availability( + db: Session, membership_id: int, selected_shift_ids: set[int], owned_shift_ids: set[int] +) -> None: + """Applies one availability answer as a delta over the shifts it governs. + + `owned_shift_ids` is every shift on the day(s) the answered question(s) + cover — not just the ones its options happen to group right now. Shifts + outside that set belong to a different day's question, possibly on a + different form, and are left exactly as they are. + + Scoping by day rather than by the options' current contents matters: if a + TD regroups an option so it no longer mentions some shift, that shift is + still part of the day being answered about, so a member who drops it must + actually lose it instead of keeping it forever as an orphan.""" existing_ids = { shift_id for (shift_id,) in db.query(TournamentMembershipAvailability.tournament_shift_id) .filter(TournamentMembershipAvailability.membership_id == membership_id) .all() } - incoming_ids = set(tournament_shift_ids) - to_remove = existing_ids - incoming_ids + to_remove = (existing_ids & owned_shift_ids) - selected_shift_ids if to_remove: db.query(TournamentMembershipAvailability).filter( TournamentMembershipAvailability.membership_id == membership_id, TournamentMembershipAvailability.tournament_shift_id.in_(to_remove), ).delete(synchronize_session=False) - for shift_id in incoming_ids - existing_ids: + for shift_id in selected_shift_ids - existing_ids: db.add(TournamentMembershipAvailability(membership_id=membership_id, tournament_shift_id=shift_id)) db.flush() diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 36da71a0..a8aeae8e 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -5,6 +5,7 @@ from datetime import date, datetime, timedelta, timezone import pytest +from sqlalchemy.orm.attributes import flag_modified from tests.conftest import grant_role, login from tests.api.chapter._helpers import make_chapter, make_university, make_user @@ -1249,8 +1250,8 @@ def test_availability_write_through_on_tournament_form(self, client, db, td_user shift = TournamentShift( tournament_id=td_tournament.id, label="Saturday", - start=datetime.now(timezone.utc), - end=datetime.now(timezone.utc) + timedelta(hours=8), + start=datetime(2026, 3, 15, tzinfo=timezone.utc), + end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=8), ) db.add(shift) db.flush() @@ -1295,8 +1296,8 @@ def _membership_id(self, db, user, tournament): def test_grouped_availability_option_writes_one_row_per_shift(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="published") - morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) - afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) + morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime(2026, 3, 15, tzinfo=timezone.utc), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4)) + afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=8)) db.add_all([morning, afternoon]) db.flush() field = _make_field( @@ -1314,8 +1315,8 @@ def test_grouped_availability_option_writes_one_row_per_shift(self, client, db, def test_overlapping_selected_options_dedupe_shared_shift(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="published") - morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) - afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) + morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime(2026, 3, 15, tzinfo=timezone.utc), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4)) + afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=8)) db.add_all([morning, afternoon]) db.flush() field = _make_field( @@ -1355,8 +1356,8 @@ def _flag(self, db, form, user, field): def test_deselecting_option_keeps_shift_still_covered_by_another(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament, status="published") - morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) - afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime.now(timezone.utc) + timedelta(hours=4), end=datetime.now(timezone.utc) + timedelta(hours=8)) + morning = TournamentShift(tournament_id=td_tournament.id, label="Morning", start=datetime(2026, 3, 15, tzinfo=timezone.utc), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4)) + afternoon = TournamentShift(tournament_id=td_tournament.id, label="Afternoon", start=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=8)) db.add_all([morning, afternoon]) db.flush() field = _make_field( @@ -1383,9 +1384,73 @@ def test_deselecting_option_keeps_shift_still_covered_by_another(self, client, d membership_id = self._membership_id(db, td_user, td_tournament) assert self._shift_ids(db, membership_id) == {morning.id} + def test_availability_across_two_forms_both_persist(self, client, db, td_user, td_tournament): + """Every availability question feeds one shared pool, so answering a + Sunday form must not disturb the Saturday availability a different + form collected. Write-through is bounded by the days a submission + actually asked about.""" + saturday = TournamentShift(tournament_id=td_tournament.id, label="Saturday", start=datetime(2026, 3, 14, tzinfo=timezone.utc), end=datetime(2026, 3, 14, tzinfo=timezone.utc) + timedelta(hours=4)) + sunday = TournamentShift(tournament_id=td_tournament.id, label="Sunday", start=datetime(2026, 3, 15, tzinfo=timezone.utc), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4)) + db.add_all([saturday, sunday]) + db.flush() + + form_sat = _make_form(db, td_user, td_tournament, name="Saturday form", status="published") + field_sat = _make_field( + db, form_sat, field_key="availability_20260314", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_sat", "value": [saturday.id], "label": "Saturday"}]}, + ) + form_sun = _make_form(db, td_user, td_tournament, name="Sunday form", status="published") + field_sun = _make_field( + db, form_sun, field_key="availability_20260315", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_sun", "value": [sunday.id], "label": "Sunday"}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form_sat.id}/responses/", json={"answers": [{"field_id": field_sat.id, "value": ["opt_sat"]}]}) + assert res.status_code == 200, res.json() + res = client.post(f"/forms/{form_sun.id}/responses/", json={"answers": [{"field_id": field_sun.id, "value": ["opt_sun"]}]}) + assert res.status_code == 200, res.json() + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._shift_ids(db, membership_id) == {saturday.id, sunday.id} + + def test_regrouping_an_option_still_releases_its_old_shift(self, client, db, td_user, td_tournament): + """The TD drops a shift out of an option. A member who re-answers must + actually lose it — if ownership came from the options' current + contents, that shift would belong to nothing and linger forever.""" + one = TournamentShift(tournament_id=td_tournament.id, label="Early", start=datetime(2026, 3, 15, 8, tzinfo=timezone.utc), end=datetime(2026, 3, 15, 10, tzinfo=timezone.utc)) + two = TournamentShift(tournament_id=td_tournament.id, label="Mid", start=datetime(2026, 3, 15, 10, tzinfo=timezone.utc), end=datetime(2026, 3, 15, 12, tzinfo=timezone.utc)) + db.add_all([one, two]) + db.flush() + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field( + db, form, field_key="availability_20260315", question_type="multi_select_checkbox", + config={"required": False, "options": [ + {"option_id": "opt_morning", "value": [one.id, two.id], "label": "Morning"}, + ]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_morning"]}]}) + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._shift_ids(db, membership_id) == {one.id, two.id} + + # Morning now covers only the later shift. + field.config = {"required": False, "options": [ + {"option_id": "opt_morning", "value": [two.id], "label": "Morning"}, + ]} + flag_modified(field, "config") + self._flag(db, form, td_user, field) + + res = client.patch(f"/forms/{form.id}/responses/me/", json={"answers": [{"field_id": field.id, "value": ["opt_morning"]}]}) + assert res.status_code == 200, res.json() + assert self._shift_ids(db, membership_id) == {two.id} + def test_two_availability_fields_disjoint_selections_both_persist(self, client, db, td_user, td_tournament): - saturday = TournamentShift(tournament_id=td_tournament.id, label="Saturday", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) - sunday = TournamentShift(tournament_id=td_tournament.id, label="Sunday", start=datetime.now(timezone.utc) + timedelta(days=1), end=datetime.now(timezone.utc) + timedelta(days=1, hours=4)) + saturday = TournamentShift(tournament_id=td_tournament.id, label="Saturday", start=datetime(2026, 3, 14, tzinfo=timezone.utc), end=datetime(2026, 3, 14, tzinfo=timezone.utc) + timedelta(hours=4)) + sunday = TournamentShift(tournament_id=td_tournament.id, label="Sunday", start=datetime(2026, 3, 15, tzinfo=timezone.utc), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4)) db.add_all([saturday, sunday]) db.flush() form = _make_form(db, td_user, td_tournament, status="published") @@ -1413,7 +1478,7 @@ def test_two_availability_fields_disjoint_selections_both_persist(self, client, assert self._shift_ids(db, membership_id) == {saturday.id, sunday.id} def test_two_availability_fields_overlapping_selections_dedupe(self, client, db, td_user, td_tournament): - shared = TournamentShift(tournament_id=td_tournament.id, label="Shared", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) + shared = TournamentShift(tournament_id=td_tournament.id, label="Shared", start=datetime(2026, 3, 15, tzinfo=timezone.utc), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4)) db.add(shared) db.flush() form = _make_form(db, td_user, td_tournament, status="published") @@ -1444,8 +1509,8 @@ def test_two_availability_fields_overlapping_selections_dedupe(self, client, db, assert [row.tournament_shift_id for row in rows] == [shared.id] def test_blanking_one_of_two_availability_fields_only_clears_its_own_shifts(self, client, db, td_user, td_tournament): - saturday = TournamentShift(tournament_id=td_tournament.id, label="Saturday", start=datetime.now(timezone.utc), end=datetime.now(timezone.utc) + timedelta(hours=4)) - sunday = TournamentShift(tournament_id=td_tournament.id, label="Sunday", start=datetime.now(timezone.utc) + timedelta(days=1), end=datetime.now(timezone.utc) + timedelta(days=1, hours=4)) + saturday = TournamentShift(tournament_id=td_tournament.id, label="Saturday", start=datetime(2026, 3, 14, tzinfo=timezone.utc), end=datetime(2026, 3, 14, tzinfo=timezone.utc) + timedelta(hours=4)) + sunday = TournamentShift(tournament_id=td_tournament.id, label="Sunday", start=datetime(2026, 3, 15, tzinfo=timezone.utc), end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4)) db.add_all([saturday, sunday]) db.flush() form = _make_form(db, td_user, td_tournament, status="published") From 8d5be4a1bf4514f4cb7ed6624dbfb413a9b85168 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 21:49:44 -0700 Subject: [PATCH 56/92] feat(forms): flag regrouped preset options and pin availability shifts to their date --- backend/app/api/routes/forms.py | 5 ++- backend/app/core/form/changes.py | 32 +++++++++++++++- backend/app/core/form/validation.py | 44 ++++++++++++++++++---- backend/tests/core/test_form_changes.py | 41 ++++++++++++++++++++ backend/tests/core/test_form_validation.py | 31 +++++++++++++-- 5 files changed, 140 insertions(+), 13 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 92d219cc..8b136ebb 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -25,6 +25,7 @@ AVAILABILITY_FIELD_KEY_PATTERN, LUNCH_FIELD_KEY_PATTERN, FormFieldValidationError, + availability_field_date, collect_active_field_errors, validate_availability_options, validate_field_config, @@ -608,7 +609,9 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> validate_reserved_field_key(field_key, question_type) validate_tournament_preset(field_key, form.tournament_id) if AVAILABILITY_FIELD_KEY_PATTERN.match(field_key): - validate_availability_options(db, form.tournament_id, normalized) + validate_availability_options( + db, form.tournament_id, normalized, availability_field_date(field_key), + ) validate_track_status_options(db, form.tournament_id, field_key, question_type, normalized) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) diff --git a/backend/app/core/form/changes.py b/backend/app/core/form/changes.py index c9cb66c6..8161a0d2 100644 --- a/backend/app/core/form/changes.py +++ b/backend/app/core/form/changes.py @@ -34,11 +34,14 @@ QUESTION_TYPE_CHANGED = "question_type_changed" OPTION_ADDED = "option_added" OPTION_INVALIDATED = "option_invalidated" +OPTION_REGROUPED = "option_regrouped" NOW_REQUIRED = "now_required" KEY_CHANGED = "key_changed" TEXT_CHANGED = "text_changed" -MANDATORY_REASONS = frozenset({QUESTION_TYPE_CHANGED, OPTION_ADDED, OPTION_INVALIDATED, NOW_REQUIRED}) +MANDATORY_REASONS = frozenset({ + QUESTION_TYPE_CHANGED, OPTION_ADDED, OPTION_INVALIDATED, OPTION_REGROUPED, NOW_REQUIRED, +}) # Default for each judgment call when the caller doesn't send one. key_changed # defaults on because the consequence of skipping it is invisible: those @@ -82,6 +85,19 @@ def _labels_by_option_id(config: dict | None) -> dict[str, str]: } +def _entity_ids_by_option_id(config: dict | None) -> dict[str, tuple]: + """What each option resolves to on an entity-backed preset. On those, + `value` holds the shift/event ids the option groups — the substance of + the question, not display text — so a change here means the option now + means something different from what a respondent agreed to.""" + grouped = {} + for option in (config or {}).get("options") or []: + value = option.get("value") + if isinstance(value, list) and all(isinstance(item, int) for item in value): + grouped[option["option_id"]] = tuple(sorted(value)) + return grouped + + def classify_field_change( old_field, new_question_type: str, @@ -110,6 +126,20 @@ def classify_field_change( if old_ids - new_ids: reasons.add(OPTION_INVALIDATED) + # On an entity-backed preset, regrouping which shifts/events an option + # covers changes what picking it means — "Morning" quietly stops + # including the 7am shift. The option_id and label are untouched, so + # nothing above catches it, but a previous answer now commits the + # respondent to something they didn't choose. + if is_preset_key(new_field_key): + old_groups = _entity_ids_by_option_id(old_config) + new_groups = _entity_ids_by_option_id(new_config) + if any( + option_id in old_groups and old_groups[option_id] != group + for option_id, group in new_groups.items() + ): + reasons.add(OPTION_REGROUPED) + if new_config and new_config.get("required") and not old_config.get("required"): reasons.add(NOW_REQUIRED) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 1de6830c..485ec678 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -14,6 +14,8 @@ from pydantic import ValidationError from sqlalchemy.orm import Session +from datetime import datetime + from app.models.models import Form, FormField, TournamentShift, TournamentTrack from app.schemas.form import QUESTION_TYPE_CONFIG_SCHEMAS @@ -295,7 +297,10 @@ def collect_active_field_errors(db: Session, form: Form) -> list[str]: if AVAILABILITY_FIELD_KEY_PATTERN.match(field.field_key): try: - validate_availability_options(db, form.tournament_id, normalized_config) + validate_availability_options( + db, form.tournament_id, normalized_config, + availability_field_date(field.field_key), + ) except FormFieldValidationError as e: errors.append(f"field '{field.field_key}': {e}") @@ -319,7 +324,16 @@ def validate_form_for_publish(db: Session, form: Form) -> None: _require(not errors, "; ".join(errors)) -def validate_availability_options(db: Session, tournament_id: int | None, config: dict) -> None: +def availability_field_date(field_key: str): + """The day an `availability_{YYYYMMDD}` question covers, or None if the + key isn't one.""" + match = AVAILABILITY_FIELD_KEY_PATTERN.match(field_key) + return datetime.strptime(match.group(1), "%Y%m%d").date() if match else None + + +def validate_availability_options( + db: Session, tournament_id: int | None, config: dict, field_date=None +) -> None: """A field with field_key matching AVAILABILITY_FIELD_KEY_PATTERN (single_select_radio or multi_select_checkbox) must have every option's `value` be a non-empty @@ -328,6 +342,10 @@ def validate_availability_options(db: Session, tournament_id: int | None, config TD-labeled choice (e.g. "All Day" -> [1, 2, 3]). Validated strictly since a bad value directly corrupts MembershipAvailability write-through. + `field_date` additionally pins every referenced shift to the day in the + field_key; see the check itself for why a stray shift from another day is + not merely untidy. + Chapter-owned forms have no tournament shift catalog to validate against, so this is a no-op there (a chapter-owned availability field is valid but never write-throughs — see form-question-types-reference.md).""" @@ -353,14 +371,26 @@ def validate_availability_options(db: Session, tournament_id: int | None, config ) shift_ids.update(value) - valid_ids = { - shift_id - for (shift_id,) in db.query(TournamentShift.id) + rows = ( + db.query(TournamentShift.id, TournamentShift.start) .filter(TournamentShift.tournament_id == tournament_id, TournamentShift.id.in_(shift_ids)) .all() - } - missing = shift_ids - valid_ids + ) + missing = shift_ids - {shift_id for shift_id, _ in rows} _require( not missing, f"availability option value(s) do not reference a real TournamentShift on this tournament: {sorted(missing)}", ) + + # An availability question owns its day: write-through may add or remove + # exactly the shifts falling on the date in its field_key, so a shift from + # another day listed here would be added by this question and then removed + # by that day's own question, or the reverse, depending on submission + # order. Reject it rather than let the two fight. + if field_date is not None: + wrong_day = sorted(shift_id for shift_id, start in rows if start.date() != field_date) + _require( + not wrong_day, + f"availability option value(s) reference shifts outside this question's date " + f"({field_date.isoformat()}): {wrong_day}", + ) diff --git a/backend/tests/core/test_form_changes.py b/backend/tests/core/test_form_changes.py index 4a8d03fa..5e0cab2c 100644 --- a/backend/tests/core/test_form_changes.py +++ b/backend/tests/core/test_form_changes.py @@ -199,3 +199,44 @@ def test_option_diffs_still_report_within_a_shape_class(self): ]} reasons = _classify(field, new_question_type="single_select_dropdown", new_config=config) assert reasons == {changes.OPTION_ADDED} + + +class TestEntityRegrouping: + """On an entity-backed preset, an option's `value` is the shift/event ids + it groups — the substance of the question, not display text.""" + + def _availability(self, groups): + return _field( + field_key="availability_20260315", + question_type="multi_select_checkbox", + config={"required": False, "options": [ + {"option_id": option_id, "value": list(value), "label": option_id.title()} + for option_id, value in groups.items() + ]}, + ) + + def test_regrouping_shifts_flags(self): + field = self._availability({"morning": [1, 2], "afternoon": [3]}) + config = {**field.config, "options": [ + {"option_id": "morning", "value": [2], "label": "Morning"}, + {"option_id": "afternoon", "value": [3], "label": "Afternoon"}, + ]} + assert changes.OPTION_REGROUPED in _classify(field, new_config=config) + + def test_reordering_ids_within_an_option_is_not_a_change(self): + field = self._availability({"morning": [1, 2]}) + config = {**field.config, "options": [ + {"option_id": "morning", "value": [2, 1], "label": "Morning"}, + ]} + assert _classify(field, new_config=config) == set() + + def test_regrouping_is_mandatory(self): + """A respondent's stored answer now commits them to shifts they never + picked, so the TD can't opt out of asking.""" + assert changes.OPTION_REGROUPED in changes.MANDATORY_REASONS + + def test_plain_question_value_edit_stays_cosmetic(self): + """Same edit shape on a non-preset key is just TD-facing text.""" + field = _field(field_key="favorite_color") + options = [{**field.config["options"][0], "value": "crimson"}, field.config["options"][1]] + assert _classify(field, new_config={**field.config, "options": options}) == set() diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py index 901ea3e0..cba40644 100644 --- a/backend/tests/core/test_form_validation.py +++ b/backend/tests/core/test_form_validation.py @@ -3,7 +3,7 @@ field_key pairing, branching option targets, availability's TournamentShift resolution, and the aggregate whole-form publish pass. See tests/api/test_forms.py for the route-level wiring of these checks.""" -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone import pytest @@ -80,12 +80,13 @@ def _make_field(db, form, *, order=1, field_key="favorite_color", question_type= return field -def _make_shift(db, tournament, label="Saturday"): +def _make_shift(db, tournament, label="Saturday", day=None): + start = datetime(2026, 3, 15, tzinfo=timezone.utc) if day is None else day shift = TournamentShift( tournament_id=tournament.id, label=label, - start=datetime.now(timezone.utc), - end=datetime.now(timezone.utc) + timedelta(hours=8), + start=start, + end=start + timedelta(hours=8), ) db.add(shift) db.flush() @@ -330,6 +331,28 @@ def test_shift_id_not_on_tournament_rejected(self, db, td_user, td_tournament, o with pytest.raises(FormFieldValidationError): validate_availability_options(db, td_tournament.id, config) + def test_shift_from_another_day_rejected(self, db, td_user, td_tournament): + """Availability write-through owns a whole day: a stray shift from a + different date would be added by this question and removed by that + day's own question, or the reverse, depending on submission order.""" + wrong_day = _make_shift(db, td_tournament, "Sunday", day=datetime(2026, 3, 16, tzinfo=timezone.utc)) + db.commit() + config = {"options": [{"value": [wrong_day.id], "label": "Sunday"}]} + with pytest.raises(FormFieldValidationError, match="outside this question's date"): + validate_availability_options(db, td_tournament.id, config, date(2026, 3, 15)) + + def test_shift_on_the_field_date_passes(self, db, td_user, td_tournament): + shift = _make_shift(db, td_tournament, day=datetime(2026, 3, 15, 8, tzinfo=timezone.utc)) + db.commit() + config = {"options": [{"value": [shift.id], "label": "Morning"}]} + validate_availability_options(db, td_tournament.id, config, date(2026, 3, 15)) # no raise + + def test_date_check_skipped_when_no_field_date_given(self, db, td_user, td_tournament): + shift = _make_shift(db, td_tournament, day=datetime(2026, 3, 16, tzinfo=timezone.utc)) + db.commit() + config = {"options": [{"value": [shift.id], "label": "Whenever"}]} + validate_availability_options(db, td_tournament.id, config) # no raise + def test_non_list_value_rejected(self, db, td_user, td_tournament): config = {"options": [{"value": "not_a_list", "label": "Whenever"}]} with pytest.raises(FormFieldValidationError): From ab24be59dc67493653b16fd6b27d278ed4c5164e Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 21:53:29 -0700 Subject: [PATCH 57/92] docs(forms): document day-scoped availability write-through and option regrouping --- backend/form-edit-lifecycle.md | 42 ++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md index c39ad5b4..3366f449 100644 --- a/backend/form-edit-lifecycle.md +++ b/backend/form-edit-lifecycle.md @@ -63,12 +63,20 @@ override. | `question_type` changes shape class (below) | `question_type_changed` | everyone who answered | | Option added or reopened | `option_added` | everyone who answered | | Option invalidated | `option_invalidated` | only those who selected it | +| Option regrouped (preset keys) | `option_regrouped` | everyone who answered | | Field becomes required | `now_required` | only those who left it blank | An added option flags everyone because a previous responder may have settled for a lesser choice when their real answer wasn't offered. Reopening a closed option is identical in effect, so it's treated the same. +**Regrouping** applies to entity-backed presets only, where an option's `value` +is the set of shifts or events it covers rather than display text. Changing it +leaves the `option_id` and the label alone, so nothing else notices — but +"Morning" quietly stops including the 7am shift, and a stored answer now +commits the respondent to something they never picked. On a plain question the +same edit is cosmetic and raises nothing. + **Never raised:** | Change | @@ -187,8 +195,9 @@ what the TD asked them to revisit, enforced server-side rather than by the UI. - Only the patched fields' answers are replaced. Everything else is untouched. - Required-field validation applies to the patched fields only — the rest already satisfied it at creation. -- Write-through runs for the patched fields only. - Each patched field's pending update is cleared. +- Write-through is re-derived, bounded by what the patched questions govern — + see below. It is *not* limited to the patched fields themselves. ## Clearing a pending update @@ -228,6 +237,35 @@ This is why answers are never rewritten when a `field_key` moves between preset and standard: the old answers keep their original semantics, and only new submissions write through under the new key. +### Availability is bounded by day, not by field or form + +Every `availability_*` question across every form feeds one shared +`MembershipAvailability` pool, so a submission must not be allowed to disturb +shifts it didn't ask about — answering a Sunday form has to leave the Saturday +availability another form collected exactly as it was. + +The boundary is the **day**: an `availability_{YYYYMMDD}` question governs +every tournament shift falling on that date. A submission may add the shifts +its selected options cover, and remove only shifts on the days it asked about. +Everything outside is untouched. + +Day, rather than "the shifts this question's options currently list" — those +are not the same set. If a TD regroups an option so it no longer mentions a +shift, that shift still belongs to the day being asked about, so a respondent +who drops it must actually lose it. Ownership by option contents would leave it +claimed by nothing and stuck in the pool forever. + +Two consequences: + +- An `availability_{date}` question's options may only reference shifts on that + date. A stray shift from another day would be added by one question and + removed by that day's own question, order deciding the winner; it's rejected + at validation instead. +- Several availability questions in one submission are unioned — both their + selections and the days they cover — before a single write. Applied one at a + time, a later question's removals could undo an earlier one's additions where + their days overlap. + **Rows already written stay, by design.** Moving a question away from a preset does not remove what it previously wrote to `MembershipAvailability` or track statuses. Those tables are shared — multiple questions, across multiple forms, @@ -240,7 +278,7 @@ Cleanup on **Invalidate**: | Target | Rule | |---|---| | `TournamentMembershipLunch` | keyed by (membership, category) — delete the field's rows | -| `MembershipAvailability` | **never deleted.** Rows are a union across every active `availability_*` field; per-field deletion is undefined. | +| `MembershipAvailability` | **never deleted.** Another question may cover the same day, and the invalidated field's own contribution can't be separated from theirs after the fact. | | Track statuses | **never deleted.** A track's state may have been set by a later form; removing this field's contribution can't be done without replay. | A blanket "reset all availability" is a separate, explicit TD action, not a From f4b0f6c4799c1658eb778721104f19e913a8ad5b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 22:30:10 -0700 Subject: [PATCH 58/92] feat(forms): add archive, unarchive, and invalidate verbs for fields and options --- backend/app/api/routes/forms.py | 125 +++++++++-- backend/app/core/form/__init__.py | 2 +- backend/app/core/form/changes.py | 30 ++- backend/app/models/models.py | 4 +- backend/form-edit-lifecycle.md | 49 +++-- backend/tests/api/test_forms.py | 342 ++++++++++++++++++++++++++---- 6 files changed, 465 insertions(+), 87 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 8b136ebb..d48ce8b6 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -8,7 +8,6 @@ from app.core.auth import get_current_user from app.core.chapters import require_officer_or_lead from app.core.form import ( - apply_option_archiving, assign_option_ids, field_key_taken_in_tournament, delete_pending_updates_for_field, @@ -555,15 +554,17 @@ def bulk_update_fields( db.query(FormResponse.id).filter(FormResponse.form_id == form.id).first() is not None ) - live_fields = ( - db.query(FormField) - .filter(FormField.form_id == form.id, FormField.is_archived == False) - .all() - ) - live_by_id = {f.id: f for f in live_fields} + # Archived fields are addressable here too: naming one in the payload + # unarchives it. The payload is the target state, and a question the TD + # wants back is part of that state — it keeps its id, so its answers + # re-link with no extra work. + existing_by_id = { + f.id: f for f in db.query(FormField).filter(FormField.form_id == form.id).all() + } + live_by_id = {fid: f for fid, f in existing_by_id.items() if not f.is_archived} submitted_ids = {e.id for e in payload.fields if e.id is not None} - unknown_ids = submitted_ids - set(live_by_id) + unknown_ids = submitted_ids - set(existing_by_id) if unknown_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -624,14 +625,21 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> order = 1 for entry in payload.fields: if entry.id is not None: - field = live_by_id[entry.id] + field = existing_by_id[entry.id] + # A previously archived field named in the payload is coming back. + # Nothing is flagged: the question and its answers are exactly as + # they were left, so there's nothing for a responder to review. + unarchiving = field.is_archived type_changed = entry.question_type != field.question_type # entry.field_key is None/blank when the caller isn't renaming # this field at all (the common case — most edits touch label/ # config, not the key) — that means "leave it alone", not "set it # to slugify('')", which the model's snake_case validator rejects. new_field_key = slugify(entry.field_key) if entry.field_key else field.field_key - if new_field_key != field.field_key: + # An archived field doesn't reserve its key, so another question + # may have taken it meanwhile — coming back needs the same + # availability check as a rename. + if new_field_key != field.field_key or unarchiving: _check_field_key_available(new_field_key) normalized_config = _validate_config(entry.question_type, entry.config, new_field_key) @@ -641,7 +649,7 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> # the question_type/field_key each answer was given under, so past # answers remain readable under the old semantics rather than being # reinterpreted through the new type. See form-edit-lifecycle.md. - if is_history_preserving: + if is_history_preserving and not unarchiving: reasons = changes.classify_field_change( field, new_question_type=entry.question_type, @@ -651,9 +659,17 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> new_description=entry.description, ) reasons = changes.resolve_reasons(reasons, entry.notify_responders) - normalized_config, archived_option_ids = apply_option_archiving(field.config, normalized_config) + # The submitted option list is authoritative, including its + # is_archived flags — closing an option means sending it back + # marked archived, so an option the payload omits was + # deliberately invalidated and really does leave storage. + # Whoever picked it is flagged before it goes. + removed_option_ids = sorted( + changes.removed_option_ids(field.config, normalized_config) + ) if reasons: - pending_flags.append((field, reasons, archived_option_ids)) + pending_flags.append((field, reasons, removed_option_ids)) + field.is_archived = False field.order = order field.label = entry.label field.description = entry.description @@ -869,7 +885,7 @@ def patch_form_response( fields_by_id = {f.id: f for f in _active_fields(db, form) if f.id in patched_ids} # A flag should only ever point at a live field; anything missing here - # means one was retired without its flags being cleaned up. + # means one was archived without its flags being cleaned up. missing = patched_ids - set(fields_by_id) if missing: raise HTTPException( @@ -1004,6 +1020,87 @@ def _write_through_reserved_fields( ) +# --------------------------------------------------------------------------- +# DELETE /forms/{form_id}/fields/{field_id}/ — invalidate a question: erase it +# and everything it collected. +# +# Deliberately its own route rather than a flag on the bulk update. This is the +# only destructive action in the field lifecycle, and burying it in a target +# field list would mean a client bug could reach it; here it takes a single +# explicit call naming one field. +# +# Use archive (omit the field from the bulk payload) to retire a question and +# keep its history — that stays undoable. Invalidate is for a question that +# should never have been asked, whose answers are not worth keeping, and it +# cannot be undone. +# +# Write-through rows are handled per target: lunch has a single owner and can +# be cleared, while availability and track statuses are shared with other +# questions and are left alone — see form-edit-lifecycle.md. +# --------------------------------------------------------------------------- +@router.delete("/forms/{form_id}/fields/{field_id}/", status_code=status.HTTP_204_NO_CONTENT) +def invalidate_form_field( + field_id: str, + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + field = ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.id == field_id) + .first() + ) + if field is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Field not found on this form") + + if LUNCH_FIELD_KEY_PATTERN.match(field.field_key) and form.owner_type == "tournament": + lunch_date, category = parse_lunch_field_key(field.field_key) + _clear_lunch_write_through(db, form, field, lunch_date, category) + + # FormAnswer's FK has no ON DELETE, so its rows go first; pending updates + # cascade with the field. + db.query(FormAnswer).filter(FormAnswer.field_id == field.id).delete(synchronize_session=False) + db.delete(field) + db.flush() + + # Same whole-form pass the bulk update runs. The row is gone, so a live + # option still branching to it would leave the form unpublishable — + # reject rather than commit a form nobody can fix without finding it. + errors = collect_active_field_errors(db, form) + if errors: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Deleting this field would break the form: {'; '.join(errors)}", + ) + + form.updated_at = utcnow() + db.commit() + + +def _clear_lunch_write_through(db: Session, form: Form, field: FormField, lunch_date, category) -> None: + """Drops the lunch rows this field produced, for every member who answered + it. Keyed by (membership, date, category), so no other question can be + contributing the same rows.""" + user_ids = { + user_id + for (user_id,) in db.query(FormResponse.user_id) + .join(FormAnswer, FormAnswer.response_id == FormResponse.id) + .filter(FormAnswer.field_id == field.id) + .all() + } + if not user_ids: + return + membership_ids = { + membership_id + for (membership_id,) in db.query(TournamentMembership.id).filter( + TournamentMembership.tournament_id == form.tournament_id, + TournamentMembership.user_id.in_(user_ids), + ) + } + for membership_id in membership_ids: + sync_lunch(db, membership_id, lunch_date, category, []) + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/responses/ — all responses to a form. Manage access # only — this is roster data, not something every member should see. diff --git a/backend/app/core/form/__init__.py b/backend/app/core/form/__init__.py index 81164e37..074f0a68 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -277,7 +277,7 @@ def _add(response_id: str, reason_set: set[str]) -> None: def delete_pending_updates_for_field(db: Session, field_id: str) -> None: """Drops every open flag on `field_id` — for when the field stops being - answerable at all. A flag pointing at a retired field can never clear, + answerable at all. A flag pointing at an archived field can never clear, since clearing requires the respondent to answer it.""" db.query(FormResponsePendingUpdate).filter( FormResponsePendingUpdate.field_id == field_id diff --git a/backend/app/core/form/changes.py b/backend/app/core/form/changes.py index 8161a0d2..57ae6ab4 100644 --- a/backend/app/core/form/changes.py +++ b/backend/app/core/form/changes.py @@ -68,13 +68,15 @@ def shape_class(question_type: str) -> str | None: return SHAPE_CLASSES.get(question_type) -def _option_ids(config: dict | None) -> set[str]: - """Live option ids only — an archived option isn't offered to anyone, so - it can't be what a respondent 'gained' or 'lost'.""" +def _option_ids(config: dict | None, *, live_only: bool = True) -> set[str]: + """Option ids, by default only the ones actually offered to a respondent. + + `live_only=False` includes archived options, which still exist in storage + — that difference is what separates archiving an option from deleting it.""" return { option["option_id"] for option in (config or {}).get("options") or [] - if not option.get("is_archived") + if not (live_only and option.get("is_archived")) } @@ -98,6 +100,12 @@ def _entity_ids_by_option_id(config: dict | None) -> dict[str, tuple]: return grouped +def removed_option_ids(old_config: dict | None, new_config: dict | None) -> set[str]: + """Options the edit drops from storage entirely — invalidated, not + archived. Their answers are what OPTION_INVALIDATED flags.""" + return _option_ids(old_config, live_only=False) - _option_ids(new_config, live_only=False) + + def classify_field_change( old_field, new_question_type: str, @@ -120,10 +128,18 @@ def classify_field_change( # Reporting "an option was added" alongside the type change would be noise # describing a consequence of it, not a separate thing to review. if not shape_changed: - old_ids, new_ids = _option_ids(old_config), _option_ids(new_config) - if new_ids - old_ids: + # Four verbs, distinguished by whether an option is present at all and + # whether it's archived — see form-edit-lifecycle.md: + # add absent -> live flag everyone + # unarchive archived -> live flag everyone (same as add) + # archive live -> archived flag nobody; answers stay valid + # invalidate present -> absent flag whoever picked it + old_live, old_all = _option_ids(old_config), _option_ids(old_config, live_only=False) + new_live, new_all = _option_ids(new_config), _option_ids(new_config, live_only=False) + + if new_live - old_live: reasons.add(OPTION_ADDED) - if old_ids - new_ids: + if old_all - new_all: reasons.add(OPTION_INVALIDATED) # On an entity-backed preset, regrouping which shifts/events an option diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 3fb2e0e8..f9b9cbf5 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -835,7 +835,7 @@ class FormField(Base): __table_args__ = ( # Live fields only. An archived field doesn't reserve its key — see - # field_key_taken_in_tournament — so a retired question and the one + # field_key_taken_in_tournament — so an archived question and the one # replacing it can share a name. A plain UniqueConstraint here would # block that at the DB even though the application allows it. Index( @@ -916,7 +916,7 @@ class FormAnswer(Base): # `reasons` is a set, not a single value: one save can legitimately trigger # several on the same field (an option added *and* the wording changed), so # they union rather than override. A row is cleared when the respondent -# patches that field, and is deleted outright if the field is retired or +# patches that field, and is deleted outright if the field is archived or # invalidated — a flag on a question that can no longer be answered is # unclearable by construction. See backend/form-edit-lifecycle.md. # --------------------------------------------------------------------------- diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md index 3366f449..0f3d4ef6 100644 --- a/backend/form-edit-lifecycle.md +++ b/backend/form-edit-lifecycle.md @@ -7,8 +7,8 @@ which covers config/option shapes. ## Principles 1. **Edits mutate in place.** A field keeps its `id` across every edit — - `question_type`, `field_key`, options, all of it. Archiving is for - retirement, not for editing. + `question_type`, `field_key`, options, all of it. Archiving is for taking + a question out of use, never a step in changing one. 2. **Answers are self-describing.** A stored answer records the shape it was answered under, so reading history never depends on the field's current configuration. @@ -61,13 +61,13 @@ override. | Change | `reason` | Who is flagged | |---|---|---| | `question_type` changes shape class (below) | `question_type_changed` | everyone who answered | -| Option added or reopened | `option_added` | everyone who answered | +| Option added or unarchived | `option_added` | everyone who answered | | Option invalidated | `option_invalidated` | only those who selected it | | Option regrouped (preset keys) | `option_regrouped` | everyone who answered | | Field becomes required | `now_required` | only those who left it blank | An added option flags everyone because a previous responder may have settled -for a lesser choice when their real answer wasn't offered. Reopening a closed +for a lesser choice when their real answer wasn't offered. Unarchiving an option is identical in effect, so it's treated the same. **Regrouping** applies to entity-backed presets only, where an option's `value` @@ -83,8 +83,8 @@ same edit is cosmetic and raises nothing. |---| | `question_type` changes within its shape class | | Option `value` edited (TD-facing text only) | -| Option closed | -| Field retired | +| Option archived | +| Field archived | | Field order, `display_style`, branching targets | **TD's choice:** @@ -141,15 +141,15 @@ Four verbs. The TD picks; the system never guesses. | Verb | Meaning | Storage | Pending update | |---|---|---|---| | **Add** | new choice available | appended | everyone | -| **Close** | ran out; existing answers still valid | `is_archived: true` | nobody | -| **Reopen** | a closed option is available again | `is_archived: false` | everyone | +| **Archive** | ran out; existing answers still valid | `is_archived: true` | nobody | +| **Unarchive** | an archived option is available again | `is_archived: false` | everyone | | **Invalidate** | never valid; existing answers are wrong | removed | only those who selected it | All four keep the same `field_id` — the question didn't change, its choices did. An invalidated option's past answers still render from their snapshot; they're flagged as stale, not corrupted. -Closed options are never shown to respondents and never appear as editable +Archived options are never shown to respondents and never appear as editable rows in the builder. They live in storage only. ## Field lifecycle @@ -157,27 +157,34 @@ rows in the builder. They live in storage only. | Action | Effect | Answers | Pending update | |---|---|---|---| | **Edit** | mutate in place; `id` preserved | untouched | per the tiers above | -| **Retire** | `is_archived: true`; key released | kept as history | **open ones deleted** | -| **Restore** | `is_archived: false` | re-link automatically via `field_id` | none | -| **Invalidate** | `is_archived: true` | **purged**, with write-through cleanup | **open ones deleted** | +| **Archive** | `is_archived: true`; key released | kept as history | **open ones deleted** | +| **Unarchive** | `is_archived: false` | re-link automatically via `field_id` | none | +| **Invalidate** | row **deleted** | **purged**, with write-through cleanup | deleted with the field | -Retiring or invalidating a field **deletes its open pending updates.** A flag +Archiving or invalidating a field **deletes its open pending updates.** A flag on a field the respondent can no longer answer is unclearable by construction — `PATCH` would reject the field, and the question isn't rendered. This is not optional cleanup; skipping it strands respondents permanently. -**Restore** works because editing never changes `field_id`, so `FormAnswer` -rows still point at the field. On restore, re-validate: `next_field_id` may -point at something since retired, and the `field_key` may have been claimed by +**Unarchive** works because editing never changes `field_id`, so `FormAnswer` +rows still point at the field. On unarchive, re-validate: `next_field_id` may +point at something since archived, and the `field_key` may have been claimed by a live field while it was gone. -**Invalidate** is the only destructive action. It's for a question that should -never have been asked — the answers are not history worth keeping. Requires -explicit confirmation. +**Invalidate** is the only destructive action, and the only one that can't be +undone: the field row and its answers are gone. It's for a question that should +never have been asked, whose answers aren't history worth keeping. It has its +own endpoint rather than a flag in the bulk update, so a client can't reach it +by accident, and it requires explicit confirmation. + +It's refused when a live option still branches to the field. The row would +stop existing while something still pointed at it, leaving the form +unpublishable for a reason nothing on screen would explain — better to make +the TD clear the branch first. Archived fields appear in the builder in a collapsed **Archived questions** section, never inline — they must not participate in `order` or be selectable -as branching targets. Restoring appends to the end of `order`. +as branching targets. Unarchiving appends to the end of `order`. ## Response routes @@ -309,7 +316,7 @@ always the field they originally answered — where a question is replaced rather than edited, the flag follows the successor. A row is cleared when that field is patched, and deleted outright when the -field is retired or invalidated. +field is archived or invalidated. ## Track status ordering diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index a8aeae8e..c77c5daf 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -735,67 +735,125 @@ def test_whole_batch_rejected_together_on_dangling_next_field_id(self, client, d db.refresh(other_field) assert other_field.label != "New label that should not stick" - def test_option_removed_archives_not_dropped(self, client, db, td_user, td_tournament): - form = _make_form(db, td_user, td_tournament) - field = _make_field( + OPTIONS = [ + {"option_id": "opt_red", "value": "red", "label": "Red"}, + {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, + ] + + def _colour_field(self, db, form): + return _make_field( db, form, field_key="color", question_type="multi_select_checkbox", - config={ - "required": False, - "options": [ - {"option_id": "opt_red", "value": "red", "label": "Red"}, - {"option_id": "opt_blue", "value": "blue", "label": "Blue"}, - ], - }, + config={"required": False, "options": [dict(o) for o in self.OPTIONS]}, ) + + def _save_options(self, client, form, field, options): + return client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{ + "id": field.id, "label": "Favorite color", + "question_type": "multi_select_checkbox", + "config": {"required": False, "options": options}, + }]}, + ) + + def test_archiving_an_option_keeps_it_and_flags_nobody(self, client, db, td_user, td_tournament): + """Archive means "we ran out" — the option stops being offered, but + everyone who already picked it still has a valid answer.""" + form = _make_form(db, td_user, td_tournament) + field = self._colour_field(db, form) db.commit() login(client, "td@test.com", "tdpass") self._publish(client, form) - # A response answers with the option we're about to remove. res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_red"]}]}) - assert res.status_code == 200 response_id = res.json()["id"] - res = client.put( - f"/forms/{form.id}/fields/", - json={ - "fields": [ - { - "id": field.id, "label": "Favorite color", "question_type": "multi_select_checkbox", - "config": {"required": False, "options": [{"option_id": "opt_blue", "value": "blue", "label": "Blue"}]}, - }, - ] - }, - ) - assert res.status_code == 200 - # PUT returns the raw config (the editor's view) — archived options - # stay present with is_archived: true, not silently dropped. - returned_ids = {o["option_id"]: o["is_archived"] for o in res.json()[0]["config"]["options"]} - assert returned_ids == {"opt_blue": False, "opt_red": True} + res = self._save_options(client, form, field, [ + {**self.OPTIONS[0], "is_archived": True}, + self.OPTIONS[1], + ]) + assert res.status_code == 200, res.json() - db.refresh(field) - stored_ids = {o["option_id"]: o["is_archived"] for o in field.config["options"]} - assert stored_ids == {"opt_blue": False, "opt_red": True} + # PUT returns the raw config (the editor's view): the archived option + # is still there, just marked. + returned = {o["option_id"]: o["is_archived"] for o in res.json()[0]["config"]["options"]} + assert returned == {"opt_red": True, "opt_blue": False} - # But GET (the respondent-facing render) filters archived options out. + # GET (the respondent-facing render) filters it out. res = client.get(f"/forms/{form.id}/") - rendered_ids = {o["option_id"] for o in res.json()["fields"][0]["config"]["options"]} - assert rendered_ids == {"opt_blue"} + assert {o["option_id"] for o in res.json()["fields"][0]["config"]["options"]} == {"opt_blue"} + + assert self._pending(db, response_id, field.id) is None + + def test_invalidating_an_option_removes_it_and_flags_who_picked_it(self, client, db, td_user, td_tournament): + """Omitting an option entirely is the invalidate verb: it leaves + storage, and whoever chose it is asked to answer again.""" + form = _make_form(db, td_user, td_tournament) + field = self._colour_field(db, form) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) - # The prior answer referencing opt_red is untouched in storage — it - # keeps the value/label snapshot from when it was submitted, even - # though the option itself is now archived. + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_red"]}]}) + response_id = res.json()["id"] + + res = self._save_options(client, form, field, [self.OPTIONS[1]]) + assert res.status_code == 200, res.json() + assert {o["option_id"] for o in res.json()[0]["config"]["options"]} == {"opt_blue"} + + db.refresh(field) + assert {o["option_id"] for o in field.config["options"]} == {"opt_blue"} + + # The stored answer still renders from its own snapshot — flagged as + # stale, not corrupted. answer = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one() assert answer.value == [{"option_id": "opt_red", "value": "red", "label": "Red"}] - pending = ( - db.query(FormResponsePendingUpdate) - .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_id == field.id) - .first() - ) + pending = self._pending(db, response_id, field.id) assert pending is not None assert pending.reasons == ["option_invalidated"] + def test_invalidating_an_option_spares_who_did_not_pick_it(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament) + field = self._colour_field(db, form) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_blue"]}]}) + response_id = res.json()["id"] + + self._save_options(client, form, field, [self.OPTIONS[1]]) + assert self._pending(db, response_id, field.id) is None + + def test_unarchiving_an_option_flags_everyone(self, client, db, td_user, td_tournament): + """An option coming back is the same as a new one: someone may have + settled for a lesser choice while it was unavailable.""" + form = _make_form(db, td_user, td_tournament) + field = _make_field( + db, form, field_key="color", question_type="multi_select_checkbox", + config={"required": False, "options": [ + {**self.OPTIONS[0], "is_archived": True}, + dict(self.OPTIONS[1]), + ]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_blue"]}]}) + response_id = res.json()["id"] + + res = self._save_options(client, form, field, [ + {**self.OPTIONS[0], "is_archived": False}, + self.OPTIONS[1], + ]) + assert res.status_code == 200, res.json() + + pending = self._pending(db, response_id, field.id) + assert pending is not None + assert pending.reasons == ["option_added"] + def test_pending_update_cleared_on_fresh_submission(self, client, db, td_user, td_tournament): form = _make_form(db, td_user, td_tournament) field = _make_field( @@ -890,6 +948,82 @@ def test_within_shape_type_change_flags_nobody(self, client, db, td_user, td_tou ) assert self._pending(db, response_id, field.id) is None + def test_archived_field_can_be_unarchived_and_keeps_its_answers(self, client, db, td_user, td_tournament): + """A question deleted by mistake comes back by naming it in the + payload. It never lost its id, so its answers re-link with no work.""" + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}) + + # Archive it by leaving it out. + res = client.put(f"/forms/{form.id}/fields/", json={"fields": []}) + assert res.status_code == 200 + db.refresh(field) + assert field.is_archived is True + + # Name it again to bring it back. + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": field.id, "label": "Color", "question_type": "short_text", "config": {"required": False, "max_length": 50}}]}, + ) + assert res.status_code == 200, res.json() + assert [f["id"] for f in res.json()] == [field.id] + + db.refresh(field) + assert field.is_archived is False + answer = db.query(FormAnswer).filter(FormAnswer.field_id == field.id).one() + assert answer.value == "blue" + + def test_unarchiving_raises_no_pending_update(self, client, db, td_user, td_tournament): + """The question is exactly as it was left, so there's nothing for a + previous responder to review.""" + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}) + response_id = res.json()["id"] + + client.put(f"/forms/{form.id}/fields/", json={"fields": []}) + client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"id": field.id, "label": "Color", "question_type": "short_text", "config": {"required": False, "max_length": 50}}]}, + ) + assert self._pending(db, response_id, field.id) is None + + def test_unarchiving_rejected_when_key_was_claimed(self, client, db, td_user, td_tournament): + """An archived field doesn't reserve its key, so the name may be gone + by the time the TD wants the question back.""" + form = _make_form(db, td_user, td_tournament) + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + self._publish(client, form) + client.put(f"/forms/{form.id}/fields/", json={"fields": []}) + + # A new question takes the freed key. + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [{"field_key": "color", "label": "Colour", "question_type": "short_text", "config": {"required": False, "max_length": 50}}]}, + ) + assert res.status_code == 200 + replacement_id = res.json()[0]["id"] + + res = client.put( + f"/forms/{form.id}/fields/", + json={"fields": [ + {"id": replacement_id, "label": "Colour", "question_type": "short_text", "config": {"required": False, "max_length": 50}}, + {"id": field.id, "label": "Color", "question_type": "short_text", "config": {"required": False, "max_length": 50}}, + ]}, + ) + assert res.status_code == 409 + def test_retiring_a_field_deletes_its_open_flags(self, client, db, td_user, td_tournament): """A flag on a question nobody can answer any more could never clear, so retirement takes them with it.""" @@ -1011,6 +1145,130 @@ def test_duplicate_option_id_within_field_rejected(self, client, db, td_user, td # POST /forms/{form_id}/responses/ — submission and resubmission # --------------------------------------------------------------------------- +class TestInvalidateField: + """DELETE /forms/{id}/fields/{field_id}/ — the one destructive field + action: archive the question *and* destroy what it collected.""" + + def test_answers_and_flags_are_purged(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + keep = _make_field(db, form, order=2, field_key="name", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": field.id, "value": "blue"}, + {"field_id": keep.id, "value": "sam"}, + ]}) + response_id = res.json()["id"] + db.add(FormResponsePendingUpdate(response_id=response_id, field_id=field.id, reasons=["text_changed"])) + db.commit() + + field_id = field.id + res = client.delete(f"/forms/{form.id}/fields/{field_id}/") + assert res.status_code == 204 + + assert db.query(FormField).filter(FormField.id == field_id).first() is None + assert db.query(FormAnswer).filter(FormAnswer.field_id == field_id).count() == 0 + assert db.query(FormResponsePendingUpdate).filter( + FormResponsePendingUpdate.field_id == field_id + ).count() == 0 + + # Only that question's data goes. + assert db.query(FormAnswer).filter(FormAnswer.field_id == keep.id).count() == 1 + + def test_archiving_instead_keeps_the_field_and_answers(self, client, db, td_user, td_tournament): + """The contrast that makes the separate route worth having: leaving a + field out of the bulk payload retires it without touching history, and + stays undoable.""" + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color", question_type="short_text", config={"required": False, "max_length": 50}) + db.commit() + login(client, "td@test.com", "tdpass") + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}) + + res = client.put(f"/forms/{form.id}/fields/", json={"fields": []}) + assert res.status_code == 200 + db.refresh(field) + assert field.is_archived is True + assert db.query(FormAnswer).filter(FormAnswer.field_id == field.id).count() == 1 + + def test_lunch_write_through_is_cleared(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field( + db, form, field_key="lunch_20270213_protein", question_type="single_select_radio", + config={"required": False, "options": [ + {"option_id": "opt_chicken", "value": "chicken", "label": "Chicken"}, + ]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "opt_chicken"}]}) + assert db.query(TournamentMembershipLunch).count() == 1 + + res = client.delete(f"/forms/{form.id}/fields/{field.id}/") + assert res.status_code == 204 + assert db.query(TournamentMembershipLunch).count() == 0 + + def test_availability_write_through_is_left_alone(self, client, db, td_user, td_tournament): + """Availability rows are shared with whatever else covers that day, so + this field's contribution can't be separated out after the fact.""" + shift = TournamentShift( + tournament_id=td_tournament.id, label="Morning", + start=datetime(2026, 3, 15, tzinfo=timezone.utc), + end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4), + ) + db.add(shift) + db.flush() + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field( + db, form, field_key="availability_20260315", question_type="multi_select_checkbox", + config={"required": False, "options": [ + {"option_id": "opt_morning", "value": [shift.id], "label": "Morning"}, + ]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": ["opt_morning"]}]}) + assert db.query(TournamentMembershipAvailability).count() == 1 + + res = client.delete(f"/forms/{form.id}/fields/{field.id}/") + assert res.status_code == 204 + assert db.query(TournamentMembershipAvailability).count() == 1 + + def test_rejected_when_a_live_option_branches_to_it(self, client, db, td_user, td_tournament): + """Deleting the row would leave a dangling next_field_id and make the + form unpublishable, with nothing on screen explaining why.""" + form = _make_form(db, td_user, td_tournament, status="published") + target = _make_field(db, form, order=2, field_key="target", question_type="short_text", config={"required": False, "max_length": 50}) + _make_field( + db, form, order=1, field_key="chooser", question_type="single_select_radio", + config={"required": False, "options": [ + {"option_id": "opt_yes", "value": "yes", "label": "Yes", "next_field_id": target.id}, + ]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.delete(f"/forms/{form.id}/fields/{target.id}/") + assert res.status_code == 409 + assert db.query(FormField).filter(FormField.id == target.id).first() is not None + + def test_unknown_field_is_404(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + db.commit() + login(client, "td@test.com", "tdpass") + assert client.delete(f"/forms/{form.id}/fields/nonexistent1/").status_code == 404 + + def test_requires_manage_access(self, client, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field(db, form, field_key="color") + db.commit() + grant_role(db, td_tournament, other_user, "Runner") + login(client, "other@test.com", "otherpass") + assert client.delete(f"/forms/{form.id}/fields/{field.id}/").status_code == 403 + + class TestPatchResponse: """PATCH /forms/{id}/responses/me/ — the only way to change a submitted answer, and only for questions the TD flagged.""" From 65d20103c8ab0542466a4a21d2ed43159f93aefe Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 22:46:30 -0700 Subject: [PATCH 59/92] feat(forms): expose pending updates and the new lifecycle routes to the client --- backend/app/schemas/form.py | 12 ++++++ frontend/lib/api.ts | 76 ++++++++++++++++++++++++++++++------- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 00121f83..7249f656 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -397,6 +397,17 @@ class FormResponseCreate(BaseModel): answers: list[FormAnswerCreate] +class FormPendingUpdateRead(BaseModel): + """A question this response is being asked to look at again. `field_id` is + the only field PATCH .../responses/me/ will accept — see + backend/form-edit-lifecycle.md.""" + field_id: str + reasons: list[str] = [] + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + class FormResponseRead(BaseModel): id: str form_id: str @@ -404,5 +415,6 @@ class FormResponseRead(BaseModel): submitted_at: datetime updated_at: datetime answers: list[FormAnswerRead] = [] + pending_updates: list[FormPendingUpdateRead] = [] model_config = ConfigDict(from_attributes=True) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 21721ec9..594779d0 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1225,10 +1225,11 @@ export interface FormField { updated_at: string } -// One entry in a PUT .../fields/ bulk-update payload. `id` omitted = create; -// `id` present must match a currently-live field. `field_key` only matters -// on create — the server ignores/derives it otherwise (see BulkFieldEntry -// in backend/app/schemas/form.py). +// One entry in a PUT .../fields/ bulk-update payload — the full target state +// for one question. `id` omitted = create. `id` present names an existing +// field, archived ones included: naming an archived field unarchives it. +// Omitting `field_key` on an update leaves the key alone; sending one renames. +// See BulkFieldEntry in backend/app/schemas/form.py. export interface FormFieldInput { id?: string field_key?: string @@ -1236,6 +1237,12 @@ export interface FormFieldInput { description?: string | null question_type: FormQuestionType config?: FormFieldConfig | null + /** The TD's answer, for this question, to "ask previous responders to + review this?" — collected by the save-time confirmation modal. + Governs only the judgment-call changes (wording, and moving between a + preset and a standard key); changes that actually invalidate an answer + prompt regardless. Omit to accept each change's own default. */ + notify_responders?: boolean | null } export type PrerequisiteMatch = "any" | "all" @@ -1347,13 +1354,34 @@ export interface FormAnswerInput { value: unknown } +/** Why a question was flagged for another look. Several can apply at once — + one save can add an option *and* reword the question. */ +export type PendingUpdateReason = + | "question_type_changed" + | "option_added" + | "option_invalidated" + | "option_regrouped" + | "now_required" + | "key_changed" + | "text_changed" + +/** A question this response is being asked to revisit. `field_id` is the only + thing `patchResponse` will accept — everything else on the response is + locked. */ +export interface FormPendingUpdate { + field_id: string + reasons: PendingUpdateReason[] + created_at: string +} + export interface FormResponse { - id: string - form_id: string - user_id: number - submitted_at: string - updated_at: string - answers: FormAnswer[] + id: string + form_id: string + user_id: number + submitted_at: string + updated_at: string + answers: FormAnswer[] + pending_updates: FormPendingUpdate[] } // Matches OnboardingFormRead — a tournament form selected into the ordered @@ -1397,14 +1425,34 @@ export const formsApi = { update: (formId: string, body: FormUpdateInput) => api.patch(`/forms/${formId}/`, body), // 409s if the form has any responses — check response_count client-side first. delete: (formId: string) => api.delete(`/forms/${formId}/`), - // Full ordered target field list — see FormFieldInput and the Edit - // Lifecycle section of form-question-types-reference.md. On a published - // form, an existing option missing from the submitted config must still - // be echoed back (via its option_id) or the server archives it. + // Full ordered target field list — see FormFieldInput and + // backend/form-edit-lifecycle.md. + // + // The submitted config is authoritative, options included. An existing + // option must be echoed back by its option_id — send it with + // `is_archived: true` to archive it (stops being offered, past answers stay + // valid); leave it out entirely and it's *invalidated*, removed from storage + // with whoever picked it asked to answer again. Dropping archived options + // before saving therefore destroys them. + // + // A live field omitted from the list is archived; naming an archived field + // brings it back. putFields: (formId: string, fields: FormFieldInput[]) => api.put(`/forms/${formId}/fields/`, { fields }), + // Destroys the question and every answer to it, permanently. Archiving — + // omitting the field from putFields — is the undoable alternative. 409s + // while another question's option still branches to this one. + deleteField: (formId: string, fieldId: string) => + api.delete(`/forms/${formId}/fields/${fieldId}/`), + // First submission only; 409s once a response exists. Later edits go + // through patchResponse. submitResponse: (formId: string, answers: FormAnswerInput[]) => api.post(`/forms/${formId}/responses/`, { answers }), + // Edits a submitted response, limited to questions carrying a pending + // update — anything else 403s. Send only the questions being changed; the + // rest of the response is left alone, not overwritten. + patchResponse: (formId: string, answers: FormAnswerInput[]) => + api.patch(`/forms/${formId}/responses/me/`, { answers }), listResponses: (formId: string) => api.get(`/forms/${formId}/responses/`), getMyResponse: (formId: string) => api.get(`/forms/${formId}/responses/me/`), } From a6adbedca2ea4a1bb478dc7042b7705dadd45fd0 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 23:00:58 -0700 Subject: [PATCH 60/92] feat(forms): add an archived questions section with restore and permanent delete --- backend/app/api/routes/forms.py | 23 +++ backend/tests/api/test_forms.py | 45 ++++++ .../forms/ArchivedFieldsSection.tsx | 135 ++++++++++++++++++ frontend/components/forms/FieldList.tsx | 37 ++++- frontend/components/ui/Icons.tsx | 10 ++ frontend/lib/api.ts | 4 + 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 frontend/components/forms/ArchivedFieldsSection.tsx diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index d48ce8b6..19023bcc 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -445,6 +445,29 @@ def get_form_for_rendering( return form +# --------------------------------------------------------------------------- +# GET /forms/{form_id}/fields/archived/ — questions taken out of use, for the +# builder's archived section. Manage access, and separate from the form read +# above deliberately: that one is what a respondent renders, and archived +# questions are not part of a form anyone fills out. +# +# Config comes back raw (unresolved), like `?raw=true` — an archived field is +# only ever read here to be sent straight back to PUT .../fields/, which +# unarchives it. +# --------------------------------------------------------------------------- +@router.get("/forms/{form_id}/fields/archived/", response_model=list[FormFieldRead]) +def list_archived_fields( + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + return ( + db.query(FormField) + .filter(FormField.form_id == form.id, FormField.is_archived == True) + .order_by(FormField.updated_at.desc()) + .all() + ) + + # --------------------------------------------------------------------------- # PATCH /forms/{form_id}/ — name/description/status. # --------------------------------------------------------------------------- diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index c77c5daf..a25ce71d 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -1145,6 +1145,51 @@ def test_duplicate_option_id_within_field_rejected(self, client, db, td_user, td # POST /forms/{form_id}/responses/ — submission and resubmission # --------------------------------------------------------------------------- +class TestListArchivedFields: + def test_lists_only_archived_fields(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + live = _make_field(db, form, field_key="live_one") + archived = _make_field(db, form, order=2, field_key="archived_one", is_archived=True) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.get(f"/forms/{form.id}/fields/archived/") + assert res.status_code == 200 + assert [f["id"] for f in res.json()] == [archived.id] + assert live.id not in {f["id"] for f in res.json()} + + def test_config_comes_back_unresolved(self, client, db, td_user, td_tournament): + """It's read only to be sent straight back to PUT .../fields/, so the + option values must be the round-trippable ids, not a rendering.""" + shift = TournamentShift( + tournament_id=td_tournament.id, label="Morning", + start=datetime(2026, 3, 15, tzinfo=timezone.utc), + end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4), + ) + db.add(shift) + db.flush() + form = _make_form(db, td_user, td_tournament, status="published") + _make_field( + db, form, field_key="availability_20260315", question_type="multi_select_checkbox", + is_archived=True, + config={"required": False, "options": [ + {"option_id": "opt_morning", "value": [shift.id], "label": "Morning"}, + ]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.get(f"/forms/{form.id}/fields/archived/") + assert res.json()[0]["config"]["options"][0]["value"] == [shift.id] + + def test_requires_manage_access(self, client, db, td_user, td_tournament, other_user): + form = _make_form(db, td_user, td_tournament, status="published") + db.commit() + grant_role(db, td_tournament, other_user, "Runner") + login(client, "other@test.com", "otherpass") + assert client.get(f"/forms/{form.id}/fields/archived/").status_code == 403 + + class TestInvalidateField: """DELETE /forms/{id}/fields/{field_id}/ — the one destructive field action: archive the question *and* destroy what it collected.""" diff --git a/frontend/components/forms/ArchivedFieldsSection.tsx b/frontend/components/forms/ArchivedFieldsSection.tsx new file mode 100644 index 00000000..fe47de7a --- /dev/null +++ b/frontend/components/forms/ArchivedFieldsSection.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Modal } from "@/components/ui/Modal"; +import { IconChevronDown, IconRestore, IconTrash } from "@/components/ui/Icons"; +import { ApiError, FormField, formsApi } from "@/lib/api"; +import { QUESTION_TYPE_OPTIONS } from "@/lib/forms/fieldTypes"; + +const TYPE_LABELS = Object.fromEntries(QUESTION_TYPE_OPTIONS.map((o) => [o.value, o.label])); + +// Questions taken out of use, listed below the builder rather than inline — +// they must not sit in the ordered list, where they'd join drag ordering and +// show up as branch targets. Collapsed by default: on a form that's been +// edited for a while this gets long, and it's a recovery surface rather than +// somewhere a TD works. +// +// Two actions, deliberately unequal in weight: +// Restore — puts the question back, answers and all. Reversible. +// Delete — erases the question and every answer to it. Permanent. +export function ArchivedFieldsSection({ formId, fields, onRestore, onDeleted }: { + formId: string; + fields: FormField[]; + /** Hands the field to the builder, which unarchives it on the next Save — + restoring isn't its own request, it's part of the target field list. */ + onRestore: (field: FormField) => void; + onDeleted: (fieldId: string) => void; +}) { + const [open, setOpen] = useState(false); + const [confirming, setConfirming] = useState(null); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(undefined); + + if (fields.length === 0) return null; + + async function handleDelete() { + if (!confirming) return; + setError(undefined); + setDeleting(true); + try { + await formsApi.deleteField(formId, confirming.id); + onDeleted(confirming.id); + setConfirming(null); + } catch (err: unknown) { + // The likely 409 is "another question still branches to this one", + // which the TD can only resolve by editing that other question. + setError(err instanceof ApiError ? err.message : "Something went wrong. Try again."); + } finally { + setDeleting(false); + } + } + + return ( +
+ + + {open && ( +
+ {fields.map((field) => ( + +
+
+ {field.label || "Untitled question"} +
+
+ {field.field_key} · {TYPE_LABELS[field.question_type] ?? field.question_type} +
+
+
+ + +
+
+ ))} +
+ )} + + {confirming && ( + setConfirming(null)} variant="danger"> +
+

+ Delete {confirming.label || "this question"} and every answer anyone + gave it? This can’t be undone — leave it archived instead if you might want the + responses later. +

+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/components/forms/FieldList.tsx b/frontend/components/forms/FieldList.tsx index 3a690024..e29f2efe 100644 --- a/frontend/components/forms/FieldList.tsx +++ b/frontend/components/forms/FieldList.tsx @@ -8,7 +8,7 @@ import { import { SortableContext, verticalListSortingStrategy, arrayMove, } from "@dnd-kit/sortable"; -import { formsApi, tournamentsApi, tournamentShiftsApi, Form, Tournament, TournamentShift } from "@/lib/api"; +import { formsApi, tournamentsApi, tournamentShiftsApi, Form, FormField, Tournament, TournamentShift } from "@/lib/api"; import { enumerateDates } from "@/lib/date"; import { useFormValidation } from "@/lib/forms/useFormValidation"; import { Button } from "@/components/ui/Button"; @@ -19,6 +19,7 @@ import { IconForms, IconPlus } from "@/components/ui/Icons"; import { TOPBAR_HEIGHT } from "@/components/layout/Topbar"; import { FieldCard, FieldCardDragPreview, FocusIntent } from "@/components/forms/FieldCard"; import { FieldToolbar } from "@/components/forms/FieldToolbar"; +import { ArchivedFieldsSection } from "@/components/forms/ArchivedFieldsSection"; import { EditableOption } from "@/components/forms/OptionsEditor"; import { EditableField, withOptionClientKeys, newField, toFieldInput, deriveBranchingEnabled, deriveCustomValuesEnabled, @@ -67,6 +68,13 @@ export function FieldList({ form }: { form: Form }) { // re-fetching the same list — EntityOptionsEditor fetches its own copy // too, but only while a field is actually expanded/being edited. const [shifts, setShifts] = useState(null); + // Questions taken out of use. Not part of `fields` — they must not join the + // ordered list or read as branch targets — so they're fetched separately and + // move between the two lists only when the TD restores one. + const [archivedFields, setArchivedFields] = useState([]); + useEffect(() => { + formsApi.listArchivedFields(form.id).then(setArchivedFields).catch(() => {}); + }, [form.id]); useEffect(() => { if (form.tournament_id == null) return; tournamentShiftsApi.list(form.tournament_id).then(setShifts).catch(() => {}); @@ -289,6 +297,24 @@ export function FieldList({ form }: { form: Form }) { // Cleared field_key on the copy — a reserved key (availability, ...) can // only exist once per tournament, and a freeform key the TD chose // deliberately shouldn't silently duplicate either. + // Restoring is staged, not a request: the field joins the target list and + // the next Save unarchives it. Keeping it in the same batch means it goes + // through the same key-availability check as everything else — an archived + // field doesn't reserve its key, so another question may have taken it. + function restoreField(field: FormField) { + setArchivedFields((prev) => prev.filter((f) => f.id !== field.id)); + const restored: EditableField = { + ...withOptionClientKeys(field), + clientKey: String(field.id), + showDescription: !!field.description, + branchingEnabled: deriveBranchingEnabled(field), + customValuesEnabled: deriveCustomValuesEnabled(field), + }; + setFields((prev) => [...prev, restored]); + setExpandedKey(restored.clientKey); + setPendingScrollKey(restored.clientKey); + } + function duplicateField(clientKey: string) { const source = fields.find((f) => f.clientKey === clientKey); if (!source) return; @@ -354,6 +380,9 @@ export function FieldList({ form }: { form: Form }) { setExpandedKey(next[expandedIndex]?.clientKey ?? next[0]?.clientKey ?? null); baselineRef.current = JSON.stringify(next); validation.clearAll(); + // The response only carries live fields, so anything this save archived + // (or unarchived) has to be re-read rather than derived from it. + formsApi.listArchivedFields(form.id).then(setArchivedFields).catch(() => {}); } catch (err) { validation.handle422(err); } finally { @@ -487,6 +516,12 @@ export function FieldList({ form }: { form: Form }) { )} + setArchivedFields((prev) => prev.filter((f) => f.id !== fieldId))} + /> + + + ); +} + export function IconInvite({ size = 16, ...props }: IconProps) { return ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 594779d0..6b481585 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1439,6 +1439,10 @@ export const formsApi = { // brings it back. putFields: (formId: string, fields: FormFieldInput[]) => api.put(`/forms/${formId}/fields/`, { fields }), + // Questions taken out of use. Config comes back raw, so an entry can go + // straight back into putFields — which is how a question is unarchived. + listArchivedFields: (formId: string) => + api.get(`/forms/${formId}/fields/archived/`), // Destroys the question and every answer to it, permanently. Archiving — // omitting the field from putFields — is the undoable alternative. 409s // while another question's option still branches to this one. From b631cba95ab6b8cf192e0c0a85b8b476b63239f8 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 23:20:37 -0700 Subject: [PATCH 61/92] feat(forms): confirm which edits send questions back to previous responders --- frontend/components/forms/FieldList.tsx | 51 +++++- .../forms/NotifyRespondersModal.tsx | 107 ++++++++++++ frontend/lib/forms/changeClassification.ts | 164 ++++++++++++++++++ frontend/lib/forms/editableField.ts | 6 +- 4 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 frontend/components/forms/NotifyRespondersModal.tsx create mode 100644 frontend/lib/forms/changeClassification.ts diff --git a/frontend/components/forms/FieldList.tsx b/frontend/components/forms/FieldList.tsx index e29f2efe..527274b4 100644 --- a/frontend/components/forms/FieldList.tsx +++ b/frontend/components/forms/FieldList.tsx @@ -20,11 +20,13 @@ import { TOPBAR_HEIGHT } from "@/components/layout/Topbar"; import { FieldCard, FieldCardDragPreview, FocusIntent } from "@/components/forms/FieldCard"; import { FieldToolbar } from "@/components/forms/FieldToolbar"; import { ArchivedFieldsSection } from "@/components/forms/ArchivedFieldsSection"; +import { NotifyRespondersModal } from "@/components/forms/NotifyRespondersModal"; import { EditableOption } from "@/components/forms/OptionsEditor"; import { EditableField, withOptionClientKeys, newField, toFieldInput, deriveBranchingEnabled, deriveCustomValuesEnabled, } from "@/lib/forms/editableField"; import { DISPLAY_STYLE_TYPES } from "@/lib/forms/fieldTypes"; +import { ClassifiedChange, classifyEdits, defaultNotify } from "@/lib/forms/changeClassification"; // How long a scroll-into-view keeps following a card that's still growing // (see scrollCardIntoView) — long enough to cover a shifts/events fetch on @@ -72,6 +74,19 @@ export function FieldList({ form }: { form: Form }) { // ordered list or read as branch targets — so they're fetched separately and // move between the two lists only when the TD restores one. const [archivedFields, setArchivedFields] = useState([]); + // Set when a save on a form with responses turns out to change something + // consequential — the save waits here until the TD decides who gets asked + // to re-answer. null means no save is pending confirmation. + const [pendingChanges, setPendingChanges] = useState(null); + const [notifyByKey, setNotifyByKey] = useState>({}); + // Only these questions have a TD decision attached; everything else in the + // payload omits notify_responders and takes the server's defaults. + const notifyRef = useRef>({}); + // What the server last confirmed, which is what an edit is judged against. + // The `form` prop is the initial load and never updates, so a second save in + // the same session would otherwise classify against stale fields and + // re-report changes already saved. + const savedFieldsRef = useRef(form.fields); useEffect(() => { formsApi.listArchivedFields(form.id).then(setArchivedFields).catch(() => {}); }, [form.id]); @@ -350,6 +365,10 @@ export function FieldList({ form }: { form: Form }) { } } + // A form nobody has answered can't strand anyone, so edits apply silently; + // once responses exist, every consequential edit is the TD's call. + const hasResponses = form.status === "published" || form.response_count > 0; + async function handleSave() { const issues = validation.validate(fields); if (issues.length > 0) { @@ -362,13 +381,31 @@ export function FieldList({ form }: { form: Form }) { setSaveAttempt((n) => n + 1); return; } + + if (hasResponses && pendingChanges === null) { + const changes = classifyEdits(savedFieldsRef.current, fields); + if (changes.length > 0) { + setNotifyByKey(Object.fromEntries(changes.map((c) => [c.clientKey, defaultNotify(c)]))); + setPendingChanges(changes); + return; + } + } + await commitSave(); + } + + async function commitSave() { + setPendingChanges(null); setSaving(true); try { // A not-yet-saved field's clientKey is a client UUID that the server // response replaces with String(new id) — track position instead of // identity so the same card stays open across that swap. const expandedIndex = fields.findIndex((f) => f.clientKey === expandedKey); - const updated = await formsApi.putFields(form.id, fields.map(toFieldInput)); + const decided = notifyRef.current; + const updated = await formsApi.putFields( + form.id, + fields.map((f) => toFieldInput(f, decided[f.clientKey])), + ); const next = updated .filter((f) => !f.is_archived) .sort((a, b) => a.order - b.order) @@ -379,6 +416,7 @@ export function FieldList({ form }: { form: Form }) { setFields(next); setExpandedKey(next[expandedIndex]?.clientKey ?? next[0]?.clientKey ?? null); baselineRef.current = JSON.stringify(next); + savedFieldsRef.current = updated; validation.clearAll(); // The response only carries live fields, so anything this save archived // (or unarchived) has to be re-read rather than derived from it. @@ -386,6 +424,7 @@ export function FieldList({ form }: { form: Form }) { } catch (err) { validation.handle422(err); } finally { + notifyRef.current = {}; setSaving(false); } } @@ -516,6 +555,16 @@ export function FieldList({ form }: { form: Form }) { )} + {pendingChanges && ( + setNotifyByKey((prev) => ({ ...prev, [clientKey]: value }))} + onCancel={() => setPendingChanges(null)} + onConfirm={() => { notifyRef.current = notifyByKey; commitSave(); }} + saving={saving} + /> + )} whether to ask previous responders about this question. */ + notify: Record; + onToggle: (clientKey: string, value: boolean) => void; + onCancel: () => void; + onConfirm: () => void; + saving: boolean; +}) { + const asking = changes.filter((c) => notify[c.clientKey]).length; + + return ( + +
+

+ This form already has responses. These questions changed — pick which + ones previous responders should be asked to look at again. +

+ +
+ {changes.map((change) => { + const on = !!notify[change.clientKey]; + return ( +
+
+
+ {change.label} +
+
    + {change.reasons.map((reason) => ( +
  • + {REASON_LABELS[reason]} + {/* Only for the judgment calls — a locked row's + consequence isn't the TD's to weigh. */} + {!change.locked && REASON_CONSEQUENCES[reason] && ( + + {" "}{REASON_CONSEQUENCES[reason]} + + )} +
  • + ))} +
+
+
+ {change.locked && ( + + + + )} + onToggle(change.clientKey, v)} locked={change.locked} /> +
+
+ ); + })} +
+ + {/* The summary sits on its own line rather than beside the buttons: + it's a full sentence, and sharing the row squeezed the labels + until "Save changes" wrapped. */} + + {asking === 0 + ? "Nobody will be asked to re-answer." + : `${asking} question${asking === 1 ? "" : "s"} will be sent back to previous responders.`} + + +
+ + +
+
+ + ); +} diff --git a/frontend/lib/forms/changeClassification.ts b/frontend/lib/forms/changeClassification.ts new file mode 100644 index 00000000..c362d084 --- /dev/null +++ b/frontend/lib/forms/changeClassification.ts @@ -0,0 +1,164 @@ +import { FormField, FormFieldConfig, FormQuestionType, PendingUpdateReason } from "@/lib/api"; +import { EditableField } from "@/lib/forms/editableField"; +import { activePresetKind } from "@/lib/forms/fieldKeyPresets"; + +// Mirrors backend/app/core/form/changes.py. The server decides what actually +// gets flagged — this exists so the TD can *see* it before saving, and so the +// confirmation modal knows which toggles to lock. Any rule added there has to +// be added here too, or the modal will quietly under-report. + +// Answer storage shape per question_type. A type change only matters when it +// moves between classes — radio -> dropdown leaves every stored answer valid. +const SHAPE_CLASSES: Record = { + short_text: "text", + long_text: "text", + single_select_radio: "single_select", + single_select_dropdown: "single_select", + multi_select_checkbox: "multi", + ranked_choice: "ranked", + acknowledgment: "bool", +}; + +export const MANDATORY_REASONS: PendingUpdateReason[] = [ + "question_type_changed", "option_added", "option_invalidated", "option_regrouped", "now_required", +]; + +/** Default for each judgment call when the TD doesn't touch the toggle. */ +export const OPTIONAL_REASON_DEFAULTS: Partial> = { + key_changed: true, + text_changed: false, +}; + +export const REASON_LABELS: Record = { + question_type_changed: "The answer format changed", + option_added: "An option was added", + option_invalidated: "An option was removed", + option_regrouped: "An option covers different shifts or events", + now_required: "This question is now required", + key_changed: "Switched between a preset and a standard question", + text_changed: "The wording changed", +}; + +/** What the TD is actually deciding, for the judgment calls only. */ +export const REASON_CONSEQUENCES: Partial> = { + key_changed: + "Their answers won't reach availability, lunch or track status unless they resubmit.", + text_changed: + "Only ask again if the new wording changes what you're asking for.", +}; + +export function isMandatory(reason: PendingUpdateReason): boolean { + return MANDATORY_REASONS.includes(reason); +} + +function optionIds(config: FormFieldConfig | null | undefined, liveOnly: boolean): Set { + const options = config?.options ?? []; + return new Set( + options + .filter((o) => !(liveOnly && o.is_archived)) + .map((o) => o.option_id) + .filter(Boolean) as string[] + ); +} + +function labelsById(config: FormFieldConfig | null | undefined): Map { + return new Map((config?.options ?? []).map((o) => [o.option_id as string, o.label])); +} + +/** Entity ids per option, for presets where `value` is shifts/events rather + than display text. */ +function entityIdsById(config: FormFieldConfig | null | undefined): Map { + const grouped = new Map(); + for (const option of config?.options ?? []) { + const value = option.value; + if (Array.isArray(value) && value.every((v) => typeof v === "number")) { + grouped.set(option.option_id as string, [...(value as number[])].sort((a, b) => a - b).join(",")); + } + } + return grouped; +} + +export function classifyFieldChange(before: FormField, after: EditableField): PendingUpdateReason[] { + const reasons: PendingUpdateReason[] = []; + const oldConfig = before.config; + const newConfig = after.config; + + const shapeChanged = + SHAPE_CLASSES[after.question_type as FormQuestionType] !== SHAPE_CLASSES[before.question_type]; + if (shapeChanged) reasons.push("question_type_changed"); + + // Options only compare within a shape class — across classes the whole + // answer is invalid anyway, and reporting an option change alongside would + // describe a consequence of the type change rather than a separate thing. + if (!shapeChanged) { + const oldLive = optionIds(oldConfig, true); + const oldAll = optionIds(oldConfig, false); + const newLive = optionIds(newConfig, true); + const newAll = optionIds(newConfig, false); + + if ([...newLive].some((id) => !oldLive.has(id))) reasons.push("option_added"); + if ([...oldAll].some((id) => !newAll.has(id))) reasons.push("option_invalidated"); + + if (activePresetKind(after.field_key)) { + const oldGroups = entityIdsById(oldConfig); + const newGroups = entityIdsById(newConfig); + const regrouped = [...newGroups].some(([id, group]) => oldGroups.has(id) && oldGroups.get(id) !== group); + if (regrouped) reasons.push("option_regrouped"); + } + } + + if (newConfig?.required && !oldConfig?.required) reasons.push("now_required"); + + const wasPreset = !!activePresetKind(before.field_key); + const isPreset = !!activePresetKind(after.field_key); + if (wasPreset !== isPreset) reasons.push("key_changed"); + + const oldLabels = labelsById(oldConfig); + const optionLabelChanged = [...labelsById(newConfig)].some( + ([id, label]) => oldLabels.has(id) && oldLabels.get(id) !== label + ); + const description = after.showDescription ? after.description : null; + if (after.label.trim() !== before.label || description !== before.description || optionLabelChanged) { + reasons.push("text_changed"); + } + + return reasons; +} + +export interface ClassifiedChange { + clientKey: string; + label: string; + reasons: PendingUpdateReason[]; + /** True when at least one reason is mandatory — the toggle is locked on. */ + locked: boolean; +} + +/** Every staged edit that could ask someone to answer again. Fields with no + such change aren't included: the modal is a list of consequences, not a + diff of the save. */ +export function classifyEdits(before: FormField[], after: EditableField[]): ClassifiedChange[] { + const byId = new Map(before.map((f) => [f.id, f])); + const changes: ClassifiedChange[] = []; + for (const field of after) { + const original = field.id ? byId.get(field.id) : undefined; + // A newly added field has nobody to notify, and an unarchived one comes + // back exactly as it was left. + if (!original || original.is_archived) continue; + const reasons = classifyFieldChange(original, field); + if (reasons.length === 0) continue; + changes.push({ + clientKey: field.clientKey, + label: field.label.trim() || "Untitled question", + reasons, + locked: reasons.some(isMandatory), + }); + } + return changes; +} + +/** The default toggle state for a change: on if anything mandatory applies, + otherwise whichever of its judgment calls defaults on. */ +export function defaultNotify(change: ClassifiedChange): boolean { + if (change.locked) return true; + return change.reasons.some((r) => OPTIONAL_REASON_DEFAULTS[r]); +} diff --git a/frontend/lib/forms/editableField.ts b/frontend/lib/forms/editableField.ts index c5840d20..0252c5b6 100644 --- a/frontend/lib/forms/editableField.ts +++ b/frontend/lib/forms/editableField.ts @@ -79,7 +79,7 @@ export function newField(order: number): EditableField { // short_text -> ranked_choice -> short_text leaves it stripped by // sanitizeConfigForType on the way in, since ranked_choice's config schema // doesn't carry it either. -export function toFieldInput(field: EditableField): FormFieldInput { +export function toFieldInput(field: EditableField, notifyResponders?: boolean): FormFieldInput { const config: FormFieldConfig = { ...(field.config ?? {}) }; if (config.options) { config.options = (config.options as EditableOption[]).map((option) => { @@ -108,5 +108,9 @@ export function toFieldInput(field: EditableField): FormFieldInput { description: field.showDescription ? field.description : null, question_type: field.question_type, config, + // Omitted unless the confirmation modal actually asked — the server falls + // back to each change's own default, which is what an unprompted save + // (draft form, or nothing consequential changed) should get. + ...(notifyResponders === undefined ? {} : { notify_responders: notifyResponders }), }; } From f2490c85e7a7c24d2fb5d2ba9f65188eb4bd22c6 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 23:30:45 -0700 Subject: [PATCH 62/92] fix(forms): route previous responders to a patch flow instead of a failing resubmit --- frontend/app/forms/[formId]/view/page.tsx | 42 +++++- frontend/components/forms/FormUpdateFlow.tsx | 129 +++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 frontend/components/forms/FormUpdateFlow.tsx diff --git a/frontend/app/forms/[formId]/view/page.tsx b/frontend/app/forms/[formId]/view/page.tsx index 4eac96f5..59dcde8c 100644 --- a/frontend/app/forms/[formId]/view/page.tsx +++ b/frontend/app/forms/[formId]/view/page.tsx @@ -2,8 +2,9 @@ import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { ApiError, Form, formsApi } from "@/lib/api"; +import { ApiError, Form, FormResponse, formsApi } from "@/lib/api"; import { FormFillFlow } from "@/components/forms/FormFillFlow"; +import { FormUpdateFlow } from "@/components/forms/FormUpdateFlow"; import { Spinner } from "@/components/ui/Spinner"; // Respondent-facing form renderer. `redirect` is optional so this can serve @@ -21,6 +22,11 @@ export default function FormViewPage() { const redirect = useMemo(() => internalRedirect(searchParams.get("redirect")), [searchParams]); const [form, setForm] = useState(null); const [loadError, setLoadError] = useState(null); + // The response this user already gave, if any. A form can only be submitted + // once — coming back is an update, and only for the questions the TD + // flagged, so which flow renders depends on whether this resolves. + const [existing, setExisting] = useState(null); + const [checkedExisting, setCheckedExisting] = useState(false); useEffect(() => { formsApi.get(formId) @@ -28,6 +34,14 @@ export default function FormViewPage() { .catch((error) => setLoadError(error instanceof ApiError ? error.message : "Failed to load form.")); }, [formId]); + useEffect(() => { + // 404 is the ordinary "hasn't answered yet" case, not a failure. + formsApi.getMyResponse(formId) + .then(setExisting) + .catch(() => setExisting(null)) + .finally(() => setCheckedExisting(true)); + }, [formId]); + async function submitResponse(answers: Record) { await formsApi.submitResponse( formId, @@ -44,10 +58,34 @@ export default function FormViewPage() { ); } - if (!form) { + if (!form || !checkedExisting) { return
; } + if (existing) { + // Nothing left to review — the response stands as submitted, and there's + // no self-serve way to revise it (see backend/form-edit-lifecycle.md). + if (existing.pending_updates.length === 0) { + return ( +
+

+ You’ve already completed this form. Ask an organizer if something needs changing. +

+
+ ); + } + return ( + { + if (redirect) router.replace(redirect); + else formsApi.getMyResponse(formId).then(setExisting).catch(() => {}); + }} + /> + ); + } + return ( void; +}) { + const flaggedReasons = useMemo( + () => new Map(response.pending_updates.map((p) => [p.field_id, p.reasons])), + [response.pending_updates] + ); + const fields = useMemo(() => form.fields.filter((f) => !f.is_archived), [form.fields]); + + // Flagged questions start blank — the point is to answer them again, and + // prefilling the answer being questioned invites a reflexive resubmit. + const [answers, setAnswers] = useState>({}); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(undefined); + + const previousByField = useMemo( + () => new Map(response.answers.map((a) => [a.field_id, a.value])), + [response.answers] + ); + + const unanswered = [...flaggedReasons.keys()].filter((fieldId) => { + const value = answers[fieldId]; + return value === undefined || value === null || value === "" || + (Array.isArray(value) && value.length === 0); + }); + + async function handleSubmit() { + setError(undefined); + setSaving(true); + try { + // Only the flagged questions — a patch isn't a resubmit, and sending + // untouched answers back would re-fire their write-through. + await formsApi.patchResponse( + form.id, + [...flaggedReasons.keys()].map((field_id) => ({ field_id, value: answers[field_id] ?? null })), + ); + onUpdated(); + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Something went wrong. Try again."); + setSaving(false); + } + } + + return ( +
+ + + {fields.map((field) => { + const reasons = flaggedReasons.get(field.id); + const editable = reasons !== undefined; + return ( + + {editable && ( +
+ {reasons.map((r: PendingUpdateReason) => REASON_LABELS[r]).join(" · ")} +
+ )} + setAnswers((prev) => ({ ...prev, [field.id]: value }))} + /> +
+ ); + })} + + {error && ( +

+ {error} +

+ )} + +
+ {unanswered.length > 0 && ( + + {unanswered.length} still to answer + + )} + +
+
+ ); +} From 625d6edb703a09e2e9d35905472ef0d589337ff1 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Thu, 27 Aug 2026 23:41:31 -0700 Subject: [PATCH 63/92] fix(forms): recover from staged fields whose rows were deleted elsewhere --- frontend/components/forms/FieldList.tsx | 35 ++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/components/forms/FieldList.tsx b/frontend/components/forms/FieldList.tsx index 527274b4..4cd35baa 100644 --- a/frontend/components/forms/FieldList.tsx +++ b/frontend/components/forms/FieldList.tsx @@ -8,7 +8,7 @@ import { import { SortableContext, verticalListSortingStrategy, arrayMove, } from "@dnd-kit/sortable"; -import { formsApi, tournamentsApi, tournamentShiftsApi, Form, FormField, Tournament, TournamentShift } from "@/lib/api"; +import { ApiError, formsApi, tournamentsApi, tournamentShiftsApi, Form, FormField, Tournament, TournamentShift } from "@/lib/api"; import { enumerateDates } from "@/lib/date"; import { useFormValidation } from "@/lib/forms/useFormValidation"; import { Button } from "@/components/ui/Button"; @@ -365,6 +365,14 @@ export function FieldList({ form }: { form: Form }) { } } + // The server names the offending ids in its 400 detail; parsing them back + // out is ugly but it's the only signal that distinguishes "your draft + // references a dead row" from an ordinary validation failure. + function unknownFieldIds(err: unknown): string[] { + if (!(err instanceof ApiError) || !err.message.includes("field id(s) not found")) return []; + return [...err.message.matchAll(/'([^']+)'/g)].map((m) => m[1]); + } + // A form nobody has answered can't strand anyone, so edits apply silently; // once responses exist, every consequential edit is the TD's call. const hasResponses = form.status === "published" || form.response_count > 0; @@ -422,7 +430,21 @@ export function FieldList({ form }: { form: Form }) { // (or unarchived) has to be re-read rather than derived from it. formsApi.listArchivedFields(form.id).then(setArchivedFields).catch(() => {}); } catch (err) { - validation.handle422(err); + // A staged field the server has never heard of can't be fixed by + // retrying — it was hard-deleted (here, in another tab, or as a draft + // removal), and every save will fail on it until it's out of the list. + // Drop it and say which question went, instead of leaving the TD + // wedged on a raw id with no action available but a page reload. + const orphanIds = unknownFieldIds(err); + if (orphanIds.length > 0) { + const lost = fields.filter((f) => f.id && orphanIds.includes(f.id)); + setFields((prev) => prev.filter((f) => !(f.id && orphanIds.includes(f.id)))); + validation.setSaveError( + `${lost.map((f) => f.label.trim() || "A question").join(", ")} was deleted elsewhere and has been removed. Save again to apply your other changes.` + ); + } else { + validation.handle422(err); + } } finally { notifyRef.current = {}; setSaving(false); @@ -569,7 +591,14 @@ export function FieldList({ form }: { form: Form }) { formId={form.id} fields={archivedFields} onRestore={restoreField} - onDeleted={(fieldId) => setArchivedFields((prev) => prev.filter((f) => f.id !== fieldId))} + onDeleted={(fieldId) => { + setArchivedFields((prev) => prev.filter((f) => f.id !== fieldId)); + // Also drop it from the staged list. A restored-then-deleted field + // can sit in both, and a staged id the server no longer has makes + // every subsequent save fail on an id the TD can't act on. + setFields((prev) => prev.filter((f) => f.id !== fieldId)); + savedFieldsRef.current = savedFieldsRef.current.filter((f) => f.id !== fieldId); + }} /> Date: Thu, 27 Aug 2026 23:55:46 -0700 Subject: [PATCH 64/92] fix(forms): only require unique option values on freeform questions --- backend/app/schemas/form.py | 40 +++++++++------------- backend/tests/core/test_form_validation.py | 33 ++++++++++++++++++ 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/backend/app/schemas/form.py b/backend/app/schemas/form.py index 7249f656..33f97d14 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -17,37 +17,31 @@ # --------------------------------------------------------------------------- def _unique_option_fields(options: list) -> list: - """option_id and value each need to be unique within a field's option - list — option_id is the durable identity (edit-lifecycle archiving, - write-through, branching match), value is the TD-facing stored/matched - payload. A collision on either would make selection ambiguous. value is - normally a string, but an entity-backed reserved field_key (e.g. - availability grouping several TournamentShifts, event_preference - grouping several TournamentEvents under one option) may set it to a - list[int] instead — hashed as a tuple here since lists aren't hashable.""" + """option_id must be unique within a field's option list — it's the + durable identity behind edit-lifecycle archiving, write-through and + branching match, so a collision there really would make selection + ambiguous. + + `value` is only checked when it's a plain string. On a freeform question + that string *is* the stored answer, so two options sharing it can't be + told apart. On an entity-backed reserved field_key it isn't: the answer + records option_id, and value is the set of shifts/events the option + groups. Two options grouping the same entities are redundant, not + ambiguous — and requiring them to differ would reject the ordinary + in-progress state where several options have nothing picked yet and are + all still empty.""" seen_ids, seen_values = set(), set() for option in options: if option.option_id in seen_ids: raise ValueError(f"duplicate option_id '{option.option_id}'") seen_ids.add(option.option_id) - value_key = _option_value_key(option.value) - if value_key in seen_values: - raise ValueError(f"duplicate option value '{option.value}'") - seen_values.add(value_key) + if isinstance(option.value, str): + if option.value in seen_values: + raise ValueError(f"duplicate option value '{option.value}'") + seen_values.add(option.value) return options -def _option_value_key(value: Any): - """Make the supported JSON option values comparable for uniqueness.""" - if isinstance(value, BaseModel): - return _option_value_key(value.model_dump()) - if isinstance(value, list): - return tuple(_option_value_key(item) for item in value) - if isinstance(value, dict): - return tuple(sorted((key, _option_value_key(item)) for key, item in value.items())) - return value - - class TrackStatusAssignment(BaseModel): """One track status attached to a selectable option.""" model_config = ConfigDict(extra="forbid") diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py index cba40644..bbcd450c 100644 --- a/backend/tests/core/test_form_validation.py +++ b/backend/tests/core/test_form_validation.py @@ -137,6 +137,39 @@ def test_single_select_duplicate_option_values_rejected(self): }, ) + def test_entity_backed_options_may_share_a_value(self): + """On an availability/event_preference question the answer records + option_id, so two options grouping the same entities are redundant + rather than ambiguous — and several empty ones is the ordinary state + while the TD is still picking.""" + validate_field_config( + "multi_select_checkbox", + { + "required": False, + "options": [ + {"option_id": "opt_1", "value": [], "label": "Morning"}, + {"option_id": "opt_2", "value": [], "label": "Afternoon"}, + {"option_id": "opt_3", "value": [3, 2], "label": "All day"}, + {"option_id": "opt_4", "value": [3, 2], "label": "Both halves"}, + ], + }, + ) + + def test_duplicate_option_id_still_rejected(self): + """option_id is the durable identity — a collision there really does + make selection ambiguous.""" + with pytest.raises(FormFieldValidationError, match="duplicate option_id"): + validate_field_config( + "multi_select_checkbox", + { + "required": False, + "options": [ + {"option_id": "same", "value": [1], "label": "One"}, + {"option_id": "same", "value": [2], "label": "Two"}, + ], + }, + ) + def test_single_select_option_missing_value_rejected(self): with pytest.raises(FormFieldValidationError): validate_field_config( From 5ead41f761fa52996e2cea619c2627da9fff544d Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 00:08:50 -0700 Subject: [PATCH 65/92] feat(forms): let TDs archive an option instead of removing it --- .../components/forms/EntityOptionsEditor.tsx | 6 +- frontend/components/forms/FieldCard.tsx | 4 + frontend/components/forms/FieldList.tsx | 1 + frontend/components/forms/OptionsEditor.tsx | 131 +++++++++++++++--- .../components/forms/QuestionRenderer.tsx | 29 ++-- 5 files changed, 141 insertions(+), 30 deletions(-) diff --git a/frontend/components/forms/EntityOptionsEditor.tsx b/frontend/components/forms/EntityOptionsEditor.tsx index 17b7954d..69217058 100644 --- a/frontend/components/forms/EntityOptionsEditor.tsx +++ b/frontend/components/forms/EntityOptionsEditor.tsx @@ -28,6 +28,9 @@ interface EntityOptionsEditorProps { branchTargets?: BranchTarget[] errors?: string[] trackStatusEnabled?: boolean + /** Forwarded to OptionsEditor — whether archiving an option is offered + alongside removing it. */ + allowArchive?: boolean } const STATUS_OPTIONS: { value: TrackStatus; label: string }[] = [ @@ -88,7 +91,7 @@ function shiftIdsFor(option: EditableOption): number[] { // Shared options editor for entity-backed presets and Track Status. The only // difference is whether the row also has a shift/event picker; track chips // live here for both Track Status and opted-in Availability fields. -export function EntityOptionsEditor({ fieldKey, tournament, questionType, options, onChange, displayStyle, branchTargets, errors, trackStatusEnabled = false }: EntityOptionsEditorProps) { +export function EntityOptionsEditor({ fieldKey, tournament, questionType, options, onChange, displayStyle, branchTargets, errors, trackStatusEnabled = false, allowArchive = false }: EntityOptionsEditorProps) { const isEntity = fieldKey !== 'track_status' const hasTracks = fieldKey === 'track_status' || trackStatusEnabled const [entities, setEntities] = useState(isEntity ? null : []) @@ -137,6 +140,7 @@ export function EntityOptionsEditor({ fieldKey, tournament, questionType, option
diff --git a/frontend/components/forms/FieldList.tsx b/frontend/components/forms/FieldList.tsx index 4cd35baa..8928bbc1 100644 --- a/frontend/components/forms/FieldList.tsx +++ b/frontend/components/forms/FieldList.tsx @@ -566,6 +566,7 @@ export function FieldList({ form }: { form: Form }) { shifts={shifts} allFields={fields} errors={validation.errorsFor(field.clientKey)} + allowArchive={hasResponses} /> ))} diff --git a/frontend/components/forms/OptionsEditor.tsx b/frontend/components/forms/OptionsEditor.tsx index 02648232..ffc5710d 100644 --- a/frontend/components/forms/OptionsEditor.tsx +++ b/frontend/components/forms/OptionsEditor.tsx @@ -14,7 +14,7 @@ import { Button } from '@/components/ui/Button' import { Dropdown, DropdownOption } from '@/components/ui/Dropdown' import { RadioCircle } from '@/components/ui/RadioCircle' import { Checkbox } from '@/components/ui/Checkbox' -import { IconGripVertical, IconX, IconPlus } from '@/components/ui/Icons' +import { IconArchive, IconGripVertical, IconRestore, IconX, IconPlus } from '@/components/ui/Icons' // Same option shape the backend expects, plus a client-only stable id for // React/dnd-kit — option_id itself is blank ("") for a not-yet-saved option @@ -165,6 +165,9 @@ interface OptionsEditorProps { `value` — entity-backed variants keep `value` as the selected id array, so editing the label shouldn't touch it. */ syncValueWithLabel?: boolean + /** Whether archiving an option is offered alongside removing it. Only + meaningful once the form has responses — see QuestionRenderer. */ + allowArchive?: boolean /** This field's validation messages (useFormValidation's per-field issues) — only consulted to gate the two option-shaped ones ("needs a label"/ "must be unique") so a row's own Input.error stays blank until a Save @@ -183,8 +186,13 @@ interface OptionsEditorProps { // handle is hidden while its card is expanded. export function OptionsEditor({ options, onChange, questionType, displayStyle, branchTargets, renderExtra, - createOption = newOption, syncValueWithLabel = true, errors = [], + createOption = newOption, syncValueWithLabel = true, errors = [], allowArchive = false, }: OptionsEditorProps) { + // `options` carries archived entries too, but they're not part of the list + // a respondent sees — they're listed separately below so they don't join + // drag ordering or take up a bullet number. + const liveOptions = options.filter((o) => !o.is_archived) + const archivedOptions = options.filter((o) => o.is_archived) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })) const bulletType = bulletTypeFor(questionType) @@ -197,7 +205,7 @@ export function OptionsEditor({ const duplicateKeys = new Set() if (flagDuplicateLabels) { const counts = new Map() - for (const o of options) { + for (const o of liveOptions) { const key = o.label.trim().toLowerCase() if (key) counts.set(key, (counts.get(key) ?? 0) + 1) } @@ -216,10 +224,10 @@ export function OptionsEditor({ function handleDragEnd(e: DragEndEvent) { const { active, over } = e if (!over || active.id === over.id) return - const oldIndex = options.findIndex((o) => o.clientKey === active.id) - const newIndex = options.findIndex((o) => o.clientKey === over.id) + const oldIndex = liveOptions.findIndex((o) => o.clientKey === active.id) + const newIndex = liveOptions.findIndex((o) => o.clientKey === over.id) if (oldIndex === -1 || newIndex === -1) return - onChange(arrayMove(options, oldIndex, newIndex)) + onChange([...arrayMove(liveOptions, oldIndex, newIndex), ...archivedOptions]) } function updateOption(clientKey: string, label: string) { @@ -234,25 +242,33 @@ export function OptionsEditor({ onChange(options.map((o) => (o.clientKey === clientKey ? applyBranchValue(o, value) : o))) } - // A question needs at least one option to mean anything, so the last row - // can't be deleted — only cleared out and edited in place. + // A question needs at least one *offerable* option to mean anything, so the + // last live row can't be removed — only cleared out and edited in place. + // Archived rows don't count toward that: none of them can be picked. function removeOption(clientKey: string) { - if (options.length <= 1) return + if (liveOptions.length <= 1) return onChange(options.filter((o) => o.clientKey !== clientKey)) } + // Archiving keeps the option in storage so past answers still resolve, and + // asks nobody to re-answer — "we ran out", not "this was never valid". + // Removing it outright is the other verb, and does flag whoever picked it. + function setArchived(clientKey: string, is_archived: boolean) { + onChange(options.map((o) => (o.clientKey === clientKey ? { ...o, is_archived } : o))) + } + function addOption() { - onChange([...options, createOption()]) + onChange([...liveOptions, createOption(), ...archivedOptions]) } // Enter from inside a row inserts right after it, rather than appending at // the end — the row you're typing into isn't necessarily the last one. function addOptionAfter(clientKey: string) { - const insertIndex = options.findIndex((o) => o.clientKey === clientKey) + 1 + const insertIndex = liveOptions.findIndex((o) => o.clientKey === clientKey) + 1 const created = createOption() - const next = [...options] + const next = [...liveOptions] next.splice(insertIndex, 0, created) - onChange(next) + onChange([...next, ...archivedOptions]) setFocusKey(created.clientKey) } @@ -281,8 +297,8 @@ export function OptionsEditor({ return (
- o.clientKey)} strategy={verticalListSortingStrategy}> - {options.map((option, index) => ( + o.clientKey)} strategy={verticalListSortingStrategy}> + {liveOptions.map((option, index) => ( 1} + canRemove={liveOptions.length > 1} + allowArchive={allowArchive} autoFocus={option.clientKey === focusKey} onChange={(label) => updateOption(option.clientKey, label)} onRemove={() => removeOption(option.clientKey)} + onArchive={() => setArchived(option.clientKey, true)} onEnter={() => addOptionAfter(option.clientKey)} /> ))} - + + setArchived(clientKey, false)} + onRemove={(clientKey) => onChange(options.filter((o) => o.clientKey !== clientKey))} + /> +
+ ) +} + +// Options no longer offered, kept so past answers still resolve. Listed apart +// from the live rows rather than dimmed in place: they don't belong in the +// drag order or the bullet numbering, and mixing them in makes it hard to see +// what the question actually asks now. +function ArchivedOptions({ options, onUnarchive, onRemove }: { + options: EditableOption[] + onUnarchive: (clientKey: string) => void + onRemove: (clientKey: string) => void +}) { + if (options.length === 0) return null + return ( +
+ + No longer offered + + {options.map((option) => ( +
+ + {option.label || 'Untitled option'} + + + +
+ ))}
) } @@ -338,7 +406,7 @@ function AddOptionRow({ bulletType, number, displayStyle, onClick }: { // This is "the general look" every options list shares; EntityOptionsEditor // builds on it purely through OptionsEditor's renderExtra/ // createOption/syncValueWithLabel props rather than rendering its own rows. -function OptionRow({ option, bulletType, number, displayStyle, trailing, extra, error, canRemove, autoFocus, onChange, onRemove, onEnter }: { +function OptionRow({ option, bulletType, number, displayStyle, trailing, extra, error, canRemove, allowArchive, autoFocus, onChange, onRemove, onArchive, onEnter }: { option: EditableOption bulletType: BulletType /** 1-based position — only rendered when bulletType is 'number' (dropdown). */ @@ -348,10 +416,14 @@ function OptionRow({ option, bulletType, number, displayStyle, trailing, extra, extra?: ReactNode error?: string canRemove: boolean + /** Whether "stop offering" is on the table — only meaningful once the form + has responses worth preserving. */ + allowArchive: boolean /** True for the row just inserted by pressing Enter in the row above it. */ autoFocus: boolean onChange: (label: string) => void onRemove: () => void + onArchive: () => void onEnter: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: option.clientKey }) @@ -423,8 +495,29 @@ function OptionRow({ option, bulletType, number, displayStyle, trailing, extra, fullWidth /> {trailing} + {/* Two verbs, shown together rather than behind a menu: both are one + click, and seeing them side by side is what makes the difference + legible. Before any responses exist there's nothing to preserve, + so only the plain remove appears. */} + {canRemove && allowArchive && ( + + )} {canRemove && ( - )} diff --git a/frontend/components/forms/QuestionRenderer.tsx b/frontend/components/forms/QuestionRenderer.tsx index 5f2ee247..10a2d4fb 100644 --- a/frontend/components/forms/QuestionRenderer.tsx +++ b/frontend/components/forms/QuestionRenderer.tsx @@ -78,6 +78,11 @@ interface QuestionRendererProps { their value is always the picked entity ids). Toggle lives in FieldToolbar, same as branchingEnabled. */ customValuesEnabled?: boolean + /** edit mode only — whether archiving an option is a meaningful choice, + i.e. the form already has responses. On a form nobody has answered + there's nothing to preserve, so removing an option just removes it and + the extra control would be noise. */ + allowArchive?: boolean /** edit mode only — this field's useFormValidation messages (label/key errors are handled by the caller — see FieldCard — so only the body-relevant ones need to reach here: confirmation text, options, @@ -98,6 +103,7 @@ interface QuestionRendererProps { export function QuestionRenderer({ field, mode = 'view', interactive = false, value, onChange, error, shifts, showHeader = true, onFieldChange, tournament, branchTargets, branchingEnabled, customValuesEnabled, errors = [], + allowArchive = false, }: QuestionRendererProps) { const config = field.config ?? {} @@ -129,6 +135,7 @@ export function QuestionRenderer({ branchingEnabled={branchingEnabled} customValuesEnabled={customValuesEnabled} errors={errors} + allowArchive={allowArchive} /> ) : ( @@ -353,7 +360,7 @@ function QuestionBody({ field, interactive, value, onChange, error, shifts }: { // happen to be entity-backed — an availability field is still real, // addressable rows a TD can jump from or lay out as buttons, same as any // other single_select_radio/dropdown field. -function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, branchingEnabled, customValuesEnabled, errors = [] }: { +function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, branchingEnabled, customValuesEnabled, errors = [], allowArchive = false }: { field: QuestionFieldData onFieldChange: (updates: FieldUpdate) => void tournament: Tournament | null @@ -361,6 +368,7 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra branchingEnabled?: boolean customValuesEnabled?: boolean errors?: string[] + allowArchive?: boolean }) { const presetKind = activePresetKind(field.field_key ?? '') const supportsBranching = BRANCHING_TYPES.includes(field.question_type) @@ -384,16 +392,15 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra const usesTrackEditor = hasTracks && !!tournament - // Archived options are storage, not editable rows (see QuestionBody) — the - // TD can't meaningfully delete one, since the backend re-merges it from the - // stored config on every save to keep old answers resolvable. Hide them, - // but carry them back on each edit so the staged config still mirrors - // storage rather than relying on that re-merge to undo a silent drop. + // Archived options reach the editor now that there's something to do with + // them — OptionsEditor lists them separately with a restore action. They're + // still never shown to a respondent; that filtering lives in QuestionBody. + // The submitted list is authoritative, so an archived option dropped here + // would be deleted outright rather than merely hidden. const allOptions = (field.config?.options as EditableOption[] | undefined) ?? [] const liveOptions = allOptions.filter((option) => !option.is_archived) - const archivedOptions = allOptions.filter((option) => option.is_archived) const setOptions = (options: EditableOption[]) => - onFieldChange({ config: { ...field.config, options: [...options, ...archivedOptions] } }) + onFieldChange({ config: { ...field.config, options } }) if (isEntity || usesTrackEditor || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { return ( @@ -403,17 +410,19 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra fieldKey={presetKind as 'availability' | 'event_preference' | 'track_status'} tournament={tournament!} questionType={field.question_type} - options={liveOptions} + options={allOptions} onChange={setOptions} displayStyle={field.config?.display_style} branchTargets={supportsBranching && branchingEnabled ? branchTargets : undefined} errors={errors} trackStatusEnabled={hasTracks} + allowArchive={allowArchive} /> ) : ( Date: Fri, 28 Aug 2026 00:16:52 -0700 Subject: [PATCH 66/92] feat(forms): show archived fields immediately and use archive/unarchive throughout --- .../forms/ArchivedFieldsSection.tsx | 14 +++--- frontend/components/forms/FieldList.tsx | 21 +++++--- frontend/components/forms/OptionsEditor.tsx | 50 ++++++++----------- 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/frontend/components/forms/ArchivedFieldsSection.tsx b/frontend/components/forms/ArchivedFieldsSection.tsx index fe47de7a..6f23df6a 100644 --- a/frontend/components/forms/ArchivedFieldsSection.tsx +++ b/frontend/components/forms/ArchivedFieldsSection.tsx @@ -17,14 +17,14 @@ const TYPE_LABELS = Object.fromEntries(QUESTION_TYPE_OPTIONS.map((o) => [o.value // somewhere a TD works. // // Two actions, deliberately unequal in weight: -// Restore — puts the question back, answers and all. Reversible. -// Delete — erases the question and every answer to it. Permanent. -export function ArchivedFieldsSection({ formId, fields, onRestore, onDeleted }: { +// Unarchive — puts the question back, answers and all. Reversible. +// Delete — erases the question and every answer to it. Permanent. +export function ArchivedFieldsSection({ formId, fields, onUnarchive, onDeleted }: { formId: string; fields: FormField[]; /** Hands the field to the builder, which unarchives it on the next Save — - restoring isn't its own request, it's part of the target field list. */ - onRestore: (field: FormField) => void; + it isn't its own request, it's part of the target field list. */ + onUnarchive: (field: FormField) => void; onDeleted: (fieldId: string) => void; }) { const [open, setOpen] = useState(false); @@ -87,8 +87,8 @@ export function ArchivedFieldsSection({ formId, fields, onRestore, onDeleted }:
- - )} + {/* One control. Once the form has responses this archives rather than + deletes — removing an option for good is a second, deliberate step + from the archived group below, so it can't happen on a stray click + while tidying up the list. */} {canRemove && (
@@ -585,7 +573,6 @@ export default function MembersPage() { {showFilterModal && ( setShowFilterModal(false)} diff --git a/frontend/components/tournament/MemberPanel.tsx b/frontend/components/tournament/MemberPanel.tsx index 97ee925b..1d39e5fe 100644 --- a/frontend/components/tournament/MemberPanel.tsx +++ b/frontend/components/tournament/MemberPanel.tsx @@ -6,9 +6,7 @@ import { canonicalEventsApi, membershipsApi, } from "@/lib/api"; import { formatDate } from "@/lib/timeFormat"; -import { STATUS_VARIANT } from "@/lib/membershipDisplay"; import { DockedPanel } from "@/components/layout/DockedPanel"; -import { Badge } from "@/components/ui/Badge"; import { Spinner } from "@/components/ui/Spinner"; import { ProfileHeader } from "@/components/profile/sections/ProfileHeader"; import { ProfileCard } from "@/components/profile/ProfileCard"; @@ -94,16 +92,6 @@ export function MemberPanel({
-
-
- Status -
- {full.status} -
; @@ -15,7 +15,6 @@ export function isMembersFilterActive(filters: MembersFilterState): boolean { interface MembersFilterModalProps { roleOptions: FilterOption[]; - statusOptions: FilterOption[]; filters: MembersFilterState; /** Fires on Apply only — the modal closes itself afterwards. */ onApply: (filters: MembersFilterState) => void; @@ -23,11 +22,9 @@ interface MembersFilterModalProps { } // Roles are open-ended (one per tournament role, plus "No roles"), so they get -// the checkbox list; status has two fixed values, so it gets the button group -// — same split Events uses for Category vs. Division/Type. -export function MembersFilterModal({ roleOptions, statusOptions, filters, onApply, onClose }: MembersFilterModalProps) { +// the checkbox list rather than a button group. +export function MembersFilterModal({ roleOptions, filters, onApply, onClose }: MembersFilterModalProps) { const sections: FilterSectionConfig[] = [ - { key: "status", title: "Status", options: statusOptions, control: "buttons" }, { key: "role", title: "Roles", options: roleOptions, control: "checkbox" }, ]; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 0780ceb1..e5f6b7d2 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -610,8 +610,6 @@ export const seasonEventsApi = { // ------------------------------------------------------------------------- // Memberships // ------------------------------------------------------------------------- -export type MembershipStatus = 'interested' | 'confirmed' - // How a membership was created. "manual" covers staff-add, owner-on-create, // and sync import — collapsed into one value until manual add-by-staff is // actually removed. @@ -655,7 +653,6 @@ export interface MembershipJoinCodeInfo { export interface MembershipSlim { id: number source: MembershipSource - status: MembershipStatus join_code: MembershipJoinCodeInfo | null // When they joined THIS tournament — distinct from user.created_at // (their NEXUS account age). @@ -669,7 +666,6 @@ export interface MembershipSlim { export interface MembershipFull { id: number tournament_id: number - status: MembershipStatus role_preference: string[] | null event_preference: string[] | null availability: AvailabilitySlot[] | null @@ -703,7 +699,6 @@ export interface MembershipCoordinatorUpdate { export interface MembershipMe { membership_id: number | null is_owner: boolean - status: MembershipStatus | null roles: Role[] permissions: Permission[] } diff --git a/frontend/lib/membershipDisplay.ts b/frontend/lib/membershipDisplay.ts index f4730328..de14270a 100644 --- a/frontend/lib/membershipDisplay.ts +++ b/frontend/lib/membershipDisplay.ts @@ -1,12 +1,7 @@ -import { MembershipSource, MembershipStatus } from "@/lib/api"; +import { MembershipSource } from "@/lib/api"; export const SOURCE_LABELS: Record = { join_code: "Invite", public: "Public", manual: "Manual", }; - -export const STATUS_VARIANT: Record = { - interested: "interested", - confirmed: "confirmed", -}; From 784227a0050aa0a0f0c7af53c0ff7b64d9c9d00e Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 16:42:31 -0700 Subject: [PATCH 74/92] feat(forms): write track statuses through on submit and patch --- backend/app/api/routes/forms.py | 67 ++++++++++---- backend/tests/api/test_forms.py | 153 ++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 15 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index cdae1b3f..380c18b8 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -27,6 +27,8 @@ availability_field_date, collect_active_field_errors, option_shift_ids, + option_track_assignments, + track_status_enabled, validate_availability_options, validate_field_config, validate_form_for_publish, @@ -40,6 +42,7 @@ shift_ids_on_dates, sync_availability, sync_lunch, + sync_track_statuses, ) from app.core.tournament.form_prerequisites import member_meets_form_prerequisites from app.core.tournament.memberships import get_membership_by_user @@ -833,9 +836,12 @@ def _require_published(form: Form) -> None: def _active_fields(db: Session, form: Form) -> list[FormField]: + # Ordered because track status write-through resolves two fields naming + # the same track by document order — see _write_through_reserved_fields. return ( db.query(FormField) .filter(FormField.form_id == form.id, FormField.is_archived == False) + .order_by(FormField.order) .all() ) @@ -924,7 +930,9 @@ def submit_form_response( _store_answers(db, response, {f.id: f for f in active_fields}, payload.answers) if form.owner_type == "tournament": - _write_through_reserved_fields(db, form, active_fields, answers_by_field, current_user) + # No track scope — a first submission answers the whole form, so every + # field is legitimately writing for the first time. + _write_through_reserved_fields(db, form, active_fields, answers_by_field, current_user, response) db.commit() db.refresh(response) @@ -1011,19 +1019,22 @@ def patch_form_response( response.updated_at = utcnow() if form.owner_type == "tournament": - # Deliberately *not* scoped to the patched fields. Availability is a - # union across every availability_* field on the form, diffed against - # the membership's whole set — handing it a subset would delete the - # shifts the unpatched fields contribute. Recomputing from the full - # response is safe because that diff is idempotent: unpatched fields - # resolve to the same ids they already produced. + # Availability is recomputed from the *whole* response, not just the + # patched fields: it's a union across every availability_* field, + # diffed against the membership's whole set, so handing it a subset + # would delete the shifts the unpatched fields contribute. That's safe + # because the diff is idempotent — unpatched fields resolve to the same + # ids they already produced. # - # A last-write-wins target (track status, when it lands) would *not* - # be safe this way and will need real per-field scoping. + # Track status is last-write-wins with no such diff, so it *is* scoped + # to the patched fields. Replaying an unpatched field would re-fire a + # write the respondent didn't make here, and the transition rule only + # blocks demotions — a stale "confirmed" would still land. db.flush() active_fields = _active_fields(db, form) _write_through_reserved_fields( - db, form, active_fields, _stored_answer_option_ids(db, response, active_fields), current_user + db, form, active_fields, _stored_answer_option_ids(db, response, active_fields), current_user, + response, track_scope_field_ids=patched_ids, ) db.commit() @@ -1037,9 +1048,11 @@ def _write_through_reserved_fields( active_fields: list[FormField], answers_by_field: dict[str, object], current_user: User, + response: FormResponse, + track_scope_field_ids: set[str] | None = None, ) -> None: - """Syncs `availability_{date}`/`lunch_{date}_{category}` answers into - their structural tables — tournament-owned forms only (see + """Syncs `availability_{date}`/`lunch_{date}_{category}`/`track_status_*` + answers into their structural tables — tournament-owned forms only (see form-question-types-reference.md). Runs over every active field, not just answered ones, so a reserved field left blank clears any previously-synced rows rather than leaving them stale. @@ -1055,7 +1068,15 @@ def _write_through_reserved_fields( Fields are unioned before that single call rather than synced one at a time: two questions covering the same day would otherwise have the second - call's removal undo the first call's addition.""" + call's removal undo the first call's addition. + + `track_scope_field_ids` limits which fields may write track statuses; None + means all of them. PATCH passes only the fields it patched, because track + status is last-write-wins with no idempotent diff to fall back on — + replaying an untouched field would re-fire a write the respondent didn't + make on this request. Availability deliberately has no such limit: its diff + is idempotent, and narrowing it would delete the shifts the unpatched + fields contribute.""" membership = ( db.query(TournamentMembership) .filter( @@ -1072,10 +1093,26 @@ def _write_through_reserved_fields( availability_shift_ids: set[int] = set() availability_dates: set[date] = set() + # track_id -> {"status", "field_id"}. Later fields overwrite earlier ones, + # so document order decides which question wins when two name the same + # track — hence _active_fields' order_by. Whether that intent actually + # lands is then up to sync_track_statuses' transition rule. + intended_track_statuses: dict[int, dict] = {} for field in active_fields: value = answers_by_field.get(field.id) selected = value if isinstance(value, list) else ([value] if value else []) + options_by_id = {opt["option_id"]: opt for opt in (field.config or {}).get("options", [])} + + # Not an elif with the branches below: an availability field can opt + # into track statuses, so it feeds both this and the shift pool. + in_track_scope = track_scope_field_ids is None or field.id in track_scope_field_ids + if in_track_scope and track_status_enabled(field.field_key, field.config or {}): + for option_id in selected: + for assignment in option_track_assignments(options_by_id.get(option_id) or {}): + intended_track_statuses[assignment["id"]] = { + "status": assignment["status"], "field_id": field.id, + } if AVAILABILITY_FIELD_KEY_PATTERN.match(field.field_key): # `selected` is the chosen option_id(s) — each option groups real @@ -1085,7 +1122,6 @@ def _write_through_reserved_fields( # via set union. Read through option_shift_ids, not off `value`: # once the option also carries track statuses the ids move under # a `shift_ids` key. - options_by_id = {opt["option_id"]: opt for opt in (field.config or {}).get("options", [])} for option_id in selected: availability_shift_ids.update(option_shift_ids(options_by_id.get(option_id) or {})) # The day is what this question governs, independent of which @@ -1099,7 +1135,6 @@ def _write_through_reserved_fields( # `selected` is now option_id(s) (see branching.py's matching and # PlainOption/BranchingOption's option_id) — resolve each back to # its stored value/label snapshot before write-through. - options_by_id = {opt["option_id"]: opt for opt in (field.config or {}).get("options", [])} values = [ {"value": options_by_id[v]["value"], "label": options_by_id[v]["label"]} for v in selected @@ -1115,6 +1150,8 @@ def _write_through_reserved_fields( shift_ids_on_dates(db, form.tournament_id, availability_dates), ) + sync_track_statuses(db, membership.id, intended_track_statuses, response.id) + # --------------------------------------------------------------------------- # DELETE /forms/{form_id}/fields/{field_id}/ — invalidate a question: erase it diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 7f0214fb..c04e05fc 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -20,6 +20,7 @@ TournamentMembership, TournamentMembershipAvailability, TournamentMembershipLunch, + TournamentMembershipTrackStatus, TournamentForm, TournamentRole, TournamentShift, @@ -1783,6 +1784,158 @@ def test_overlapping_selected_options_dedupe_shared_shift(self, client, db, td_u membership_id = self._membership_id(db, td_user, td_tournament) assert self._shift_ids(db, membership_id) == {morning.id, afternoon.id} + def _track(self, db, tournament, name="Test Writing"): + track = TournamentTrack(tournament_id=tournament.id, name=name) + db.add(track) + db.flush() + return track + + def _track_status(self, db, membership_id, track_id): + row = ( + db.query(TournamentMembershipTrackStatus) + .filter( + TournamentMembershipTrackStatus.membership_id == membership_id, + TournamentMembershipTrackStatus.track_id == track_id, + ) + .one_or_none() + ) + return row.status if row else None + + def _track_field(self, db, form, track, *, order=1, field_key="track_status_interest", statuses=None): + """A track_status_* question: "Yes" assigns the given statuses, "No" + assigns nothing.""" + return _make_field( + db, form, order=order, field_key=field_key, question_type="single_select_radio", + config={ + "required": True, + "options": [ + {"option_id": "opt_yes", "label": "Yes", + "value": statuses if statuses is not None else [{"id": track.id, "status": "interested"}]}, + {"option_id": "opt_no", "label": "No", "value": []}, + ], + }, + ) + + def test_track_status_write_through_on_submit(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + track = self._track(db, td_tournament) + field = self._track_field(db, form, track, statuses=[{"id": track.id, "status": "confirmed"}]) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "opt_yes"}]}) + assert res.status_code == 200, res.json() + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._track_status(db, membership_id, track.id) == "confirmed" + + def test_selecting_an_option_with_no_assignments_writes_nothing(self, client, db, td_user, td_tournament): + form = _make_form(db, td_user, td_tournament, status="published") + track = self._track(db, td_tournament) + field = self._track_field(db, form, track) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "opt_no"}]}) + assert res.status_code == 200, res.json() + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._track_status(db, membership_id, track.id) is None + + def test_later_field_wins_when_two_name_the_same_track(self, client, db, td_user, td_tournament): + """Document order decides intent — the second question's answer is the + one the respondent gave last.""" + form = _make_form(db, td_user, td_tournament, status="published") + track = self._track(db, td_tournament) + first = self._track_field( + db, form, track, order=1, field_key="track_status_first", + statuses=[{"id": track.id, "status": "declined"}], + ) + second = self._track_field( + db, form, track, order=2, field_key="track_status_second", + statuses=[{"id": track.id, "status": "confirmed"}], + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": first.id, "value": "opt_yes"}, + {"field_id": second.id, "value": "opt_yes"}, + ]}) + assert res.status_code == 200, res.json() + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._track_status(db, membership_id, track.id) == "confirmed" + + def test_patch_does_not_refire_an_unpatched_field(self, client, db, td_user, td_tournament): + """Track status is last-write-wins with no idempotent diff, so a PATCH + must only write for the fields it actually carried. The unpatched + field here would re-assert `confirmed` over the patched `declined`.""" + form = _make_form(db, td_user, td_tournament, status="published") + track = self._track(db, td_tournament) + stale = self._track_field( + db, form, track, order=1, field_key="track_status_stale", + statuses=[{"id": track.id, "status": "confirmed"}], + ) + patched = self._track_field( + db, form, track, order=2, field_key="track_status_patched", + statuses=[{"id": track.id, "status": "declined"}], + ) + db.commit() + login(client, "td@test.com", "tdpass") + + client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": stale.id, "value": "opt_yes"}, + {"field_id": patched.id, "value": "opt_no"}, + ]}) + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._track_status(db, membership_id, track.id) == "confirmed" + + self._flag(db, form, td_user, patched) + res = client.patch( + f"/forms/{form.id}/responses/me/", + json={"answers": [{"field_id": patched.id, "value": "opt_yes"}]}, + ) + assert res.status_code == 200, res.json() + + assert self._track_status(db, membership_id, track.id) == "declined" + + def test_opted_in_availability_writes_shifts_and_statuses(self, client, db, td_user, td_tournament): + """One field, both targets — the track opt-in doesn't displace the + availability write-through.""" + form = _make_form(db, td_user, td_tournament, status="published") + shift = TournamentShift( + tournament_id=td_tournament.id, label="Morning", + start=datetime(2026, 3, 15, tzinfo=timezone.utc), + end=datetime(2026, 3, 15, tzinfo=timezone.utc) + timedelta(hours=4), + ) + track = self._track(db, td_tournament, "Day 1") + db.add(shift) + db.flush() + field = _make_field( + db, form, field_key="availability_20260315", question_type="single_select_radio", + config={ + "required": False, + "track_status_enabled": True, + "options": [{ + "option_id": "opt_morning", "label": "Morning", + "value": { + "shift_ids": [shift.id], + "track_statuses": [{"id": track.id, "status": "confirmed"}], + }, + }], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "opt_morning"}]}) + assert res.status_code == 200, res.json() + + membership_id = self._membership_id(db, td_user, td_tournament) + assert self._shift_ids(db, membership_id) == {shift.id} + assert self._track_status(db, membership_id, track.id) == "confirmed" + def _flag(self, db, form, user, field): """Open a pending update on `field` so PATCH will accept it. Which reason doesn't matter here — the gate only checks that one exists.""" From d947e9154a9f374d470b173234f325a973bac80a Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 17:04:07 -0700 Subject: [PATCH 75/92] feat(tracks): expose member track statuses on membership reads --- .../app/api/routes/tournament/memberships.py | 2 + backend/app/models/models.py | 5 +- backend/app/schemas/tournament/membership.py | 17 +++++ backend/app/schemas/tournament/track.py | 28 ++++++++ backend/tests/api/tournament/test_tracks.py | 65 +++++++++++++++++++ .../components/tournament/MemberPanel.tsx | 28 ++++++++ frontend/lib/api.ts | 13 ++++ 7 files changed, 157 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/tournament/memberships.py b/backend/app/api/routes/tournament/memberships.py index b5365277..ccb03b34 100644 --- a/backend/app/api/routes/tournament/memberships.py +++ b/backend/app/api/routes/tournament/memberships.py @@ -20,6 +20,7 @@ MembershipCoordinatorUpdate, MembershipFullResponse, MembershipMeResponse, MembershipMeUpdate, MembershipSlimResponse, ) +from app.schemas.tournament.track import MembershipTrackStatusRead def _resolve_join_code_creators(db: Session, tournament_id: int, memberships: list[TournamentMembership], responses: list): @@ -173,6 +174,7 @@ def get_my_membership( return MembershipMeResponse( membership_id=membership.id, is_owner=is_owner, roles=membership.roles, permissions=permissions, + track_statuses=[MembershipTrackStatusRead.from_row(row) for row in membership.track_statuses], ) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index c7169140..067a68fe 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1020,7 +1020,10 @@ class TournamentMembershipTrackStatus(Base): updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) membership = relationship("TournamentMembership", back_populates="track_statuses") - track = relationship("TournamentTrack", back_populates="member_statuses") + # lazy="joined": every read of a status row wants the track's name to + # render it, so the hop is worth folding into the same query rather than + # N+1-ing per row through the shared get_scoped_or_404 path. + track = relationship("TournamentTrack", back_populates="member_statuses", lazy="joined") __table_args__ = ( UniqueConstraint("membership_id", "track_id", name="uq_membership_track_status"), diff --git a/backend/app/schemas/tournament/membership.py b/backend/app/schemas/tournament/membership.py index 620755b4..6ac53f15 100644 --- a/backend/app/schemas/tournament/membership.py +++ b/backend/app/schemas/tournament/membership.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, field_validator from app.schemas.tournament.role import RoleRead +from app.schemas.tournament.track import MembershipTrackStatusRead from app.schemas.user import UserFullResponse, UserSlimResponse @@ -82,6 +83,9 @@ class MembershipMeResponse(_MembershipRolesMixin): membership_id: int | None is_owner: bool permissions: list[str] = [] + # Their own per-track statuses — readable without manage_members, unlike + # the tournament-wide roster. + track_statuses: list[MembershipTrackStatusRead] = [] class MembershipFullResponse(_MembershipRolesMixin): @@ -98,4 +102,17 @@ class MembershipFullResponse(_MembershipRolesMixin): created_at: datetime updated_at: datetime + track_statuses: list[MembershipTrackStatusRead] = [] + user: UserFullResponse + + # Same shape problem as _unwrap_roles: the ORM rows don't carry the track + # name, it's a relationship hop away. Routes that build this from a + # TournamentMembership get the flattening for free; anything passing + # already-built schema objects passes straight through. + @field_validator("track_statuses", mode="before") + @classmethod + def _flatten_track_statuses(cls, v): + if v and hasattr(v[0], "track"): + return [MembershipTrackStatusRead.from_row(row) for row in v] + return v diff --git a/backend/app/schemas/tournament/track.py b/backend/app/schemas/tournament/track.py index b5dd456a..b7a800d7 100644 --- a/backend/app/schemas/tournament/track.py +++ b/backend/app/schemas/tournament/track.py @@ -49,3 +49,31 @@ class TournamentTrackRead(BaseModel): updated_at: datetime model_config = ConfigDict(from_attributes=True) + + +class MembershipTrackStatusRead(BaseModel): + """One member's status on one track. Carries the track's `name` alongside + its id so a renderer never needs a second catalog request — same treatment + resolve_field_options gives track assignments on a form field. + + `is_archived` comes along because an archived track's statuses stay + readable: the catalog entry is retired, but the fact that someone + confirmed for it is still history worth showing.""" + track_id: int + name: str + is_archived: bool + status: str + updated_at: datetime + + @classmethod + def from_row(cls, row) -> "MembershipTrackStatusRead": + """Flattens the track relationship — `name`/`is_archived` live on + TournamentTrack, not on the status row itself, so from_attributes + alone can't build this.""" + return cls( + track_id=row.track_id, + name=row.track.name, + is_archived=row.track.is_archived, + status=row.status, + updated_at=row.updated_at, + ) diff --git a/backend/tests/api/tournament/test_tracks.py b/backend/tests/api/tournament/test_tracks.py index 065ba1d9..a84b120e 100644 --- a/backend/tests/api/tournament/test_tracks.py +++ b/backend/tests/api/tournament/test_tracks.py @@ -1,5 +1,6 @@ from tests.conftest import grant_role, login + from app.models.models import ( Form, FormField, @@ -105,6 +106,70 @@ def test_unused_track_can_be_deleted_but_referenced_track_cannot(client, db, td_ assert blocked.status_code == 409 +def _set_status(db, membership_id, track_id, status): + db.add(TournamentMembershipTrackStatus( + membership_id=membership_id, track_id=track_id, status=status, + )) + db.commit() + + +def _td_membership(db, td_user, td_tournament): + return ( + db.query(TournamentMembership) + .filter( + TournamentMembership.user_id == td_user.id, + TournamentMembership.tournament_id == td_tournament.id, + ) + .one() + ) + + +def test_member_reads_their_own_track_statuses(client, db, td_user, td_tournament): + """memberships/me/ carries them so a member sees their own without + manage_tournament, and with the track name resolved.""" + login(client, "td@test.com", "tdpass") + track = _create_track(client, td_tournament.id, "Test Writing").json() + membership = _td_membership(db, td_user, td_tournament) + _set_status(db, membership.id, track["id"], "confirmed") + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/me/") + assert res.status_code == 200 + assert res.json()["track_statuses"] == [{ + "track_id": track["id"], "name": "Test Writing", "is_archived": False, + "status": "confirmed", + "updated_at": res.json()["track_statuses"][0]["updated_at"], + }] + + +def test_member_detail_carries_track_statuses(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + track = _create_track(client, td_tournament.id, "Day 1").json() + membership = _td_membership(db, td_user, td_tournament) + _set_status(db, membership.id, track["id"], "interested") + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/{membership.id}/") + assert res.status_code == 200 + statuses = res.json()["track_statuses"] + assert [(s["track_id"], s["name"], s["status"]) for s in statuses] == [ + (track["id"], "Day 1", "interested"), + ] + + +def test_archived_track_statuses_stay_readable(client, db, td_user, td_tournament): + """Archiving retires the catalog entry, not the history of who committed + to it.""" + login(client, "td@test.com", "tdpass") + track = _create_track(client, td_tournament.id, "Retired").json() + membership = _td_membership(db, td_user, td_tournament) + _set_status(db, membership.id, track["id"], "confirmed") + client.patch(f"/tournaments/{td_tournament.id}/tracks/{track['id']}/", json={"is_archived": True}) + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/me/") + assert res.status_code == 200 + assert res.json()["track_statuses"][0]["is_archived"] is True + assert res.json()["track_statuses"][0]["status"] == "confirmed" + + def test_deleting_a_track_takes_its_member_statuses_with_it(client, db, td_user, td_tournament): """No form references it, so the TD is removing it for good — leaving orphaned statuses would let a re-created track of the same name inherit diff --git a/frontend/components/tournament/MemberPanel.tsx b/frontend/components/tournament/MemberPanel.tsx index 1d39e5fe..d5954103 100644 --- a/frontend/components/tournament/MemberPanel.tsx +++ b/frontend/components/tournament/MemberPanel.tsx @@ -7,6 +7,7 @@ import { } from "@/lib/api"; import { formatDate } from "@/lib/timeFormat"; import { DockedPanel } from "@/components/layout/DockedPanel"; +import { Badge } from "@/components/ui/Badge"; import { Spinner } from "@/components/ui/Spinner"; import { ProfileHeader } from "@/components/profile/sections/ProfileHeader"; import { ProfileCard } from "@/components/profile/ProfileCard"; @@ -116,6 +117,33 @@ export function MemberPanel({
+ {full.track_statuses.length > 0 && ( +
+
+ Tracks +
+
+ {full.track_statuses.map((ts) => ( + // An archived track's statuses stay readable — the + // catalog entry is retired, the commitment still + // happened — so it's dimmed rather than hidden. + + {ts.name} · {ts.status} + + ))} +
+
+ )} +
Date: Fri, 28 Aug 2026 17:20:20 -0700 Subject: [PATCH 76/92] fix(db): make alembic the source of truth for schema on startup --- backend/app/db/init_db.py | 34 +++++++++-- backend/app/models/models.py | 25 ++++---- backend/tests/test_migrations.py | 98 ++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_migrations.py diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 3a198548..6f7cb38d 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -1,24 +1,48 @@ """ Database initialization utilities. -Run directly to create tables: +Run directly to bring the schema up to date: python -m app.db.init_db Or called automatically from app startup lifespan. """ +from pathlib import Path + from sqlalchemy.orm import Session -from app.db.session import engine, Base from app.models import models # noqa: F401 — must import so Base sees the models from app.core.config import get_settings settings = get_settings() +# backend/ — where alembic.ini and the alembic/ directory live. +BACKEND_ROOT = Path(__file__).resolve().parents[2] + def init_db() -> None: - """Create all tables defined on Base metadata.""" - Base.metadata.create_all(bind=engine) - print("✓ Database tables created.") + """Bring the database up to the migration head. + + This used to be Base.metadata.create_all(), which was a quiet source of + drift: create_all builds any *missing* table straight from the models, so + a migration's schema half would appear to have been applied while its data + half never ran. That's how every tournament-owned form ended up without + its tournament_forms companion row — the table existed, the backfill in + 8d55ec2b6640 never executed, and nothing errored. create_all also can't + express an ALTER, so column-level changes never reached an existing dev + database at all. + + Running the migrations instead makes alembic the single source of truth + for schema in every environment. Note this is only wired up for + development/preview (see app/main.py's lifespan) — production applies + migrations as its own deploy step, not on boot. + """ + from alembic import command + from alembic.config import Config + + cfg = Config(str(BACKEND_ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) + command.upgrade(cfg, "head") + print("✓ Database migrated to head.") def seed_dev_data(db: Session) -> None: diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 067a68fe..2029cd5f 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -196,8 +196,8 @@ class User(Base): shirt_size = Column(String(16), nullable=True) dietary_restriction = Column(String(255), nullable=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) memberships = relationship( "TournamentMembership", back_populates="user", cascade="all, delete-orphan" @@ -334,8 +334,8 @@ class Tournament(Base): # won't re-archive it. Cleared on re-archive. archive_override_at = Column(DateTime(timezone=True), nullable=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) owner = relationship("User", back_populates="tournaments", foreign_keys=[owner_id]) sheet_configs = relationship( @@ -405,8 +405,8 @@ class TournamentMembership(Base): notes = Column(Text, nullable=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) # Relationships user = relationship("User", back_populates="memberships") @@ -569,8 +569,8 @@ class TournamentEvent(Base): start_time = Column(DateTime(timezone=True), nullable=True) end_time = Column(DateTime(timezone=True), nullable=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) tournament = relationship("Tournament", back_populates="events") event = relationship("Event") @@ -694,8 +694,8 @@ class SheetConfig(Base): column_mappings = Column(JSON, nullable=False, default=dict) is_active = Column(Boolean, default=True) last_synced_at = Column(DateTime(timezone=True), nullable=True) - created_at = Column(DateTime(timezone=True), default=utcnow) - updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) tournament = relationship("Tournament", back_populates="sheet_configs") @@ -708,7 +708,10 @@ class SheetConfig(Base): class Form(Base): __tablename__ = "forms" - id = Column(String(12), primary_key=True, default=generate_public_id) + # index=True is redundant beside the primary key, but 7db31ae17e3c created + # ix_forms_id and prod has it — declared here so the model matches the + # migrations rather than silently drifting from them. + id = Column(String(12), primary_key=True, default=generate_public_id, index=True) owner_type = Column(String(16), nullable=False) # "tournament" | "chapter" tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=True) chapter_id = Column(Integer, ForeignKey("alumni_chapters.id", ondelete="CASCADE"), nullable=True) diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 00000000..9a764c9a --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,98 @@ +"""Guards that the Alembic chain is the source of truth for schema. + +The test suite builds its database with Base.metadata.create_all (see +conftest), which is fast but means every other test validates the *models* +while production only ever runs the *migrations*. Nothing checks the two +agree, and that gap is not hypothetical: forms.id carried an index and five +tables carried NOT NULL timestamps that existed in every migrated database but +were never declared on the models, unnoticed since March 2026. + +This builds a throwaway database from the migrations alone and asserts it +matches the models exactly, so the two can't drift apart silently again. +""" +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from sqlalchemy import create_engine, text + +from app.core.config import get_settings +from app.db.session import Base +from app.models import models # noqa: F401 — populates Base.metadata + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +SCRATCH_DB = "nexus_migration_check" + + +def _server_url() -> str: + """The Postgres server, minus the database name.""" + return get_settings().database_url.rsplit("/", 1)[0] + + +@pytest.fixture +def migrated_db_url(): + """A fresh database with the full migration chain applied, dropped after. + + The upgrade runs in a subprocess because alembic/env.py reads the URL from + app settings at import time — an in-process config override is silently + clobbered by it, which is a trap worth not re-stepping into. + """ + admin = create_engine(f"{_server_url()}/postgres", isolation_level="AUTOCOMMIT") + url = f"{_server_url()}/{SCRATCH_DB}" + try: + with admin.connect() as conn: + conn.execute(text(f"DROP DATABASE IF EXISTS {SCRATCH_DB}")) + conn.execute(text(f"CREATE DATABASE {SCRATCH_DB}")) + + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=BACKEND_ROOT, + env={**os.environ, "DATABASE_URL": url, "PYTHONIOENCODING": "utf-8"}, + capture_output=True, text=True, + ) + assert result.returncode == 0, f"alembic upgrade head failed:\n{result.stderr}" + yield url + finally: + with admin.connect() as conn: + conn.execute(text(f"DROP DATABASE IF EXISTS {SCRATCH_DB}")) + admin.dispose() + + +def test_migrations_build_a_schema_matching_the_models(migrated_db_url): + engine = create_engine(migrated_db_url) + try: + with engine.connect() as conn: + diff = compare_metadata(MigrationContext.configure(conn), Base.metadata) + finally: + engine.dispose() + + assert diff == [], ( + "The models and the migration chain disagree. Each entry below is a change " + "autogenerate would apply to a migrated database to reach the models.\n" + "Fix whichever side is wrong: add a migration if the models are right, or " + "correct the model declaration if the migrations already describe production.\n" + + "\n".join(f" {d}" for d in diff) + ) + + +def test_migrations_create_every_model_table(migrated_db_url): + """A migrated database must stand on its own. Asserted separately from the + diff above because a missing table is the failure that matters most — + create_all used to paper over it at startup, so a chain that couldn't build + from empty still looked healthy.""" + engine = create_engine(migrated_db_url) + try: + with engine.connect() as conn: + built = { + row[0] for row in + conn.execute(text("SELECT tablename FROM pg_tables WHERE schemaname='public'")) + } + finally: + engine.dispose() + + missing = set(Base.metadata.tables) - built + assert not missing, f"Migrations don't create: {sorted(missing)}" From 6967901b2d413384a1d3ec808583da8d8109d5d5 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 17:22:43 -0700 Subject: [PATCH 77/92] docs(forms): document track status write-through --- backend/form-edit-lifecycle.md | 66 ++++++++++++++++++------ backend/form-question-types-reference.md | 10 ++-- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md index 0f3d4ef6..22671810 100644 --- a/backend/form-edit-lifecycle.md +++ b/backend/form-edit-lifecycle.md @@ -320,26 +320,58 @@ field is archived or invalidated. ## Track status ordering -Track status write-through is last-write-wins, and "last" is the order -write-through runs, not submission order. Left unconstrained, a respondent -editing an older form could demote a track that a newer form already set to -`confirmed`. +Track statuses live in `TournamentMembershipTrackStatus`, one row per +(membership, track). Write-through **only ever upserts** — the rows are shared +across questions and forms, so no field owns one and none can withdraw its +contribution. -Three rules narrow this to near-zero: +Ordering is enforced by a transition rule rather than by comparing submission +times, because the only damage an out-of-order write can do *is* a demotion: + +| stored → incoming | `interested` | `confirmed` | `declined` | +|---|---|---|---| +| *(unset)* | ✓ | ✓ | ✓ | +| `interested` | ✓ | ✓ | ✓ | +| `confirmed` | ✗ | ✓ | ✓ | +| `declined` | ✗ | ✓ | ✓ | + +In one sentence: **a track never falls back to `interested` once it's moved +past it.** `declined → confirmed` stays open so someone who changes their mind +can commit without TD intervention. A refused write is skipped silently, not +rejected — it's a legitimate outcome of the rules, not a client error. + +Three structural rules still apply, and the transition rule closes what they +left open: 1. **Forward-only write-through** — historical answers are never replayed. -2. **Locked responses** — a respondent can only edit questions the TD flagged, - so no spontaneous edits to old forms. -3. **Patch-scoped write-through** — an edit carries only the flagged fields, - so no other field's write-through re-fires. - -**Remaining exposure:** a TD raises a pending update on a track question in an -*older* form, and the respondent's new answer overwrites a newer form's status. -This requires a deliberate TD action on that specific question, so it's -visible rather than silent — but it is not prevented. - -Closing it fully needs write-through to record which response last set each -track status and reject an out-of-order write. +2. **Locked responses** — a respondent can only edit questions the TD flagged. +3. **Patch-scoped write-through** — `PATCH` writes track statuses only for the + fields it actually carried. Unlike availability, there's no idempotent diff + to fall back on, so replaying an unpatched field would re-assert a status + the respondent didn't touch on this request. Availability keeps its + whole-response recompute; the two scopes deliberately differ. + +The exposure this used to name — a TD flagging a track question on an *older* +form, whose answer then demotes a newer form's status — is closed by the table +above, with no notion of "which response is newer" needed. + +**Cost:** no form can walk a mistaken `confirmed` back down to `interested`. +Correcting that needs a path that bypasses the guard; the planned member +self-edit of their own membership is one, and `confirmed → declined` is +already allowed without it. + +### Which field wins + +Two questions in one submission can name the same track. Field order in the +form decides intent — later fields overwrite earlier ones — and the transition +rule then decides whether that intent lands. Within a single +`multi_select_checkbox`, validation already rejects two options assigning one +track conflicting statuses, so only the cross-field case needs a rule. + +Provenance (`source_response_id`, `source_field_id`) is recorded for debugging +only. It is not load-bearing: the transition rule, not the history, is what +keeps writes ordered. A NULL `source_response_id` means the status did not come +from a form. ## Out of scope diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index 0d178247..b36d1612 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -142,10 +142,10 @@ Only `single_select_radio` and `single_select_dropdown` options may carry branch | `field_key` | Allowed `question_type`(s) | Write-through | |---|---|---| -| `availability_{date}` — e.g. `availability_20260315` (`^availability_\d{8}$`), one per date; a bare `availability` (no date) is **not** a valid reserved key | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipAvailability` (tournament-owned forms only); selected option_id(s) across **every** active `availability_*` field on the response are expanded into their grouped `TournamentShift` ids, unioned, and diffed as one set — every date's question feeds the same centralized "shifts this member is available for" pool, not a per-date table | +| `availability_{date}` — e.g. `availability_20260315` (`^availability_\d{8}$`), one per date; a bare `availability` (no date) is **not** a valid reserved key | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipAvailability` (tournament-owned forms only); selected option_id(s) across **every** active `availability_*` field on the response are expanded into their grouped `TournamentShift` ids, unioned, and diffed as one set — every date's question feeds the same centralized "shifts this member is available for" pool, not a per-date table. With `track_status_enabled: true` the option's shift ids move under a `shift_ids` key and the field additionally writes track statuses — read them through `option_shift_ids` / `option_track_assignments` rather than off `value`, whose shape is only interpretable alongside `field_key`. | | `lunch_{date}_{category}` — e.g. `lunch_20270213_protein` (`^lunch_\d{8}_[a-z0-9_]+$`), one per (date, category) pair | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipLunch` (tournament-owned forms only); selected option_id(s) resolve to their stored `value`/`label`, no catalog table — stores whatever option was selected, keyed by category string | -| `event_preference_{suffix}` — e.g. `event_preference_morning` (`^event_preference_[a-z0-9_]+$`), one per independently-ranked axis; a bare `event_preference` (no suffix) is **not** a valid reserved key | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer`, same as any custom question (option `value` may be `list[int]` of real `TournamentEvent` ids, resolved on render; not yet strictly validated against real events). Unlike `availability`, different suffixes are **not** merged into one pool — each suffix is read as its own axis by querying `FormAnswer` directly wherever event preferences are needed downstream, rather than being synced into a dedicated structural table. `TournamentMembership.event_preference` is an unrelated, already-deprecated manual-entry JSON column (along with `role_preference`, `availability`, `lunch_order`, `extra_data` on that model) — not read or written by this write-through. | -| `track_status_{suffix}` — e.g. `track_status_volunteer_interest` (`^track_status_[a-z0-9_]+$`), one per independently named status question | `single_select_radio` or `multi_select_checkbox`, and `required` **must** be `true` | pending membership-track status write-through; each option's `value` is the list of track assignments it applies (shape below). An `availability_*` field may carry assignments too, but only with `track_status_enabled: true`. Checkbox options may repeat a track only when they assign it the same status. | +| `event_preference_{suffix}` — e.g. `event_preference_morning` (`^event_preference_[a-z0-9_]+$`), one per independently-ranked axis; a bare `event_preference` (no suffix) is **not** a valid reserved key | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer`, same as any custom question (option `value` may be `list[int]` of real `TournamentEvent` ids, resolved on render; not yet strictly validated against real events). Unlike `availability`, different suffixes are **not** merged into one pool — each suffix is read as its own axis by querying `FormAnswer` directly wherever event preferences are needed downstream, rather than being synced into a dedicated structural table. `TournamentMembership` once carried manual-entry `event_preference` / `role_preference` / `availability` / `lunch_order` / `extra_data` columns; those are gone, as is `status` — per-track participation now lives in `TournamentMembershipTrackStatus`. | +| `track_status_{suffix}` — e.g. `track_status_volunteer_interest` (`^track_status_[a-z0-9_]+$`), one per independently named status question | `single_select_radio` or `multi_select_checkbox`, and `required` **must** be `true` | `TournamentMembershipTrackStatus` (tournament-owned forms only), one row per (membership, track); each option's `value` is the list of track assignments it applies (shape below). An `availability_*` field may carry assignments too, but only with `track_status_enabled: true` — that field then writes to **both** targets, its shifts and its statuses. **Upsert-only, never deleted**, and guarded by a transition rule (a track never falls back to `interested`); where two fields name one track, later document order wins. Checkbox options may repeat a track only when they assign it the same status. See `form-edit-lifecycle.md`'s "Track status ordering". | | any TD-typed slug | any type | none — generic `FormAnswer` | Reserved keys are currently valid only on tournament-owned forms. `track_status_*` also requires tracks from that tournament's catalog. @@ -224,7 +224,9 @@ Duplicate track ids *within a single option* are rejected on shape 2 only — `_unique_track_statuses` is wired into `AvailabilityTrackStatusValue` but not into a bare `list[TrackStatusAssignment]`, so shape 5 currently accepts `[{"id": 7, "status": "interested"}, {"id": 7, "status": "declined"}]`. -Asymmetry, not intent. +Asymmetry, not intent. Write-through resolves such a duplicate by last-one-wins +within the option, then applies the transition rule — defined behavior, but not +behavior anyone chose. **Why `value` and not a dedicated key:** the option schemas union `str | list[int] | list[TrackStatusAssignment] | AvailabilityTrackStatusValue` From 7e148a9c5973fffec9ed443509431b6f59da8a1b Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 17:39:20 -0700 Subject: [PATCH 78/92] refactor(members): show track statuses as a list beside roles --- .../components/tournament/MemberPanel.tsx | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/frontend/components/tournament/MemberPanel.tsx b/frontend/components/tournament/MemberPanel.tsx index d5954103..afa959b1 100644 --- a/frontend/components/tournament/MemberPanel.tsx +++ b/frontend/components/tournament/MemberPanel.tsx @@ -117,49 +117,56 @@ export function MemberPanel({
- {full.track_statuses.length > 0 && ( +
+ {full.track_statuses.length > 0 && ( +
+
+ Tracks +
+
+ {full.track_statuses.map((ts) => ( + // An archived track's statuses stay readable — the + // catalog entry is retired, the commitment still + // happened — so it's dimmed rather than hidden. +
+ + {ts.name} + + {ts.status} +
+ ))} +
+
+ )} +
- Tracks + Roles
-
- {full.track_statuses.map((ts) => ( - // An archived track's statuses stay readable — the - // catalog entry is retired, the commitment still - // happened — so it's dimmed rather than hidden. - - {ts.name} · {ts.status} - - ))} -
-
- )} - -
-
- Roles +
-
From a0c5050b481855de9313f641db91fe07be1e9043 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 18:24:42 -0700 Subject: [PATCH 79/92] feat(forms): enforce allow_duplicates on ranked-choice submissions --- backend/app/api/routes/forms.py | 16 ++++++++- backend/app/core/form/branching.py | 20 ++++++++++++ backend/tests/core/test_form_branching.py | 40 ++++++++++++++++++++++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 380c18b8..04e34024 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -18,7 +18,7 @@ snapshot_answer_value, ) from app.core.form import changes -from app.core.form.branching import missing_required_field_keys +from app.core.form.branching import duplicate_ranked_choice_field_keys, missing_required_field_keys from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( AVAILABILITY_FIELD_KEY_PATTERN, @@ -923,6 +923,13 @@ def submit_form_response( detail=f"Missing required field(s): {sorted(missing_required)}", ) + duplicate_ranks = duplicate_ranked_choice_field_keys(active_fields, answers_by_field) + if duplicate_ranks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate option selected at multiple ranks: {sorted(duplicate_ranks)}", + ) + response = FormResponse(form_id=form.id, user_id=current_user.id) db.add(response) db.flush() @@ -1006,6 +1013,13 @@ def patch_form_response( detail=f"Missing required field(s): {sorted(missing_required)}", ) + duplicate_ranks = duplicate_ranked_choice_field_keys(list(fields_by_id.values()), answers_by_field) + if duplicate_ranks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate option selected at multiple ranks: {sorted(duplicate_ranks)}", + ) + db.query(FormAnswer).filter( FormAnswer.response_id == response.id, FormAnswer.field_id.in_(patched_ids) ).delete(synchronize_session=False) diff --git a/backend/app/core/form/branching.py b/backend/app/core/form/branching.py index faae8698..fa06823d 100644 --- a/backend/app/core/form/branching.py +++ b/backend/app/core/form/branching.py @@ -70,3 +70,23 @@ def missing_required_field_keys(fields: list[FormField], answers: dict[str, Any] for field in fields if field.id in reachable and (field.config or {}).get("required") and _is_blank(answers.get(field.id)) ] + + +def duplicate_ranked_choice_field_keys(fields: list[FormField], answers: dict[str, Any]) -> list[str]: + """field_keys of ranked_choice fields whose answer repeats the same + option_id at more than one rank, for a field whose config doesn't allow + it. `allow_duplicates` is a required RankedChoiceConfig field, but until + now nothing server-side actually read it — only the picker UI + (RankedList.tsx) used it client-side to trim its remaining-options pool. + This is the enforcement that makes it real.""" + offenders = [] + for field in fields: + if field.question_type != "ranked_choice" or (field.config or {}).get("allow_duplicates"): + continue + value = answers.get(field.id) + if not isinstance(value, dict): + continue + option_ids = list(value.values()) + if len(option_ids) != len(set(option_ids)): + offenders.append(field.field_key) + return offenders diff --git a/backend/tests/core/test_form_branching.py b/backend/tests/core/test_form_branching.py index 5f90291b..73c0a664 100644 --- a/backend/tests/core/test_form_branching.py +++ b/backend/tests/core/test_form_branching.py @@ -3,7 +3,11 @@ could actually reach. Fields here are built directly (not through the DB) since compute_reachable_field_ids/missing_required_field_keys are pure functions over a field list + an answers dict.""" -from app.core.form.branching import compute_reachable_field_ids, missing_required_field_keys +from app.core.form.branching import ( + compute_reachable_field_ids, + duplicate_ranked_choice_field_keys, + missing_required_field_keys, +) from app.models.models import FormField @@ -173,3 +177,37 @@ def test_blank_values_treated_as_unanswered(self): def test_non_blank_answer_satisfies_required(self): fields = [_field(1, 1, config={"required": True})] assert missing_required_field_keys(fields, {1: "hello"}) == [] + + +class TestDuplicateRankedChoiceFieldKeys: + def _ranked_field(self, id, allow_duplicates=False, field_key=None): + return _field( + id, 1, question_type="ranked_choice", + config={"required": False, "ranks": 3, "allow_duplicates": allow_duplicates, "options": []}, + field_key=field_key, + ) + + def test_repeated_option_across_ranks_rejected_by_default(self): + fields = [self._ranked_field(1)] + assert duplicate_ranked_choice_field_keys(fields, {1: {"1": "opt_a", "2": "opt_b", "3": "opt_a"}}) == ["field_1"] + + def test_repeated_option_allowed_when_config_permits(self): + fields = [self._ranked_field(1, allow_duplicates=True)] + assert duplicate_ranked_choice_field_keys(fields, {1: {"1": "opt_a", "2": "opt_a"}}) == [] + + def test_no_repeats_passes(self): + fields = [self._ranked_field(1)] + assert duplicate_ranked_choice_field_keys(fields, {1: {"1": "opt_a", "2": "opt_b"}}) == [] + + def test_non_ranked_choice_fields_untouched(self): + fields = [_field(1, 1, question_type="multi_select_checkbox", config={"required": False})] + assert duplicate_ranked_choice_field_keys(fields, {1: ["opt_a", "opt_a"]}) == [] + + def test_unanswered_ranked_field_not_reported(self): + fields = [self._ranked_field(1)] + assert duplicate_ranked_choice_field_keys(fields, {}) == [] + + def test_multiple_offending_fields_all_reported(self): + fields = [self._ranked_field(1, field_key="field_1"), self._ranked_field(2, field_key="field_2")] + answers = {1: {"1": "opt_a", "2": "opt_a"}, 2: {"1": "opt_b", "2": "opt_b"}} + assert duplicate_ranked_choice_field_keys(fields, answers) == ["field_1", "field_2"] From 21584833f9806f68387eca0692a70baed560e858 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 22:11:53 -0700 Subject: [PATCH 80/92] feat(forms): validate event_preference options strictly --- backend/app/api/routes/forms.py | 4 ++ backend/app/core/form/validation.py | 75 +++++++++++++++++++- backend/tests/core/test_form_validation.py | 79 +++++++++++++++++++++- 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 04e34024..50db9a57 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -22,6 +22,7 @@ from app.core.form.permissions import require_form_manage_access, require_form_view_access from app.core.form.validation import ( AVAILABILITY_FIELD_KEY_PATTERN, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, LUNCH_FIELD_KEY_PATTERN, FormFieldValidationError, availability_field_date, @@ -30,6 +31,7 @@ option_track_assignments, track_status_enabled, validate_availability_options, + validate_event_preference_options, validate_field_config, validate_form_for_publish, validate_reserved_field_key, @@ -702,6 +704,8 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> validate_availability_options( db, form.tournament_id, normalized, availability_field_date(field_key), ) + if EVENT_PREFERENCE_FIELD_KEY_PATTERN.match(field_key): + validate_event_preference_options(db, form.tournament_id, question_type, normalized) validate_track_status_options(db, form.tournament_id, field_key, question_type, normalized) except FormFieldValidationError as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) diff --git a/backend/app/core/form/validation.py b/backend/app/core/form/validation.py index 8852123f..67e20b57 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -16,7 +16,7 @@ from datetime import datetime -from app.models.models import Form, FormField, TournamentShift, TournamentTrack +from app.models.models import Form, FormField, TournamentEvent, TournamentShift, TournamentTrack from app.schemas.form import QUESTION_TYPE_CONFIG_SCHEMAS BRANCHING_QUESTION_TYPES = {"single_select_radio", "single_select_dropdown"} @@ -322,6 +322,12 @@ def collect_active_field_errors(db: Session, form: Form) -> list[str]: except FormFieldValidationError as e: errors.append(f"field '{field.field_key}': {e}") + if EVENT_PREFERENCE_FIELD_KEY_PATTERN.match(field.field_key): + try: + validate_event_preference_options(db, form.tournament_id, field.question_type, normalized_config) + except FormFieldValidationError as e: + errors.append(f"field '{field.field_key}': {e}") + return errors @@ -412,3 +418,70 @@ def validate_availability_options( f"availability option value(s) reference shifts outside this question's date " f"({field_date.isoformat()}): {wrong_day}", ) + + +def validate_event_preference_options( + db: Session, tournament_id: int | None, question_type: str, config: dict +) -> None: + """A field with field_key matching EVENT_PREFERENCE_FIELD_KEY_PATTERN must + have every option's `value` be a non-empty list[int] of real + TournamentEvent ids belonging to the field's own tournament — one option + groups one or more events under a single TD-labeled choice. + + Unlike availability (where a shift may appear in multiple options), an + event may not appear in more than one option: write-through expands each + selected option into rows keyed by event id, so an event split across + options would make "which option did they pick" ambiguous. + + A ranked_choice event_preference field must also have + `allow_duplicates: false` — ranking the same event at two ranks is never + meaningful, and it's what lets the write-through table use a plain + (membership, key, event) unique constraint with no rank in it. Options are + already guaranteed mutually exclusive by the check above, so this is + enforced at answer time too (see duplicate_ranked_choice_field_keys) — + this is just the config-time half of the same rule. + + Chapter-owned forms have no tournament event catalog to validate against, + so this is a no-op there, same as availability.""" + if tournament_id is None: + return + + if question_type == "ranked_choice": + _require( + not config.get("allow_duplicates"), + "event_preference ranked_choice fields must have allow_duplicates set to false", + ) + + options = config.get("options") or [] + if not options: + return + + event_ids: set[int] = set() + seen_ids: set[int] = set() + duplicated: set[int] = set() + for option in options: + value = option.get("value") + _require( + isinstance(value, list) and len(value) > 0 and all(isinstance(v, int) for v in value), + f"event_preference option value '{value}' must be a non-empty list of TournamentEvent ids", + ) + duplicated |= seen_ids & set(value) + seen_ids |= set(value) + event_ids.update(value) + + _require( + not duplicated, + f"event_preference option value(s) reference the same event in more than one option: {sorted(duplicated)}", + ) + + valid_ids = { + event_id + for (event_id,) in db.query(TournamentEvent.id) + .filter(TournamentEvent.tournament_id == tournament_id, TournamentEvent.id.in_(event_ids)) + .all() + } + missing = event_ids - valid_ids + _require( + not missing, + f"event_preference option value(s) do not reference a real TournamentEvent on this tournament: {sorted(missing)}", + ) diff --git a/backend/tests/core/test_form_validation.py b/backend/tests/core/test_form_validation.py index 59d0a81f..5d3a6219 100644 --- a/backend/tests/core/test_form_validation.py +++ b/backend/tests/core/test_form_validation.py @@ -15,13 +15,14 @@ option_track_assignments, validate_availability_options, validate_branching_options, + validate_event_preference_options, validate_field_config, validate_form_for_publish, validate_reserved_field_key, validate_track_status_options, validate_tournament_preset, ) -from app.models.models import Form, FormField, TournamentShift, TournamentTrack +from app.models.models import Form, FormField, TournamentEvent, TournamentShift, TournamentTrack # --------------------------------------------------------------------------- @@ -95,6 +96,13 @@ def _make_shift(db, tournament, label="Saturday", day=None): return shift +def _make_event(db, tournament, name="Anatomy", division="B"): + event = TournamentEvent(tournament_id=tournament.id, name=name, division=division) + db.add(event) + db.flush() + return event + + @pytest.fixture def chapter(db): university = make_university(db) @@ -399,6 +407,75 @@ def test_empty_list_value_rejected(self, db, td_user, td_tournament): validate_availability_options(db, td_tournament.id, config) +# --------------------------------------------------------------------------- +# validate_event_preference_options +# --------------------------------------------------------------------------- + +class TestValidateEventPreferenceOptions: + def test_chapter_owned_form_skips_check(self, db): + config = {"options": [{"value": ["not_a_real_event_id"], "label": "Whenever"}]} + validate_event_preference_options(db, None, "multi_select_checkbox", config) # no raise + + def test_valid_event_ids_pass(self, db, td_user, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + config = {"options": [{"value": [event.id], "label": event.name}]} + validate_event_preference_options(db, td_tournament.id, "multi_select_checkbox", config) # no raise + + def test_grouped_event_ids_all_validated(self, db, td_user, td_tournament): + e1 = _make_event(db, td_tournament, "Anatomy") + e2 = _make_event(db, td_tournament, "Astronomy") + db.commit() + config = {"options": [{"value": [e1.id, e2.id], "label": "Life Science"}]} + validate_event_preference_options(db, td_tournament.id, "multi_select_checkbox", config) # no raise + + def test_event_id_not_on_tournament_rejected(self, db, td_user, td_tournament, other_tournament): + event = _make_event(db, other_tournament) + db.commit() + config = {"options": [{"value": [event.id], "label": event.name}]} + with pytest.raises(FormFieldValidationError): + validate_event_preference_options(db, td_tournament.id, "multi_select_checkbox", config) + + def test_non_list_value_rejected(self, db, td_user, td_tournament): + config = {"options": [{"value": "not_a_list", "label": "Whenever"}]} + with pytest.raises(FormFieldValidationError): + validate_event_preference_options(db, td_tournament.id, "multi_select_checkbox", config) + + def test_empty_list_value_rejected(self, db, td_user, td_tournament): + config = {"options": [{"value": [], "label": "Whenever"}]} + with pytest.raises(FormFieldValidationError): + validate_event_preference_options(db, td_tournament.id, "multi_select_checkbox", config) + + def test_event_in_two_options_rejected(self, db, td_user, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + config = {"options": [ + {"value": [event.id], "label": "Option A"}, + {"value": [event.id], "label": "Option B"}, + ]} + with pytest.raises(FormFieldValidationError, match="more than one option"): + validate_event_preference_options(db, td_tournament.id, "multi_select_checkbox", config) + + def test_ranked_choice_allow_duplicates_true_rejected(self, db, td_user, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + config = {"allow_duplicates": True, "options": [{"value": [event.id], "label": event.name}]} + with pytest.raises(FormFieldValidationError, match="allow_duplicates"): + validate_event_preference_options(db, td_tournament.id, "ranked_choice", config) + + def test_ranked_choice_allow_duplicates_false_passes(self, db, td_user, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + config = {"allow_duplicates": False, "options": [{"value": [event.id], "label": event.name}]} + validate_event_preference_options(db, td_tournament.id, "ranked_choice", config) # no raise + + def test_non_ranked_choice_ignores_allow_duplicates(self, db, td_user, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + config = {"allow_duplicates": True, "options": [{"value": [event.id], "label": event.name}]} + validate_event_preference_options(db, td_tournament.id, "multi_select_checkbox", config) # no raise + + # --------------------------------------------------------------------------- # validate_track_status_options # --------------------------------------------------------------------------- From 7d3420a9d0661f90feaaae2425a3f68eababbdf5 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 22:12:05 -0700 Subject: [PATCH 81/92] feat(forms): hide duplicate-ranks toggle for event_preference fields --- .../components/forms/QuestionRenderer.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/frontend/components/forms/QuestionRenderer.tsx b/frontend/components/forms/QuestionRenderer.tsx index 10a2d4fb..3dbba66e 100644 --- a/frontend/components/forms/QuestionRenderer.tsx +++ b/frontend/components/forms/QuestionRenderer.tsx @@ -430,10 +430,14 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra errors={errors} /> )} - {/* Ranks/duplicates apply to ranked_choice regardless of whether its - options are entity-backed (event_preference) or freeform — the - rank mechanics are a property of the question type, not of where - the option rows come from. */} + {/* Ranks applies to ranked_choice regardless of whether its options + are entity-backed (event_preference) or freeform — the rank + mechanics are a property of the question type, not of where the + option rows come from. Duplicate ranks are different: an + event_preference field can never allow them (ranking the same + event twice is meaningless, and the backend rejects the config + outright — see validate_event_preference_options), so the toggle + is hidden rather than shown disabled. */} {field.question_type === 'ranked_choice' && (() => { const options = liveOptions const ranks = field.config?.ranks ?? 1 @@ -456,15 +460,17 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra fullWidth />
-
- - Allow duplicate ranks - - onFieldChange({ config: { ...field.config, allow_duplicates: checked } })} - /> -
+ {presetKind !== 'event_preference' && ( +
+ + Allow duplicate ranks + + onFieldChange({ config: { ...field.config, allow_duplicates: checked } })} + /> +
+ )}
) })()} From eec7e6bc2eb4d2114abe472575567a96a3cc730f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 22:12:22 -0700 Subject: [PATCH 82/92] feat(db): add tournament_membership_event_preferences table --- ...0211be_add_tournament_membership_event_.py | 50 +++++++++++++++++++ backend/app/models/models.py | 33 ++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 backend/alembic/versions/8e43330211be_add_tournament_membership_event_.py diff --git a/backend/alembic/versions/8e43330211be_add_tournament_membership_event_.py b/backend/alembic/versions/8e43330211be_add_tournament_membership_event_.py new file mode 100644 index 00000000..ce94135a --- /dev/null +++ b/backend/alembic/versions/8e43330211be_add_tournament_membership_event_.py @@ -0,0 +1,50 @@ +"""add tournament membership event preferences + +Revision ID: 8e43330211be +Revises: a3f81c60e274 +Create Date: 2026-08-28 00:00:00.000000 + +Write-through target for event_preference_* answers. One row per +(membership, key, event) — an event can only appear in one option per field +(see validate_event_preference_options) and a ranked_choice event_preference +field is required to have allow_duplicates=false, so rank is stored per row +but isn't part of the uniqueness. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '8e43330211be' +down_revision: Union[str, None] = 'a3f81c60e274' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'tournament_membership_event_preferences', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('membership_id', sa.Integer(), nullable=False), + sa.Column('tournament_event_id', sa.Integer(), nullable=False), + sa.Column('key', sa.String(length=64), nullable=False), + sa.Column('rank', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['membership_id'], ['tournament_memberships.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tournament_event_id'], ['tournament_events.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('membership_id', 'key', 'tournament_event_id', name='uq_membership_event_preference'), + ) + op.create_index( + op.f('ix_tournament_membership_event_preferences_id'), + 'tournament_membership_event_preferences', ['id'], unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f('ix_tournament_membership_event_preferences_id'), + table_name='tournament_membership_event_preferences', + ) + op.drop_table('tournament_membership_event_preferences') diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 2029cd5f..6747eff0 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -416,6 +416,7 @@ class TournamentMembership(Base): availability_shifts = relationship("TournamentMembershipAvailability", back_populates="membership", cascade="all, delete-orphan") lunch_selections = relationship("TournamentMembershipLunch", back_populates="membership", cascade="all, delete-orphan") track_statuses = relationship("TournamentMembershipTrackStatus", back_populates="membership", cascade="all, delete-orphan") + event_preferences = relationship("TournamentMembershipEventPreference", back_populates="membership", cascade="all, delete-orphan") @hybrid_property def is_over_18(self) -> Optional[bool]: @@ -1031,3 +1032,35 @@ class TournamentMembershipTrackStatus(Base): __table_args__ = ( UniqueConstraint("membership_id", "track_id", name="uq_membership_track_status"), ) + + +# --------------------------------------------------------------------------- +# TournamentMembershipEventPreference — write-through target for a form's +# "event_preference_{suffix}" answers. One row per (membership, key, event): +# an event may only appear in one option per field (see +# validate_event_preference_options), and a ranked_choice event_preference +# field is required to have allow_duplicates=false, so an event can never +# legitimately need two rows under the same key — rank is stored per row but +# isn't part of the uniqueness, unlike a naive (membership, key, event, rank) +# scheme would need. +# --------------------------------------------------------------------------- +class TournamentMembershipEventPreference(Base): + __tablename__ = "tournament_membership_event_preferences" + + id = Column(Integer, primary_key=True, index=True) + membership_id = Column(Integer, ForeignKey("tournament_memberships.id", ondelete="CASCADE"), nullable=False) + tournament_event_id = Column(Integer, ForeignKey("tournament_events.id", ondelete="CASCADE"), nullable=False) + + key = Column(String(64), nullable=False) # the event_preference_{suffix} suffix + + # ranked_choice: the submitted rank; single_select: 1; checkbox: null. + rank = Column(Integer, nullable=True) + + created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) + + membership = relationship("TournamentMembership", back_populates="event_preferences") + tournament_event = relationship("TournamentEvent") + + __table_args__ = ( + UniqueConstraint("membership_id", "key", "tournament_event_id", name="uq_membership_event_preference"), + ) From a9f50a0f412d44d196490cd54953d03e00ae31ca Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 22:27:18 -0700 Subject: [PATCH 83/92] feat(forms): write through event_preference answers to structural table --- backend/app/api/routes/forms.py | 74 ++++++++-- backend/app/core/form/write_through.py | 61 ++++++++- backend/tests/api/test_forms.py | 122 +++++++++++++++++ backend/tests/core/test_form_write_through.py | 127 ++++++++++++++++++ 4 files changed, 372 insertions(+), 12 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 50db9a57..d727f708 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -40,9 +40,11 @@ ) from app.core.form.write_through import ( parse_availability_field_key, + parse_event_preference_field_key, parse_lunch_field_key, shift_ids_on_dates, sync_availability, + sync_event_preferences, sync_lunch, sync_track_statuses, ) @@ -854,16 +856,31 @@ def _stored_answer_option_ids(db: Session, response: FormResponse, active_fields """The response's answers as option_id lists, in the shape write-through expects. A submitted payload carries bare option_ids, but most stored answers hold {option_id, value, label} snapshots instead — so replaying - from storage has to unwrap them first.""" + from storage has to unwrap them first. + + ranked_choice is the one exception: it's flattened to a rank -> option_id + dict (matching the shape a submitted payload carries) instead of the + unordered option_id list every other type gets, since event_preference + write-through needs each option's rank, not just whether it was picked — + unlike track status, the only other reserved consumer of ranked_choice + answers, which only cares which options were selected.""" stored = { answer.field_id: answer.value for answer in db.query(FormAnswer).filter(FormAnswer.response_id == response.id).all() } - return { - field.id: sorted(selected_option_ids(field, stored[field.id])) - for field in active_fields - if field.id in stored - } + result = {} + for field in active_fields: + if field.id not in stored: + continue + value = stored[field.id] + if field.question_type == "ranked_choice" and isinstance(value, dict): + result[field.id] = { + rank: (item.get("option_id") if isinstance(item, dict) else item) + for rank, item in value.items() + } + else: + result[field.id] = sorted(selected_option_ids(field, value)) + return result def _store_answers(db: Session, response: FormResponse, fields_by_id: dict, answers: list) -> None: @@ -1069,11 +1086,11 @@ def _write_through_reserved_fields( response: FormResponse, track_scope_field_ids: set[str] | None = None, ) -> None: - """Syncs `availability_{date}`/`lunch_{date}_{category}`/`track_status_*` - answers into their structural tables — tournament-owned forms only (see - form-question-types-reference.md). Runs over every active field, not - just answered ones, so a reserved field left blank clears any - previously-synced rows rather than leaving them stale. + """Syncs `availability_{date}`/`lunch_{date}_{category}`/`track_status_*`/ + `event_preference_{suffix}` answers into their structural tables — + tournament-owned forms only (see form-question-types-reference.md). Runs + over every active field, not just answered ones, so a reserved field left + blank clears any previously-synced rows rather than leaving them stale. Availability write-through is scoped by *day*, not by form or field. Every `availability_*` field across every form feeds one centralized @@ -1159,6 +1176,41 @@ def _write_through_reserved_fields( if v in options_by_id ] sync_lunch(db, membership.id, lunch_date, category, values) + continue + + if EVENT_PREFERENCE_FIELD_KEY_PATTERN.match(field.field_key): + suffix = parse_event_preference_field_key(field.field_key) + items: list[dict] = [] + if field.question_type == "ranked_choice": + # `value` here is a rank -> option_id dict (see the raw + # payload shape and _stored_answer_option_ids' ranked_choice + # exception), not the flattened `selected` list every other + # branch reads — ranked_choice never matches any other + # reserved pattern, so this is the only place it needs one. + for rank, option_id in (value if isinstance(value, dict) else {}).items(): + option = options_by_id.get(option_id) + if option is None: + continue + for event_id in option.get("value") or []: + items.append({"tournament_event_id": event_id, "rank": int(rank)}) + else: + # single_select_dropdown: one option at rank 1. + # multi_select_checkbox: every selected option, unranked — + # options are mutually exclusive by event (see + # validate_event_preference_options), so no event can + # collide across two selected options here. + rank = 1 if field.question_type == "single_select_dropdown" else None + for option_id in selected: + option = options_by_id.get(option_id) + if option is None: + continue + for event_id in option.get("value") or []: + items.append({"tournament_event_id": event_id, "rank": rank}) + # A suffix is one field's exclusive key (unlike availability's + # shared day pool), so this can sync straight from this field's + # answer with no cross-field union needed. + sync_event_preferences(db, membership.id, suffix, items) + continue if availability_dates: sync_availability( diff --git a/backend/app/core/form/write_through.py b/backend/app/core/form/write_through.py index 42f73b09..7823ee13 100644 --- a/backend/app/core/form/write_through.py +++ b/backend/app/core/form/write_through.py @@ -16,9 +16,14 @@ from sqlalchemy.orm import Session -from app.core.form.validation import AVAILABILITY_FIELD_KEY_PATTERN, LUNCH_FIELD_KEY_PATTERN +from app.core.form.validation import ( + AVAILABILITY_FIELD_KEY_PATTERN, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, + LUNCH_FIELD_KEY_PATTERN, +) from app.models.models import ( TournamentMembershipAvailability, + TournamentMembershipEventPreference, TournamentMembershipLunch, TournamentMembershipTrackStatus, TournamentShift, @@ -40,6 +45,12 @@ def parse_availability_field_key(field_key: str) -> date_type: return datetime.strptime(match.group(1), "%Y%m%d").date() +def parse_event_preference_field_key(field_key: str) -> str: + """The suffix an `event_preference_{suffix}` field carries (already known + to match EVENT_PREFERENCE_FIELD_KEY_PATTERN).""" + return EVENT_PREFERENCE_FIELD_KEY_PATTERN.match(field_key).group(1) + + def shift_ids_on_dates(db: Session, tournament_id: int, dates: set[date_type]) -> set[int]: """Every shift the given tournament days contain — the set an availability question for those days is answering about, whether or not @@ -131,6 +142,54 @@ def sync_lunch( db.flush() +def sync_event_preferences( + db: Session, + membership_id: int, + key: str, + items: list[dict], +) -> None: + """Diffs `items` (each `{"tournament_event_id": ..., "rank": ...}`) + against this membership's existing TournamentMembershipEventPreference + rows for this `key` only — rows for any other suffix on the same + membership are never touched. A suffix is one field's exclusive key + (unlike availability's shared day pool), so this can safely delete + outright rather than needing an owned-scope parameter. + + Unlike sync_lunch, an existing row whose event stays selected but whose + rank changed (a ranked-choice re-ranking) is updated in place rather than + deleted and re-inserted — lunch's value/label have no equivalent + "same selection, different detail" case.""" + existing_rows = ( + db.query(TournamentMembershipEventPreference) + .filter( + TournamentMembershipEventPreference.membership_id == membership_id, + TournamentMembershipEventPreference.key == key, + ) + .all() + ) + existing_by_event = {row.tournament_event_id: row for row in existing_rows} + incoming_by_event = {item["tournament_event_id"]: item for item in items} + + for event_id, row in existing_by_event.items(): + if event_id not in incoming_by_event: + db.delete(row) + elif row.rank != incoming_by_event[event_id]["rank"]: + row.rank = incoming_by_event[event_id]["rank"] + + for event_id in set(incoming_by_event) - set(existing_by_event): + item = incoming_by_event[event_id] + db.add( + TournamentMembershipEventPreference( + membership_id=membership_id, + key=key, + tournament_event_id=event_id, + rank=item["rank"], + ) + ) + + db.flush() + + TRACK_STATUSES = ("interested", "confirmed", "declined") diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index c04e05fc..7768592a 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -17,8 +17,10 @@ FormField, FormResponse, FormResponsePendingUpdate, + TournamentEvent, TournamentMembership, TournamentMembershipAvailability, + TournamentMembershipEventPreference, TournamentMembershipLunch, TournamentMembershipTrackStatus, TournamentForm, @@ -100,6 +102,13 @@ def _chapter_lead(db, chapter, email="chapterlead@test.com", password="LeadPass1 return user +def _make_event(db, tournament, name="Anatomy", division="B"): + event = TournamentEvent(tournament_id=tournament.id, name=name, division=division) + db.add(event) + db.flush() + return event + + # --------------------------------------------------------------------------- # POST /tournaments/{tournament_id}/forms/ and POST /chapters/{chapter_id}/forms/ # --------------------------------------------------------------------------- @@ -2176,6 +2185,119 @@ def test_lunch_write_through_on_tournament_form(self, client, db, td_user, td_to assert rows[0].category == "protein" assert rows[0].date == date(2027, 2, 13) + def test_event_preference_write_through_ranked_choice(self, client, db, td_user, td_tournament): + e1 = _make_event(db, td_tournament, "Anatomy") + e2 = _make_event(db, td_tournament, "Astronomy") + db.commit() + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field( + db, form, field_key="event_preference_morning", question_type="ranked_choice", + config={ + "required": False, "ranks": 2, "allow_duplicates": False, + "options": [ + {"option_id": "opt_e1", "value": [e1.id], "label": "Anatomy"}, + {"option_id": "opt_e2", "value": [e2.id], "label": "Astronomy"}, + ], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": {"1": "opt_e1", "2": "opt_e2"}}]}, + ) + assert res.status_code == 200, res.json() + + membership_id = self._membership_id(db, td_user, td_tournament) + rows = { + row.tournament_event_id: row.rank + for row in db.query(TournamentMembershipEventPreference).filter( + TournamentMembershipEventPreference.membership_id == membership_id + ).all() + } + assert rows == {e1.id: 1, e2.id: 2} + + def test_event_preference_write_through_checkbox_grouped_events(self, client, db, td_user, td_tournament): + e1 = _make_event(db, td_tournament, "Anatomy") + e2 = _make_event(db, td_tournament, "Astronomy") + db.commit() + form = _make_form(db, td_user, td_tournament, status="published") + field = _make_field( + db, form, field_key="event_preference_afternoon", question_type="multi_select_checkbox", + config={ + "required": False, + "options": [{"option_id": "opt_life", "value": [e1.id, e2.id], "label": "Life Science"}], + }, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": ["opt_life"]}]}, + ) + assert res.status_code == 200, res.json() + + membership_id = self._membership_id(db, td_user, td_tournament) + rows = db.query(TournamentMembershipEventPreference).filter( + TournamentMembershipEventPreference.membership_id == membership_id + ).all() + assert {row.tournament_event_id for row in rows} == {e1.id, e2.id} + assert all(row.rank is None for row in rows) + + def test_event_preference_patch_clears_selection_and_preserves_other_key(self, client, db, td_user, td_tournament): + e1 = _make_event(db, td_tournament, "Anatomy") + e2 = _make_event(db, td_tournament, "Astronomy") + db.commit() + form = _make_form(db, td_user, td_tournament, status="published") + morning = _make_field( + db, form, field_key="event_preference_morning", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_e1", "value": [e1.id], "label": "Anatomy"}]}, + ) + afternoon = _make_field( + db, form, order=2, field_key="event_preference_afternoon", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_e2", "value": [e2.id], "label": "Astronomy"}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": morning.id, "value": ["opt_e1"]}, + {"field_id": afternoon.id, "value": ["opt_e2"]}, + ]}) + assert res.status_code == 200, res.json() + membership_id = self._membership_id(db, td_user, td_tournament) + + self._flag(db, form, td_user, morning) + res = client.patch( + f"/forms/{form.id}/responses/me/", + json={"answers": [{"field_id": morning.id, "value": []}]}, + ) + assert res.status_code == 200, res.json() + + rows = db.query(TournamentMembershipEventPreference).filter( + TournamentMembershipEventPreference.membership_id == membership_id + ).all() + assert {row.tournament_event_id for row in rows} == {e2.id} + + def test_event_preference_answer_on_chapter_form_saves_but_does_not_write_through(self, client, db, td_user, chapter): + form = _make_chapter_form(db, td_user, chapter, status="published") + field = _make_field( + db, form, field_key="event_preference_morning", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_1", "value": ["not_a_real_event_id"], "label": "Whenever"}]}, + ) + db.commit() + _chapter_lead(db, chapter) + login(client, "chapterlead@test.com", "LeadPass123!") + + res = client.post( + f"/forms/{form.id}/responses/", + json={"answers": [{"field_id": field.id, "value": ["opt_1"]}]}, + ) + assert res.status_code == 200 + assert db.query(TournamentMembershipEventPreference).count() == 0 + def test_availability_answer_on_chapter_form_saves_but_does_not_write_through(self, client, db, td_user, chapter): form = _make_chapter_form(db, td_user, chapter, status="published") field = _make_field( diff --git a/backend/tests/core/test_form_write_through.py b/backend/tests/core/test_form_write_through.py index 9853ada5..17ae94c9 100644 --- a/backend/tests/core/test_form_write_through.py +++ b/backend/tests/core/test_form_write_through.py @@ -10,6 +10,7 @@ can_set_track_status, parse_lunch_field_key, sync_availability, + sync_event_preferences, sync_lunch, sync_track_statuses, ) @@ -17,8 +18,10 @@ Form, FormField, FormResponse, + TournamentEvent, TournamentMembership, TournamentMembershipAvailability, + TournamentMembershipEventPreference, TournamentMembershipLunch, TournamentMembershipTrackStatus, TournamentShift, @@ -68,6 +71,24 @@ def _lunch_rows(db, membership_id, lunch_date, category): ) +def _make_event(db, tournament, name="Anatomy", division="B"): + event = TournamentEvent(tournament_id=tournament.id, name=name, division=division) + db.add(event) + db.flush() + return event + + +def _event_preference_rows(db, membership_id, key): + return ( + db.query(TournamentMembershipEventPreference) + .filter( + TournamentMembershipEventPreference.membership_id == membership_id, + TournamentMembershipEventPreference.key == key, + ) + .all() + ) + + # --------------------------------------------------------------------------- # sync_availability # --------------------------------------------------------------------------- @@ -210,6 +231,112 @@ def test_date_isolation(self, db, membership): assert {row.value for row in other_rows} == {"tofu"} +# --------------------------------------------------------------------------- +# sync_event_preferences +# --------------------------------------------------------------------------- + +class TestSyncEventPreferences: + def test_insert_only(self, db, membership, td_tournament): + e1 = _make_event(db, td_tournament, "Anatomy") + e2 = _make_event(db, td_tournament, "Astronomy") + db.commit() + + sync_event_preferences( + db, membership.id, "morning", + [{"tournament_event_id": e1.id, "rank": 1}, {"tournament_event_id": e2.id, "rank": 2}], + ) + db.commit() + + rows = {row.tournament_event_id: row.rank for row in _event_preference_rows(db, membership.id, "morning")} + assert rows == {e1.id: 1, e2.id: 2} + + def test_delete_only(self, db, membership, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + + sync_event_preferences(db, membership.id, "morning", [{"tournament_event_id": event.id, "rank": None}]) + db.commit() + + sync_event_preferences(db, membership.id, "morning", []) + db.commit() + + assert _event_preference_rows(db, membership.id, "morning") == [] + + def test_mixed_diff(self, db, membership, td_tournament): + e1 = _make_event(db, td_tournament, "Anatomy") + e2 = _make_event(db, td_tournament, "Astronomy") + e3 = _make_event(db, td_tournament, "Chemistry") + db.commit() + + sync_event_preferences( + db, membership.id, "morning", + [{"tournament_event_id": e1.id, "rank": None}, {"tournament_event_id": e2.id, "rank": None}], + ) + db.commit() + + # Drop e1, keep e2, add e3. + sync_event_preferences( + db, membership.id, "morning", + [{"tournament_event_id": e2.id, "rank": None}, {"tournament_event_id": e3.id, "rank": None}], + ) + db.commit() + + rows = {row.tournament_event_id for row in _event_preference_rows(db, membership.id, "morning")} + assert rows == {e2.id, e3.id} + + def test_rank_change_on_unchanged_event_updates_in_place(self, db, membership, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + + sync_event_preferences(db, membership.id, "morning", [{"tournament_event_id": event.id, "rank": 1}]) + db.commit() + + sync_event_preferences(db, membership.id, "morning", [{"tournament_event_id": event.id, "rank": 2}]) + db.commit() + + rows = _event_preference_rows(db, membership.id, "morning") + assert len(rows) == 1 + assert rows[0].rank == 2 + + def test_key_isolation(self, db, membership, td_tournament): + """Syncing one suffix never touches another suffix's rows for the + same membership.""" + morning_event = _make_event(db, td_tournament, "Anatomy") + afternoon_event = _make_event(db, td_tournament, "Astronomy") + db.commit() + + sync_event_preferences(db, membership.id, "morning", [{"tournament_event_id": morning_event.id, "rank": None}]) + sync_event_preferences(db, membership.id, "afternoon", [{"tournament_event_id": afternoon_event.id, "rank": None}]) + db.commit() + + sync_event_preferences(db, membership.id, "morning", []) + db.commit() + + assert _event_preference_rows(db, membership.id, "morning") == [] + afternoon_rows = _event_preference_rows(db, membership.id, "afternoon") + assert {row.tournament_event_id for row in afternoon_rows} == {afternoon_event.id} + + def test_repeated_ranked_selection_across_ranks_allowed(self, db, membership, td_tournament): + """sync_event_preferences itself doesn't enforce the no-repeat-rank + rule — that's validate_event_preference_options'/ + duplicate_ranked_choice_field_keys' job, upstream of write-through. + A caller handing it the same event at two ranks (e.g. before that + validation existed, or from a stale answer) diffs by event id, so the + second item just overwrites the first's rank rather than producing + two rows.""" + event = _make_event(db, td_tournament) + db.commit() + + sync_event_preferences( + db, membership.id, "morning", + [{"tournament_event_id": event.id, "rank": 1}, {"tournament_event_id": event.id, "rank": 2}], + ) + db.commit() + + rows = _event_preference_rows(db, membership.id, "morning") + assert len(rows) == 1 + + # --------------------------------------------------------------------------- # Track statuses # --------------------------------------------------------------------------- From 78cd2a0214ad8a1b74862c1e043540f5392269c2 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 22:44:33 -0700 Subject: [PATCH 84/92] fix(forms): invalidate event_preference fields to clear their write-through rows --- backend/app/api/routes/forms.py | 36 +++++++++++++++++++++++++++--- backend/tests/api/test_forms.py | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index d727f708..4c7739d0 100644 --- a/backend/app/api/routes/forms.py +++ b/backend/app/api/routes/forms.py @@ -1237,9 +1237,10 @@ def _write_through_reserved_fields( # should never have been asked, whose answers are not worth keeping, and it # cannot be undone. # -# Write-through rows are handled per target: lunch has a single owner and can -# be cleared, while availability and track statuses are shared with other -# questions and are left alone — see form-edit-lifecycle.md. +# Write-through rows are handled per target: lunch and event preference each +# have a single owning field and can be cleared, while availability and track +# statuses are shared with other questions and are left alone — see +# form-edit-lifecycle.md. # --------------------------------------------------------------------------- @router.delete("/forms/{form_id}/fields/{field_id}/", status_code=status.HTTP_204_NO_CONTENT) def invalidate_form_field( @@ -1259,6 +1260,9 @@ def invalidate_form_field( lunch_date, category = parse_lunch_field_key(field.field_key) _clear_lunch_write_through(db, form, field, lunch_date, category) + if EVENT_PREFERENCE_FIELD_KEY_PATTERN.match(field.field_key) and form.owner_type == "tournament": + _clear_event_preference_write_through(db, form, field) + # FormAnswer's FK has no ON DELETE, so its rows go first; pending updates # cascade with the field. db.query(FormAnswer).filter(FormAnswer.field_id == field.id).delete(synchronize_session=False) @@ -1304,6 +1308,32 @@ def _clear_lunch_write_through(db: Session, form: Form, field: FormField, lunch_ sync_lunch(db, membership_id, lunch_date, category, []) +def _clear_event_preference_write_through(db: Session, form: Form, field: FormField) -> None: + """Drops the event preference rows this field produced, for every member + who answered it. Keyed by (membership, suffix), and a suffix is one + field's exclusive key, so no other question can be contributing the same + rows — same reasoning as lunch's (membership, date, category).""" + user_ids = { + user_id + for (user_id,) in db.query(FormResponse.user_id) + .join(FormAnswer, FormAnswer.response_id == FormResponse.id) + .filter(FormAnswer.field_id == field.id) + .all() + } + if not user_ids: + return + membership_ids = { + membership_id + for (membership_id,) in db.query(TournamentMembership.id).filter( + TournamentMembership.tournament_id == form.tournament_id, + TournamentMembership.user_id.in_(user_ids), + ) + } + suffix = parse_event_preference_field_key(field.field_key) + for membership_id in membership_ids: + sync_event_preferences(db, membership_id, suffix, []) + + # --------------------------------------------------------------------------- # GET /forms/{form_id}/responses/ — all responses to a form. Manage access # only — this is roster data, not something every member should see. diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index 7768592a..2444ee2a 100644 --- a/backend/tests/api/test_forms.py +++ b/backend/tests/api/test_forms.py @@ -1367,6 +1367,45 @@ def test_lunch_write_through_is_cleared(self, client, db, td_user, td_tournament assert res.status_code == 204 assert db.query(TournamentMembershipLunch).count() == 0 + def test_event_preference_write_through_is_cleared(self, client, db, td_user, td_tournament): + event = _make_event(db, td_tournament) + db.commit() + form = _make_form(db, td_user, td_tournament, status="published") + target = _make_field( + db, form, field_key="event_preference_morning", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_e1", "value": [event.id], "label": "Anatomy"}]}, + ) + other = _make_field( + db, form, order=2, field_key="event_preference_afternoon", question_type="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_e1", "value": [event.id], "label": "Anatomy"}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + client.post(f"/forms/{form.id}/responses/", json={"answers": [ + {"field_id": target.id, "value": ["opt_e1"]}, + {"field_id": other.id, "value": ["opt_e1"]}, + ]}) + membership_id = self._membership_id(db, td_user, td_tournament) + assert db.query(TournamentMembershipEventPreference).filter( + TournamentMembershipEventPreference.membership_id == membership_id + ).count() == 2 + + res = client.delete(f"/forms/{form.id}/fields/{target.id}/") + assert res.status_code == 204 + + rows = db.query(TournamentMembershipEventPreference).filter( + TournamentMembershipEventPreference.membership_id == membership_id + ).all() + assert [row.key for row in rows] == ["afternoon"] + + def _membership_id(self, db, user, tournament): + return ( + db.query(TournamentMembership) + .filter(TournamentMembership.user_id == user.id, TournamentMembership.tournament_id == tournament.id) + .first() + .id + ) + def test_availability_write_through_is_left_alone(self, client, db, td_user, td_tournament): """Availability rows are shared with whatever else covers that day, so this field's contribution can't be separated out after the fact.""" From 639d6cb996e959802706bb62e4a88d9c2e3da793 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 22:50:27 -0700 Subject: [PATCH 85/92] feat(memberships): expose grouped event preferences on membership reads --- .../app/api/routes/tournament/memberships.py | 3 +- backend/app/models/models.py | 4 +- backend/app/schemas/tournament/membership.py | 57 +++++++++++ backend/tests/api/tournament/test_events.py | 95 +++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/tournament/memberships.py b/backend/app/api/routes/tournament/memberships.py index ccb03b34..30153bc3 100644 --- a/backend/app/api/routes/tournament/memberships.py +++ b/backend/app/api/routes/tournament/memberships.py @@ -17,7 +17,7 @@ User, ) from app.schemas.tournament.membership import ( - MembershipCoordinatorUpdate, MembershipFullResponse, MembershipMeResponse, + MembershipCoordinatorUpdate, MembershipEventPreferenceRead, MembershipFullResponse, MembershipMeResponse, MembershipMeUpdate, MembershipSlimResponse, ) from app.schemas.tournament.track import MembershipTrackStatusRead @@ -175,6 +175,7 @@ def get_my_membership( membership_id=membership.id, is_owner=is_owner, roles=membership.roles, permissions=permissions, track_statuses=[MembershipTrackStatusRead.from_row(row) for row in membership.track_statuses], + event_preferences=MembershipEventPreferenceRead.group_rows(membership.event_preferences), ) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 6747eff0..271cdb72 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1059,7 +1059,9 @@ class TournamentMembershipEventPreference(Base): created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False) membership = relationship("TournamentMembership", back_populates="event_preferences") - tournament_event = relationship("TournamentEvent") + # lazy="joined": every read of a preference row wants the event's name/ + # division to render it, same reasoning as TrackStatus.track. + tournament_event = relationship("TournamentEvent", lazy="joined") __table_args__ = ( UniqueConstraint("membership_id", "key", "tournament_event_id", name="uq_membership_event_preference"), diff --git a/backend/app/schemas/tournament/membership.py b/backend/app/schemas/tournament/membership.py index 6ac53f15..44c5f8c2 100644 --- a/backend/app/schemas/tournament/membership.py +++ b/backend/app/schemas/tournament/membership.py @@ -44,6 +44,52 @@ class MembershipJoinCodeInfo(BaseModel): model_config = {"from_attributes": True} +class MembershipEventPreferenceEventRead(BaseModel): + """One event within a preference group — same {id, name, division} shape + resolve_field_options gives an event_preference option's events, so a + renderer can reuse the same event-display component either way.""" + id: int + name: str | None = None + division: str | None = None + rank: int | None = None + + +class MembershipEventPreferenceRead(BaseModel): + """One event_preference_{suffix} question's answer, grouped by key with + its events resolved. Each suffix is its own independent axis — see + form-question-types-reference.md — so this is a list, not a single + preference.""" + key: str + events: list[MembershipEventPreferenceEventRead] + + @classmethod + def group_rows(cls, rows) -> list["MembershipEventPreferenceRead"]: + """Groups flat TournamentMembershipEventPreference rows (one per + event) into one entry per key, ordered by key then by rank (nulls + last) then event id within each key.""" + by_key: dict[str, list] = {} + for row in rows: + by_key.setdefault(row.key, []).append(row) + return [ + cls( + key=key, + events=[ + MembershipEventPreferenceEventRead( + id=row.tournament_event_id, + name=row.tournament_event.name, + division=row.tournament_event.division, + rank=row.rank, + ) + for row in sorted( + by_key[key], + key=lambda r: (r.rank is None, r.rank or 0, r.tournament_event_id), + ) + ], + ) + for key in sorted(by_key) + ] + + class _MembershipRolesMixin(BaseModel): """Shared roles handling for response schemas. @@ -86,6 +132,7 @@ class MembershipMeResponse(_MembershipRolesMixin): # Their own per-track statuses — readable without manage_members, unlike # the tournament-wide roster. track_statuses: list[MembershipTrackStatusRead] = [] + event_preferences: list[MembershipEventPreferenceRead] = [] class MembershipFullResponse(_MembershipRolesMixin): @@ -103,6 +150,7 @@ class MembershipFullResponse(_MembershipRolesMixin): updated_at: datetime track_statuses: list[MembershipTrackStatusRead] = [] + event_preferences: list[MembershipEventPreferenceRead] = [] user: UserFullResponse @@ -116,3 +164,12 @@ def _flatten_track_statuses(cls, v): if v and hasattr(v[0], "track"): return [MembershipTrackStatusRead.from_row(row) for row in v] return v + + # Same treatment for event preferences — the flat per-event rows need + # grouping by key before they match this schema's shape. + @field_validator("event_preferences", mode="before") + @classmethod + def _group_event_preferences(cls, v): + if v and hasattr(v[0], "tournament_event_id"): + return MembershipEventPreferenceRead.group_rows(v) + return v diff --git a/backend/tests/api/tournament/test_events.py b/backend/tests/api/tournament/test_events.py index 1b495181..51b804da 100644 --- a/backend/tests/api/tournament/test_events.py +++ b/backend/tests/api/tournament/test_events.py @@ -296,3 +296,98 @@ def test_delete_event(client, td_user, td_tournament): def test_delete_event_not_found(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") assert client.delete(f"/tournaments/{td_tournament.id}/events/9999/").status_code == 404 + + +# --------------------------------------------------------------------------- +# Membership event preferences — grouped read on memberships/me/ and +# memberships/{id}/. See app/schemas/tournament/membership.py's +# MembershipEventPreferenceRead. +# --------------------------------------------------------------------------- + +def _td_membership(db, td_user, td_tournament): + from app.models.models import TournamentMembership + return ( + db.query(TournamentMembership) + .filter( + TournamentMembership.user_id == td_user.id, + TournamentMembership.tournament_id == td_tournament.id, + ) + .one() + ) + + +def _add_preference(db, membership_id, key, event_id, rank): + from app.models.models import TournamentMembershipEventPreference + db.add(TournamentMembershipEventPreference( + membership_id=membership_id, key=key, tournament_event_id=event_id, rank=rank, + )) + db.commit() + + +def test_member_reads_their_own_event_preferences_grouped(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + e1 = _make_event(client, td_tournament.id, name="Anatomy").json() + e2 = _make_event(client, td_tournament.id, name="Astronomy").json() + membership = _td_membership(db, td_user, td_tournament) + # Ranked entries deliberately out of order to check the response sorts. + _add_preference(db, membership.id, "morning", e2["id"], 2) + _add_preference(db, membership.id, "morning", e1["id"], 1) + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/me/") + assert res.status_code == 200 + assert res.json()["event_preferences"] == [{ + "key": "morning", + "events": [ + {"id": e1["id"], "name": "Anatomy", "division": "C", "rank": 1}, + {"id": e2["id"], "name": "Astronomy", "division": "C", "rank": 2}, + ], + }] + +def test_member_event_preferences_unranked_ordered_by_event_id(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + e1 = _make_event(client, td_tournament.id, name="Anatomy").json() + e2 = _make_event(client, td_tournament.id, name="Astronomy").json() + membership = _td_membership(db, td_user, td_tournament) + # Inserted in reverse id order — checkbox rows carry no rank, so they + # must fall back to ordering by event id. + _add_preference(db, membership.id, "afternoon", e2["id"], None) + _add_preference(db, membership.id, "afternoon", e1["id"], None) + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/me/") + assert res.status_code == 200 + ids = [e["id"] for e in res.json()["event_preferences"][0]["events"]] + assert ids == sorted([e1["id"], e2["id"]]) + +def test_member_event_preferences_grouped_by_key_sorted(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id).json() + membership = _td_membership(db, td_user, td_tournament) + _add_preference(db, membership.id, "morning", event["id"], 1) + _add_preference(db, membership.id, "afternoon", event["id"], 1) + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/me/") + assert res.status_code == 200 + assert [g["key"] for g in res.json()["event_preferences"]] == ["afternoon", "morning"] + +def test_member_detail_carries_event_preferences(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, name="Anatomy").json() + membership = _td_membership(db, td_user, td_tournament) + _add_preference(db, membership.id, "morning", event["id"], 1) + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/{membership.id}/") + assert res.status_code == 200 + assert res.json()["event_preferences"] == [{ + "key": "morning", "events": [{"id": event["id"], "name": "Anatomy", "division": "C", "rank": 1}], + }] + +def test_member_slim_response_has_no_event_preferences(client, db, td_user, td_tournament): + """Roster/search stays unchanged — the grouped shape is a full-response-only field.""" + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id).json() + membership = _td_membership(db, td_user, td_tournament) + _add_preference(db, membership.id, "morning", event["id"], 1) + + res = client.get(f"/tournaments/{td_tournament.id}/memberships/") + assert res.status_code == 200 + assert "event_preferences" not in res.json()[0] From 03250fcfdc1bd1a3023c997aefb6f71955eafde1 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Fri, 28 Aug 2026 22:53:13 -0700 Subject: [PATCH 86/92] docs(forms): document event_preference write-through and allow_duplicates enforcement --- .gitignore | 3 ++- backend/form-edit-lifecycle.md | 9 ++++++--- backend/form-question-types-reference.md | 10 +++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index c1882349..7a400548 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ CLAUDE_OUTPUT_BEHAVIOR.md # Misc password.txt docs/ -CLAUDE.md \ No newline at end of file +CLAUDE.md +TASK.md \ No newline at end of file diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md index 22671810..a1b0b7b3 100644 --- a/backend/form-edit-lifecycle.md +++ b/backend/form-edit-lifecycle.md @@ -91,7 +91,7 @@ same edit is cosmetic and raises nothing. | Change | `reason` | Default | Why it's a judgment call | |---|---|---|---| -| `field_key` moves between preset and standard | `key_changed` | **on** | Nothing changed for the respondent — the labels can be identical, and their answer is still correct. But write-through is forward-only, so their data won't reach `MembershipAvailability` / `TournamentMembershipLunch` / track statuses unless they resubmit. The TD is deciding whether they need that data for people who already answered. | +| `field_key` moves between preset and standard | `key_changed` | **on** | Nothing changed for the respondent — the labels can be identical, and their answer is still correct. But write-through is forward-only, so their data won't reach `MembershipAvailability` / `TournamentMembershipLunch` / `TournamentMembershipEventPreference` / track statuses unless they resubmit. The TD is deciding whether they need that data for people who already answered. | | Question label | `text_changed` | off | Rewording may or may not change what's being asked. | | Question description | `text_changed` | off | Same. | | Option label (respondent-facing text) | `text_changed` | off | Same. | @@ -277,14 +277,17 @@ Two consequences: does not remove what it previously wrote to `MembershipAvailability` or track statuses. Those tables are shared — multiple questions, across multiple forms, contribute to the same rows, so no single field owns any of them and none can -be safely withdrawn. Lunch is the exception: keyed by (membership, category), -it has a single owner and can be deleted. +be safely withdrawn. Lunch and event preference are the exception: each is +keyed by a value only one field can ever produce — (membership, category) for +lunch, (membership, suffix) for event preference — so each has a single owner +and can be deleted. Cleanup on **Invalidate**: | Target | Rule | |---|---| | `TournamentMembershipLunch` | keyed by (membership, category) — delete the field's rows | +| `TournamentMembershipEventPreference` | keyed by (membership, suffix) — delete the field's rows | | `MembershipAvailability` | **never deleted.** Another question may cover the same day, and the invalidated field's own contribution can't be separated from theirs after the fact. | | Track statuses | **never deleted.** A track's state may have been set by a later form; removing this field's contribution can't be done without replay. | diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index b36d1612..8d2ac82e 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -110,13 +110,15 @@ Rank a fixed number of options in order of preference. Answer value: dict of rank → option `option_id`, e.g. `{"1": "a1b2c3d4e5", "2": "f6e5d4c3b2"}` — stored as rank → `{option_id, value, label}` snapshot. Branching: not supported. +`allow_duplicates: false` is enforced at submission/patch time, not just advisory for the picker UI: an answer that selects the same `option_id` at more than one rank is rejected with a 400 (`duplicate_ranked_choice_field_keys`). `allow_duplicates: true` allows it. + **Reserved-key note (`event_preference`):** allowed on this type, `multi_select_checkbox`, or `single_select_dropdown`. An option's stored `value` may be `list[int]` — one or more real `TournamentEvent` ids grouped under a single label (the same grouping pattern as availability's shift ids), auto-loadable from the tournament's event catalog. `GET`-rendering resolves `value` in place into one `{id, name, division}` entry per event, ordered by id (`resolve_field_options`'s event_preference branch) — same "reuse `value`, one entry per grouped entity" treatment as availability: ```json { "option_id": "a1b2c3d4e5", "label": "Life Science", "value": [{ "id": 5, "name": "Anatomy and Physiology", "division": "B" }, { "id": 9, "name": "Disease Detectives", "division": "C" }] } ``` -A `value` that's still a plain string (a single legacy id) passes through unresolved — strict validation that every `event_preference` option's ids are real `TournamentEvent`s isn't built yet, unlike `availability`'s strict shift-id check. +Strictly validated (`validate_event_preference_options`): every id must be a non-empty `list[int]` of real `TournamentEvent`s belonging to the field's own tournament, and no event id may appear in more than one option on the same field. On this question type specifically, `allow_duplicates` must be `false` — ranking the same event at two ranks is never meaningful, and options are already guaranteed mutually exclusive by event, so the general `allow_duplicates` answer-time check above is what actually blocks a repeat. The builder hides the "Allow duplicate ranks" toggle for an `event_preference` ranked-choice field rather than showing a control that can never be turned on. ## `short_text` / `long_text` Free text — `short_text` single line, `long_text` multi-line. @@ -144,7 +146,7 @@ Only `single_select_radio` and `single_select_dropdown` options may carry branch |---|---|---| | `availability_{date}` — e.g. `availability_20260315` (`^availability_\d{8}$`), one per date; a bare `availability` (no date) is **not** a valid reserved key | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipAvailability` (tournament-owned forms only); selected option_id(s) across **every** active `availability_*` field on the response are expanded into their grouped `TournamentShift` ids, unioned, and diffed as one set — every date's question feeds the same centralized "shifts this member is available for" pool, not a per-date table. With `track_status_enabled: true` the option's shift ids move under a `shift_ids` key and the field additionally writes track statuses — read them through `option_shift_ids` / `option_track_assignments` rather than off `value`, whose shape is only interpretable alongside `field_key`. | | `lunch_{date}_{category}` — e.g. `lunch_20270213_protein` (`^lunch_\d{8}_[a-z0-9_]+$`), one per (date, category) pair | `single_select_radio` or `multi_select_checkbox` | `TournamentMembershipLunch` (tournament-owned forms only); selected option_id(s) resolve to their stored `value`/`label`, no catalog table — stores whatever option was selected, keyed by category string | -| `event_preference_{suffix}` — e.g. `event_preference_morning` (`^event_preference_[a-z0-9_]+$`), one per independently-ranked axis; a bare `event_preference` (no suffix) is **not** a valid reserved key | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | none — generic `FormAnswer`, same as any custom question (option `value` may be `list[int]` of real `TournamentEvent` ids, resolved on render; not yet strictly validated against real events). Unlike `availability`, different suffixes are **not** merged into one pool — each suffix is read as its own axis by querying `FormAnswer` directly wherever event preferences are needed downstream, rather than being synced into a dedicated structural table. `TournamentMembership` once carried manual-entry `event_preference` / `role_preference` / `availability` / `lunch_order` / `extra_data` columns; those are gone, as is `status` — per-track participation now lives in `TournamentMembershipTrackStatus`. | +| `event_preference_{suffix}` — e.g. `event_preference_morning` (`^event_preference_[a-z0-9_]+$`), one per independently-ranked axis; a bare `event_preference` (no suffix) is **not** a valid reserved key | `ranked_choice`, `multi_select_checkbox`, or `single_select_dropdown` | `TournamentMembershipEventPreference` (tournament-owned forms only), one row per (membership, key, event) — the suffix is one field's exclusive key, so unlike `availability` there's no cross-field union: each field's answer diff-replaces only its own key's rows (`sync_event_preferences`). Selected option(s) expand into `{tournament_event_id, rank}` rows: `ranked_choice` writes each option's rank; `single_select_dropdown` writes rank `1`; `multi_select_checkbox` writes rank `null`. Options are validated mutually exclusive by event and `allow_duplicates` is required `false` on `ranked_choice` (see above), so `(membership_id, key, tournament_event_id)` is a plain unique constraint — no rank in the key. `TournamentMembership` once carried manual-entry `event_preference` / `role_preference` / `availability` / `lunch_order` / `extra_data` columns; those are gone, as is `status` — per-track participation now lives in `TournamentMembershipTrackStatus`. | | `track_status_{suffix}` — e.g. `track_status_volunteer_interest` (`^track_status_[a-z0-9_]+$`), one per independently named status question | `single_select_radio` or `multi_select_checkbox`, and `required` **must** be `true` | `TournamentMembershipTrackStatus` (tournament-owned forms only), one row per (membership, track); each option's `value` is the list of track assignments it applies (shape below). An `availability_*` field may carry assignments too, but only with `track_status_enabled: true` — that field then writes to **both** targets, its shifts and its statuses. **Upsert-only, never deleted**, and guarded by a transition rule (a track never falls back to `interested`); where two fields name one track, later document order wins. Checkbox options may repeat a track only when they assign it the same status. See `form-edit-lifecycle.md`'s "Track status ordering". | | any TD-typed slug | any type | none — generic `FormAnswer` | @@ -183,7 +185,9 @@ non-empty. ``` **3. `event_preference_{suffix}`.** `value` is the `TournamentEvent` ids -grouped under one label. Not yet strictly validated against real events. +grouped under one label, strictly validated: every id must be real and belong +to the field's own tournament, and no event may appear in more than one +option. On `ranked_choice`, `allow_duplicates` must be `false`. ```json { "required": true, "ranks": 3, "allow_duplicates": false, "options": [ From 5647dfb260a7880a59bc38c3e98a486d90c930fd Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sun, 30 Aug 2026 16:38:28 -0700 Subject: [PATCH 87/92] fix(dev): drop non-ascii checkmarks from seed prints so startup survives cp1252 consoles --- backend/app/db/init_db.py | 8 ++++---- backend/app/db/seed_canon_events.py | 2 +- backend/app/db/seed_universities.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 6f7cb38d..0675fdb8 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -42,7 +42,7 @@ def init_db() -> None: cfg = Config(str(BACKEND_ROOT / "alembic.ini")) cfg.set_main_option("script_location", str(BACKEND_ROOT / "alembic")) command.upgrade(cfg, "head") - print("✓ Database migrated to head.") + print("Database migrated to head.") def seed_dev_data(db: Session) -> None: @@ -60,7 +60,7 @@ def seed_dev_data(db: Session) -> None: # Skip if already seeded if db.query(User).filter(User.email == "admin@nexus.dev").first(): - print("✓ Dev seed already exists, skipping.") + print("Dev seed already exists, skipping.") return # Admin account — full site-wide access, bypasses all tournament checks. @@ -90,8 +90,8 @@ def seed_dev_data(db: Session) -> None: db.commit() - print("✓ Seeded: admin@nexus.dev / admin1234 (role=admin)") - print("✓ Seeded: user1@nexus.dev .. user15@nexus.dev / user1234 (role=user)") + print("Seeded: admin@nexus.dev / admin1234 (role=admin)") + print("Seeded: user1@nexus.dev .. user15@nexus.dev / user1234 (role=user)") if __name__ == "__main__": diff --git a/backend/app/db/seed_canon_events.py b/backend/app/db/seed_canon_events.py index f4b37e6d..3610e854 100644 --- a/backend/app/db/seed_canon_events.py +++ b/backend/app/db/seed_canon_events.py @@ -232,7 +232,7 @@ def seed_events_and_categories(db: Session) -> None: db.execute(stmt) db.commit() - print(f"✓ Seeded/verified {len(CATEGORIES)} categories and {len(EVENTS)} events.") + print(f"Seeded/verified {len(CATEGORIES)} categories and {len(EVENTS)} events.") if __name__ == "__main__": diff --git a/backend/app/db/seed_universities.py b/backend/app/db/seed_universities.py index 27253f60..029ab152 100644 --- a/backend/app/db/seed_universities.py +++ b/backend/app/db/seed_universities.py @@ -47,7 +47,7 @@ def seed_universities(db: Session) -> None: db.execute(stmt) db.commit() - print(f"✓ Seeded/verified {len(UNIVERSITIES)} universities.") + print(f"Seeded/verified {len(UNIVERSITIES)} universities.") if __name__ == "__main__": From 0c9d4d3ab930f4479b39c201bb424b246d0e09d8 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sun, 30 Aug 2026 16:38:53 -0700 Subject: [PATCH 88/92] feat(dev): give each git worktree its own database via post-checkout hook --- .gitattributes | 5 +++ .githooks/post-checkout | 12 +++++++ scripts/setup-worktree-db.sh | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 .gitattributes create mode 100755 .githooks/post-checkout create mode 100755 scripts/setup-worktree-db.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..09aa19f1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell scripts must keep LF endings. Git's autocrlf would otherwise check them +# out with CRLF on Windows, and bash fails on the shebang: +# bad interpreter: /usr/bin/env bash^M +*.sh text eol=lf +.githooks/* text eol=lf diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 00000000..a2cd003c --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Runs after `git worktree add` and after every branch checkout. +# +# $3 is 1 for a branch checkout, 0 for a file checkout (`git checkout -- path`). +# Only the former can land us in a new worktree. +[[ ${3:-0} == 1 ]] || exit 0 + +root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +[[ -f "$root/scripts/setup-worktree-db.sh" ]] || exit 0 + +# Never fail a checkout over dev-environment setup. +bash "$root/scripts/setup-worktree-db.sh" || true diff --git a/scripts/setup-worktree-db.sh b/scripts/setup-worktree-db.sh new file mode 100755 index 00000000..327fc80e --- /dev/null +++ b/scripts/setup-worktree-db.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Give this worktree its own Postgres database. +# +# Every worktree points at the same Postgres container on 127.0.0.1:5432, so +# sharing one database means whichever worktree migrates last re-stamps +# alembic_version and breaks the others ("Can't locate revision identified +# by ..."). One database per worktree keeps each branch's migration lineage +# independent. +# +# Idempotent — safe to run repeatedly. Invoked automatically by +# .githooks/post-checkout; run by hand any time with: +# bash scripts/setup-worktree-db.sh +set -euo pipefail + +PGUSER=nexus +PGPASSWORD=nexus +PGPORT=5432 + +repo_root=$(git rev-parse --show-toplevel) +worktree_name=$(basename "$repo_root") + +# nexus -> nexus, nexus-member-profiles -> nexus_member_profiles +db_name=$(printf '%s' "$worktree_name" | tr '[:upper:]-' '[:lower:]_' | tr -cd 'a-z0-9_') +[[ $db_name == nexus* ]] || db_name="nexus_${db_name}" + +env_file="$repo_root/backend/.env" +db_url="postgresql://${PGUSER}:${PGPASSWORD}@127.0.0.1:${PGPORT}/${db_name}" + +# Already pointed at the right database? Nothing to do. This keeps the hook +# cheap on ordinary branch checkouts, which also fire post-checkout. +if [[ -f $env_file ]] && grep -qx "DATABASE_URL=${db_url}" "$env_file"; then + exit 0 +fi + +# Find the running postgres container by image rather than name — the compose +# project name is derived from the directory, so it differs per worktree. +container=$(docker ps --filter ancestor=postgres:16 --format '{{.Names}}' | head -n1) +if [[ -z $container ]]; then + echo "worktree-db: no postgres:16 container running; start it with" >&2 + echo " docker compose -f backend/docker-compose.yaml up -d" >&2 + echo "worktree-db: then re-run: bash scripts/setup-worktree-db.sh" >&2 + exit 0 # don't fail the checkout +fi + +# CREATE DATABASE can't run inside a transaction, so this can't be an +# idempotent DO block — check for existence first instead. +exists=$(docker exec "$container" psql -U "$PGUSER" -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='${db_name}'") +if [[ $exists != 1 ]]; then + docker exec "$container" psql -U "$PGUSER" -d postgres \ + -c "CREATE DATABASE \"${db_name}\" OWNER \"${PGUSER}\"" >/dev/null + echo "worktree-db: created database ${db_name}" +fi + +if [[ ! -f $env_file ]]; then + cp "$repo_root/backend/.env.example" "$env_file" + echo "worktree-db: seeded backend/.env from .env.example" +fi + +# Replace only the DATABASE_URL line so the rest of .env survives. +if grep -q '^DATABASE_URL=' "$env_file"; then + tmp=$(mktemp) + sed "s|^DATABASE_URL=.*|DATABASE_URL=${db_url}|" "$env_file" >"$tmp" + mv "$tmp" "$env_file" +else + printf '\nDATABASE_URL=%s\n' "$db_url" >>"$env_file" +fi + +echo "worktree-db: ${worktree_name} -> ${db_name}" +echo "worktree-db: schema is applied on app startup (init_db runs alembic upgrade head)" From 0345bc9c3841c547f06b61fdc46598d20fdc55c4 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sun, 30 Aug 2026 16:38:58 -0700 Subject: [PATCH 89/92] fix(dev): make SQL echo opt-in and stop alembic fileConfig disabling uvicorn loggers --- backend/alembic/env.py | 7 +++++-- backend/app/core/config.py | 4 ++++ backend/app/db/session.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 56ea157e..76d632c0 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -20,9 +20,12 @@ # Alembic Config object — provides access to alembic.ini values config = context.config -# Wire up Python logging from alembic.ini +# Wire up Python logging from alembic.ini. +# disable_existing_loggers must be False: init_db() runs this inside the app's +# startup lifespan, and the default (True) would disable every logger not named +# in alembic.ini — including uvicorn.access, silencing the request log. if config.config_file_name is not None: - fileConfig(config.config_file_name) + fileConfig(config.config_file_name, disable_existing_loggers=False) # Override sqlalchemy.url with value from .env config.set_main_option("sqlalchemy.url", settings.database_url) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 52f02921..6cb3f2c5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -12,6 +12,10 @@ class Settings(BaseSettings): database_url: str = "postgresql://nexus:nexus@127.0.0.1:5432/nexus" + # Echo every SQL statement. Off by default — the seed INSERTs alone bury + # the request log at startup. Set SQL_ECHO=true in .env when debugging queries. + sql_echo: bool = False + google_service_account_file: str = "./credentials.json" google_service_account_json: str = "" # JSON string — used in production instead of file diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 3596351a..80bb5f03 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -16,7 +16,7 @@ engine = create_engine( settings.database_url, connect_args=connect_args, - echo=(settings.app_env == "development"), # Log SQL in dev + echo=settings.sql_echo, # opt-in via SQL_ECHO=true pool_pre_ping=True, # Railway's proxy drops idle connections; ping before reuse pool_recycle=300, # recycle before Railway's idle timeout kills the socket ) From 2d8f0e3aa5dbbd2bfefabbb9e4b8f4057433a622 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sun, 30 Aug 2026 16:39:01 -0700 Subject: [PATCH 90/92] chore: ignore .claude/settings.local.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7a400548..61b17586 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Claude CLAUDE_OUTPUT_BEHAVIOR.md +.claude/settings.local.json # Misc password.txt From 19055d67c48a5c125f036032bb7d43bf4ee84a6f Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sat, 29 Aug 2026 00:30:27 -0700 Subject: [PATCH 91/92] fix(onboarding): require every profile field except pronouns --- backend/app/api/routes/users.py | 2 +- backend/app/core/profile_status.py | 13 ++++++------- backend/tests/core/test_profile_status.py | 23 ++++++++++------------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 25c22ebb..167a5dfa 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -137,7 +137,7 @@ def get_me( response = UserMeSlimResponse.model_validate(current_user) response.is_profile_complete = is_profile_complete(current_user, db=db) - response.is_onboarding_complete = is_onboarding_complete(current_user) + response.is_onboarding_complete = is_onboarding_complete(current_user, db=db) return response diff --git a/backend/app/core/profile_status.py b/backend/app/core/profile_status.py index e4264ee0..421eb987 100644 --- a/backend/app/core/profile_status.py +++ b/backend/app/core/profile_status.py @@ -57,10 +57,9 @@ def is_profile_complete(user: User, *, db: Optional[Session] = None) -> bool: return not compute_missing_profile_fields(user, db=db) -ONBOARDING_REQUIRED = ["first_name", "last_name", "phone", "date_of_birth"] - -def compute_missing_onboarding_fields(user: User) -> list[str]: - return [f for f in ONBOARDING_REQUIRED if not getattr(user, f)] - -def is_onboarding_complete(user: User) -> bool: - return not compute_missing_onboarding_fields(user) \ No newline at end of file +def is_onboarding_complete(user: User, *, db: Optional[Session] = None) -> bool: + """Onboarding is complete once every profile field is filled except + pronouns — which is nullable and never in compute_missing_profile_fields' + output. Delegates rather than keeping a second required-field list, since + two lists drift the first time a column is added.""" + return not compute_missing_profile_fields(user, db=db) \ No newline at end of file diff --git a/backend/tests/core/test_profile_status.py b/backend/tests/core/test_profile_status.py index 6e7190d4..4f9548d1 100644 --- a/backend/tests/core/test_profile_status.py +++ b/backend/tests/core/test_profile_status.py @@ -11,7 +11,6 @@ import pytest from app.core.profile_status import ( - compute_missing_onboarding_fields, compute_missing_profile_fields, is_onboarding_complete, is_profile_complete, @@ -161,20 +160,18 @@ def test_without_db_uses_relationship_volunteer(user_factory, db): # --------------------------------------------------------------------------- -# Onboarding — a much smaller gate than full profile completeness +# Onboarding — delegates to compute_missing_profile_fields; pronouns is the +# only field that can be blank and still count as onboarded. # --------------------------------------------------------------------------- -def test_onboarding_complete_ignores_profile_only_fields(user_factory, db): - """Onboarding asks for name/phone/DOB only, so a user missing shirt_size is - onboarded but not profile-complete.""" - user = user_factory(shirt_size=None) - assert compute_missing_onboarding_fields(user) == [] - assert is_onboarding_complete(user) is True - assert is_profile_complete(user, db=db) is False +def test_onboarding_complete_ignores_pronouns(user_factory, db): + user = user_factory(pronouns=None) + assert is_onboarding_complete(user, db=db) is True -@pytest.mark.parametrize("field", ["first_name", "last_name", "phone", "date_of_birth"]) -def test_onboarding_reports_each_required_field(user_factory, field): +@pytest.mark.parametrize( + "field", ["first_name", "last_name", "phone", "date_of_birth", "shirt_size", "dietary_restriction"] +) +def test_onboarding_reports_each_required_field(user_factory, db, field): user = user_factory(**{field: None}) - assert compute_missing_onboarding_fields(user) == [field] - assert is_onboarding_complete(user) is False + assert is_onboarding_complete(user, db=db) is False From 5cb197a901531bc22191e57ac9d698f37ff406e5 Mon Sep 17 00:00:00 2001 From: Ethan Shih Date: Sat, 29 Aug 2026 00:39:22 -0700 Subject: [PATCH 92/92] fix(onboarding): require every newly-mandatory profile field, no skip loopholes --- frontend/app/onboarding/page.tsx | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx index 5fe0278c..239b8ca0 100644 --- a/frontend/app/onboarding/page.tsx +++ b/frontend/app/onboarding/page.tsx @@ -430,7 +430,6 @@ function OnboardingContent() { setState(STATE.STUDENT_STATUS + 3)} isActive={state === STATE.STUDENT_STATUS} > { - setProfileData((d) => ({ ...d, university_id: undefined, university_name: undefined, major: undefined, year_level: undefined, graduation_year: undefined })); - setState(STATE.UNIVERSITY + 2); - }} onNext={() => { const ers: typeof errors = {}; @@ -514,7 +509,6 @@ function OnboardingContent() { {state >= STATE.EMPLOYER && profileData.student_status === "Non-Student" && ( setState(STATE.COMPETED_BEFORE)} onNext={() => { !profileData.employer ? setErrors((er) => ({ ...er, employer: "Cannot be empty." })) : setState(STATE.COMPETED_BEFORE); }} @@ -537,7 +531,6 @@ function OnboardingContent() { setState(STATE.COMPETED_BEFORE + 2)} onNext={competitionLocked ? () => setState(STATE.VOLUNTEERED_BEFORE) : undefined} isActive={state === STATE.COMPETED_BEFORE} > @@ -563,7 +556,7 @@ function OnboardingContent() { { - setProfileData((d) => ({ ...d, has_competition_experience: undefined })); + setProfileData((d) => ({ ...d, has_competition_experience: false })); setCompetitionRows([]); setState(STATE.VOLUNTEERED_BEFORE); }} @@ -591,7 +584,6 @@ function OnboardingContent() { setState(STATE.SHIRT_SIZE)} onNext={volunteerLocked ? () => setState(STATE.SHIRT_SIZE) : undefined} isActive={state === STATE.VOLUNTEERED_BEFORE} > @@ -617,7 +609,7 @@ function OnboardingContent() { { - setProfileData((d) => ({ ...d, has_volunteer_experience: undefined })); + setProfileData((d) => ({ ...d, has_volunteer_experience: false })); setVolunteerRows([]); setState(STATE.SHIRT_SIZE); }} @@ -645,10 +637,6 @@ function OnboardingContent() { { - setProfileData((d) => ({ ...d, shirt_size: undefined })); - setState(STATE.DIETARY_RESTRICTIONS); - }} isActive={state === STATE.SHIRT_SIZE} > = STATE.DIETARY_RESTRICTIONS && ( setState(STATE.COMPLETE)} isActive={state === STATE.DIETARY_RESTRICTIONS} > = STATE.DIETARY_TEXT && hasDietary && ( { - setHasDietary(null); - setState(STATE.COMPLETE); - }} onNext={() => { !profileData.dietary_restriction ? setErrors((er) => ({ ...er, dietary_restriction: "Cannot be empty." })) : setState(STATE.COMPLETE); @@ -709,7 +692,7 @@ function OnboardingContent() { type="submit" variant="primary" size="lg" - disabled={state < STATE.DATE_OF_BIRTH} + disabled={state < STATE.COMPLETE} loading={loading} fullWidth >