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/.gitignore b/.gitignore index c1882349..61b17586 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ # Claude CLAUDE_OUTPUT_BEHAVIOR.md +.claude/settings.local.json # 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/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/alembic/versions/066d0807d514_add_tournament_membership_track_statuses.py b/backend/alembic/versions/066d0807d514_add_tournament_membership_track_statuses.py new file mode 100644 index 00000000..5d9ca59e --- /dev/null +++ b/backend/alembic/versions/066d0807d514_add_tournament_membership_track_statuses.py @@ -0,0 +1,58 @@ +"""add tournament membership track statuses + +Revision ID: 066d0807d514 +Revises: d8c1e4c9b52d +Create Date: 2026-08-28 14:16:15.320805 + +Write-through target for track_status_* answers (and opted-in availability_* +answers). One row per (membership, track). + +Autogenerate also picked up pre-existing drift between the models and the dev +database — a dropped ix_forms_id index and a batch of created_at/updated_at +nullability flips on unrelated tables. Those are not this change and were +stripped out; they need their own revision if they're real. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '066d0807d514' +down_revision: Union[str, None] = 'd8c1e4c9b52d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'tournament_membership_track_statuses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('membership_id', sa.Integer(), nullable=False), + sa.Column('track_id', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('source_response_id', sa.String(length=12), nullable=True), + sa.Column('source_field_id', sa.String(length=12), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['membership_id'], ['tournament_memberships.id'], ondelete='CASCADE'), + # SET NULL, not CASCADE: invalidating the question that set a status + # must not delete the status. See backend/form-edit-lifecycle.md. + sa.ForeignKeyConstraint(['source_field_id'], ['form_fields.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['source_response_id'], ['form_responses.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['track_id'], ['tournament_tracks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('membership_id', 'track_id', name='uq_membership_track_status'), + ) + op.create_index( + op.f('ix_tournament_membership_track_statuses_id'), + 'tournament_membership_track_statuses', ['id'], unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f('ix_tournament_membership_track_statuses_id'), + table_name='tournament_membership_track_statuses', + ) + op.drop_table('tournament_membership_track_statuses') 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..d88e8428 --- /dev/null +++ b/backend/alembic/versions/8d55ec2b6640_tournament_forms_and_onboarded_at.py @@ -0,0 +1,83 @@ +"""tournament forms, onboarding, prerequisites, and tracks + +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. + +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, plus the +TournamentForm.prerequisites JSON configuration used by standard form +visibility rules and the tournament_tracks catalog used by later track fields. + +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('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('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'), + sa.PrimaryKeyConstraint('form_id') + ) + + op.add_column('tournament_memberships', sa.Column('onboarded_at', sa.DateTime(timezone=True), nullable=True)) + + op.create_table( + 'tournament_tracks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tournament_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('is_archived', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tournament_id', 'name', name='uq_tournament_track_name'), + ) + op.create_index(op.f('ix_tournament_tracks_id'), 'tournament_tracks', ['id'], unique=False) + + # 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 (form_id, tournament_id, is_onboarding, created_at) + SELECT id, tournament_id, false, now() + FROM forms + WHERE tournament_id IS NOT NULL + """ + ) + op.alter_column('tournament_forms', 'prerequisites', server_default=None) + + +def downgrade() -> None: + op.drop_index(op.f('ix_tournament_tracks_id'), table_name='tournament_tracks') + op.drop_table('tournament_tracks') + op.drop_column('tournament_memberships', 'onboarded_at') + op.drop_table('tournament_forms') 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/alembic/versions/a3f81c60e274_drop_membership_status.py b/backend/alembic/versions/a3f81c60e274_drop_membership_status.py new file mode 100644 index 00000000..af1b7f46 --- /dev/null +++ b/backend/alembic/versions/a3f81c60e274_drop_membership_status.py @@ -0,0 +1,41 @@ +"""drop membership status + +Revision ID: a3f81c60e274 +Revises: 066d0807d514 +Create Date: 2026-08-28 15:02:00.000000 + +TournamentMembership.status ("interested" | "confirmed") is superseded by +per-track statuses: one tournament-wide flag can't express a member who is +confirmed on one track and declined on another, which is what complex +tournaments actually need. See tournament_membership_track_statuses. + +The column gated nothing and was effectively write-only — set at join and at +tournament creation, then never moved (mark_confirmed, its only mutator, had +zero callers). Nothing to migrate into the new table: an "interested" here +never named a track, so there's no per-track fact to recover from it. + +Irreversible in practice — downgrade recreates the column with the old default +rather than the values it held. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a3f81c60e274' +down_revision: Union[str, None] = '066d0807d514' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column('tournament_memberships', 'status') + + +def downgrade() -> None: + op.add_column( + 'tournament_memberships', + sa.Column('status', sa.String(length=32), nullable=False, server_default='interested'), + ) + op.alter_column('tournament_memberships', 'status', server_default=None) 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') diff --git a/backend/app/api/routes/forms.py b/backend/app/api/routes/forms.py index 141d9dae..4c7739d0 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 @@ -6,28 +8,49 @@ 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, - flag_pending_updates_for_archived_options, - flag_pending_updates_for_field, + delete_pending_updates_for_field, + flag_pending_updates, resolve_field_options, + selected_option_ids, slugify, snapshot_answer_value, ) -from app.core.form.branching import missing_required_field_keys +from app.core.form import changes +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, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, LUNCH_FIELD_KEY_PATTERN, FormFieldValidationError, + availability_field_date, collect_active_field_errors, + option_shift_ids, + option_track_assignments, + track_status_enabled, validate_availability_options, + validate_event_preference_options, 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_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, ) -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 @@ -38,20 +61,26 @@ FormField, FormResponse, FormResponsePendingUpdate, + TournamentForm, TournamentMembership, + TournamentRole, + TournamentShift, User, utcnow, ) from app.schemas.chapter.membership import ChapterMemberResponse from app.schemas.form import ( BulkFieldsUpdate, + FieldChangeRead, FormCreate, FormFieldRead, FormListRead, + MemberFormRead, FormRead, FormResponseCreate, FormResponseRead, FormUpdate, + TournamentFormPrerequisitesUpdate, ) from app.schemas.tournament.membership import MembershipSlimResponse from app.schemas.user import UserSlimResponse @@ -90,6 +119,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 @@ -159,12 +195,70 @@ def list_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/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 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/", @@ -179,7 +273,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() ) @@ -247,9 +341,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 @@ -289,6 +454,94 @@ def get_form_for_rendering( return form +# --------------------------------------------------------------------------- +# POST /forms/{form_id}/fields/classify/ — dry run for the builder's save +# confirmation: given a proposed field list, which questions would ask +# previous responders to look again, and why. +# +# Nothing is written. This exists so the confirmation shows the server's own +# verdict rather than a second implementation of the rules living in the +# client — the modal's whole promise is "this is what saving will do", and a +# mirrored rule set can quietly under-report the moment the two drift. +# +# Empty on a form nobody has answered: there's no one to notify, so a save +# there never raises anything. +# --------------------------------------------------------------------------- +@router.post("/forms/{form_id}/fields/classify/", response_model=list[FieldChangeRead]) +def classify_field_changes( + payload: BulkFieldsUpdate, + db: Session = Depends(get_db), + form: Form = Depends(require_form_manage_access), +): + if not _is_history_preserving(db, form): + return [] + + existing_by_id = { + f.id: f for f in db.query(FormField).filter(FormField.form_id == form.id).all() + } + + result = [] + for entry in payload.fields: + field = existing_by_id.get(entry.id) if entry.id else None + # A new question has nobody to notify, and an unarchived one comes + # back exactly as it was left. + if field is None or field.is_archived: + continue + + reasons = changes.classify_field_change( + field, + new_question_type=entry.question_type, + # Same fallback as the save path: an omitted key means "leave it". + new_field_key=slugify(entry.field_key) if entry.field_key else field.field_key, + # Options the TD just added have no option_id yet; assigning here + # keeps the diff from tripping over a missing key. The ids differ + # from the ones the real save will mint, which doesn't matter — + # either way they're absent from the old config, so they read as + # added. + new_config=assign_option_ids(entry.config), + new_label=entry.label, + new_description=entry.description, + ) + if not reasons: + continue + + optional = reasons - changes.MANDATORY_REASONS + locked = bool(reasons & changes.MANDATORY_REASONS) + result.append(FieldChangeRead( + field_id=field.id, + label=entry.label, + reasons=sorted(reasons), + locked=locked, + notify_default=locked or any( + changes.OPTIONAL_REASON_DEFAULTS.get(reason, False) for reason in optional + ), + )) + return result + + +# --------------------------------------------------------------------------- +# 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. # --------------------------------------------------------------------------- @@ -298,12 +551,15 @@ 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 in {"draft", "archived"}: + _reject_if_onboarding(db, form) + if payload.status == "published": try: validate_form_for_publish(db, form) @@ -325,18 +581,18 @@ def update_form( # --------------------------------------------------------------------------- -# POST /forms/{form_id}/archive/ — soft delete via status="archived". -# Responses and fields are left in place. +# 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. # --------------------------------------------------------------------------- -@router.post("/forms/{form_id}/archive/", response_model=FormRead) -def archive_form( - db: Session = Depends(get_db), - form: Form = Depends(require_form_manage_access), -): - form.status = "archived" - db.commit() - db.refresh(form) - return form +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", + ) # --------------------------------------------------------------------------- @@ -356,6 +612,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() @@ -369,13 +626,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 @@ -388,17 +645,19 @@ def bulk_update_fields( db: Session = Depends(get_db), form: Form = Depends(require_form_manage_access), ): - is_published = form.status == "published" - - 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} + is_history_preserving = _is_history_preserving(db, form) + + # 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, @@ -419,7 +678,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 ) @@ -438,53 +701,77 @@ 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_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)) return normalized - pending_flags: list[tuple[str, str, str | None, list[str]]] = [] - # (field_key, reason, field_id_for_answer_lookup, 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: if entry.id is not None: - field = live_by_id[entry.id] - normalized_config = _validate_config(entry.question_type, entry.config, field.field_key) + 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 - - if type_changed and is_published: - 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("-", "_") - pending_flags.append((old_key, "field_replaced", field.id, [])) - - new_field = FormField( - form_id=form.id, - order=order, - label=entry.label, - description=entry.description, - question_type=entry.question_type, - field_key=old_key, - config=normalized_config, - is_archived=False, + # 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 + # 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) + + # 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 and not unarchiving: + 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, ) - db.add(new_field) - else: - if is_published: - 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)) - field.order = order - field.label = entry.label - field.description = entry.description - field.question_type = entry.question_type - field.config = normalized_config - flag_modified(field, "config") + reasons = changes.resolve_reasons(reasons, entry.notify_responders) + # 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, removed_option_ids)) + field.is_archived = False + 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) @@ -504,9 +791,11 @@ 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: + # 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.field_key, "field_replaced", field.id, [])) + delete_pending_updates_for_field(db, field.id) else: db.delete(field) @@ -517,11 +806,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_key, reason, field_id, archived_option_ids in pending_flags: - if reason == "field_replaced": - flag_pending_updates_for_field(db, field_id, field_key, "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 @@ -538,11 +824,89 @@ def _validate_config(question_type: str, config: dict | None, field_key: str) -> ) +def _is_history_preserving(db: Session, form: Form) -> bool: + """Whether edits must preserve what's already been answered. An + unpublish/restore makes a form editable as a draft again, but it must + never reopen the destructive draft-edit path once answers exist.""" + return form.status == "published" or ( + db.query(FormResponse.id).filter(FormResponse.form_id == form.id).first() is not None + ) + + +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]: + # 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() + ) + + +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. + + 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() + } + 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: + 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( @@ -551,21 +915,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, @@ -580,39 +944,133 @@ 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() + + _store_answers(db, response, {f.id: f for f in active_fields}, payload.answers) + + if form.owner_type == "tournament": + # 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) + 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: - 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)) - - # 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 + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No response to update — submit the form first", + ) + + 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_keys: - db.query(FormResponsePendingUpdate).filter( - FormResponsePendingUpdate.response_id == response.id, - FormResponsePendingUpdate.field_key.in_(answered_field_keys), - ).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 archived 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)}", + ) + + 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) + _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) + # 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. + # + # 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, + response, track_scope_field_ids=patched_ids, + ) db.commit() db.refresh(response) @@ -625,21 +1083,35 @@ 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 - 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.""" + """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 + 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. + + `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( @@ -655,21 +1127,42 @@ 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's `value` - # is the list of real TournamentShift ids it groups together - # (see validate_availability_options); expand and flatten - # before diffing, so overlapping shifts across multiple + # `selected` is the chosen option_id(s) — each option groups real + # TournamentShift ids (see validate_availability_options); expand + # and flatten before diffing, so overlapping shifts across multiple # selected options (within or across fields) naturally dedupe - # via set union. - options_by_id = {opt["option_id"]: opt for opt in (field.config or {}).get("options", [])} + # 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. for option_id in selected: - availability_shift_ids.update(options_by_id.get(option_id, {}).get("value") or []) + availability_shift_ids.update(option_shift_ids(options_by_id.get(option_id) 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): @@ -677,16 +1170,168 @@ 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 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( + db, + membership.id, + availability_shift_ids, + 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 +# 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 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( + 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) + + 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) + 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() - if any(AVAILABILITY_FIELD_KEY_PATTERN.match(field.field_key) for field in active_fields): - sync_availability(db, membership.id, list(availability_shift_ids)) + +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, []) + + +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, []) # --------------------------------------------------------------------------- diff --git a/backend/app/api/routes/join.py b/backend/app/api/routes/join.py index 35661a19..f542b992 100644 --- a/backend/app/api/routes/join.py +++ b/backend/app/api/routes/join.py @@ -101,8 +101,8 @@ def redeem_join_code( def _redeem_tournament_code(join_code: JoinCode, current_user: User, db: Session) -> JoinRedeemResponse: - """Creates a bare TournamentMembership with no roles, status='interested' — - staff assign roles afterward.""" + """Creates a bare TournamentMembership with no roles — staff assign roles + afterward, and per-track participation comes from form write-through.""" existing = ( db.query(TournamentMembership) .filter( @@ -117,7 +117,6 @@ def _redeem_tournament_code(join_code: JoinCode, current_user: User, db: Session membership = TournamentMembership( user_id=current_user.id, tournament_id=join_code.tournament_id, - status="interested", source="join_code", join_code_id=join_code.id, ) diff --git a/backend/app/api/routes/tournament/__init__.py b/backend/app/api/routes/tournament/__init__.py index b7087d30..267de8c0 100644 --- a/backend/app/api/routes/tournament/__init__.py +++ b/backend/app/api/routes/tournament/__init__.py @@ -97,12 +97,11 @@ def create_tournament( db.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - # Confirmed membership for the creator, no roles yet — owner_id alone - # already grants full permissions. + # Membership for the creator, no roles yet — owner_id alone already grants + # full permissions. membership = TournamentMembership( user_id=current_user.id, tournament_id=tournament.id, - status="confirmed", source="manual", ) db.add(membership) diff --git a/backend/app/api/routes/tournament/memberships.py b/backend/app/api/routes/tournament/memberships.py index b97efde7..30153bc3 100644 --- a/backend/app/api/routes/tournament/memberships.py +++ b/backend/app/api/routes/tournament/memberships.py @@ -17,9 +17,10 @@ User, ) from app.schemas.tournament.membership import ( - MembershipCoordinatorUpdate, MembershipFullResponse, MembershipMeResponse, + MembershipCoordinatorUpdate, MembershipEventPreferenceRead, 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): @@ -167,12 +168,14 @@ def get_my_membership( # already granted access via its admin bypass. if not membership: return MembershipMeResponse( - membership_id=None, is_owner=is_owner, status=None, roles=[], permissions=permissions, + membership_id=None, is_owner=is_owner, roles=[], permissions=permissions, ) return MembershipMeResponse( - membership_id=membership.id, is_owner=is_owner, status=membership.status, + 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/api/routes/tournament/onboarding.py b/backend/app/api/routes/tournament/onboarding.py new file mode 100644 index 00000000..b46e8bae --- /dev/null +++ b/backend/app/api/routes/tournament/onboarding.py @@ -0,0 +1,227 @@ +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.auth import get_current_user +from app.core.tournament import get_tournament, require_not_archived +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, + 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: + base = _to_list_read(tf.form, creator) + 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 +# 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") + # 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) + .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} + + 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 + + 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/api/routes/tournament/tracks.py b/backend/app/api/routes/tournament/tracks.py new file mode 100644 index 00000000..93e2dd0f --- /dev/null +++ b/backend/app/api/routes/tournament/tracks.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.auth import get_current_user +from app.core.form import track_referenced_by_form_field +from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived +from app.core.tournament.permissions import MANAGE_TOURNAMENT, require_permission +from app.db.session import get_db +from app.models.models import TournamentTrack, User +from app.schemas.tournament.track import TournamentTrackCreate, TournamentTrackRead, TournamentTrackUpdate + + +router = APIRouter(prefix="/tournaments/{tournament_id}/tracks", tags=["tournaments"]) + + +def _name_taken(db: Session, tournament_id: int, name: str, *, excluding_id: int | None = None) -> bool: + query = db.query(TournamentTrack).filter( + TournamentTrack.tournament_id == tournament_id, + TournamentTrack.name == name, + ) + if excluding_id is not None: + query = query.filter(TournamentTrack.id != excluding_id) + return query.first() is not None + + +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/tracks/ — manage_tournament-gated catalog +# list. Archived tracks remain here so staff can restore them and interpret +# historical track fields. +# --------------------------------------------------------------------------- +@router.get("/", response_model=list[TournamentTrackRead]) +def list_tracks( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_TOURNAMENT)), +): + get_tournament(tournament_id, db) + return ( + db.query(TournamentTrack) + .filter(TournamentTrack.tournament_id == tournament_id) + .order_by(TournamentTrack.is_archived, TournamentTrack.name) + .all() + ) + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/tracks/ — creates a tournament-scoped +# catalog entry. Names are unique within a tournament. +# --------------------------------------------------------------------------- +@router.post("/", response_model=TournamentTrackRead, status_code=status.HTTP_201_CREATED) +def create_track( + tournament_id: int, + payload: TournamentTrackCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_TOURNAMENT)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + if _name_taken(db, tournament_id, payload.name): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A track with this name already exists") + + track = TournamentTrack(tournament_id=tournament_id, name=payload.name) + try: + db.add(track) + db.commit() + db.refresh(track) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A track with this name already exists") + return track + + +# --------------------------------------------------------------------------- +# PATCH /tournaments/{tournament_id}/tracks/{track_id}/ — rename a track or +# archive/restore it. Archiving preserves references from historical fields. +# --------------------------------------------------------------------------- +@router.patch("/{track_id}/", response_model=TournamentTrackRead) +def update_track( + tournament_id: int, + track_id: int, + payload: TournamentTrackUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_TOURNAMENT)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + track = get_scoped_or_404(db, TournamentTrack, track_id, tournament_id, "Track") + + updates = payload.model_dump(exclude_unset=True) + if "name" in updates and _name_taken(db, tournament_id, updates["name"], excluding_id=track.id): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A track with this name already exists") + for field, value in updates.items(): + setattr(track, field, value) + + try: + db.commit() + db.refresh(track) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A track with this name already exists") + return track + + +# --------------------------------------------------------------------------- +# DELETE /tournaments/{tournament_id}/tracks/{track_id}/ — only tracks with +# no form-field references may be removed. Referenced tracks must be archived. +# +# Every member's status on the track goes with it (CASCADE). That's deliberate: +# a track no form points at is being removed for good, and leaving orphaned +# statuses behind would mean a re-created track of the same name silently +# inherits them. +# --------------------------------------------------------------------------- +@router.delete("/{track_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_track( + tournament_id: int, + track_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_TOURNAMENT)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + track = get_scoped_or_404(db, TournamentTrack, track_id, tournament_id, "Track") + if track_referenced_by_form_field(db, tournament_id, track.id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Track is referenced by a form field and cannot be deleted; archive it instead", + ) + + db.delete(track) + db.commit() 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/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/core/form/__init__.py b/backend/app/core/form/__init__.py index c637892c..074f0a68 100644 --- a/backend/app/core/form/__init__.py +++ b/backend/app/core/form/__init__.py @@ -1,6 +1,21 @@ 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 import changes +from app.core.form.validation import ( + AVAILABILITY_FIELD_KEY_PATTERN, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, + TRACK_STATUS_FIELD_KEY_PATTERN, + track_status_assignments, +) +from app.models.models import ( + Form, + FormAnswer, + FormField, + FormResponse, + FormResponsePendingUpdate, + TournamentEvent, + TournamentShift, + TournamentTrack, +) import re import secrets @@ -93,19 +108,50 @@ 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 ) +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 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. + """ + fields = ( + db.query(FormField) + .join(Form, Form.id == FormField.form_id) + .filter(Form.tournament_id == tournament_id) + .all() + ) + return any( + any( + assignment.get("id") == track_id + for assignment in track_status_assignments(field.config or {}) + ) + for field in fields + ) + + def shift_referenced_by_live_field(db: Session, tournament_id: int, shift_id: int) -> bool: """True if `shift_id` appears inside any non-archived availability_* field's option `value` (the list of grouped TournamentShift ids) on @@ -164,48 +210,99 @@ 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, 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( 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)) - 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.""" + db.add(FormResponsePendingUpdate(response_id=response_id, field_id=field_id, reasons=sorted(reasons))) + else: + existing.reasons = sorted(set(existing.reasons or []) | reasons) + + +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_key, 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_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: +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 - 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.field_key, "option_archived") + + 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: + """Drops every open flag on `field_id` — for when the field stops being + 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 + ).delete(synchronize_session=False) + + +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: @@ -218,20 +315,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, } @@ -285,5 +389,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] - - return options \ No newline at end of file + 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/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/app/core/form/changes.py b/backend/app/core/form/changes.py new file mode 100644 index 00000000..57ae6ab4 --- /dev/null +++ b/backend/app/core/form/changes.py @@ -0,0 +1,185 @@ +"""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" +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, 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 +# 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, *, 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 (live_only and 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 _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 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, + 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: + # 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_all - new_all: + 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) + + 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/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/form/validation.py b/backend/app/core/form/validation.py index e247e052..67e20b57 100644 --- a/backend/app/core/form/validation.py +++ b/backend/app/core/form/validation.py @@ -14,7 +14,9 @@ from pydantic import ValidationError from sqlalchemy.orm import Session -from app.models.models import Form, FormField, TournamentShift +from datetime import datetime + +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"} @@ -40,12 +42,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 +87,19 @@ 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 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 {} + if "track_status_enabled" not in source: + normalized.pop("track_status_enabled", 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 +122,108 @@ 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 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")) + ) + + +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 option_track_assignments(option: dict) -> list[dict]: + """The track assignments one option carries, whichever shape holds them — + `value` *is* the list on a track_status_* field, or `value.track_statuses` + on an opted-in availability field. The single place that knows how to dig + them out; callers that hand-roll it get the value shapes wrong (see + _is_assignment).""" + value = option.get("value") + if isinstance(value, list): + return [item for item in value if _is_assignment(item)] + if isinstance(value, dict): + return [item for item in (value.get("track_statuses") or []) if _is_assignment(item)] + return [] + + +def option_shift_ids(option: dict) -> list[int]: + """The TournamentShift ids one availability option groups. `value` is that + list directly, or sits under `shift_ids` once the option also carries track + statuses — read it through here rather than off `value`, or the opted-in + shape yields the dict's keys instead of ids.""" + value = option.get("value") + if isinstance(value, dict): + value = value.get("shift_ids") + return [item for item in (value or []) if isinstance(item, int)] + + +def track_status_assignments(config: dict) -> list[dict]: + """Every track assignment carried by a field's options.""" + return [ + assignment + for option in config.get("options") or [] + for assignment in option_track_assignments(option) + ] + + +def validate_track_status_options( + db: Session, + tournament_id: int | None, + field_key: str, + question_type: str, + config: dict, +) -> None: + """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) + 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["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["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 +300,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 ), @@ -185,7 +315,16 @@ 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}") + + 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}") @@ -209,7 +348,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 @@ -218,6 +366,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).""" @@ -231,20 +383,105 @@ 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", ) 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}", + ) + + +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/app/core/form/write_through.py b/backend/app/core/form/write_through.py index b5bc8ff5..7823ee13 100644 --- a/backend/app/core/form/write_through.py +++ b/backend/app/core/form/write_through.py @@ -1,9 +1,13 @@ """Diff-sync for the structural tables that a form response's reserved-key -answers (`availability`, `lunch_{date}_{category}`) write through to on -tournament-owned forms — see form-question-types-reference.md. Each function -diffs the submitted values against what's already stored and applies only -the delta (insert new, delete removed) rather than replace-all, so an -untouched row (e.g. a different lunch date/category) is never disturbed. +answers (`availability`, `lunch_{date}_{category}`, `track_status_{suffix}`) +write through to on tournament-owned forms — see +form-question-types-reference.md. Each function diffs the submitted values +against what's already stored and applies only the delta (insert new, delete +removed) rather than replace-all, so an untouched row (e.g. a different lunch +date/category) is never disturbed. + +Track status is the exception to "diff": it only ever upserts, and a write can +be refused outright by the transition rule. See sync_track_statuses. Callers commit — these only add/delete/flush, so the write-through and the FormAnswer rows it's derived from land in the same transaction.""" @@ -12,8 +16,18 @@ 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, + EVENT_PREFERENCE_FIELD_KEY_PATTERN, + LUNCH_FIELD_KEY_PATTERN, +) +from app.models.models import ( + TournamentMembershipAvailability, + TournamentMembershipEventPreference, + TournamentMembershipLunch, + TournamentMembershipTrackStatus, + TournamentShift, +) def parse_lunch_field_key(field_key: str) -> tuple[date_type, str]: @@ -24,25 +38,63 @@ 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 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 + 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() @@ -88,3 +140,131 @@ 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") + + +def can_set_track_status(current: str | None, incoming: str) -> bool: + """Whether a write may move a track from `current` to `incoming`. + + The whole rule: **a track never falls back to `interested` once it's moved + past it.** Everything else is permitted — interested→confirmed, either→ + declined, declined→confirmed for someone who changes their mind, and any + status re-written as itself. + + This is what keeps track statuses ordered, in place of comparing submission + times. Write-through is forward-only, but a TD can still raise a pending + update on a track question in an *older* form, and that patch would + otherwise demote a track a newer form already confirmed. Since the only + damage an out-of-order write can do is a demotion, refusing demotions + closes it without any notion of "which response is newer". + + The cost is that no form can walk a mistaken `confirmed` back down to + `interested` — that needs a path that bypasses this guard.""" + return incoming != "interested" or current in (None, "interested") + + +def sync_track_statuses( + db: Session, + membership_id: int, + intended: dict[int, dict], + response_id: str | None = None, +) -> None: + """Upserts one submission's track statuses. + + `intended` maps track_id -> {"status": ..., "field_id": ...}, already + resolved to a single status per track by the caller (see + _write_through_reserved_fields — later fields in document order win). + + **Never deletes.** Every track_status_* question across every form writes + into these rows, so no field owns one and none can withdraw its + contribution after the fact — a member who stops selecting an option keeps + the status it granted. Same reasoning as availability, minus availability's + day boundary: there's no equivalent scope that would make a removal safe. + + A write the transition rule refuses is skipped silently rather than + raising. It's a legitimate outcome of the rules — a respondent answering + what they were asked — not a client error worth failing the submission + over.""" + if not intended: + return + + existing_by_track = { + row.track_id: row + for row in db.query(TournamentMembershipTrackStatus).filter( + TournamentMembershipTrackStatus.membership_id == membership_id, + TournamentMembershipTrackStatus.track_id.in_(intended), + ) + } + + for track_id, write in intended.items(): + status = write["status"] + row = existing_by_track.get(track_id) + if not can_set_track_status(row.status if row else None, status): + continue + + if row is None: + db.add( + TournamentMembershipTrackStatus( + membership_id=membership_id, + track_id=track_id, + status=status, + source_response_id=response_id, + source_field_id=write.get("field_id"), + ) + ) + else: + row.status = status + row.source_response_id = response_id + row.source_field_id = write.get("field_id") + + db.flush() 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/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/core/tournament/memberships.py b/backend/app/core/tournament/memberships.py index f777348f..ff4c9fe4 100644 --- a/backend/app/core/tournament/memberships.py +++ b/backend/app/core/tournament/memberships.py @@ -63,20 +63,3 @@ def has_any_membership(user: "User", tournament_id: int, db: Session) -> bool: if user.role == "admin": return True return get_membership_by_user(db, tournament_id, user.id) is not None - - -def mark_confirmed(db: Session, membership_id: int) -> TournamentMembership: - """ - Mark a TournamentMembership as confirmed. Meant to be called by the - eventual confirmation-form submission handler (forms system, not yet - built) — status doesn't gate role/event assignment, this just records - that the member confirmed their participation. - """ - membership = db.query(TournamentMembership).filter(TournamentMembership.id == membership_id).first() - if membership is None: - raise ValueError(f"TournamentMembership {membership_id} not found") - - membership.status = "confirmed" - db.commit() - db.refresh(membership) - return membership diff --git a/backend/app/core/tournament/onboarding.py b/backend/app/core/tournament/onboarding.py new file mode 100644 index 00000000..762ecbca --- /dev/null +++ b/backend/app/core/tournament/onboarding.py @@ -0,0 +1,68 @@ +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. + """ + 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) + .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) + return next_step.form_id if next_step else None diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 3a198548..0675fdb8 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: @@ -36,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. @@ -66,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__": 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 ) diff --git a/backend/app/main.py b/backend/app/main.py index 54e75317..58eed3b0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,8 @@ 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.tournament import tracks as tournament_tracks 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 +106,9 @@ 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(tournament_onboarding.member_router, prefix="", dependencies=[api_key_dependency]) +app.include_router(tournament_tracks.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]) @@ -125,4 +130,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/models/models.py b/backend/app/models/models.py index acc6afd2..271cdb72 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 @@ -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( @@ -353,7 +353,9 @@ class Tournament(Base): join_codes = relationship("JoinCode", back_populates="tournament", cascade="all, delete-orphan") audit_log = relationship("AuditLogEntry", back_populates="tournament", cascade="all, delete-orphan") event_shifts = relationship("TournamentShift", back_populates="tournament", cascade="all, delete-orphan") + tracks = relationship("TournamentTrack", 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 @@ -392,13 +394,19 @@ class TournamentMembership(Base): Integer, ForeignKey("join_codes.id", ondelete="SET NULL"), nullable=True ) - # "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) - 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") @@ -407,6 +415,8 @@ class TournamentMembership(Base): join_code = relationship("JoinCode") 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]: @@ -560,8 +570,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") @@ -619,6 +629,31 @@ def availability_count(self) -> int: return len(self.membership_availabilities) +# --------------------------------------------------------------------------- +# TournamentTrack — a TD-managed volunteer track such as Test Writing or +# Day 1. Form fields reference the track's stable database ID in their config, +# so `name` can change without invalidating historical answers or statuses. +# --------------------------------------------------------------------------- +class TournamentTrack(Base): + __tablename__ = "tournament_tracks" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False) + name = Column(String(255), nullable=False) + is_archived = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + tournament = relationship("Tournament", back_populates="tracks") + member_statuses = relationship( + "TournamentMembershipTrackStatus", back_populates="track", cascade="all, delete-orphan" + ) + + __table_args__ = ( + UniqueConstraint("tournament_id", "name", name="uq_tournament_track_name"), + ) + + # --------------------------------------------------------------------------- # TournamentEventShift — bridge table: TournamentEvent <-> TournamentShift. # --------------------------------------------------------------------------- @@ -660,8 +695,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") @@ -674,7 +709,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) @@ -697,6 +735,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( @@ -713,6 +752,52 @@ 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 +# 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 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" + + form_id = Column(String(12), ForeignKey("forms.id", ondelete="CASCADE"), primary_key=True) + 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") + form = relationship("Form", back_populates="tournament_form") + # --------------------------------------------------------------------------- # FormField — a single question on a Form. question_type drives how config @@ -754,7 +839,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 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( + "uq_form_field_key", "form_id", "field_key", + unique=True, + postgresql_where=text("is_archived = false"), + ), ) @validates("field_key") @@ -799,6 +892,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") @@ -809,27 +910,35 @@ 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. +# +# `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 archived 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) - reason = Column(String(32), nullable=False) # "field_replaced" | "option_archived" + field_id = Column(String(12), ForeignKey("form_fields.id", ondelete="CASCADE"), nullable=False) + # 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") __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"), ) @@ -873,3 +982,87 @@ class TournamentMembershipLunch(Base): __table_args__ = ( UniqueConstraint("membership_id", "date", "category", "value", name="uq_membership_lunch_selection"), ) + + +# --------------------------------------------------------------------------- +# TournamentMembershipTrackStatus — write-through target for a form's +# "track_status_{suffix}" answers, and for "availability_{date}" answers on a +# field that opted in via config.track_status_enabled. +# +# One row per (membership, track): a track's status is a single current fact, +# not a history. Several questions across several forms feed the same row, so +# no field owns it and none can withdraw its contribution — write-through only +# ever upserts here, never deletes (see backend/form-edit-lifecycle.md). +# +# Writes are guarded by a transition rule rather than by submission ordering: +# a track never falls back to `interested` once it's moved past it, so a stale +# answer replayed out of order can never demote a track someone already +# confirmed. See can_set_track_status in app/core/form/write_through.py. +# --------------------------------------------------------------------------- +class TournamentMembershipTrackStatus(Base): + __tablename__ = "tournament_membership_track_statuses" + + id = Column(Integer, primary_key=True, index=True) + membership_id = Column(Integer, ForeignKey("tournament_memberships.id", ondelete="CASCADE"), nullable=False) + # CASCADE, unlike a form field's reference to a track: a TD who hard-deletes + # a track is removing it for good, and delete_track already refuses while + # any form field still references it. + track_id = Column(Integer, ForeignKey("tournament_tracks.id", ondelete="CASCADE"), nullable=False) + + # "interested" | "confirmed" | "declined" + status = Column(String(32), nullable=False) + + # Where this status came from, recorded for debugging and TD-facing + # provenance only — the transition rule, not this, is what keeps writes + # ordered. Both SET NULL: invalidating the question that set a status must + # not take the status with it. NULL also means "not set by a form", which + # is what a future member self-edit path will write. + source_response_id = Column(String(12), ForeignKey("form_responses.id", ondelete="SET NULL"), nullable=True) + source_field_id = Column(String(12), ForeignKey("form_fields.id", ondelete="SET NULL"), nullable=True) + + created_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + membership = relationship("TournamentMembership", back_populates="track_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"), + ) + + +# --------------------------------------------------------------------------- +# 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") + # 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/form.py b/backend/app/schemas/form.py index 38b837da..bf3a64ae 100644 --- a/backend/app/schemas/form.py +++ b/backend/app/schemas/form.py @@ -17,26 +17,59 @@ # --------------------------------------------------------------------------- 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 = tuple(option.value) if isinstance(option.value, list) else 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 +class TrackStatusAssignment(BaseModel): + """One track status attached to a selectable option.""" + model_config = ConfigDict(extra="forbid") + + id: int = Field(gt=0) + status: Literal["interested", "confirmed", "declined"] + + +def _unique_track_statuses(assignments: list[TrackStatusAssignment]) -> list[TrackStatusAssignment]: + 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 + + +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. @@ -47,7 +80,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] = Field(min_length=1) + value: str | list[int] | list[TrackStatusAssignment] | AvailabilityTrackStatusValue label: str = Field(min_length=1) is_archived: bool = False @@ -57,12 +90,13 @@ 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] | AvailabilityTrackStatusValue label: str = Field(min_length=1) is_archived: bool = False next_field_id: str | None = None action: Literal["submit_form"] | None = None + @model_validator(mode="after") def _mutually_exclusive(self): if self.next_field_id is not None and self.action is not None: @@ -84,6 +118,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 +142,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") @@ -174,21 +210,88 @@ 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 FieldChangeRead(BaseModel): + """One question a proposed save would ask previous responders to review. + Returned by the classify dry-run so the builder's confirmation can show + the server's own verdict instead of re-deriving the rules client-side.""" + field_id: str + label: str + reasons: list[str] + # True when at least one reason is mandatory — the TD can see it but not + # switch it off, because the change invalidated the stored answer. + locked: bool + # What notify_responders should default to for this field if the TD + # doesn't touch it. + notify_default: bool 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 +309,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 +336,21 @@ class FormListRead(BaseModel): created_at: datetime updated_at: datetime response_count: int = 0 + prerequisites: TournamentFormPrerequisites | None = None + + 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) @@ -262,6 +381,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 # --------------------------------------------------------------------------- @@ -283,6 +406,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 @@ -290,5 +424,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/backend/app/schemas/tournament/membership.py b/backend/app/schemas/tournament/membership.py index fc35287c..44c5f8c2 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 @@ -43,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. @@ -66,7 +113,6 @@ class MembershipSlimResponse(_MembershipRolesMixin): """List view — members page roster. No onboarding/logistics fields.""" id: int source: str - status: str # Resolved server-side — see MembershipJoinCodeInfo. None when source # isn't "join_code". Supersedes the bare join_code_id FK. join_code: MembershipJoinCodeInfo | None = None @@ -82,15 +128,17 @@ class MembershipMeResponse(_MembershipRolesMixin): """GET .../memberships/me/ — current user's membership + effective permissions.""" membership_id: int | None is_owner: bool - status: str | None = None permissions: list[str] = [] + # 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): """Detail view — the expanded side panel for a single member.""" id: int tournament_id: int - status: str notes: Optional[str] = None source: str join_code: MembershipJoinCodeInfo | None = None @@ -101,4 +149,27 @@ class MembershipFullResponse(_MembershipRolesMixin): created_at: datetime updated_at: datetime + track_statuses: list[MembershipTrackStatusRead] = [] + event_preferences: list[MembershipEventPreferenceRead] = [] + 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 + + # 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/app/schemas/tournament/onboarding.py b/backend/app/schemas/tournament/onboarding.py new file mode 100644 index 00000000..18890ef8 --- /dev/null +++ b/backend/app/schemas/tournament/onboarding.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from datetime import datetime +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): + # `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 diff --git a/backend/app/schemas/tournament/track.py b/backend/app/schemas/tournament/track.py new file mode 100644 index 00000000..b7a800d7 --- /dev/null +++ b/backend/app/schemas/tournament/track.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class _TrackName(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(max_length=255) + + @field_validator("name") + @classmethod + def _strip_and_require_name(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("name must not be blank") + return value + + +class TournamentTrackCreate(_TrackName): + pass + + +class TournamentTrackUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, max_length=255) + is_archived: bool | None = None + + @field_validator("name") + @classmethod + def _strip_and_require_name(cls, value: str | None) -> str | None: + if value is None: + return value + value = value.strip() + if not value: + raise ValueError("name must not be blank") + return value + + +class TournamentTrackRead(BaseModel): + id: int + tournament_id: int + name: str + is_archived: bool + created_at: datetime + 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/app/services/sync_service.py b/backend/app/services/sync_service.py index 8184b701..b704eea8 100644 --- a/backend/app/services/sync_service.py +++ b/backend/app/services/sync_service.py @@ -626,7 +626,6 @@ def sync_sheet( membership = TournamentMembership( user_id=user.id, tournament_id=tournament.id, - status="interested", source="manual", availability=merged_availability, extra_data=extra_data or None, diff --git a/backend/form-edit-lifecycle.md b/backend/form-edit-lifecycle.md new file mode 100644 index 00000000..a1b0b7b3 --- /dev/null +++ b/backend/form-edit-lifecycle.md @@ -0,0 +1,382 @@ +# 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 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. +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 + +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 | `reason` | Who is flagged | +|---|---|---| +| `question_type` changes shape class (below) | `question_type_changed` | 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. 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` +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 | +|---| +| `question_type` changes within its shape class | +| Option `value` edited (TD-facing text only) | +| Option archived | +| Field archived | +| Field order, `display_style`, branching targets | + +**TD's choice:** + +| 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` / `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. | + +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 | +| **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. + +Archived 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 | +| **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 | + +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. + +**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, 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. Unarchiving 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. +- 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 + +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. + +### 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, +contribute to the same rows, so no single field owns any of them and none can +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. | + +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. + +## Storage + +- `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. +- `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 archived or invalidated. + +## Track status ordering + +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. + +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. +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 + +A TD editing another user's response. Responses are locked to flagged fields +and there is no TD override. diff --git a/backend/form-question-types-reference.md b/backend/form-question-types-reference.md index 58be800e..8d2ac82e 100644 --- a/backend/form-question-types-reference.md +++ b/backend/form-question-types-reference.md @@ -18,17 +18,19 @@ 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`. -**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. +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. 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. @@ -108,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. @@ -140,38 +144,107 @@ 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. | +| `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` | -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. ---- +### 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, 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`. -## Edit Lifecycle +```json +{ "required": true, "ranks": 3, "allow_duplicates": false, "options": [ + { "option_id": "a1b2c3d4e5", "label": "Life Science", "value": [5, 9] } +] } +``` -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." +**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. -**`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. +```json +{ "required": true, "display_style": "list", "options": [ + { "option_id": "a1b2c3d4e5", "label": "Vegetarian", "value": "vegetarian" } +] } +``` -**`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. +**5. `track_status_{suffix}`.** `value` **is** the assignment list — there is +no separate `track_statuses` key on the option. `required` must be `true`. -**`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. +```json +{ "required": true, "display_style": "list", "options": [ + { "option_id": "a1b2c3d4e5", "label": "Yes", "value": [ + { "id": 7, "status": "interested" } + ] }, + { "option_id": "f6e5d4c3b2", "label": "No", "value": [] } +] } +``` -**`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`. +**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. 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` +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. + +--- + +## `Form.status` + +`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. diff --git a/backend/tests/api/test_forms.py b/backend/tests/api/test_forms.py index dd94c31d..2444ee2a 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 @@ -16,10 +17,17 @@ FormField, FormResponse, FormResponsePendingUpdate, + TournamentEvent, TournamentMembership, TournamentMembershipAvailability, + TournamentMembershipEventPreference, TournamentMembershipLunch, + TournamentMembershipTrackStatus, + TournamentForm, + TournamentRole, TournamentShift, + TournamentTrack, + utcnow, ) @@ -94,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/ # --------------------------------------------------------------------------- @@ -220,7 +235,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") @@ -231,7 +248,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") @@ -251,6 +268,131 @@ 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 /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}/ # --------------------------------------------------------------------------- @@ -266,7 +408,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}/") @@ -296,7 +439,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,14 +453,51 @@ 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_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() + 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_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() @@ -412,7 +592,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() @@ -426,13 +609,57 @@ 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_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 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") + 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) @@ -449,6 +676,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}) @@ -502,66 +745,124 @@ 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) + + 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 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. + # 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_key == "color") - .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.reason == "option_archived" + 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) @@ -595,11 +896,25 @@ 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 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() @@ -609,87 +924,218 @@ def test_field_replaced_flags_pending_update_for_prior_answer(self, client, db, res = client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}) response_id = res.json()["id"] - client.put( + res = client.put( f"/forms/{form.id}/fields/", - json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, + 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() - pending = ( - db.query(FormResponsePendingUpdate) - .filter(FormResponsePendingUpdate.response_id == response_id, FormResponsePendingUpdate.field_key == "color") - .first() - ) + # 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.reason == "field_replaced" + assert pending.reasons == ["question_type_changed"] - def test_option_without_option_id_gets_one_generated(self, client, db, td_user, td_tournament): + 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() login(client, "td@test.com", "tdpass") + self._publish(client, form) - res = client.put( + 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": [ - { - "label": "Color", "field_key": "color", "question_type": "multi_select_checkbox", - "config": { - "required": False, - "options": [{"value": "red", "label": "Red"}, {"value": "blue", "label": "Blue"}], - }, - }, - ] - }, + json={"fields": [{"id": field.id, "label": "Color", "question_type": "long_text", "config": {"required": False, "max_length": 500}}]}, ) - assert res.status_code == 200 - options = res.json()[0]["config"]["options"] - ids = [o["option_id"] for o in options] - assert all(ids) - assert len(set(ids)) == 2 + assert self._pending(db, response_id, field.id) is None - def test_option_id_preserved_across_update_when_echoed_back(self, client, db, td_user, td_tournament): + 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="multi_select_checkbox", - config={"required": False, "options": [{"option_id": "opt_red", "value": "red", "label": "Red"}]}, - ) + 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": "Favorite color", "question_type": "multi_select_checkbox", - "config": { - "required": False, - "options": [ - {"option_id": "opt_red", "value": "red", "label": "Red"}, - {"value": "blue", "label": "Blue"}, - ], - }, - }, - ] - }, + json={"fields": [{"id": field.id, "label": "Color", "question_type": "short_text", "config": {"required": False, "max_length": 50}}]}, ) - assert res.status_code == 200 - options = res.json()[0]["config"]["options"] - by_value = {o["value"]: o["option_id"] for o in options} - assert by_value["red"] == "opt_red" - assert by_value["blue"] != "opt_red" - assert by_value["blue"] + assert res.status_code == 200, res.json() + assert [f["id"] for f in res.json()] == [field.id] - def test_duplicate_option_id_within_field_rejected(self, client, db, td_user, td_tournament): + 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.put( + 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": [ - { + 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.""" + 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 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) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { + "label": "Color", "field_key": "color", "question_type": "multi_select_checkbox", + "config": { + "required": False, + "options": [{"value": "red", "label": "Red"}, {"value": "blue", "label": "Blue"}], + }, + }, + ] + }, + ) + assert res.status_code == 200 + options = res.json()[0]["config"]["options"] + ids = [o["option_id"] for o in options] + assert all(ids) + assert len(set(ids)) == 2 + + def test_option_id_preserved_across_update_when_echoed_back(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="multi_select_checkbox", + config={"required": False, "options": [{"option_id": "opt_red", "value": "red", "label": "Red"}]}, + ) + db.commit() + login(client, "td@test.com", "tdpass") + + 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_red", "value": "red", "label": "Red"}, + {"value": "blue", "label": "Blue"}, + ], + }, + }, + ] + }, + ) + assert res.status_code == 200 + options = res.json()[0]["config"]["options"] + by_value = {o["value"]: o["option_id"] for o in options} + assert by_value["red"] == "opt_red" + assert by_value["blue"] != "opt_red" + assert by_value["blue"] + + def test_duplicate_option_id_within_field_rejected(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.put( + f"/forms/{form.id}/fields/", + json={ + "fields": [ + { "label": "Color", "field_key": "color", "question_type": "multi_select_checkbox", "config": { "required": False, @@ -709,6 +1155,416 @@ def test_duplicate_option_id_within_field_rejected(self, client, db, td_user, td # POST /forms/{form_id}/responses/ — submission and resubmission # --------------------------------------------------------------------------- +class TestClassifyFieldChanges: + """POST /forms/{id}/fields/classify/ — the dry run behind the builder's + save confirmation. Writes nothing.""" + + def _entry(self, field, **overrides): + # description included deliberately: omitting it means "clear it", + # which is itself a text change — the builder always sends it. + entry = { + "id": field.id, + "label": field.label, + "description": field.description, + "question_type": field.question_type, + "config": field.config, + } + entry.update(overrides) + return entry + + def test_draft_form_reports_nothing(self, client, db, td_user, td_tournament): + """Nobody has answered, so no edit can strand anyone.""" + 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") + + res = client.post(f"/forms/{form.id}/fields/classify/", json={ + "fields": [self._entry(field, question_type="single_select_radio", config={ + "required": False, "options": [{"option_id": "opt_a", "value": "a", "label": "A"}], + })], + }) + assert res.status_code == 200 + assert res.json() == [] + + def test_mandatory_change_is_locked_and_defaults_on(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}) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/fields/classify/", json={ + "fields": [self._entry(field, question_type="single_select_radio", config={ + "required": False, "options": [{"option_id": "opt_a", "value": "a", "label": "A"}], + })], + }) + assert res.status_code == 200 + assert res.json() == [{ + "field_id": field.id, "label": "Favorite color", + "reasons": ["question_type_changed"], "locked": True, "notify_default": True, + }] + + def test_wording_change_is_unlocked_and_defaults_off(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}) + db.commit() + login(client, "td@test.com", "tdpass") + + res = client.post(f"/forms/{form.id}/fields/classify/", json={ + "fields": [self._entry(field, label="What colour?")], + }) + assert res.json() == [{ + "field_id": field.id, "label": "What colour?", + "reasons": ["text_changed"], "locked": False, "notify_default": False, + }] + + def test_unchanged_and_new_fields_are_omitted(self, client, db, td_user, td_tournament): + """The confirmation lists consequences, not a diff of the save.""" + 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") + + res = client.post(f"/forms/{form.id}/fields/classify/", json={ + "fields": [ + self._entry(field), + {"field_key": "brand_new", "label": "New", "question_type": "short_text", + "config": {"required": False, "max_length": 50}}, + ], + }) + assert res.json() == [] + + def test_writes_nothing(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}) + db.commit() + login(client, "td@test.com", "tdpass") + client.post(f"/forms/{form.id}/responses/", json={"answers": [{"field_id": field.id, "value": "blue"}]}) + + client.post(f"/forms/{form.id}/fields/classify/", json={ + "fields": [self._entry(field, label="What colour?")], + }) + + db.refresh(field) + assert field.label == "Favorite color" + assert db.query(FormResponsePendingUpdate).count() == 0 + + 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.post(f"/forms/{form.id}/fields/classify/", json={"fields": []}).status_code == 403 + + +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.""" + + 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_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.""" + 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.""" + + 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") @@ -726,7 +1582,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() @@ -735,10 +1593,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") @@ -846,8 +1704,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() @@ -892,8 +1750,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( @@ -909,10 +1767,47 @@ def test_grouped_availability_option_writes_one_row_per_shift(self, client, db, membership_id = self._membership_id(db, td_user, td_tournament) assert self._shift_ids(db, membership_id) == {morning.id, afternoon.id} + def test_opted_in_availability_option_still_writes_its_shifts(self, client, db, td_user, td_tournament): + """With track_status_enabled, an availability option's shift ids move + under a `shift_ids` key instead of being `value` itself. Reading them + off `value` directly iterated the dict's keys and fed the strings + "shift_ids"/"track_statuses" into the shift set.""" + 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 = TournamentTrack(tournament_id=td_tournament.id, name="Day 1") + db.add_all([shift, track]) + 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} + 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( @@ -937,10 +1832,175 @@ 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.""" + 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)) - 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( @@ -958,15 +2018,82 @@ 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} + 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") @@ -994,7 +2121,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") @@ -1025,8 +2152,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") @@ -1048,15 +2175,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} @@ -1096,6 +2224,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/api/test_join.py b/backend/tests/api/test_join.py index 10ff1e86..6a85c1ae 100644 --- a/backend/tests/api/test_join.py +++ b/backend/tests/api/test_join.py @@ -71,7 +71,6 @@ def test_join_tournament_success_creates_interested_membership(client, td_user, TournamentMembership.tournament_id == td_tournament.id, ).first() assert membership is not None - assert membership.status == "interested" assert membership.roles == [] assert data["membership_id"] == membership.id 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] diff --git a/backend/tests/api/tournament/test_memberships.py b/backend/tests/api/tournament/test_memberships.py index f2e7a80d..a0a02c19 100644 --- a/backend/tests/api/tournament/test_memberships.py +++ b/backend/tests/api/tournament/test_memberships.py @@ -38,7 +38,7 @@ def _make_event(client, tournament_id): def _make_membership(db, tournament_id, user_id, **overrides): """Create a membership directly in the DB — memberships are created via join codes or sync now, there's no manual-create route anymore.""" - defaults = {"user_id": user_id, "tournament_id": tournament_id, "status": "interested", "source": "manual"} + defaults = {"user_id": user_id, "tournament_id": tournament_id, "source": "manual"} defaults.update(overrides) membership = TournamentMembership(**defaults) db.add(membership) @@ -74,7 +74,6 @@ def test_list_memberships_slim_shape(client, td_user, td_tournament, db): assert row["user"]["email"] == u["email"] assert row["roles"] == [] assert "notes" not in row - assert row["status"] == "interested" def test_list_memberships_requires_manage_members( @@ -259,7 +258,6 @@ def test_get_membership(client, td_user, td_tournament, db): data = response.json() assert data["id"] == m.id assert data["notes"] == "Allergic to nuts" - assert data["status"] == "interested" assert data["user"]["email"] == u["email"] @@ -313,7 +311,6 @@ def test_get_my_membership_owner(client, td_user, td_tournament): assert response.status_code == 200 data = response.json() assert data["is_owner"] is True - assert data["status"] == "confirmed" assert data["membership_id"] is not None assert [r["label"] for r in data["roles"]] == ["Tournament Director"] assert len(data["permissions"]) > 0 @@ -341,7 +338,7 @@ def test_get_my_membership_non_owner_with_role(client, td_tournament, db): def test_get_my_membership_admin_without_membership(client, admin_user, td_tournament): """A site admin who never joined the tournament still gets in via - require_membership()'s admin bypass — membership_id/status/roles are + require_membership()'s admin bypass — membership_id/roles are null/empty but permissions come back as the full admin set.""" login(client, "admin@test.com", "adminpass") response = client.get(f"/tournaments/{td_tournament.id}/memberships/me/") @@ -349,7 +346,6 @@ def test_get_my_membership_admin_without_membership(client, admin_user, td_tourn data = response.json() assert data["membership_id"] is None assert data["is_owner"] is False - assert data["status"] is None assert data["roles"] == [] assert len(data["permissions"]) > 0 @@ -393,7 +389,6 @@ def test_update_my_membership_ignores_all_fields(client, td_tournament, db): assert response.status_code == 200 data = response.json() assert data["notes"] is None - assert data["status"] == "interested" def test_update_my_membership_not_found(client, td_tournament, td_user): diff --git a/backend/tests/api/tournament/test_onboarding.py b/backend/tests/api/tournament/test_onboarding.py new file mode 100644 index 00000000..a874e73e --- /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.patch(f"/forms/{form.id}/", json={"status": "archived"}) + 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 diff --git a/backend/tests/api/tournament/test_roles.py b/backend/tests/api/tournament/test_roles.py index 22444ddd..d98355da 100644 --- a/backend/tests/api/tournament/test_roles.py +++ b/backend/tests/api/tournament/test_roles.py @@ -139,7 +139,7 @@ def test_apply_template_non_owner_without_manage_roles_forbidden(client, td_user tournament_id = _make_empty_tournament(client, name="Empty For Other") membership = TournamentMembership( - user_id=td_user.id, tournament_id=tournament_id, status="confirmed", source="manual", + user_id=td_user.id, tournament_id=tournament_id, source="manual", ) db.add(membership) db.commit() diff --git a/backend/tests/api/tournament/test_tracks.py b/backend/tests/api/tournament/test_tracks.py new file mode 100644 index 00000000..a84b120e --- /dev/null +++ b/backend/tests/api/tournament/test_tracks.py @@ -0,0 +1,195 @@ +from tests.conftest import grant_role, login + + +from app.models.models import ( + Form, + FormField, + TournamentMembership, + TournamentMembershipTrackStatus, + TournamentTrack, +) + + +def _create_track(client, tournament_id: int, name: str): + return client.post(f"/tournaments/{tournament_id}/tracks/", json={"name": name}) + + +def test_manage_tournament_can_create_list_update_and_archive_tracks(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + + created = _create_track(client, td_tournament.id, " Test Writing ") + assert created.status_code == 201 + track = created.json() + assert track["name"] == "Test Writing" + assert track["is_archived"] is False + + listed = client.get(f"/tournaments/{td_tournament.id}/tracks/") + assert listed.status_code == 200 + assert [row["id"] for row in listed.json()] == [track["id"]] + + updated = client.patch( + f"/tournaments/{td_tournament.id}/tracks/{track['id']}/", + json={"name": "Question Writing", "is_archived": True}, + ) + assert updated.status_code == 200 + assert updated.json()["name"] == "Question Writing" + assert updated.json()["is_archived"] is True + + restored = client.patch( + f"/tournaments/{td_tournament.id}/tracks/{track['id']}/", + json={"is_archived": False}, + ) + assert restored.status_code == 200 + assert restored.json()["is_archived"] is False + + +def test_track_names_are_unique_per_tournament(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + assert _create_track(client, td_tournament.id, "Day 1").status_code == 201 + + duplicate = _create_track(client, td_tournament.id, "Day 1") + assert duplicate.status_code == 409 + + +def test_track_routes_require_manage_tournament(client, db, td_tournament, other_user): + grant_role(db, td_tournament, other_user, "Runner") + login(client, "other@test.com", "otherpass") + + assert _create_track(client, td_tournament.id, "Day 1").status_code == 403 + assert client.get(f"/tournaments/{td_tournament.id}/tracks/").status_code == 403 + + +def test_track_is_scoped_to_its_tournament(client, db, td_user, td_tournament, other_tournament): + other_track = TournamentTrack(tournament_id=other_tournament.id, name="Other tournament track") + db.add(other_track) + db.commit() + login(client, "td@test.com", "tdpass") + + assert client.patch( + f"/tournaments/{td_tournament.id}/tracks/{other_track.id}/", + json={"name": "Nope"}, + ).status_code == 404 + + +def test_unused_track_can_be_deleted_but_referenced_track_cannot(client, db, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + unused = _create_track(client, td_tournament.id, "Unused").json() + assert client.delete(f"/tournaments/{td_tournament.id}/tracks/{unused['id']}/").status_code == 204 + + referenced = _create_track(client, td_tournament.id, "Referenced").json() + form = Form( + owner_type="tournament", + tournament_id=td_tournament.id, + chapter_id=None, + name="Track form", + created_by=td_user.id, + ) + db.add(form) + db.flush() + db.add(FormField( + form_id=form.id, + order=1, + field_key="track_status_interest", + label="Track status", + question_type="single_select_radio", + config={ + "required": True, + "options": [{ + "option_id": "interested", "label": "Interested", + "value": [{"id": referenced["id"], "status": "interested"}], + }], + }, + )) + db.commit() + + blocked = client.delete(f"/tournaments/{td_tournament.id}/tracks/{referenced['id']}/") + 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 + them.""" + login(client, "td@test.com", "tdpass") + track = _create_track(client, td_tournament.id, "Day 1").json() + membership = ( + db.query(TournamentMembership) + .filter( + TournamentMembership.user_id == td_user.id, + TournamentMembership.tournament_id == td_tournament.id, + ) + .one() + ) + db.add(TournamentMembershipTrackStatus( + membership_id=membership.id, track_id=track["id"], status="confirmed", + )) + db.commit() + + assert client.delete(f"/tournaments/{td_tournament.id}/tracks/{track['id']}/").status_code == 204 + assert db.query(TournamentMembershipTrackStatus).filter( + TournamentMembershipTrackStatus.track_id == track["id"] + ).count() == 0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d7c309ad..96c7e5b3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -179,7 +179,6 @@ def _make_tournament_with_td(db: Session, owner: User, name: str) -> Tournament: membership = TournamentMembership( user_id=owner.id, tournament_id=tournament.id, - status="confirmed", source="manual", ) db.add(membership) @@ -191,7 +190,7 @@ def _make_tournament_with_td(db: Session, owner: User, name: str) -> Tournament: return tournament -def grant_role(db: Session, tournament: Tournament, user: User, role_label: str, status: str = "confirmed") -> TournamentMembership: +def grant_role(db: Session, tournament: Tournament, user: User, role_label: str) -> TournamentMembership: """ Give `user` a membership in `tournament` holding the role identified by `role_label` (must already exist on the tournament — DEFAULT_ROLES covers @@ -205,7 +204,7 @@ def grant_role(db: Session, tournament: Tournament, user: User, role_label: str, .first() ) if not membership: - membership = TournamentMembership(user_id=user.id, tournament_id=tournament.id, status=status, source="manual") + membership = TournamentMembership(user_id=user.id, tournament_id=tournament.id, source="manual") db.add(membership) db.flush() 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"] diff --git a/backend/tests/core/test_form_changes.py b/backend/tests/core/test_form_changes.py new file mode 100644 index 00000000..5e0cab2c --- /dev/null +++ b/backend/tests/core/test_form_changes.py @@ -0,0 +1,242 @@ +"""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} + + +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 19825116..5d3a6219 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 @@ -11,13 +11,18 @@ from app.core.form.validation import ( FormFieldValidationError, + option_shift_ids, + 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 +from app.models.models import Form, FormField, TournamentEvent, TournamentShift, TournamentTrack # --------------------------------------------------------------------------- @@ -78,18 +83,26 @@ 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() 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) @@ -134,6 +147,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( @@ -230,6 +276,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 @@ -305,6 +374,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): @@ -316,6 +407,214 @@ 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 +# --------------------------------------------------------------------------- + +class TestOptionValueReaders: + """option_track_assignments / option_shift_ids are the shared readers for + an option's `value`, whose shape depends on the field_key. Getting the + discrimination wrong is the bug they exist to prevent: a plain + event_preference option's list[int] must not read as assignments, and an + opted-in availability option's dict must not read as shift ids.""" + + def test_assignments_as_the_value_itself(self): + option = {"value": [{"id": 7, "status": "confirmed"}]} + assert option_track_assignments(option) == [{"id": 7, "status": "confirmed"}] + + def test_assignments_nested_under_availability_value(self): + option = {"value": {"shift_ids": [1, 2], "track_statuses": [{"id": 7, "status": "declined"}]}} + assert option_track_assignments(option) == [{"id": 7, "status": "declined"}] + + def test_grouped_entity_ids_are_not_assignments(self): + assert option_track_assignments({"value": [5, 9]}) == [] + + def test_plain_text_value_has_no_assignments(self): + assert option_track_assignments({"value": "vegetarian"}) == [] + + def test_shift_ids_from_a_plain_availability_option(self): + assert option_shift_ids({"value": [3, 2, 5]}) == [3, 2, 5] + + def test_shift_ids_from_an_opted_in_availability_option(self): + option = {"value": {"shift_ids": [3, 2], "track_statuses": [{"id": 7, "status": "confirmed"}]}} + assert option_shift_ids(option) == [3, 2] + + def test_missing_value_yields_nothing(self): + assert option_shift_ids({}) == [] + assert option_track_assignments({}) == [] + + +class TestValidateTrackStatusOptions: + def _track(self, db, tournament): + track = TournamentTrack(tournament_id=tournament.id, name="Test Writing") + db.add(track) + 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", "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) + 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", "label": "A", + "value": [{"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._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 + ) + + 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", "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) + + # --------------------------------------------------------------------------- # validate_form_for_publish — aggregate pass # --------------------------------------------------------------------------- diff --git a/backend/tests/core/test_form_write_through.py b/backend/tests/core/test_form_write_through.py index b3849ad8..17ae94c9 100644 --- a/backend/tests/core/test_form_write_through.py +++ b/backend/tests/core/test_form_write_through.py @@ -1,17 +1,31 @@ -"""Tests for app/core/form/write_through.py — the diff-sync functions that -apply availability/lunch reserved-key answers to their structural tables. -See tests/api/test_forms.py for the route-level wiring (fires on +"""Tests for app/core/form/write_through.py — the sync functions that apply +availability/lunch/track_status reserved-key answers to their structural +tables. See tests/api/test_forms.py for the route-level wiring (fires on tournament-owned forms, no-ops on chapter-owned forms).""" from datetime import date, datetime, timedelta, timezone import pytest -from app.core.form.write_through import parse_lunch_field_key, sync_availability, sync_lunch +from app.core.form.write_through import ( + can_set_track_status, + parse_lunch_field_key, + sync_availability, + sync_event_preferences, + sync_lunch, + sync_track_statuses, +) from app.models.models import ( + Form, + FormField, + FormResponse, + TournamentEvent, TournamentMembership, TournamentMembershipAvailability, + TournamentMembershipEventPreference, TournamentMembershipLunch, + TournamentMembershipTrackStatus, TournamentShift, + TournamentTrack, ) @@ -57,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 # --------------------------------------------------------------------------- @@ -67,7 +99,7 @@ def test_insert_only(self, db, td_tournament, membership): s2 = _make_shift(db, td_tournament, "Afternoon") db.commit() - sync_availability(db, membership.id, [s1.id, s2.id]) + sync_availability(db, membership.id, {s1.id, s2.id}, {s1.id, s2.id}) db.commit() assert _availability_shift_ids(db, membership.id) == {s1.id, s2.id} @@ -76,10 +108,10 @@ def test_delete_only(self, db, td_tournament, membership): s1 = _make_shift(db, td_tournament, "Morning") s2 = _make_shift(db, td_tournament, "Afternoon") db.commit() - sync_availability(db, membership.id, [s1.id, s2.id]) + sync_availability(db, membership.id, {s1.id, s2.id}, {s1.id, s2.id}) db.commit() - sync_availability(db, membership.id, []) + sync_availability(db, membership.id, set(), {s1.id, s2.id}) db.commit() assert _availability_shift_ids(db, membership.id) == set() @@ -88,12 +120,13 @@ def test_mixed_diff(self, db, td_tournament, membership): s1 = _make_shift(db, td_tournament, "Morning") s2 = _make_shift(db, td_tournament, "Afternoon") s3 = _make_shift(db, td_tournament, "Evening") + owned = {s1.id, s2.id, s3.id} db.commit() - sync_availability(db, membership.id, [s1.id, s2.id]) + sync_availability(db, membership.id, {s1.id, s2.id}, owned) db.commit() # Drop s1, keep s2, add s3. - sync_availability(db, membership.id, [s2.id, s3.id]) + sync_availability(db, membership.id, {s2.id, s3.id}, owned) db.commit() assert _availability_shift_ids(db, membership.id) == {s2.id, s3.id} @@ -101,14 +134,29 @@ def test_mixed_diff(self, db, td_tournament, membership): def test_resync_with_same_ids_is_a_noop(self, db, td_tournament, membership): s1 = _make_shift(db, td_tournament, "Morning") db.commit() - sync_availability(db, membership.id, [s1.id]) + sync_availability(db, membership.id, {s1.id}, {s1.id}) db.commit() - sync_availability(db, membership.id, [s1.id]) + sync_availability(db, membership.id, {s1.id}, {s1.id}) db.commit() assert _availability_shift_ids(db, membership.id) == {s1.id} + def test_shifts_outside_the_owned_set_survive(self, db, td_tournament, membership): + """The boundary is the day, not the answer: a shift this submission + didn't ask about belongs to another day's question and stays put.""" + mine = _make_shift(db, td_tournament, "Saturday") + theirs = _make_shift(db, td_tournament, "Sunday") + db.commit() + sync_availability(db, membership.id, {mine.id, theirs.id}, {mine.id, theirs.id}) + db.commit() + + # Answer only the day `mine` falls on, selecting nothing. + sync_availability(db, membership.id, set(), {mine.id}) + db.commit() + + assert _availability_shift_ids(db, membership.id) == {theirs.id} + # --------------------------------------------------------------------------- # sync_lunch @@ -183,6 +231,313 @@ 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 +# --------------------------------------------------------------------------- + +class TestCanSetTrackStatus: + """The whole rule: a track never falls back to `interested` once it's + moved past it.""" + + @pytest.mark.parametrize("incoming", ["interested", "confirmed", "declined"]) + def test_anything_may_be_set_from_unset(self, incoming): + assert can_set_track_status(None, incoming) is True + + @pytest.mark.parametrize( + "current,incoming", + [ + ("interested", "interested"), + ("interested", "confirmed"), + ("interested", "declined"), + ("confirmed", "confirmed"), + ("confirmed", "declined"), + # Someone who declined and changed their mind can still commit. + ("declined", "confirmed"), + ("declined", "declined"), + ], + ) + def test_allowed_transitions(self, current, incoming): + assert can_set_track_status(current, incoming) is True + + @pytest.mark.parametrize("current", ["confirmed", "declined"]) + def test_nothing_falls_back_to_interested(self, current): + assert can_set_track_status(current, "interested") is False + + +class TestSyncTrackStatuses: + def _track(self, db, tournament, name="Test Writing"): + track = TournamentTrack(tournament_id=tournament.id, name=name) + db.add(track) + db.flush() + return track + + def _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 test_insert_from_unset(self, db, td_tournament, membership): + track = self._track(db, td_tournament) + db.commit() + + sync_track_statuses(db, membership.id, {track.id: {"status": "interested"}}) + db.commit() + + assert self._status(db, membership.id, track.id) == "interested" + + def test_upsert_does_not_duplicate(self, db, td_tournament, membership): + track = self._track(db, td_tournament) + db.commit() + sync_track_statuses(db, membership.id, {track.id: {"status": "interested"}}) + db.commit() + + sync_track_statuses(db, membership.id, {track.id: {"status": "confirmed"}}) + db.commit() + + rows = db.query(TournamentMembershipTrackStatus).filter( + TournamentMembershipTrackStatus.membership_id == membership.id, + TournamentMembershipTrackStatus.track_id == track.id, + ).all() + assert len(rows) == 1 + assert rows[0].status == "confirmed" + + def test_demotion_to_interested_is_refused(self, db, td_tournament, membership): + """The case the rule exists for: a stale answer replayed out of order + must not walk a confirmed track back down.""" + track = self._track(db, td_tournament) + db.commit() + sync_track_statuses(db, membership.id, {track.id: {"status": "confirmed"}}) + db.commit() + + sync_track_statuses(db, membership.id, {track.id: {"status": "interested"}}) + db.commit() + + assert self._status(db, membership.id, track.id) == "confirmed" + + def test_declining_a_confirmed_track_is_allowed(self, db, td_tournament, membership): + track = self._track(db, td_tournament) + db.commit() + sync_track_statuses(db, membership.id, {track.id: {"status": "confirmed"}}) + db.commit() + + sync_track_statuses(db, membership.id, {track.id: {"status": "declined"}}) + db.commit() + + assert self._status(db, membership.id, track.id) == "declined" + + def _response(self, db, td_user, tournament, field_key="track_status_interest"): + """A real Form/FormField/FormResponse trio — source_response_id and + source_field_id are real FKs, so provenance can't be faked with + made-up ids.""" + form = Form( + owner_type="tournament", tournament_id=tournament.id, chapter_id=None, + name="Track form", created_by=td_user.id, + ) + db.add(form) + db.flush() + field = FormField( + form_id=form.id, order=1, field_key=field_key, label="Interested?", + question_type="single_select_radio", config={"required": True, "options": []}, + ) + response = FormResponse(form_id=form.id, user_id=td_user.id) + db.add_all([field, response]) + db.flush() + return response, field + + def _row(self, db, membership_id, track_id): + return ( + db.query(TournamentMembershipTrackStatus) + .filter( + TournamentMembershipTrackStatus.membership_id == membership_id, + TournamentMembershipTrackStatus.track_id == track_id, + ) + .one() + ) + + def test_provenance_is_recorded(self, db, td_user, td_tournament, membership): + track = self._track(db, td_tournament) + response, field = self._response(db, td_user, td_tournament) + db.commit() + + sync_track_statuses( + db, membership.id, + {track.id: {"status": "confirmed", "field_id": field.id}}, + response.id, + ) + db.commit() + + row = self._row(db, membership.id, track.id) + assert row.source_response_id == response.id + assert row.source_field_id == field.id + + def test_a_refused_write_leaves_provenance_alone(self, db, td_user, td_tournament, membership): + track = self._track(db, td_tournament) + first_response, first_field = self._response(db, td_user, td_tournament) + second_response, second_field = self._response(db, td_user, td_tournament, "track_status_later") + db.commit() + + sync_track_statuses( + db, membership.id, + {track.id: {"status": "confirmed", "field_id": first_field.id}}, + first_response.id, + ) + db.commit() + + sync_track_statuses( + db, membership.id, + {track.id: {"status": "interested", "field_id": second_field.id}}, + second_response.id, + ) + db.commit() + + row = self._row(db, membership.id, track.id) + assert row.status == "confirmed" + assert row.source_response_id == first_response.id + assert row.source_field_id == first_field.id + + def test_untouched_tracks_survive(self, db, td_tournament, membership): + """Rows are shared across questions and forms — a submission only + speaks to the tracks it named.""" + mine = self._track(db, td_tournament, "Day 1") + theirs = self._track(db, td_tournament, "Test Writing") + db.commit() + sync_track_statuses(db, membership.id, { + mine.id: {"status": "interested"}, + theirs.id: {"status": "confirmed"}, + }) + db.commit() + + sync_track_statuses(db, membership.id, {mine.id: {"status": "declined"}}) + db.commit() + + assert self._status(db, membership.id, mine.id) == "declined" + assert self._status(db, membership.id, theirs.id) == "confirmed" + + def test_nothing_is_ever_deleted(self, db, td_tournament, membership): + """No delete path at all — an empty intent is a no-op, not a clear.""" + track = self._track(db, td_tournament) + db.commit() + sync_track_statuses(db, membership.id, {track.id: {"status": "confirmed"}}) + db.commit() + + sync_track_statuses(db, membership.id, {}) + db.commit() + + assert self._status(db, membership.id, track.id) == "confirmed" + + # --------------------------------------------------------------------------- # parse_lunch_field_key # --------------------------------------------------------------------------- diff --git a/backend/tests/core/test_forms.py b/backend/tests/core/test_forms.py index a6ee5b9d..4bf72e04 100644 --- a/backend/tests/core/test_forms.py +++ b/backend/tests/core/test_forms.py @@ -24,6 +24,7 @@ FormField, FormResponse, TournamentEvent, + TournamentForm, TournamentShift, ) @@ -273,6 +274,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 +316,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) @@ -368,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 # --------------------------------------------------------------------------- @@ -428,7 +500,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 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 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 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)}" 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 new file mode 100644 index 00000000..74a6b2e7 --- /dev/null +++ b/frontend/app/dashboard/tournaments/[id]/forms/onboarding/page.tsx @@ -0,0 +1,304 @@ +"use client"; + +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 { formsApi, tournamentOnboardingApi, FormListItem, OnboardingForm, ApiError } from "@/lib/api"; +import { useAuth } from "@/lib/useAuth"; +import { useMyMembership } from "@/lib/useMyMembership"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { Spinner } from "@/components/ui/Spinner"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { Popover } from "@/components/ui/Popover"; +import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; +import { IconEdit, IconForms, IconGripVertical, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; + +export default function OnboardingFormsPage() { + const params = useParams(); + 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 [allForms, setAllForms] = useState(null); + const [baseline, setBaseline] = useState(null); + const [draft, setDraft] = useState([]); + const [loadError, setLoadError] = useState(null); + const [removingId, setRemovingId] = useState(null); + const [saving, setSaving] = useState(false); + 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), tournamentOnboardingApi.listForms(tournamentId)]) + .then(([forms, onboarding]) => { + setAllForms(forms); + setBaseline(onboarding); + setDraft(onboarding); + }) + .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) { + const created = await tournamentOnboardingApi.addForm(tournamentId, formId); + const next = [...draft, created]; + setBaseline(next); + setDraft(next); + } + + async function handleRemove(formId: string) { + setRemovingId(formId); + try { + await tournamentOnboardingApi.removeForm(tournamentId, formId); + const next = draft.filter((f) => f.id !== formId); + setBaseline(next); + setDraft(next); + } catch (e) { + setLoadError(e instanceof ApiError ? e.message : "Failed to remove form from onboarding."); + } finally { + setRemovingId(null); + } + } + + 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) 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); + setSaveError(undefined); + try { + const updated = await tournamentOnboardingApi.reorderForms(tournamentId, draft.map((f) => f.id)); + setBaseline(updated); + setDraft(updated); + } catch (e) { + 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 ( +
+ +
+ ); + } + + if (!canManageForms) { + return ( + + } + title="No access" + description="You need the manage forms permission to view this page." + /> + + ); + } + + if (baseline === null || allForms === null) { + return ( +
+ +
+ ); + } + + return ( +
+ + {loadError && ( +

+ {loadError} +

+ )} + +
+ + 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 above to start the onboarding sequence." + /> + + ) : ( + + setActiveId(null)} + > + f.id)} strategy={verticalListSortingStrategy}> + {draft.map((form, i) => ( + window.open(`/forms/${form.id}/edit`, "_blank", "noopener,noreferrer")} + onRemove={() => handleRemove(form.id)} + /> + ))} + + + {activeId && ( + f.id === activeId)!} dragging /> + )} + + + + )} + + +
+ ); +} + +function OnboardingFormPill({ form, dragging = false }: { form: OnboardingForm; dragging?: boolean }) { + return ( +
+ {form.name} +
+ ); +} + +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, + opacity: isDragging ? 0 : 1, + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ ...style, position: "relative", display: "flex", alignItems: "center", gap: "20px", padding: "4px 10px" }} + > + + + {step}. + +
+ {form.name} +
+ + +
+
+
+ ); +} diff --git a/frontend/app/dashboard/tournaments/[id]/forms/page.tsx b/frontend/app/dashboard/tournaments/[id]/forms/page.tsx index b2250f9e..38d670b8 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"; @@ -69,7 +68,7 @@ function FormRow({ form, isLast }: { @@ -111,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(); @@ -139,26 +137,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 ( -
- -
- -
+
+
); } @@ -166,19 +158,16 @@ 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 (
- setCreating(true)}> - New Form - - } - /> +
+ +
{loadError && (

diff --git a/frontend/app/dashboard/tournaments/[id]/members/page.tsx b/frontend/app/dashboard/tournaments/[id]/members/page.tsx index f73ea225..bc16b2c1 100644 --- a/frontend/app/dashboard/tournaments/[id]/members/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/members/page.tsx @@ -5,7 +5,6 @@ import { useParams } from "next/navigation"; import { membershipsApi, rolesApi, MembershipSlim, Role, ApiError } from "@/lib/api"; import { formatPhone } from "@/lib/auth"; import { formatDuration, formatDateTime } from "@/lib/timeFormat"; -import { STATUS_VARIANT } from "@/lib/membershipDisplay"; import { useAuth } from "@/lib/useAuth"; import { useTournament } from "@/lib/useTournament"; import { useMemberRoleLock } from "@/lib/roles/useMemberRoleLock"; @@ -13,7 +12,6 @@ import { useSetLayoutPanel } from "@/lib/useLayoutPanel"; import { usePanelSelection } from "@/lib/usePanelSelection"; import { PageHeader } from "@/components/ui/PageHeader"; 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"; @@ -34,10 +32,10 @@ import { usePersistedFilter } from "@/lib/usePersistedFilter"; import { MembersFilterModal, isMembersFilterActive, MEMBERS_FILTER_KEYS } from "@/components/tournament/MembersFilterModal"; import { IconLock, IconSearch, IconArrowDown, IconExpand, IconTrash, IconMembers, IconFilter, IconX } from "@/components/ui/Icons"; -// Name / Email / Phone / Account Age / Join Date / Join Method / Status / Roles / Actions -const MEMBER_ROW_COLUMNS = "0.8fr 1.2fr 0.6fr 90px 90px 110px 90px 2.6fr 70px"; +// Name / Email / Phone / Account Age / Join Date / Join Method / Roles / Actions +const MEMBER_ROW_COLUMNS = "0.8fr 1.2fr 0.6fr 90px 90px 110px 2.6fr 70px"; // Roles dropped — the fr tracks below just absorb its share automatically. -const MEMBER_ROW_COLUMNS_COMPACT = "0.8fr 1.2fr 0.6fr 90px 90px 110px 90px 70px"; +const MEMBER_ROW_COLUMNS_COMPACT = "0.8fr 1.2fr 0.6fr 90px 90px 110px 70px"; // Always present as a grid track (never conditionally added/removed) so its // width can transition between 0 and full instead of popping in — animating // grid-template-columns only works when the track count stays constant. @@ -59,11 +57,6 @@ const SORT_FIELD_OPTIONS = [ { value: "account_age", label: "Account age" }, ]; -const STATUS_FILTER_OPTIONS = [ - { value: "interested", label: "Interested" }, - { value: "confirmed", label: "Confirmed" }, -]; - function memberName(m: MembershipSlim): string { return `${m.user.first_name ?? ""} ${m.user.last_name ?? ""}`.trim() || m.user.email; } @@ -185,9 +178,6 @@ function MemberRow({ - - {membership.status} - {!panelOpen && ( // Stops row clicks (select toggle / focus switch) from firing when // the intent was to pick a role chip. @@ -287,7 +277,6 @@ export default function MembersPage() { // unassigned members, not hide them along with everyone else. const roleKeys = m.roles.map((r) => String(r.id)); if (roleKeys.length > 0 && roleKeys.every((k) => filters.role.has(k))) return false; - if (filters.status.has(m.status)) return false; return true; }); const sorted = [...filtered].sort((a, b) => { @@ -541,7 +530,6 @@ export default function MembersPage() { Account Age Joined Method - Status {!panelOpen && Roles} Actions

@@ -585,7 +573,6 @@ export default function MembersPage() { {showFilterModal && ( setShowFilterModal(false)} diff --git a/frontend/app/dashboard/tournaments/[id]/overview/page.tsx b/frontend/app/dashboard/tournaments/[id]/overview/page.tsx index a372edce..b2782dee 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 { useEffect, useState } from "react"; import { useParams } 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 { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; import { IconCalendar, IconLocation } from "@/components/ui/Icons"; export default function OverviewPage() { const params = useParams(); 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)) + .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,58 @@ export default function OverviewPage() {
+ {formsError && ( +

+ {formsError} +

+ )} + {forms === null ? ( +
+ ) : forms.length > 0 ? ( + +
+ Forms +
+
+ {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" : "To do"} + + {form.eligible && !form.completed && ( + + )} +
+ ))} +
+
+ ) : null}
); diff --git a/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx index 4f390da4..12b838a2 100644 --- a/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { invitesApi, Invite, ApiError } from "@/lib/api"; import { useAuth } from "@/lib/useAuth"; @@ -10,7 +10,7 @@ import { PageHeader } from "@/components/ui/PageHeader"; import { Card } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Badge } from "@/components/ui/Badge"; -import { Input } from "@/components/ui/Input"; +import { EditableText } from "@/components/ui/EditableText"; import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; import { IconArchive, IconInvite, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; @@ -27,72 +27,16 @@ function EditableLabel({ tournamentId, invite, onUpdated }: { invite: Invite; onUpdated: (invite: Invite) => void; }) { - const [editing, setEditing] = useState(false); - const [value, setValue] = useState(invite.label ?? ""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(undefined); - const inputRef = useRef(null); - - useEffect(() => { - if (editing) inputRef.current?.focus(); - }, [editing]); - - function startEdit() { - setValue(invite.label ?? ""); - setError(undefined); - setEditing(true); - } - - async function save() { - const trimmed = value.trim(); - if (trimmed === (invite.label ?? "")) { - setEditing(false); - return; - } - setSaving(true); - try { - const updated = await invitesApi.update(tournamentId, invite.id, { label: trimmed || null }); - onUpdated(updated); - setEditing(false); - } catch (err: unknown) { - setError(err instanceof ApiError ? err.message : "Failed to update label."); - } finally { - setSaving(false); - } - } - - if (editing) { - return ( - setValue(e.target.value)} - onBlur={save} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); save(); } - if (e.key === "Escape") { e.preventDefault(); setEditing(false); } - }} - error={error} - disabled={saving} - size="xs" - font="sans" - fullWidth - /> - ); - } - return ( - onUpdated(await invitesApi.update(tournamentId, invite.id, { label }))} + textStyle={{ fontFamily: "var(--font-sans)", fontSize: "13px", fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", - cursor: "pointer", }} - > - {invite.label ?? "—"} - + title="Click to edit label" + /> ); } diff --git a/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx new file mode 100644 index 00000000..c0038528 --- /dev/null +++ b/frontend/app/dashboard/tournaments/[id]/settings/tracks/page.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useAuth } from "@/lib/useAuth"; +import { useMyMembership } from "@/lib/useMyMembership"; +import { ApiError, TournamentTrack, tournamentTracksApi } from "@/lib/api"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Button } from "@/components/ui/Button"; +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 { EditableText } from "@/components/ui/EditableText"; +import { Spinner } from "@/components/ui/Spinner"; +import { Badge } from "@/components/ui/Badge"; +import { IconArchive, IconLock, IconPlus, IconTrash, IconVolunteers } from "@/components/ui/Icons"; + +// Name / Status / Actions +const TRACK_ROW_COLUMNS = "1fr 100px 68px"; + +export default function TracksSettingsPage() { + const params = useParams(); + const tournamentId = Number(params.id); + const { user: currentUser } = useAuth(); + const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); + const canManageTracks = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_tournament"); + const [tracks, setTracks] = useState(null); + const [error, setError] = useState(); + const [deleteTarget, setDeleteTarget] = useState(null); + + const loadTracks = useCallback(async () => { + try { + setTracks(await tournamentTracksApi.list(tournamentId)); + } catch (err: unknown) { + setError(err instanceof ApiError ? err.message : "Failed to load tracks."); + setTracks([]); + } + }, [tournamentId]); + + useEffect(() => { + if (canManageTracks) loadTracks(); + }, [canManageTracks, loadTracks]); + + function handleCreated(track: TournamentTrack) { + setTracks((current) => [...(current ?? []), track]); + } + + if (membershipLoading || (canManageTracks && tracks === null)) { + return
; + } + + if (!canManageTracks) { + return ( +
+ + + } title="No access" description="You need the manage tournament permission to view this page." /> + +
+ ); + } + + return ( +
+ track.name) ?? []} + onCreated={handleCreated} + trigger={ + + } + /> + }/> + + {error && ( +

+ {error} +

+ )} + + {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 && ( + setDeleteTarget(null)} + onDeleted={() => { + setTracks((current) => current?.filter((track) => track.id !== deleteTarget.id) ?? current); + setDeleteTarget(null); + }} + /> + )} +
+ ); +} + +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 [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const [hovered, setHovered] = useState(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 ( +
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", + }} + > + {track.is_archived ? ( + + {track.name} + + ) : ( + onChange(await tournamentTracksApi.update(tournamentId, track.id, { name }))} + title="Click to edit name" + /> + )} +
+ + {track.is_archived ? "Archived" : "Active"} + +
+
+ + +
+ {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/app/forms/[formId]/view/page.tsx b/frontend/app/forms/[formId]/view/page.tsx new file mode 100644 index 00000000..59dcde8c --- /dev/null +++ b/frontend/app/forms/[formId]/view/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +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 +// 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 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); + // 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) + .then(setForm) + .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, + Object.entries(answers).map(([field_id, value]) => ({ field_id, value })), + ); + if (redirect) router.replace(redirect); + } + + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + 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 ( + + ); +} diff --git a/frontend/app/join/page.tsx b/frontend/app/join/page.tsx index b3ed7ce9..147f7a7e 100644 --- a/frontend/app/join/page.tsx +++ b/frontend/app/join/page.tsx @@ -124,7 +124,7 @@ function JoinPageContent() { setJoinError(undefined); try { const result = await joinApi.redeem(code); - router.push(`/dashboard/tournaments/${result.target_id}/overview`); + router.replace(`/tournaments/${result.target_id}/onboarding`); } catch (err: unknown) { setJoinError(err instanceof ApiError ? err.message : "Something went wrong. Try again."); setJoining(false); @@ -185,8 +185,8 @@ function JoinPageContent() {

You’re already a member of this tournament.

- ) : ( 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 > 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..b061e689 --- /dev/null +++ b/frontend/app/tournaments/[id]/onboarding/page.tsx @@ -0,0 +1,39 @@ +"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) { + const redirect = encodeURIComponent(`/tournaments/${tournamentId}/onboarding`); + router.replace(`/forms/${progress.next_form_id}/view?redirect=${redirect}`); + } 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/ArchivedFieldsSection.tsx b/frontend/components/forms/ArchivedFieldsSection.tsx new file mode 100644 index 00000000..6f23df6a --- /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: +// 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 — + 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); + 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/EntityOptionsEditor.tsx b/frontend/components/forms/EntityOptionsEditor.tsx index 00264ffd..b9a3ecf4 100644 --- a/frontend/components/forms/EntityOptionsEditor.tsx +++ b/frontend/components/forms/EntityOptionsEditor.tsx @@ -2,75 +2,69 @@ 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 { 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' -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 + /** Forwarded to OptionsEditor — whether archiving an option is offered + alongside removing it. */ + allowArchive?: 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: 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') { 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 +72,222 @@ 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 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, allowArchive = false }: EntityOptionsEditorProps) { + const isEntity = fieldKey !== 'track_status' + const hasTracks = fieldKey === 'track_status' || trackStatusEnabled + const [entities, setEntities] = useState(isEntity ? null : []) + // Fetched here rather than inside TrackPicker: that renders once per + // option, so a field with eight choices was making eight identical + // requests for the same catalog. + const [tracks, setTracks] = useState([]) 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 (!hasTracks) return + tournamentTracksApi.list(tournament.id).then(setTracks).catch(() => {}) + }, [hasTracks, tournament.id]) + + useEffect(() => { + 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)} + />} + {hasTracks && 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} />} + /> +} + +function TrackPicker({ tracks, option, availability, onChange }: { tracks: TournamentTrack[]; option: EditableOption; availability: boolean; onChange: (option: EditableOption) => void }) { + 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)} /> : 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 ( - 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} - /> + + {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" /> ) } diff --git a/frontend/components/forms/FieldCard.tsx b/frontend/components/forms/FieldCard.tsx index 59cc3f3a..39795603 100644 --- a/frontend/components/forms/FieldCard.tsx +++ b/frontend/components/forms/FieldCard.tsx @@ -117,6 +117,7 @@ function applyFocusIntent(root: HTMLElement, intent: FocusIntent) { // duplicated between a respondent-facing renderer and a TD-facing editor. export function FieldCard({ field, expanded, onExpand, focusIntent, focusNonce, onFieldChange, onDuplicate, onDelete, tournament, shifts, allFields, errors, + allowArchive = false, }: { field: EditableField; expanded: boolean; @@ -142,6 +143,8 @@ export function FieldCard({ shifts: TournamentShift[] | null; allFields: EditableField[]; errors: string[]; + /** Passed through to the options editor — see QuestionRenderer. */ + allowArchive?: boolean; }) { const [hovered, setHovered] = useState(false); const presetKind = activePresetKind(field.field_key); @@ -316,6 +319,7 @@ export function FieldCard({ branchingEnabled={field.branchingEnabled} customValuesEnabled={field.customValuesEnabled} errors={bodyErrors} + allowArchive={allowArchive} />
@@ -327,6 +331,7 @@ export function FieldCard({ onFieldChange({ config: { ...field.config, required: checked } })} + locked={presetKind === "track_status"} />
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. )} diff --git a/frontend/components/forms/FieldList.tsx b/frontend/components/forms/FieldList.tsx index 8735574c..c8a11127 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 { ApiError, formsApi, tournamentsApi, tournamentShiftsApi, FieldChange, 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,12 +19,15 @@ 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 { 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"; + // 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 // a slow connection, short enough that it can't surprise you later. @@ -67,6 +70,22 @@ 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([]); + // 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>({}); + // field_id -> the TD's decision. Only these questions carry one; everything + // else in the payload omits notify_responders and takes the server's + // defaults. + const notifyRef = useRef>({}); + 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 +308,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. + // Unarchiving 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 unarchiveField(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; @@ -311,19 +348,40 @@ export function FieldList({ form }: { form: Form }) { setPendingScrollKey(copy.clientKey); } - // Deleting the expanded card would otherwise drop the open count to zero — + // Archiving the expanded card would otherwise drop the open count to zero — // fall back to whatever's now at its old index (its old neighbor below), // or the one above if it was last. - function deleteField(clientKey: string) { + function archiveField(clientKey: string) { const deletedIndex = fields.findIndex((f) => f.clientKey === clientKey); + const target = fields[deletedIndex]; const next = fields.filter((f) => f.clientKey !== clientKey); setFields(next); if (expandedKey === clientKey) { const fallback = next[deletedIndex] ?? next[deletedIndex - 1]; setExpandedKey(fallback ? fallback.clientKey : null); } + // Move it into the archived section right away rather than letting it + // vanish until the next Save. A field that was never saved has no + // archived state to go to — it just goes. The staged version is kept, not + // the server's, so edits made before archiving survive an unarchive. + if (target?.id) { + const { clientKey: _c, showDescription: _s, branchingEnabled: _b, customValuesEnabled: _v, ...stored } = target; + setArchivedFields((prev) => [{ ...stored, id: target.id as string, is_archived: true }, ...prev]); + } + } + + // 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; + async function handleSave() { const issues = validation.validate(fields); if (issues.length > 0) { @@ -336,13 +394,47 @@ export function FieldList({ form }: { form: Form }) { setSaveAttempt((n) => n + 1); return; } + + if (hasResponses && pendingChanges === null) { + // The server owns the rules — see backend/app/core/form/changes.py. The + // round trip is affordable because this only happens on a deliberate + // save of a form that already has responses. + setSaving(true); + validation.setSaveError(""); + try { + const changes = await formsApi.classifyFieldChanges(form.id, fields.map((f) => toFieldInput(f))); + if (changes.length > 0) { + setNotifyByKey(Object.fromEntries(changes.map((c) => [c.field_id, c.notify_default]))); + setPendingChanges(changes); + return; + } + } catch { + // Stop rather than save anyway. The PUT would apply each change's + // default, which is exactly the decision this step exists to give the + // TD — saving past a failed check would quietly take it away. Nothing + // has been written, so pressing Save again just retries. + validation.setSaveError("Couldn't check which questions changed, so nothing was saved. Try saving again."); + return; + } finally { + setSaving(false); + } + } + 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, f.id ? decided[f.id] : undefined)), + ); const next = updated .filter((f) => !f.is_archived) .sort((a, b) => a.order - b.order) @@ -354,9 +446,27 @@ 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); + // 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); } } @@ -471,11 +581,12 @@ export function FieldList({ form }: { form: Form }) { focusNonce={focusRequest?.key === field.clientKey ? focusRequest.nonce : 0} onFieldChange={(updates) => updateField(field.clientKey, updates)} onDuplicate={() => duplicateField(field.clientKey)} - onDelete={() => deleteField(field.clientKey)} + onDelete={() => archiveField(field.clientKey)} tournament={tournament} shifts={shifts} allFields={fields} errors={validation.errorsFor(field.clientKey)} + allowArchive={hasResponses} /> ))} @@ -487,6 +598,28 @@ export function FieldList({ form }: { form: Form }) { )} + {pendingChanges && ( + setNotifyByKey((prev) => ({ ...prev, [fieldId]: value }))} + onCancel={() => setPendingChanges(null)} + onConfirm={() => { notifyRef.current = notifyByKey; commitSave(); }} + saving={saving} + /> + )} + { + 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)); + }} + /> ; @@ -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 && (
- - - ) + + ); } diff --git a/frontend/components/forms/FormFillFlow.tsx b/frontend/components/forms/FormFillFlow.tsx index d4b6b08e..fb9ae55b 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,11 +118,18 @@ 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, // 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 @@ -176,12 +183,61 @@ 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; + // Plain number, and window.clearTimeout to match: @types/node merges its + // own setTimeout into globalThis, so both the bare global and + // window.setTimeout type as returning NodeJS.Timeout here — while the + // browser hands back a numeric handle. + let timer: number | 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) window.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 // again rather than letting showSuccess flip true the instant the last // error happens to clear. setSubmitSucceeded(false); + setSubmitError(undefined); } function handleContinue(field: FormField) { @@ -190,9 +246,10 @@ 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); } - 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 +257,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 +340,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/components/forms/FormUpdateFlow.tsx b/frontend/components/forms/FormUpdateFlow.tsx new file mode 100644 index 00000000..bfc816a8 --- /dev/null +++ b/frontend/components/forms/FormUpdateFlow.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Banner } from "@/components/ui/Banner"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { ApiError, Form, FormResponse, formsApi, PendingUpdateReason } from "@/lib/api"; +import { QuestionRenderer } from "@/components/forms/QuestionRenderer"; +import { REASON_LABELS } from "@/lib/forms/changeClassification"; + +// Re-answering a submitted response. Deliberately not FormFillFlow: that one +// reveals a question at a time because the respondent is meeting the form for +// the first time. Here they've already answered — the whole form is shown at +// once, filled in, and only the questions the TD flagged can be touched. +// +// Everything else renders read-only rather than being hidden. Seeing the +// surrounding answers is what makes a flagged question answerable ("morning, +// like I said on the other question"), and it makes clear that the rest of +// the response is intact and isn't being resubmitted. +export function FormUpdateFlow({ form, response, onUpdated }: { + form: Form; + response: FormResponse; + onUpdated: () => 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 + + )} + +
+
+ ); +} diff --git a/frontend/components/forms/NotifyRespondersModal.tsx b/frontend/components/forms/NotifyRespondersModal.tsx new file mode 100644 index 00000000..31d2a83c --- /dev/null +++ b/frontend/components/forms/NotifyRespondersModal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Modal } from "@/components/ui/Modal"; +import { Button } from "@/components/ui/Button"; +import { Toggle } from "@/components/ui/Toggle"; +import { IconLock } from "@/components/ui/Icons"; +import { FieldChange } from "@/lib/api"; +import { REASON_CONSEQUENCES, REASON_LABELS } from "@/lib/forms/changeClassification"; + +// Last stop before a save asks people to redo work. Shown only on a form that +// already has responses, and only for questions whose edits could actually +// raise a prompt — a save that changes nothing consequential goes straight +// through. +// +// Locked rows aren't a formality: a mandatory change invalidated the stored +// answer, so the prompt isn't optional. They're listed anyway so the TD sees +// the full blast radius, not just the part they can still change. +export function NotifyRespondersModal({ + changes, notify, onToggle, onCancel, onConfirm, saving, +}: { + /** The server's verdict for this save — see formsApi.classifyFieldChanges. */ + changes: FieldChange[]; + /** field_id -> whether to ask previous responders about this question. */ + notify: Record; + onToggle: (fieldId: string, value: boolean) => void; + onCancel: () => void; + onConfirm: () => void; + saving: boolean; +}) { + const asking = changes.filter((c) => notify[c.field_id]).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.field_id]; + 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.field_id, 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/components/forms/OptionsEditor.tsx b/frontend/components/forms/OptionsEditor.tsx index 1d067230..ea65c36e 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 { 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)} + onRemove={() => (allowArchive + ? setArchived(option.clientKey, true) + : removeOption(option.clientKey))} onEnter={() => addOptionAfter(option.clientKey)} /> ))} - + + setArchived(clientKey, false)} + onRemove={(clientKey) => onChange(options.filter((o) => o.clientKey !== clientKey))} + /> +
+ ) +} + +// Archived options, 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 ( +
+ + Archived + + {options.map((option) => ( +
+ + {option.label || 'Untitled option'} + + + +
+ ))}
) } @@ -338,7 +407,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, onEnter }: { option: EditableOption bulletType: BulletType /** 1-based position — only rendered when bulletType is 'number' (dropdown). */ @@ -348,9 +417,14 @@ function OptionRow({ option, bulletType, number, displayStyle, trailing, extra, extra?: ReactNode error?: string canRemove: boolean + /** Whether archiving 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 + /** Archives or deletes depending on allowArchive — the row only knows it's + the "take this out of the list" action. */ onRemove: () => void onEnter: () => void }) { @@ -422,12 +496,21 @@ function OptionRow({ option, bulletType, number, displayStyle, trailing, extra, size="md" fullWidth /> + {trailing} + {/* 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 && ( - )} - {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 diff --git a/frontend/components/forms/PresetPopover.tsx b/frontend/components/forms/PresetPopover.tsx index 7890064d..a10f645c 100644 --- a/frontend/components/forms/PresetPopover.tsx +++ b/frontend/components/forms/PresetPopover.tsx @@ -2,25 +2,58 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/Button"; -import { ButtonGroup } from "@/components/ui/ButtonGroup"; +import { Dropdown } from "@/components/ui/Dropdown"; import { FormPopover } from "@/components/ui/FormPopover"; import { Input } from "@/components/ui/Input"; -import { IconPresets } from "@/components/ui/Icons"; +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, parseAvailabilityFieldKey, buildAvailabilityFieldKey, parseEventPreferenceFieldKey, buildEventPreferenceFieldKey, parseLunchFieldKey, buildLunchFieldKey, + parseTrackStatusFieldKey, buildTrackStatusFieldKey, } 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" }, { value: "lunch", label: "Lunch" }, + { value: "track_status", label: "Track" }, ]; // Reserved-key presets (availability_{date}, event_preference_{suffix}, @@ -28,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, }: { @@ -66,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; } @@ -82,17 +122,26 @@ export function PresetPopover({ const fieldKey = kind === "availability" ? buildAvailabilityFieldKey(soleDay ?? "") : kind === "lunch" ? buildLunchFieldKey(soleDay ?? "", "") + : kind === "track_status" ? "track_status_" : "event_preference_"; // Every preset's allowedQuestionTypes is option-bearing, so there's // always exactly one starter row to seed here — entity-shaped (an empty // 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 options = carryOptions(field, kind, questionType); onFieldChange({ field_key: fieldKey, question_type: questionType, - config: { ...sanitizeConfigForType(field.config, questionType), options: [starterOption] }, + 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, + }, }); } @@ -105,7 +154,7 @@ export function PresetPopover({ } - width={320} + width={250} side="right" open={open} onOpenChange={(next) => { onOpenChange(next); if (next) onOpen?.(); }} @@ -121,16 +170,55 @@ export function PresetPopover({ }}> Preset - applyPresetKind(v === presetKind ? null : (v as PresetKind))} - size="sm" - fullWidth - /> +
+ applyPresetKind(value as PresetKind)} + placeholder="No preset" + size="sm" + fullWidth + /> + {presetKind && ( + + )} +
{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)) { + 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 ?? [] }; + } + return option; + }), + }, + })} + /> +
+ )} {presetKind === "event_preference" && ( @@ -138,12 +226,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..3dbba66e 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} /> ) : ( @@ -190,6 +197,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 +244,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 +274,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 +323,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 ( void tournament: Tournament | null @@ -355,10 +368,12 @@ 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) const isEntityBackedKind = isEntityBackedPreset(presetKind) + 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 @@ -375,24 +390,39 @@ function QuestionEditBody({ field, onFieldChange, tournament, branchTargets, bra return } - if (isEntity || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { + const usesTrackEditor = hasTracks && !!tournament + + // 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 setOptions = (options: EditableOption[]) => + onFieldChange({ config: { ...field.config, options } }) + + if (isEntity || usesTrackEditor || (!isEntityBackedKind && OPTION_BEARING_TYPES.includes(field.question_type))) { return ( <> - {isEntity ? ( + {isEntity || usesTrackEditor ? ( onFieldChange({ config: { ...field.config, options } })} + options={allOptions} + onChange={setOptions} displayStyle={field.config?.display_style} branchTargets={supportsBranching && branchingEnabled ? branchTargets : undefined} errors={errors} + trackStatusEnabled={hasTracks} + allowArchive={allowArchive} /> ) : ( onFieldChange({ config: { ...field.config, options } })} + options={allOptions} + onChange={setOptions} + allowArchive={allowArchive} questionType={field.question_type} displayStyle={field.config?.display_style} branchTargets={supportsBranching && branchingEnabled ? branchTargets : undefined} @@ -400,12 +430,16 @@ 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 = (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 @@ -426,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 } })} + /> +
+ )} ) })()} diff --git a/frontend/components/forms/StatusControl.tsx b/frontend/components/forms/StatusControl.tsx index d23ffd02..8137f3bb 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 }: { @@ -31,11 +31,35 @@ 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); + } + } + + 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.update(form.id, { status: "draft" })); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to restore form."); + } finally { + setBusy(false); } } @@ -71,8 +95,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/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/components/tournament/MemberPanel.tsx b/frontend/components/tournament/MemberPanel.tsx index 97ee925b..afa959b1 100644 --- a/frontend/components/tournament/MemberPanel.tsx +++ b/frontend/components/tournament/MemberPanel.tsx @@ -6,7 +6,6 @@ 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"; @@ -94,16 +93,6 @@ export function MemberPanel({
-
-
- Status -
- {full.status} -
-
-
- Roles +
+ {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} +
+ ))} +
+
+ )} + +
+
+ Roles +
+
-
diff --git a/frontend/components/tournament/MembersFilterModal.tsx b/frontend/components/tournament/MembersFilterModal.tsx index 2f93e144..093e32ee 100644 --- a/frontend/components/tournament/MembersFilterModal.tsx +++ b/frontend/components/tournament/MembersFilterModal.tsx @@ -4,7 +4,7 @@ import { FilterModal, FilterOption, FilterSectionConfig, FilterState, isFilterActive, } from "@/components/ui/FilterModal"; -export const MEMBERS_FILTER_KEYS = ["role", "status"] as const; +export const MEMBERS_FILTER_KEYS = ["role"] as const; type MembersFilterKey = (typeof MEMBERS_FILTER_KEYS)[number]; export type MembersFilterState = FilterState; @@ -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/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/components/ui/ChipInput.tsx b/frontend/components/ui/ChipInput.tsx index 5932cdaa..41709fbb 100644 --- a/frontend/components/ui/ChipInput.tsx +++ b/frontend/components/ui/ChipInput.tsx @@ -29,6 +29,8 @@ interface ChipInputProps { size?: ChipInputSize; /** Rendered as the last item in the chip row (wraps with the chips), e.g. an "add" popover trigger. */ addButton?: ReactNode; + /** Optional control rendered inside each chip, e.g. a status dropdown. */ + renderChipTrailing?: (chip: string) => 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 && ( = { - 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/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} +
+ )} +
+
+ + +
-
+ ); } diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 84676eaa..523da206 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -440,6 +440,16 @@ export function IconArchive({ size = 16, ...props }: IconProps) { ); } +// Counter-clockwise arrow — undoing/bringing something back, paired with +// IconArchive wherever an archive can be reversed. +export function IconRestore({ size = 16, ...props }: IconProps) { + return ( + + + + ); +} + export function IconInvite({ size = 16, ...props }: IconProps) { return ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 017ee9a8..f639dee2 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -610,7 +610,16 @@ export const seasonEventsApi = { // ------------------------------------------------------------------------- // Memberships // ------------------------------------------------------------------------- -export type MembershipStatus = 'interested' | 'confirmed' +// One member's status on one track — matches MembershipTrackStatusRead. +// Carries the track's name so a renderer needs no separate catalog fetch, and +// is_archived so a retired track's history can be shown as such. +export interface MembershipTrackStatus { + track_id: number + name: string + is_archived: boolean + status: TrackStatus + updated_at: string +} // 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 @@ -655,7 +664,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 +677,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 @@ -682,6 +689,7 @@ export interface MembershipFull { is_over_21: boolean | null created_at: string updated_at: string + track_statuses: MembershipTrackStatus[] roles: Role[] user: UserFull } @@ -703,9 +711,9 @@ export interface MembershipCoordinatorUpdate { export interface MembershipMe { membership_id: number | null is_owner: boolean - status: MembershipStatus | null roles: Role[] permissions: Permission[] + track_statuses: MembershipTrackStatus[] } export const membershipsApi = { @@ -1164,7 +1172,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 +1180,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 +1216,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 { @@ -1215,10 +1233,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 @@ -1226,6 +1245,50 @@ 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" + +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 TournamentTrack { + id: number + tournament_id: number + name: string + is_archived: boolean + created_at: string + updated_at: string +} + +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 { @@ -1241,6 +1304,7 @@ export interface Form { created_at: string updated_at: string response_count: number + prerequisites: TournamentFormPrerequisites | null fields: FormField[] } @@ -1268,6 +1332,7 @@ export interface FormListItem { created_at: string updated_at: string response_count: number + prerequisites: TournamentFormPrerequisites | null } export interface FormCreateInput { @@ -1297,18 +1362,65 @@ 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" + +/** One question a proposed save would ask previous responders to review — + the server's verdict, from classifyFieldChanges. */ +export interface FieldChange { + field_id: string + label: string + reasons: PendingUpdateReason[] + /** At least one reason is mandatory: the TD sees it but can't switch it + off, because the change invalidated the stored answer. */ + locked: boolean + /** What notify_responders should default to if the TD doesn't touch it. */ + notify_default: boolean +} + +/** 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 +// member onboarding sequence. +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/`), + 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 @@ -1316,6 +1428,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 }) => @@ -1330,17 +1444,72 @@ 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/`, {}), // 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 }), + // Dry run for the save confirmation: given the same payload putFields + // takes, which questions would be sent back to previous responders and + // why. Writes nothing, and returns [] on a form nobody has answered. + classifyFieldChanges: (formId: string, fields: FormFieldInput[]) => + api.post(`/forms/${formId}/fields/classify/`, { 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. + 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/`), } + +export const tournamentOnboardingApi = { + listForms: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/onboarding-forms/`), + addForm: (tournamentId: number, formId: string) => + api.post(`/tournaments/${tournamentId}/onboarding-forms/`, { form_id: formId }), + reorderForms: (tournamentId: number, formIds: string[]) => + api.patch( + `/tournaments/${tournamentId}/onboarding-forms/reorder/`, + { forms: formIds.map((form_id, index) => ({ form_id, order: index + 1 })) }, + ), + removeForm: (tournamentId: number, formId: string) => + api.delete(`/tournaments/${tournamentId}/onboarding-forms/${formId}/`), + 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}/`), +} diff --git a/frontend/lib/forms/changeClassification.ts b/frontend/lib/forms/changeClassification.ts new file mode 100644 index 00000000..4d2bdb32 --- /dev/null +++ b/frontend/lib/forms/changeClassification.ts @@ -0,0 +1,24 @@ +import { PendingUpdateReason } from "@/lib/api"; + +// Copy for the pending-update reasons the server produces. The rules +// themselves live in backend/app/core/form/changes.py and are read via +// formsApi.classifyFieldChanges — deriving them a second time here is what +// would let the save confirmation quietly under-report. + +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 weighing, 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.", +}; 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 }), }; } diff --git a/frontend/lib/forms/fieldKeyPresets.ts b/frontend/lib/forms/fieldKeyPresets.ts index ff5d9d35..930bd0cb 100644 --- a/frontend/lib/forms/fieldKeyPresets.ts +++ b/frontend/lib/forms/fieldKeyPresets.ts @@ -9,7 +9,7 @@ import { FormQuestionType } from "@/lib/api"; // per date+category for lunch). See backend/form-question-types-reference.md // ("Reserved field_keys") for the full contract, including why availability // merges into one centralized pool while event_preference/lunch don't. -export type PresetKind = "availability" | "event_preference" | "lunch"; +export type PresetKind = "availability" | "event_preference" | "lunch" | "track_status"; // availability/event_preference draw their options from real tournament // entities (shifts/events) via EntityOptionsEditor — their option.value is @@ -43,18 +43,24 @@ export const PRESETS: Record = { 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 // "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 @@ -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..3b83c326 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."); + } if (field.question_type === "ranked_choice" && (config.ranks ?? 1) > options.length) { issues.push("Ranks can't exceed the number of options."); } 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", -}; 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)"