From 32e17253e5e280264c067ff5000ee0d41f2a1e46 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 13:21:25 +0200 Subject: [PATCH 01/16] feat: switch image builds to buildx --- .gitlab-ci.yml | 66 ++++++++++++++++++++++- Dockerfile | 100 +++++++++++++++++++++++++++++++++++ docker/production/Dockerfile | 19 ------- docker/test/Dockerfile | 13 ----- tasks.py | 36 +++++++++++-- 5 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 Dockerfile delete mode 100644 docker/production/Dockerfile delete mode 100644 docker/test/Dockerfile diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e43e9cf..03ec3d1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,16 +4,74 @@ stages: - deploy variables: - DOCKER_IMAGE_ENVS: "production test" + # Disable the shared image build jobs from turniere-infra; this repo owns buildx builds below. + DOCKER_IMAGE_ENVS: "" + DOCKER_BUILDKIT: "1" + BUILDX_PLATFORMS: "linux/amd64,linux/arm64" include: - project: 'turniere/turniere-infra' file: '/ci/pipeline.yaml' +.buildx-image: + stage: build + tags: + - shell + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + - export BUILDX_BUILDER_NAME="turniere-buildx-$CI_PIPELINE_ID-$CI_JOB_ID" + - docker buildx create --name "$BUILDX_BUILDER_NAME" --driver docker-container --use + - docker buildx inspect --builder "$BUILDX_BUILDER_NAME" --bootstrap + after_script: + - export BUILDX_BUILDER_NAME="turniere-buildx-$CI_PIPELINE_ID-$CI_JOB_ID" + - docker buildx rm "$BUILDX_BUILDER_NAME" || true + - docker logout "$CI_REGISTRY" || true + +build production image: + extends: .buildx-image + script: + - >- + docker buildx build + --builder "$BUILDX_BUILDER_NAME" + --file Dockerfile + --target production + --platform "$BUILDX_PLATFORMS" + --cache-from "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:production" + --cache-to "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:production,mode=max" + --tag "$CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA" + --push + . + rules: + - if: $CI_PIPELINE_SOURCE != "push" + when: never + - when: always + +build test image: + extends: .buildx-image + script: + - >- + docker buildx build + --builder "$BUILDX_BUILDER_NAME" + --file Dockerfile + --target test + --platform "$BUILDX_PLATFORMS" + --cache-from "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:production" + --cache-from "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:test" + --cache-to "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:test,mode=max" + --tag "$CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA" + --push + . + rules: + - if: $CI_PIPELINE_SOURCE != "push" + when: never + - when: always + rails spec: stage: test tags: - docker + needs: + - build test image image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA parallel: matrix: @@ -42,6 +100,9 @@ rails e2e spec: stage: test tags: - docker + needs: + - build production image + - build test image image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA services: - name: postgres:16 @@ -93,6 +154,9 @@ rails follow sync e2e spec: stage: test tags: - docker + needs: + - build production image + - build test image image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA services: - name: postgres:16 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..00f2006 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# syntax=docker/dockerfile:1.7 + +ARG RUBY_VERSION=3.1.2 +ARG BUNDLER_VERSION=2.3.13 + +FROM ruby:${RUBY_VERSION}-slim AS runtime-base + +ARG BUNDLER_VERSION + +ENV LANG=C.UTF-8 \ + BUNDLE_JOBS=4 \ + BUNDLE_RETRY=3 \ + BUNDLE_PATH=/usr/local/bundle + +WORKDIR /app + +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + libpq5 \ + nodejs \ + openssl \ + && rm -rf /var/lib/apt/lists/* \ + && gem install bundler -v ${BUNDLER_VERSION} \ + && gem install tzinfo-data + +FROM runtime-base AS build-base + +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + libpq-dev \ + libsqlite3-dev \ + pkg-config \ + python3 \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* + +COPY Gemfile Gemfile.lock ./ + +FROM build-base AS bundle-production + +RUN bundle config set deployment 'true' \ + && bundle config set without 'development test' \ + && bundle install \ + && rm -rf /usr/local/bundle/cache/*.gem + +FROM build-base AS bundle-test + +RUN bundle config set with 'development test' \ + && bundle install \ + && rm -rf /usr/local/bundle/cache/*.gem + +FROM runtime-base AS production + +ENV RAILS_ENV=production + +COPY --from=bundle-production /usr/local/bundle /usr/local/bundle +COPY app /app/app +COPY bin /app/bin +COPY config /app/config +COPY db /app/db +COPY public /app/public +COPY script /app/script +COPY config.ru Rakefile /app/ +# Duplicate production environment to beta environment +COPY config/environments/production.rb /app/config/environments/beta.rb + +EXPOSE 3000 + +CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0"] + +FROM runtime-base AS test + +ENV RAILS_ENV=test + +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + libsqlite3-0 \ + python3 \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -m pip install --no-cache-dir invoke + +COPY --from=bundle-test /usr/local/bundle /usr/local/bundle +COPY app /app/app +COPY bin /app/bin +COPY config /app/config +COPY db /app/db +COPY public /app/public +COPY script /app/script +COPY config.ru Rakefile /app/ +COPY config/environments/production.rb /app/config/environments/beta.rb +COPY tasks.py /app/tasks.py +COPY e2e /app/e2e +COPY lib /app/lib +COPY spec /app/spec +COPY .rspec /app/.rspec + +CMD ["sh", "-lc", "bundle exec rails db:migrate && bundle exec rails spec"] diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile deleted file mode 100644 index f4d11ff..0000000 --- a/docker/production/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -ARG base_commit -FROM ruby:3.1.2-slim -ENV RAILS_ENV production -# RUN apk add --no-cache build-base tzdata sqlite sqlite-dev postgresql-dev git && gem install tzinfo-data -RUN apt-get update -qq && apt-get install -y openssl git build-essential libpq-dev nodejs && apt-get clean && gem install tzinfo-data -WORKDIR /app -COPY Gemfile* /app/ -RUN gem install bundler:2.3.13 && bundle config set deployment 'true' && bundle install -COPY app /app/app -COPY bin /app/bin -COPY config /app/config -COPY db /app/db -COPY public /app/public -COPY script /app/script -COPY config.ru Rakefile /app/ -# Duplicate production environment to beta environment -COPY config/environments/production.rb config/environments/beta.rb -EXPOSE 3000 -CMD bundle exec rails db:migrate && bundle exec rails s -p 3000 -b 0.0.0.0 diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile deleted file mode 100644 index a2446ba..0000000 --- a/docker/test/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -# Build production container locally first with following tag -ARG base_commit -FROM registry.gitlab.com/turniere/turniere-backend/production/commits:$base_commit -WORKDIR /app -RUN apt-get update -qq && apt-get install -y python3 python3-pip && apt-get clean && python3 -m pip install --no-cache-dir invoke -RUN bundle config set with 'development test' && bundle install -COPY tasks.py /app/tasks.py -COPY e2e /app/e2e -COPY lib /app/lib -COPY spec /app/spec -COPY .rspec /app/.rspec -ENV RAILS_ENV test -CMD bundle exec rails db:migrate && bundle exec rails spec diff --git a/tasks.py b/tasks.py index 6fd9f3d..af6fa28 100644 --- a/tasks.py +++ b/tasks.py @@ -31,6 +31,9 @@ E2E_ALT_USERNAME = "e2e-alt-user" PRODUCTION_TAG = "registry.gitlab.com/turniere/turniere-backend/production/commits:local" TEST_TAG = "registry.gitlab.com/turniere/turniere-backend/test/commits:local" +DOCKERFILE_PATH = "Dockerfile" +PRODUCTION_DOCKER_TARGET = "production" +TEST_DOCKER_TARGET = "test" BLACKBOX_COMPOSE_FILE = "docker-compose.blackbox.yml" BLACKBOX_PROJECT = "turniere-blackbox" BLACKBOX_FOLLOW_COMPOSE_FILE = "docker-compose.blackbox-follow.yml" @@ -228,6 +231,31 @@ def _format_command(command): return " ".join(shlex.quote(part) for part in command) +def _docker_build_command(tag, target, platforms=None, push=False): + if platforms and "," in str(platforms) and not push: + raise ValueError("multi-platform buildx builds require push=True") + + if platforms or push: + command = [ + "docker", + "buildx", + "build", + "--target", + target, + "-t", + tag, + "-f", + DOCKERFILE_PATH, + ] + if platforms: + command.extend(["--platform", platforms]) + command.append("--push" if push else "--load") + command.append(".") + return _format_command(command) + + return f"docker build --target {target} -t {tag} -f {DOCKERFILE_PATH} ." + + def _rspec_command(*paths): command = ["bundle", "exec", "rspec", *paths] if os.environ.get("CI"): @@ -814,15 +842,15 @@ def scenario_render_profile( @task(name="docker-build-production") -def docker_build_production(c, tag=PRODUCTION_TAG): +def docker_build_production(c, tag=PRODUCTION_TAG, platforms=None, push=False): """Build the production Docker image.""" - c.run(f"docker build -t {tag} -f docker/production/Dockerfile .", pty=True) + c.run(_docker_build_command(tag, PRODUCTION_DOCKER_TARGET, platforms=platforms, push=push), pty=True) @task(name="docker-build-test") -def docker_build_test(c, tag=TEST_TAG): +def docker_build_test(c, tag=TEST_TAG, platforms=None, push=False): """Build the test Docker image.""" - c.run(f"docker build --build-arg base_commit=local -t {tag} -f docker/test/Dockerfile .", pty=True) + c.run(_docker_build_command(tag, TEST_DOCKER_TARGET, platforms=platforms, push=push), pty=True) @task(name="docker-build-all") From dfb67ac8500a65620483d1f056d6ebe28f2b096e Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 13:40:37 +0200 Subject: [PATCH 02/16] ci: test shared buildx pipeline --- .gitlab-ci.yml | 76 ++++------------------------------- doc/buildx_migration_notes.md | 60 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 68 deletions(-) create mode 100644 doc/buildx_migration_notes.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 216d302..7140384 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,76 +5,22 @@ stages: - deploy variables: - # Disable the shared image build jobs from turniere-infra; this repo owns buildx builds below. - DOCKER_IMAGE_ENVS: "" - DOCKER_BUILDKIT: "1" - BUILDX_PLATFORMS: "linux/amd64,linux/arm64" + DOCKER_IMAGE_ENVS: "production test" + DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64" + DOCKERFILE_PATH: "Dockerfile" + DOCKER_BUILD_TARGETS: "production=production test=test" include: - project: 'turniere/turniere-infra' + ref: 'codex/buildx-multiarch-template' file: '/ci/pipeline.yaml' -.buildx-image: - stage: build - tags: - - shell - before_script: - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - export BUILDX_BUILDER_NAME="turniere-buildx-$CI_PIPELINE_ID-$CI_JOB_ID" - - docker buildx create --name "$BUILDX_BUILDER_NAME" --driver docker-container --use - - docker buildx inspect --builder "$BUILDX_BUILDER_NAME" --bootstrap - after_script: - - export BUILDX_BUILDER_NAME="turniere-buildx-$CI_PIPELINE_ID-$CI_JOB_ID" - - docker buildx rm "$BUILDX_BUILDER_NAME" || true - - docker logout "$CI_REGISTRY" || true - -build production image: - extends: .buildx-image - script: - - >- - docker buildx build - --builder "$BUILDX_BUILDER_NAME" - --file Dockerfile - --target production - --platform "$BUILDX_PLATFORMS" - --cache-from "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:production" - --cache-to "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:production,mode=max" - --tag "$CI_REGISTRY_IMAGE/production/commits:$CI_COMMIT_SHA" - --push - . - rules: - - if: $CI_PIPELINE_SOURCE != "push" - when: never - - when: always - -build test image: - extends: .buildx-image - script: - - >- - docker buildx build - --builder "$BUILDX_BUILDER_NAME" - --file Dockerfile - --target test - --platform "$BUILDX_PLATFORMS" - --cache-from "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:production" - --cache-from "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:test" - --cache-to "type=registry,ref=$CI_REGISTRY_IMAGE/buildcache:test,mode=max" - --tag "$CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA" - --push - . - rules: - - if: $CI_PIPELINE_SOURCE != "push" - when: never - - when: always - rails spec: stage: test - needs: - - job: build_image tags: - docker needs: - - build test image + - job: build_image image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA parallel: matrix: @@ -101,13 +47,10 @@ rails spec: rails e2e spec: stage: e2e - needs: - - job: build_image tags: - docker needs: - - build production image - - build test image + - job: build_image image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA services: - name: postgres:16 @@ -157,13 +100,10 @@ rails e2e spec: rails follow sync e2e spec: stage: e2e - needs: - - job: build_image tags: - docker needs: - - build production image - - build test image + - job: build_image image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA services: - name: postgres:16 diff --git a/doc/buildx_migration_notes.md b/doc/buildx_migration_notes.md new file mode 100644 index 0000000..d6faa5e --- /dev/null +++ b/doc/buildx_migration_notes.md @@ -0,0 +1,60 @@ +# Buildx Migration Notes + +This backend branch temporarily pins the shared GitLab include to the infra branch +`codex/buildx-multiarch-template` so the new shared image build flow can be tested +end to end before the infra MR is merged. + +After the infra MR is merged: + +- remove the temporary `ref:` override from `.gitlab-ci.yml` +- keep the repo-level variables that describe how this repo maps images to a single + multi-stage `Dockerfile` + +## Backend pattern + +Backend now uses: + +- one root `Dockerfile` +- `DOCKER_IMAGE_ENVS: "production test"` +- `DOCKERFILE_PATH: "Dockerfile"` +- `DOCKER_BUILD_TARGETS: "production=production test=test"` +- `DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64"` + +## turniere-frontend + +Frontend can move in two steps. + +1. No Dockerfile restructuring required for the shared buildx rollout itself. + Its current `docker/production/Dockerfile` already fits the shared template. +2. To add Raspberry Pi support, verify the current production image really builds on + `linux/arm64`. + The current file uses `node:16-alpine` and `alpine`; that should be checked in CI. + +Recommended frontend CI change after infra is merged: + +- keep `DOCKER_IMAGE_ENVS: "production"` +- add `DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64"` + +Optional frontend follow-up: + +- if you want the same repo shape as backend, replace `docker/production/Dockerfile` + with a root multi-stage `Dockerfile`, then add: + `DOCKERFILE_PATH: "Dockerfile"` and + `DOCKER_BUILD_TARGETS: "production=production"` + +## turniere-match + +Match also does not need a Dockerfile restructuring for the shared buildx rollout. +Its current `docker/production/Dockerfile` already matches the default shared +template contract. + +Recommended match CI change after infra is merged: + +- keep `DOCKER_IMAGE_ENVS: "production"` +- add `DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64"` + +Suggested follow-up: + +- run a multi-arch build smoke test for the Alpine-based Python image +- if packaging or native wheels become slow on arm64, consider a multi-stage + Dockerfile with a slimmer runtime image similar to the backend pattern From 5288d26fa4c92e65112398fb490124f8ec458c64 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 14:28:27 +0200 Subject: [PATCH 04/16] ci: make arm builds on-demand --- .gitlab-ci.yml | 1 - doc/buildx_migration_notes.md | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7140384..581ea77 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,7 +6,6 @@ stages: variables: DOCKER_IMAGE_ENVS: "production test" - DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64" DOCKERFILE_PATH: "Dockerfile" DOCKER_BUILD_TARGETS: "production=production test=test" diff --git a/doc/buildx_migration_notes.md b/doc/buildx_migration_notes.md index d6faa5e..0358c44 100644 --- a/doc/buildx_migration_notes.md +++ b/doc/buildx_migration_notes.md @@ -18,7 +18,8 @@ Backend now uses: - `DOCKER_IMAGE_ENVS: "production test"` - `DOCKERFILE_PATH: "Dockerfile"` - `DOCKER_BUILD_TARGETS: "production=production test=test"` -- `DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64"` +- shared infra template builds amd64 automatically on push pipelines +- shared infra template builds arm64 manually on branch pipelines and automatically on default-branch pushes ## turniere-frontend @@ -33,7 +34,7 @@ Frontend can move in two steps. Recommended frontend CI change after infra is merged: - keep `DOCKER_IMAGE_ENVS: "production"` -- add `DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64"` +- no extra platform variable needed if default shared behavior is acceptable Optional frontend follow-up: @@ -51,7 +52,7 @@ template contract. Recommended match CI change after infra is merged: - keep `DOCKER_IMAGE_ENVS: "production"` -- add `DOCKER_IMAGE_PLATFORMS: "linux/amd64,linux/arm64"` +- no extra platform variable needed if default shared behavior is acceptable Suggested follow-up: From 7151976d264ec3e380bac451df43e8916dddcfa3 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 14:35:16 +0200 Subject: [PATCH 05/16] feat: add backend beamer live updates --- README.md | 1 + app/channels/tournament_beamers_channel.rb | 20 +++ app/models/beamer.rb | 33 +++++ app/serializers/beamer_serializer.rb | 2 +- app/services/beamer_broadcast.rb | 14 ++ app/services/beamer_broadcast_payload.rb | 39 ++++++ doc/beamer_live_updates.md | 64 +++++++++ e2e/lib/action_cable_client.rb | 54 ++++++-- .../http/tournament_beamers_websocket_spec.rb | 98 ++++++++++++++ ...ment_follow_sync_beamers_websocket_spec.rb | 126 ++++++++++++++++++ spec/models/beamer_spec.rb | 50 +++++++ 11 files changed, 488 insertions(+), 13 deletions(-) create mode 100644 app/channels/tournament_beamers_channel.rb create mode 100644 app/services/beamer_broadcast.rb create mode 100644 app/services/beamer_broadcast_payload.rb create mode 100644 doc/beamer_live_updates.md create mode 100644 spec/e2e/http/tournament_beamers_websocket_spec.rb create mode 100644 spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb diff --git a/README.md b/README.md index 90bf18f..400b4c0 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ bundle exec rspec Leader/follower tournament sync docs: - [doc/leader_follower.md](doc/leader_follower.md) +- [doc/beamer_live_updates.md](doc/beamer_live_updates.md) ## Docker [Registry](https://gitlab.com/turniere/turniere-backend/container_registry) diff --git a/app/channels/tournament_beamers_channel.rb b/app/channels/tournament_beamers_channel.rb new file mode 100644 index 0000000..cbcbf7e --- /dev/null +++ b/app/channels/tournament_beamers_channel.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class TournamentBeamersChannel < ApplicationCable::Channel + class << self + def stream_name(tournament_id:, beamer_name:) + "tournament_#{tournament_id}:beamers:#{beamer_name}" + end + end + + def subscribed + tournament = Tournament.find(params[:tournament_id]) + beamer_name = params[:beamer_name].presence + beamer = tournament.beamers.find_by(name: beamer_name) || tournament.beamers.find_by(name: Beamer::DEFAULT_NAME) + + return reject unless beamer + + stream_from self.class.stream_name(tournament_id: tournament.id, beamer_name: beamer.name) + transmit BeamerBroadcastPayload.build(beamer:, event: 'snapshot') + end +end diff --git a/app/models/beamer.rb b/app/models/beamer.rb index 180f915..47f44e0 100644 --- a/app/models/beamer.rb +++ b/app/models/beamer.rb @@ -13,4 +13,37 @@ class Beamer < ApplicationRecord validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES } delegate :owner, to: :tournament + + before_update :store_previous_name_for_broadcast, if: :will_save_change_to_name? + before_destroy :store_snapshot_for_broadcast + after_commit :broadcast_state_change, on: %i[create update destroy] + + private + + def store_previous_name_for_broadcast + @broadcast_previous_name = name_in_database + end + + def store_snapshot_for_broadcast + @broadcast_snapshot = BeamerBroadcastPayload.snapshot(self) + end + + def broadcast_state_change + snapshot = @broadcast_snapshot || BeamerBroadcastPayload.snapshot(self) + + BeamerBroadcast.call( + tournament_id: snapshot.fetch(:tournament_id), + beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq, + payload: BeamerBroadcastPayload.build(snapshot: snapshot, event: broadcast_event) + ) + ensure + @broadcast_previous_name = nil + @broadcast_snapshot = nil + end + + def broadcast_event + return 'deleted' if destroyed? + + previous_changes.key?('id') ? 'created' : 'updated' + end end diff --git a/app/serializers/beamer_serializer.rb b/app/serializers/beamer_serializer.rb index ca15284..d6863f1 100644 --- a/app/serializers/beamer_serializer.rb +++ b/app/serializers/beamer_serializer.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class BeamerSerializer < ApplicationSerializer - attributes :name, :display_state, :qr_code_url, :logo_url + attributes :id, :name, :display_state, :qr_code_url, :logo_url end diff --git a/app/services/beamer_broadcast.rb b/app/services/beamer_broadcast.rb new file mode 100644 index 0000000..4fba0a2 --- /dev/null +++ b/app/services/beamer_broadcast.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class BeamerBroadcast + class << self + def call(tournament_id:, beamer_names:, payload:) + Array(beamer_names).compact.uniq.each do |beamer_name| + ActionCable.server.broadcast( + TournamentBeamersChannel.stream_name(tournament_id:, beamer_name:), + payload + ) + end + end + end +end diff --git a/app/services/beamer_broadcast_payload.rb b/app/services/beamer_broadcast_payload.rb new file mode 100644 index 0000000..cc47b80 --- /dev/null +++ b/app/services/beamer_broadcast_payload.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class BeamerBroadcastPayload + class << self + def build(beamer: nil, snapshot: nil, event:) + data = snapshot || self.snapshot(beamer) + + { + type: "beamer.#{event}", + event: event, + tournament_id: data.fetch(:tournament_id), + beamer: serialized_beamer(data) + } + end + + def snapshot(beamer) + { + id: beamer.id, + tournament_id: beamer.tournament_id, + name: beamer.name, + display_state: beamer.display_state, + qr_code_url: beamer.qr_code_url, + logo_url: beamer.logo_url + } + end + + private + + def serialized_beamer(snapshot) + { + id: snapshot.fetch(:id), + name: snapshot.fetch(:name), + display_state: snapshot.fetch(:display_state), + qr_code_url: snapshot[:qr_code_url], + logo_url: snapshot[:logo_url] + } + end + end +end diff --git a/doc/beamer_live_updates.md b/doc/beamer_live_updates.md new file mode 100644 index 0000000..155349e --- /dev/null +++ b/doc/beamer_live_updates.md @@ -0,0 +1,64 @@ +# Beamer Live Updates + +Beamer/projector live updates are backend-owned and use Rails ActionCable at `/cable`. + +## Goal + +Frontend fullscreen pages talk only to `turniere-backend`. +No `TURNIERE_MATCH_URL`. +No Socket.IO dependency on `turniere-match`. + +## Transport + +- websocket endpoint: `GET /cable` +- ActionCable channel: `TournamentBeamersChannel` +- subscription params: + - `tournament_id` + - `beamer_name` + +If requested beamer does not exist, channel falls back to tournament default beamer when present. + +## Payload contract + +Initial subscribe snapshot: + +```json +{ + "type": "beamer.snapshot", + "event": "snapshot", + "tournament_id": 12, + "beamer": { + "id": 34, + "name": "default", + "display_state": "matches_all", + "qr_code_url": null, + "logo_url": null + } +} +``` + +Mutation events use same `beamer` shape with `type` / `event` values: + +- `beamer.created` +- `beamer.updated` +- `beamer.deleted` + +## Broadcast source + +Broadcasts come from `Beamer` model commit callbacks. +That means normal HTTP writes and follower-sync imports use same broadcast path. + +Covered mutations: + +- create +- update +- delete + +## Frontend expectation + +Frontend should: + +- fetch beamer config from backend REST API +- subscribe to `TournamentBeamersChannel` +- update fullscreen state from explicit `beamer` payload +- reconnect if beamer name changes or current beamer gets deleted diff --git a/e2e/lib/action_cable_client.rb b/e2e/lib/action_cable_client.rb index b07a265..ff0c4dd 100644 --- a/e2e/lib/action_cable_client.rb +++ b/e2e/lib/action_cable_client.rb @@ -17,25 +17,26 @@ module TurniereE2E end def subscribe_team_action_lists!(tournament_id:) - connect! identifier = { channel: 'TournamentTeamActionListsChannel', tournament_id: tournament_id }.to_json - driver.text( - JSON.dump( - command: 'subscribe', - identifier: identifier - ) - ) - - handshake_message = wait_for! do |message| - message['type'] == 'confirm_subscription' || - (message['identifier'] == identifier && message['message'].is_a?(Hash)) + subscribe!(identifier:) do |message| + team_action_lists_payload_message?(message) end + end - @messages << handshake_message if team_action_lists_payload_message?(handshake_message) + def subscribe_beamer!(tournament_id:, beamer_name:) + identifier = { + channel: 'TournamentBeamersChannel', + tournament_id: tournament_id, + beamer_name: beamer_name + }.to_json + + subscribe!(identifier:) do |message| + beamer_payload_message?(message) + end end def wait_for_payload!(timeout: 20) @@ -45,6 +46,12 @@ module TurniereE2E end.fetch('message') end + def wait_for_beamer_payload!(timeout: 20) + wait_for!(timeout: timeout) do |message| + beamer_payload_message?(message) + end.fetch('message') + end + def close return if @closed @@ -67,6 +74,24 @@ module TurniereE2E attr_reader :base_url, :socket, :driver + def subscribe!(identifier:) + connect! + + driver.text( + JSON.dump( + command: 'subscribe', + identifier: identifier + ) + ) + + handshake_message = wait_for! do |message| + message['type'] == 'confirm_subscription' || + (message['identifier'] == identifier && yield(message)) + end + + @messages << handshake_message if yield(handshake_message) + end + def connect! return unless @driver.nil? @@ -143,5 +168,10 @@ module TurniereE2E payload = message['message'] payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated' end + + def beamer_payload_message?(message) + payload = message['message'] + payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.') + end end end diff --git a/spec/e2e/http/tournament_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb new file mode 100644 index 0000000..9c68aa5 --- /dev/null +++ b/spec/e2e/http/tournament_beamers_websocket_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' +require_relative '../../../e2e/lib/action_cable_client' + +RSpec.describe 'Tournament beamer websocket HTTP E2E' do + required_env_vars = %w[ + TURNIERE_E2E_BASE_URL + TURNIERE_E2E_EMAIL + TURNIERE_E2E_PASSWORD + ].freeze + + before do + missing_vars = required_env_vars.reject { |name| ENV.key?(name) } + skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty? + end + + let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL') } + let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') } + let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } + let(:client) { login_client } + + it 'broadcasts beamer snapshots and updates over websocket' do + tournament = create_tournament(name_prefix: 'Ws Beamer') + create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: { + name: 'projector-a', + display_state: 'matches_all' + }) + expect(create_beamer[:status]).to eq(201) + beamer = create_beamer.fetch(:json) + + cable_client = TurniereE2E::ActionCableClient.new(base_url:) + cable_client.subscribe_beamer!(tournament_id: tournament.fetch(:id), beamer_name: beamer.fetch(:name)) + + initial_payload = cable_client.wait_for_beamer_payload! + expect(initial_payload.fetch('type')).to eq('beamer.snapshot') + expect(initial_payload.dig('beamer', 'id')).to eq(beamer.fetch(:id)) + expect(initial_payload.dig('beamer', 'display_state')).to eq('matches_all') + + update = client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: { + display_state: 'during_game', + qr_code_url: 'https://example.com/qr', + logo_url: '/static/logo.png' + }) + expect(update[:status]).to eq(200) + + updated_payload = wait_for_beamer_payload!( + cable_client, + event_type: 'beamer.updated', + display_state: 'during_game' + ) + expect(updated_payload.dig('beamer', 'qr_code_url')).to eq('https://example.com/qr') + expect(updated_payload.dig('beamer', 'logo_url')).to eq('/static/logo.png') + + delete = client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}") + expect(delete[:status]).to eq(204) + + deleted_payload = wait_for_beamer_payload!( + cable_client, + event_type: 'beamer.deleted', + display_state: 'during_game' + ) + expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a') + ensure + cable_client&.close + end + + def login_client + api_client = TurniereE2E::ApiClient.new(base_url:) + response = api_client.login!(email: owner_email, password: owner_password) + + expect(response[:status]).to eq(200) + api_client + end + + def create_tournament(name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Websocket beamer tournament', + public: true + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + + def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('type') == event_type && + payload.dig('beamer', 'display_state') == display_state + + raise "beamer websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end +end diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb new file mode 100644 index 0000000..05e7df4 --- /dev/null +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require_relative '../../../e2e/lib/api_client' +require_relative '../../../e2e/lib/action_cable_client' + +RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do + required_env_vars = %w[ + TURNIERE_E2E_EMAIL + TURNIERE_E2E_PASSWORD + TURNIERE_SOURCE_E2E_BASE_URL + TURNIERE_FOLLOWER_E2E_BASE_URL + ].freeze + + before do + missing_vars = required_env_vars.reject { |name| ENV.key?(name) } + skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty? + end + + let(:source_base_url) { ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL') } + let(:follower_base_url) { ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL') } + let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') } + let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') } + let(:sync_token) { "sync-#{SecureRandom.hex(8)}" } + let(:source_client) { login_client(base_url: source_base_url) } + + it 'broadcasts follower beamer websocket updates after source sync import' do + source = create_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Beamer Source') + follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower') + + configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: { + sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state", + sync_auth_token: sync_token + }) + expect(configure_sync[:status]).to eq(200) + + follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id)) + + cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) + cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name)) + cable_client.wait_for_beamer_payload! + + source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: Beamer::DEFAULT_NAME) + update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: { + display_state: 'groups' + }) + expect(update[:status]).to eq(200) + + follower_payload = wait_for_beamer_payload!( + cable_client, + event_type: 'beamer.updated', + display_state: 'groups' + ) + expect(follower_payload.dig('beamer', 'name')).to eq(Beamer::DEFAULT_NAME) + ensure + cable_client&.close + end + + def login_client(base_url:) + client = TurniereE2E::ApiClient.new(base_url:) + response = client.login!(email: owner_email, password: owner_password) + expect(response[:status]).to eq(200) + client + end + + def create_tournament(client:, base_url:, name_prefix:) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower beamer websocket source tournament', + public: true + }) + expect(response[:status]).to eq(201) + fetch_tournament(base_url:, tournament_id: response.dig(:json, :id)) + end + + def create_follower_tournament(name_prefix:) + client = login_client(base_url: follower_base_url) + response = client.post('/tournaments', body: { + name: "#{name_prefix} #{SecureRandom.hex(3)}", + description: 'Follower beamer websocket target tournament', + public: true, + read_only_mode: true, + sync_auth_token: sync_token + }) + expect(response[:status]).to eq(201) + fetch_tournament(base_url: follower_base_url, tournament_id: response.dig(:json, :id)) + end + + def fetch_tournament(base_url:, tournament_id:) + client = TurniereE2E::ApiClient.new(base_url:) + response = client.get("/tournaments/#{tournament_id}") + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def fetch_beamer(base_url:, tournament_id:, beamer_name:) + client = TurniereE2E::ApiClient.new(base_url:) + response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name }) + expect(response[:status]).to eq(200) + response.fetch(:json) + end + + def wait_for_default_beamer!(tournament_id:, timeout: 20) + deadline = Time.now + timeout + loop do + beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: Beamer::DEFAULT_NAME) + return beamer if beamer.fetch(:name) == Beamer::DEFAULT_NAME + + raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline + + sleep 0.5 + end + end + + def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20) + deadline = Time.now + timeout + loop do + payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max) + return payload if payload.fetch('type') == event_type && + payload.dig('beamer', 'display_state') == display_state + + raise "follower beamer websocket payload not received within #{timeout}s" if Time.now >= deadline + end + end +end diff --git a/spec/models/beamer_spec.rb b/spec/models/beamer_spec.rb index 34d77d1..ba9681b 100644 --- a/spec/models/beamer_spec.rb +++ b/spec/models/beamer_spec.rb @@ -42,4 +42,54 @@ RSpec.describe Beamer, type: :model do expect(beamer.owner).to eq(beamer.tournament.owner) end end + + describe 'live broadcasts' do + it 'broadcasts create payload to current beamer stream' do + tournament = create(:tournament) + payload = nil + + expect(BeamerBroadcast).to receive(:call) do |args| + payload = args.fetch(:payload) + expect(args.fetch(:tournament_id)).to eq(tournament.id) + expect(args.fetch(:beamer_names)).to eq(['projector']) + end + + tournament.beamers.create!(name: 'projector', display_state: 'groups') + + expect(payload[:type]).to eq('beamer.created') + expect(payload.dig(:beamer, :display_state)).to eq('groups') + end + + it 'broadcasts update payload to previous and current beamer names when renamed' do + beamer = create(:beamer, name: 'old-name') + payload = nil + + expect(BeamerBroadcast).to receive(:call) do |args| + payload = args.fetch(:payload) + expect(args.fetch(:beamer_names)).to contain_exactly('old-name', 'new-name') + end + + beamer.update!(name: 'new-name', display_state: 'during_game') + + expect(payload[:type]).to eq('beamer.updated') + expect(payload.dig(:beamer, :name)).to eq('new-name') + expect(payload.dig(:beamer, :display_state)).to eq('during_game') + end + + it 'broadcasts delete payload with last persisted snapshot' do + beamer = create(:beamer, name: 'projector', display_state: 'between_games') + payload = nil + + expect(BeamerBroadcast).to receive(:call) do |args| + payload = args.fetch(:payload) + expect(args.fetch(:beamer_names)).to eq(['projector']) + end + + beamer.destroy! + + expect(payload[:type]).to eq('beamer.deleted') + expect(payload.dig(:beamer, :name)).to eq('projector') + expect(payload.dig(:beamer, :display_state)).to eq('between_games') + end + end end From 9d4cc21b7b9aaef1f0e94653337a76aa5f6cc4bd Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 14:50:45 +0200 Subject: [PATCH 06/16] fix: include Gemfile lock in images --- Dockerfile | 2 ++ tasks.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 00f2006..3ce651c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,7 @@ FROM runtime-base AS production ENV RAILS_ENV=production COPY --from=bundle-production /usr/local/bundle /usr/local/bundle +COPY Gemfile Gemfile.lock /app/ COPY app /app/app COPY bin /app/bin COPY config /app/config @@ -83,6 +84,7 @@ RUN apt-get update -qq \ && python3 -m pip install --no-cache-dir invoke COPY --from=bundle-test /usr/local/bundle /usr/local/bundle +COPY Gemfile Gemfile.lock /app/ COPY app /app/app COPY bin /app/bin COPY config /app/config diff --git a/tasks.py b/tasks.py index af6fa28..96f2020 100644 --- a/tasks.py +++ b/tasks.py @@ -49,9 +49,10 @@ BLACKBOX_MAILGUN_API_KEY = "blackbox-test-api-key" BLACKBOX_MAILGUN_DOMAIN = "blackbox.example.com" SPEC_ROOT = Path("spec") E2E_SPEC_ROOT = SPEC_ROOT / "e2e" / "http" -BUNDLER_VERSION = Path("Gemfile.lock").read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip() ROOT = Path(__file__).resolve().parent DB_DIR = ROOT / "db" +GEMFILE_LOCK_PATH = ROOT / "Gemfile.lock" +BUNDLER_VERSION = GEMFILE_LOCK_PATH.read_text(encoding="utf-8").split("BUNDLED WITH", 1)[1].strip().splitlines()[-1].strip() def _env(**overrides): From d58b9251d0fb491ca969b7f4a116d5bb5e6e491b Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 14:56:01 +0200 Subject: [PATCH 07/16] fix: seed valid beamer e2e tournaments --- spec/e2e/http/tournament_beamers_websocket_spec.rb | 5 ++++- .../http/tournament_follow_sync_beamers_websocket_spec.rb | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/spec/e2e/http/tournament_beamers_websocket_spec.rb b/spec/e2e/http/tournament_beamers_websocket_spec.rb index 9c68aa5..4588172 100644 --- a/spec/e2e/http/tournament_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_beamers_websocket_spec.rb @@ -79,7 +79,10 @@ RSpec.describe 'Tournament beamer websocket HTTP E2E' do response = client.post('/tournaments', body: { name: "#{name_prefix} #{SecureRandom.hex(3)}", description: 'Websocket beamer tournament', - public: true + public: true, + teams: 4.times.map do |index| + { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } + end }) expect(response[:status]).to eq(201) response.fetch(:json) diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb index 05e7df4..7fa0579 100644 --- a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -68,7 +68,10 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do response = client.post('/tournaments', body: { name: "#{name_prefix} #{SecureRandom.hex(3)}", description: 'Follower beamer websocket source tournament', - public: true + public: true, + teams: 4.times.map do |index| + { name: "#{name_prefix.tr(' ', '')}-S#{index + 1}" } + end }) expect(response[:status]).to eq(201) fetch_tournament(base_url:, tournament_id: response.dig(:json, :id)) From 5b20997cd4c54bf56397e40a54d44e6d99a480e8 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 14:56:08 +0200 Subject: [PATCH 08/16] test: run follower beamer websocket e2e --- tasks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks.py b/tasks.py index 284773a..f3624da 100644 --- a/tasks.py +++ b/tasks.py @@ -1696,6 +1696,7 @@ def blackbox_follow_service( spec_path=[ "spec/e2e/http/tournament_follow_sync_spec.rb", "spec/e2e/http/tournament_follow_sync_websocket_spec.rb", + "spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb", ], extra_env={ "TURNIERE_SOURCE_E2E_BASE_URL": source_base_url, From e76f01b0eec7ab938c28b9d346af39d5a265cf5c Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 15:06:41 +0200 Subject: [PATCH 09/16] fix: keep gems in app image --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3ce651c..9fb91e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,8 @@ ARG BUNDLER_VERSION ENV LANG=C.UTF-8 \ BUNDLE_JOBS=4 \ BUNDLE_RETRY=3 \ - BUNDLE_PATH=/usr/local/bundle + BUNDLE_PATH=/app/vendor/bundle \ + BUNDLE_APP_CONFIG=/app/vendor/bundle WORKDIR /app @@ -56,6 +57,7 @@ FROM runtime-base AS production ENV RAILS_ENV=production COPY --from=bundle-production /usr/local/bundle /usr/local/bundle +COPY --from=bundle-production /app/vendor/bundle /app/vendor/bundle COPY Gemfile Gemfile.lock /app/ COPY app /app/app COPY bin /app/bin @@ -84,6 +86,7 @@ RUN apt-get update -qq \ && python3 -m pip install --no-cache-dir invoke COPY --from=bundle-test /usr/local/bundle /usr/local/bundle +COPY --from=bundle-test /app/vendor/bundle /app/vendor/bundle COPY Gemfile Gemfile.lock /app/ COPY app /app/app COPY bin /app/bin From eb8c2a7284c10de48fe4480bd0d6e40fbbabd418 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 16:37:45 +0200 Subject: [PATCH 10/16] fix: avoid rails constant in follow e2e --- .../tournament_follow_sync_beamers_websocket_spec.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb index 7fa0579..2f64cdc 100644 --- a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -6,6 +6,7 @@ require_relative '../../../e2e/lib/api_client' require_relative '../../../e2e/lib/action_cable_client' RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do + default_beamer_name = 'default' required_env_vars = %w[ TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD @@ -41,7 +42,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name)) cable_client.wait_for_beamer_payload! - source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: Beamer::DEFAULT_NAME) + source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: default_beamer_name) update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: { display_state: 'groups' }) @@ -52,7 +53,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do event_type: 'beamer.updated', display_state: 'groups' ) - expect(follower_payload.dig('beamer', 'name')).to eq(Beamer::DEFAULT_NAME) + expect(follower_payload.dig('beamer', 'name')).to eq(default_beamer_name) ensure cable_client&.close end @@ -107,8 +108,8 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do def wait_for_default_beamer!(tournament_id:, timeout: 20) deadline = Time.now + timeout loop do - beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: Beamer::DEFAULT_NAME) - return beamer if beamer.fetch(:name) == Beamer::DEFAULT_NAME + beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: default_beamer_name) + return beamer if beamer.fetch(:name) == default_beamer_name raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline From 7314394b69bf82e712639d72b519e6f45a18517d Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 16:40:12 +0200 Subject: [PATCH 11/16] ci: use shared infra pipeline from master --- .gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 581ea77..02802aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,7 +11,6 @@ variables: include: - project: 'turniere/turniere-infra' - ref: 'codex/buildx-multiarch-template' file: '/ci/pipeline.yaml' rails spec: From c9d487f30c25a2d489d74be09988b5076690b51f Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 16:55:28 +0200 Subject: [PATCH 12/16] fix: use constant for default beamer name --- .../tournament_follow_sync_beamers_websocket_spec.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb index 2f64cdc..19eedf8 100644 --- a/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_beamers_websocket_spec.rb @@ -5,8 +5,9 @@ require 'securerandom' require_relative '../../../e2e/lib/api_client' require_relative '../../../e2e/lib/action_cable_client' +DEFAULT_BEAMER_NAME = 'default' + RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do - default_beamer_name = 'default' required_env_vars = %w[ TURNIERE_E2E_EMAIL TURNIERE_E2E_PASSWORD @@ -42,7 +43,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name)) cable_client.wait_for_beamer_payload! - source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: default_beamer_name) + source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: DEFAULT_BEAMER_NAME) update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: { display_state: 'groups' }) @@ -53,7 +54,7 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do event_type: 'beamer.updated', display_state: 'groups' ) - expect(follower_payload.dig('beamer', 'name')).to eq(default_beamer_name) + expect(follower_payload.dig('beamer', 'name')).to eq(DEFAULT_BEAMER_NAME) ensure cable_client&.close end @@ -108,8 +109,8 @@ RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do def wait_for_default_beamer!(tournament_id:, timeout: 20) deadline = Time.now + timeout loop do - beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: default_beamer_name) - return beamer if beamer.fetch(:name) == default_beamer_name + beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: DEFAULT_BEAMER_NAME) + return beamer if beamer.fetch(:name) == DEFAULT_BEAMER_NAME raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline From 05e43722505d6fe8a80b5cfc603c9cb078fcccf4 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 19:59:19 +0200 Subject: [PATCH 13/16] Fix team action not-found responses --- .../team_action_items_controller.rb | 1 + .../team_action_lists_controller.rb | 1 + .../team_action_items_controller_spec.rb | 14 ++++++++ .../team_action_lists_controller_spec.rb | 34 +++++++++++++++++++ spec/e2e/http/tournament_sync_test_spec.rb | 25 ++++++++++++++ 5 files changed, 75 insertions(+) diff --git a/app/controllers/team_action_items_controller.rb b/app/controllers/team_action_items_controller.rb index b63a5d0..a63fd07 100644 --- a/app/controllers/team_action_items_controller.rb +++ b/app/controllers/team_action_items_controller.rb @@ -5,6 +5,7 @@ class TeamActionItemsController < ApplicationController before_action :authenticate_user!, only: %i[update] before_action -> { require_owner! @team_action_item.owner }, only: %i[update] before_action -> { require_writable_tournament!(@team_action_item.tournament) }, only: %i[update] + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error # PATCH /team_action_items/1 def update diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb index bff4b94..6f07f5c 100644 --- a/app/controllers/team_action_lists_controller.rb +++ b/app/controllers/team_action_lists_controller.rb @@ -6,6 +6,7 @@ class TeamActionListsController < ApplicationController before_action -> { require_owner! @tournament.owner }, only: %i[update_item] before_action -> { require_writable_tournament!(@tournament) }, only: %i[update_item] before_action :set_team_action_item, only: %i[update_item] + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id def update_item diff --git a/spec/controllers/team_action_items_controller_spec.rb b/spec/controllers/team_action_items_controller_spec.rb index efebd7a..569d6d5 100644 --- a/spec/controllers/team_action_items_controller_spec.rb +++ b/spec/controllers/team_action_items_controller_spec.rb @@ -49,5 +49,19 @@ RSpec.describe TeamActionItemsController, type: :controller do expect(response).to have_http_status(:forbidden) end end + + context 'when item is missing' do + let(:owner) { create(:user) } + + before do + apply_authentication_headers_for owner + end + + it 'returns not found' do + patch :update, params: { id: 999_999_999, completed: true } + + expect(response).to have_http_status(:not_found) + end + end end end diff --git a/spec/controllers/team_action_lists_controller_spec.rb b/spec/controllers/team_action_lists_controller_spec.rb index fdb9322..2507fd8 100644 --- a/spec/controllers/team_action_lists_controller_spec.rb +++ b/spec/controllers/team_action_lists_controller_spec.rb @@ -70,5 +70,39 @@ RSpec.describe TeamActionListsController, type: :controller do expect(response).to have_http_status(:forbidden) end end + + context 'when list key is missing' do + before do + apply_authentication_headers_for tournament.owner + end + + it 'returns not found' do + patch :update_item, params: { + tournament_id: tournament.to_param, + key: 'unknown_key', + team_id: team.to_param, + completed: true + } + + expect(response).to have_http_status(:not_found) + end + end + + context 'when team is missing from list context' do + before do + apply_authentication_headers_for tournament.owner + end + + it 'returns not found' do + patch :update_item, params: { + tournament_id: tournament.to_param, + key: list.key, + team_id: 999_999_999, + completed: true + } + + expect(response).to have_http_status(:not_found) + end + end end end diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 9d0b871..03ff7a2 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -90,6 +90,31 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(playoff_token_item_completed?(follower_after_update, team_name: second_item.dig(:team, :name))).to eq(true) end + it 'returns 404 for missing team action resources on both update routes' do + leader = create_group_stage_tournament(name_prefix: 'Missing Action Resource Leader') + + finish_group_stage_and_create_playoffs!(leader.fetch(:id)) + + leader_after_playoffs = fetch_tournament(leader.fetch(:id)) + token_list = find_playoff_token_list!(leader_after_playoffs) + first_item = token_list.fetch(:team_action_items).first + + missing_item_update = client.patch('/team_action_items/999999999', body: { completed: true }) + expect(missing_item_update[:status]).to eq(404) + + missing_list_update = client.patch( + "/tournaments/#{leader.fetch(:id)}/team_action_lists/unknown_key/teams/#{first_item.dig(:team, :id)}", + body: { completed: true } + ) + expect(missing_list_update[:status]).to eq(404) + + missing_team_update = client.patch( + "/tournaments/#{leader.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/999999999", + body: { completed: true } + ) + expect(missing_team_update[:status]).to eq(404) + end + def login_client api_client = TurniereE2E::ApiClient.new(base_url: base_url) response = api_client.login!(email: owner_email, password: owner_password) From b40e64cde8f823d6e2c90c9b1bb48d23d8db7ac2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 22:47:51 +0200 Subject: [PATCH 14/16] feat: add manual team action list creation --- app/controllers/stages_controller.rb | 7 +- .../team_action_lists_controller.rb | 27 +++- app/models/team_action_list.rb | 1 + app/services/team_action_list_service.rb | 58 +++++---- config/routes.rb | 1 + spec/controllers/stages_controller_spec.rb | 9 +- .../team_action_lists_controller_spec.rb | 115 ++++++++++++++++-- .../tournaments_controller_spec.rb | 2 +- spec/factories/team_action_lists.rb | 4 +- .../routing/team_action_lists_routing_spec.rb | 9 +- .../services/team_action_list_service_spec.rb | 68 +++++++++++ 11 files changed, 248 insertions(+), 53 deletions(-) create mode 100644 spec/services/team_action_list_service_spec.rb diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index 5cada55..fcdac4d 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -65,11 +65,8 @@ class StagesController < ApplicationController raise ActiveRecord::Rollback end - return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, - teams: GroupStageService.get_advancing_teams(@stage)).success? - - TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament) - return + return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, + teams: GroupStageService.get_advancing_teams(@stage)).success? render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity raise ActiveRecord::Rollback diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb index 6f07f5c..9938fa9 100644 --- a/app/controllers/team_action_lists_controller.rb +++ b/app/controllers/team_action_lists_controller.rb @@ -2,12 +2,29 @@ class TeamActionListsController < ApplicationController before_action :set_tournament - before_action :authenticate_user!, only: %i[update_item] - before_action -> { require_owner! @tournament.owner }, only: %i[update_item] - before_action -> { require_writable_tournament!(@tournament) }, only: %i[update_item] + before_action :authenticate_user!, only: %i[create update_item] + before_action -> { require_owner! @tournament.owner }, only: %i[create update_item] + before_action -> { require_writable_tournament!(@tournament) }, only: %i[create update_item] before_action :set_team_action_item, only: %i[update_item] rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error + # POST /tournaments/:tournament_id/team_action_lists + def create + team_action_list = TeamActionListService.create!( + tournament: @tournament, + name: team_action_list_params.fetch(:name), + key: team_action_list_params.fetch(:key), + action_name: team_action_list_params.fetch(:action_name), + source: team_action_list_params.fetch(:source) + ) + + render json: team_action_list, status: :created + rescue TeamActionListService::InvalidSourceError, TeamActionListService::EmptySourceError => e + render json: { error: e.message }, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render json: e.record.errors, status: :unprocessable_entity + end + # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id def update_item if TeamActionItemUpdate.call( @@ -34,4 +51,8 @@ class TeamActionListsController < ApplicationController def team_action_item_params params.slice(:completed).permit(:completed) end + + def team_action_list_params + params.permit(:name, :key, :action_name, :source) + end end diff --git a/app/models/team_action_list.rb b/app/models/team_action_list.rb index 1cbedcf..018bd0a 100644 --- a/app/models/team_action_list.rb +++ b/app/models/team_action_list.rb @@ -6,6 +6,7 @@ class TeamActionList < ApplicationRecord validates :name, :key, :action_name, :source, presence: true validates :key, uniqueness: { scope: :tournament_id } + validates :key, format: { with: /\A[a-z0-9_]+\z/, message: 'may only contain lowercase letters, numbers, and underscores' } delegate :owner, to: :tournament diff --git a/app/services/team_action_list_service.rb b/app/services/team_action_list_service.rb index e613ee3..11b66e0 100644 --- a/app/services/team_action_list_service.rb +++ b/app/services/team_action_list_service.rb @@ -1,34 +1,48 @@ # frozen_string_literal: true class TeamActionListService - GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY = 'group_stage_survivor_playoff_tokens' + SUPPORTED_SOURCES = %w[all_tournament_teams group_stage_survivors].freeze + + class InvalidSourceError < StandardError; end + class EmptySourceError < StandardError; end class << self - def ensure_group_stage_survivor_playoff_token_list!(tournament) - group_stage = tournament.group_stage - return nil unless group_stage&.finished? + def create!(tournament:, name:, key:, action_name:, source:) + teams = teams_for_source!(tournament:, source:) + raise EmptySourceError, "Source #{source} does not currently contain any teams" if teams.empty? - teams = tournament.teams_advancing_from_group_stage - return nil if teams.empty? + list = nil + TeamActionList.transaction do + list = tournament.team_action_lists.new( + name:, + key:, + action_name:, + source: + ) + list.save! + list.sync_teams!(teams) + end - ensure_list!( - tournament: tournament, - key: GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY, - name: 'Playoff token collection', - action_name: 'Collect playoff token', - source: 'group_stage_survivors', - teams: teams - ) + TournamentSyncEnqueue.call(tournament) + list end - def ensure_list!(tournament:, key:, name:, action_name:, source:, teams:) - list = tournament.team_action_lists.find_or_initialize_by(key: key) - list.name = name - list.action_name = action_name - list.source = source - list.save! - list.sync_teams!(teams) - list + def source_keys + SUPPORTED_SOURCES + end + + def teams_for_source!(tournament:, source:) + case source + when 'all_tournament_teams' + tournament.teams.to_a + when 'group_stage_survivors' + group_stage = tournament.group_stage + return [] unless group_stage&.finished? + + tournament.teams_advancing_from_group_stage + else + raise InvalidSourceError, "Unsupported team action list source: #{source}" + end end end end diff --git a/config/routes.rb b/config/routes.rb index 75de6ab..9c53ca4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,7 @@ Rails.application.routes.draw do resources :teams, only: %i[show update] resources :team_action_items, only: %i[update] resources :tournaments do + resources :team_action_lists, only: %i[create] patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' resources :statistics, only: %i[index] resources :matches, only: %i[index] diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 259427f..b9e7c2b 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -92,14 +92,9 @@ RSpec.describe StagesController, type: :controller do .to match_array(GroupStageService.get_advancing_teams(running_group_stage)) end - it 'creates playoff token collection list for advancing teams' do + it 'does not auto-create team action lists' do tournament = running_group_stage.tournament.reload - list = tournament.team_action_lists.find_by!(key: TeamActionListService::GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY) - - expect(list.name).to eq('Playoff token collection') - expect(list.team_action_items.includes(:team).map(&:team)) - .to match_array(GroupStageService.get_advancing_teams(running_group_stage)) - expect(list.team_action_items.pluck(:completed).uniq).to eq([false]) + expect(tournament.team_action_lists).to be_empty end end diff --git a/spec/controllers/team_action_lists_controller_spec.rb b/spec/controllers/team_action_lists_controller_spec.rb index 2507fd8..672d321 100644 --- a/spec/controllers/team_action_lists_controller_spec.rb +++ b/spec/controllers/team_action_lists_controller_spec.rb @@ -3,20 +3,113 @@ require 'rails_helper' RSpec.describe TeamActionListsController, type: :controller do + let(:tournament) { create(:tournament, teams_count: 4) } let(:team_action_item) { create(:team_action_item) } - let(:tournament) { team_action_item.tournament } let(:list) { team_action_item.team_action_list } let(:team) { team_action_item.team } - describe 'PATCH #update_item' do + describe 'POST #create' do + let(:create_params) do + { + tournament_id: tournament.to_param, + name: 'Playoff token collection', + key: 'playoff_token_collection', + action_name: 'Collect playoff token', + source: 'all_tournament_teams' + } + end + context 'as owner' do before do apply_authentication_headers_for tournament.owner end + it 'creates a list populated from the selected source' do + post :create, params: create_params + + expect(response).to have_http_status(:created) + + created_list = tournament.team_action_lists.find_by!(key: 'playoff_token_collection') + expect(created_list.name).to eq('Playoff token collection') + expect(created_list.action_name).to eq('Collect playoff token') + expect(created_list.source).to eq('all_tournament_teams') + expect(created_list.team_action_items.includes(:team).map(&:team)).to match_array(tournament.teams) + end + + it 'enqueues tournament sync after create' do + allow(TournamentSyncEnqueue).to receive(:call) + + post :create, params: create_params + + expect(TournamentSyncEnqueue).to have_received(:call).with(tournament) + end + + it 'returns unprocessable entity for unsupported source' do + post :create, params: create_params.merge(source: 'unknown_source') + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq('Unsupported team action list source: unknown_source') + end + + it 'returns unprocessable entity when selected source resolves to no teams' do + empty_tournament = create(:tournament, teams_count: 0) + apply_authentication_headers_for empty_tournament.owner + + post :create, params: create_params.merge(tournament_id: empty_tournament.to_param) + + expect(response).to have_http_status(:unprocessable_entity) + expect(deserialize_response(response)[:error]).to eq('Source all_tournament_teams does not currently contain any teams') + end + + it 'returns unprocessable entity for duplicate key' do + tournament.team_action_lists.create!( + name: 'Existing list', + key: 'playoff_token_collection', + action_name: 'Existing action', + source: 'all_tournament_teams' + ) + + post :create, params: create_params + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'returns forbidden' do + post :create, params: create_params + + expect(response).to have_http_status(:forbidden) + end + end + + context 'for follower tournament' do + before do + tournament.update!(read_only_mode: true, sync_auth_token: 'sync-token') + apply_authentication_headers_for tournament.owner + end + + it 'returns locked' do + post :create, params: create_params + + expect(response).to have_http_status(:locked) + end + end + end + + describe 'PATCH #update_item' do + context 'as owner' do + before do + apply_authentication_headers_for list.tournament.owner + end + it 'updates item by list key and team id' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true @@ -29,7 +122,7 @@ RSpec.describe TeamActionListsController, type: :controller do it 'is idempotent when same state sent repeatedly' do 2.times do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true @@ -44,13 +137,13 @@ RSpec.describe TeamActionListsController, type: :controller do allow(TournamentSyncEnqueue).to receive(:call) patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true } - expect(TournamentSyncEnqueue).to have_received(:call).with(tournament) + expect(TournamentSyncEnqueue).to have_received(:call).with(list.tournament) end end @@ -61,7 +154,7 @@ RSpec.describe TeamActionListsController, type: :controller do it 'returns forbidden' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: team.to_param, completed: true @@ -73,12 +166,12 @@ RSpec.describe TeamActionListsController, type: :controller do context 'when list key is missing' do before do - apply_authentication_headers_for tournament.owner + apply_authentication_headers_for list.tournament.owner end it 'returns not found' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: 'unknown_key', team_id: team.to_param, completed: true @@ -90,12 +183,12 @@ RSpec.describe TeamActionListsController, type: :controller do context 'when team is missing from list context' do before do - apply_authentication_headers_for tournament.owner + apply_authentication_headers_for list.tournament.owner end it 'returns not found' do patch :update_item, params: { - tournament_id: tournament.to_param, + tournament_id: list.tournament.to_param, key: list.key, team_id: 999_999_999, completed: true diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 6939459..65d4dfd 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -214,7 +214,7 @@ RSpec.describe TournamentsController, type: :controller do body = deserialize_response response expect(body[:team_action_lists].size).to eq(1) - expect(body[:team_action_lists].first[:key]).to eq('group_stage_survivor_playoff_tokens') + expect(body[:team_action_lists].first[:key]).to eq('playoff_token_collection') expect(body[:team_action_lists].first[:team_action_items].first[:completed]).to eq(true) end end diff --git a/spec/factories/team_action_lists.rb b/spec/factories/team_action_lists.rb index 12e240c..a4d335b 100644 --- a/spec/factories/team_action_lists.rb +++ b/spec/factories/team_action_lists.rb @@ -4,8 +4,8 @@ FactoryBot.define do factory :team_action_list do tournament name { 'Playoff token collection' } - key { 'group_stage_survivor_playoff_tokens' } + key { 'playoff_token_collection' } action_name { 'Collect playoff token' } - source { 'group_stage_survivors' } + source { 'all_tournament_teams' } end end diff --git a/spec/routing/team_action_lists_routing_spec.rb b/spec/routing/team_action_lists_routing_spec.rb index 121778e..55785b0 100644 --- a/spec/routing/team_action_lists_routing_spec.rb +++ b/spec/routing/team_action_lists_routing_spec.rb @@ -4,12 +4,17 @@ require 'rails_helper' RSpec.describe TeamActionListsController, type: :routing do describe 'routing' do + it 'routes to #create via POST' do + expect(post: '/tournaments/1/team_action_lists') + .to route_to('team_action_lists#create', tournament_id: '1') + end + it 'routes to #update_item via PATCH' do - expect(patch: '/tournaments/1/team_action_lists/group_stage_survivor_playoff_tokens/teams/2') + expect(patch: '/tournaments/1/team_action_lists/playoff_token_collection/teams/2') .to route_to( 'team_action_lists#update_item', tournament_id: '1', - key: 'group_stage_survivor_playoff_tokens', + key: 'playoff_token_collection', team_id: '2' ) end diff --git a/spec/services/team_action_list_service_spec.rb b/spec/services/team_action_list_service_spec.rb new file mode 100644 index 0000000..f71d4a1 --- /dev/null +++ b/spec/services/team_action_list_service_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamActionListService do + describe '.create!' do + let(:tournament) { create(:tournament, teams_count: 4) } + + it 'creates a list with items for all tournament teams' do + list = described_class.create!( + tournament:, + name: 'Team check-in', + key: 'team_check_in', + action_name: 'Check in', + source: 'all_tournament_teams' + ) + + expect(list.team_action_items.includes(:team).map(&:team)).to match_array(tournament.teams) + end + + it 'creates a list for group stage survivors when available' do + group_stage_tournament = create(:tournament, teams_count: 0, playoff_teams_amount: 2, instant_finalists_amount: 2) + group_stage = create(:group_stage, tournament: group_stage_tournament, group_count: 0) + group = create(:group, stage: group_stage, match_count: 0, number: 1) + team_a = create(:team, tournament: group_stage_tournament, name: 'A') + team_b = create(:team, tournament: group_stage_tournament, name: 'B') + create(:group_score, group:, team: team_a, group_points: 6, scored_points: 6, received_points: 0, position: 1) + create(:group_score, group:, team: team_b, group_points: 3, scored_points: 3, received_points: 3, position: 2) + group_stage.update!(state: 'finished') + + list = described_class.create!( + tournament: group_stage_tournament, + name: 'Playoff token collection', + key: 'playoff_token_collection', + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + ) + + expect(list.team_action_items.includes(:team).map(&:team)).to match_array([team_a, team_b]) + end + + it 'raises for unsupported source' do + expect do + described_class.create!( + tournament:, + name: 'Broken', + key: 'broken', + action_name: 'Break', + source: 'unknown_source' + ) + end.to raise_error(TeamActionListService::InvalidSourceError, 'Unsupported team action list source: unknown_source') + end + + it 'raises when source resolves to no teams' do + empty_tournament = create(:tournament, teams_count: 0) + + expect do + described_class.create!( + tournament: empty_tournament, + name: 'Empty', + key: 'empty', + action_name: 'Nothing', + source: 'all_tournament_teams' + ) + end.to raise_error(TeamActionListService::EmptySourceError, 'Source all_tournament_teams does not currently contain any teams') + end + end +end From d69e08a25b8cf848c857a08d9847863992052233 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 22:48:00 +0200 Subject: [PATCH 15/16] test: cover manual team action list flows --- spec/e2e/http/tournament_follow_sync_spec.rb | 39 +++++++++++++++++-- .../tournament_follow_sync_websocket_spec.rb | 20 +++++++++- spec/e2e/http/tournament_sync_test_spec.rb | 23 +++++++++-- ...nament_team_action_lists_websocket_spec.rb | 24 ++++++++++-- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1802516..7fbd772 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -141,11 +141,29 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(follower.fetch(:stages).map { |stage| stage.fetch(:level) }).to include(-1, 0, 1) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + expect(source.fetch(:team_action_lists)).to eq([]) + expect(follower.fetch(:team_action_lists)).to eq([]) + + log_phase!('create team action list on source and verify follower mirrors it') + created_list = create_team_action_list!(source.fetch(:id)) + expect(created_list.fetch(:key)).to eq(playoff_token_list_key) + + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(playoff_token_list(source)).not_to be_nil expect(playoff_token_list(follower)).not_to be_nil expect(playoff_token_list(follower).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) .to match_array(playoff_token_list(source).fetch(:team_action_items).map { |item| item.dig(:team, :name) }) + locked_list_create = follower_owner_client.post("/tournaments/#{follower.fetch(:id)}/team_action_lists", body: { + name: 'Follower created list', + key: 'follower_created_list', + action_name: 'Should fail', + source: 'all_tournament_teams' + }) + expect(locked_list_create[:status]).to eq(423) + expect(locked_list_create.dig(:json, :error)).to eq('Tournament is in read only follower mode') + follower_group = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }.fetch(:groups).first follower_team = follower_group.fetch(:matches).first.fetch(:match_scores).first.fetch(:team) locked_team_update = follower_owner_client.patch("/teams/#{follower_team.fetch(:id)}", body: { name: 'Nope' }) @@ -155,7 +173,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do log_phase!('mark playoff token collected on source through business key route and verify follower mirrors it') source_token_item = playoff_token_list(source).fetch(:team_action_items).first source_token_update = source_owner_client.patch( - "/tournaments/#{source.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{source_token_item.dig(:team, :id)}", + "/tournaments/#{source.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{source_token_item.dig(:team, :id)}", body: { completed: true } ) expect(source_token_update[:status]).to eq(200) @@ -167,7 +185,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do follower_token_item = find_team_action_item(follower, team_name: source_token_item.dig(:team, :name)) locked_token_update = follower_owner_client.patch( - "/tournaments/#{follower.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{follower_token_item.dig(:team, :id)}", + "/tournaments/#{follower.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{follower_token_item.dig(:team, :id)}", body: { completed: false } ) expect(locked_token_update[:status]).to eq(423) @@ -265,6 +283,17 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do response.fetch(:json) end + def create_team_action_list!(tournament_id) + response = source_owner_client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def seed_unrelated_tournaments!(client:, prefix:, range:) amount = rand(range) Array.new(amount) do |index| @@ -552,10 +581,14 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do def playoff_token_list(tournament) tournament.fetch(:team_action_lists, []).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end end + def playoff_token_list_key + 'playoff_token_collection' + end + def find_team_action_item(tournament, team_name:) playoff_token_list(tournament).fetch(:team_action_items).find do |item| item.dig(:team, :name) == team_name diff --git a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb index 15b5e6b..f748a93 100644 --- a/spec/e2e/http/tournament_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_websocket_spec.rb @@ -37,6 +37,7 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do expect(configure_sync[:status]).to eq(200) finish_group_stage_and_create_playoffs!(source_id: source.fetch(:id)) + create_team_action_list!(source.fetch(:id)) follower = wait_for_list_presence!(follower.fetch(:id)) cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url) @@ -106,6 +107,17 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do response.fetch(:json) end + def create_team_action_list!(tournament_id) + response = source_client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def finish_group_stage_and_create_playoffs!(source_id:) tournament = fetch_tournament(base_url: source_base_url, tournament_id: source_id) group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } @@ -161,13 +173,17 @@ RSpec.describe 'Tournament follower sync websocket HTTP E2E' do def find_playoff_token_list!(tournament) tournament.fetch(:team_action_lists).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end || raise('playoff token list missing') end def team_action_item_completed?(payload, team_name:) - list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } + list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == playoff_token_list_key } item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } item.fetch('completed') end + + def playoff_token_list_key + 'playoff_token_collection' + end end diff --git a/spec/e2e/http/tournament_sync_test_spec.rb b/spec/e2e/http/tournament_sync_test_spec.rb index 03ff7a2..1d82f59 100644 --- a/spec/e2e/http/tournament_sync_test_spec.rb +++ b/spec/e2e/http/tournament_sync_test_spec.rb @@ -63,6 +63,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do ) finish_group_stage_and_create_playoffs!(leader.fetch(:id)) + create_team_action_list!(leader.fetch(:id)) leader_after_playoffs = fetch_tournament(leader.fetch(:id)) token_list = find_playoff_token_list!(leader_after_playoffs) @@ -73,7 +74,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(update_by_item_id[:status]).to eq(200) update_by_business_key = client.patch( - "/tournaments/#{leader.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", + "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}", body: { completed: true } ) expect(update_by_business_key[:status]).to eq(200) @@ -94,6 +95,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do leader = create_group_stage_tournament(name_prefix: 'Missing Action Resource Leader') finish_group_stage_and_create_playoffs!(leader.fetch(:id)) + create_team_action_list!(leader.fetch(:id)) leader_after_playoffs = fetch_tournament(leader.fetch(:id)) token_list = find_playoff_token_list!(leader_after_playoffs) @@ -109,7 +111,7 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(missing_list_update[:status]).to eq(404) missing_team_update = client.patch( - "/tournaments/#{leader.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/999999999", + "/tournaments/#{leader.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/999999999", body: { completed: true } ) expect(missing_team_update[:status]).to eq(404) @@ -179,6 +181,17 @@ RSpec.describe 'Tournament sync test HTTP E2E' do expect(finish_stage[:status]).to eq(200) end + def create_team_action_list!(tournament_id) + response = client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def play_match!(match_id, first_score:, second_score:) match_response = anonymous_client.get("/matches/#{match_id}") expect(match_response[:status]).to eq(200) @@ -198,10 +211,14 @@ RSpec.describe 'Tournament sync test HTTP E2E' do def find_playoff_token_list!(tournament) tournament.fetch(:team_action_lists).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end || raise('playoff token list missing') end + def playoff_token_list_key + 'playoff_token_collection' + end + def playoff_token_item_completed?(tournament, team_name:) list = find_playoff_token_list!(tournament) item = list.fetch(:team_action_items).find { |candidate| candidate.dig(:team, :name) == team_name } diff --git a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb index 4e21fec..d61149e 100644 --- a/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb +++ b/spec/e2e/http/tournament_team_action_lists_websocket_spec.rb @@ -26,6 +26,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do it 'broadcasts initial and updated team action list state over websocket' do tournament = create_group_stage_tournament(name_prefix: 'Ws Leader') finish_group_stage_and_create_playoffs!(tournament.fetch(:id)) + create_team_action_list!(tournament.fetch(:id)) tournament = fetch_tournament(tournament.fetch(:id)) token_list = find_playoff_token_list!(tournament) @@ -33,7 +34,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do cable_client.subscribe_team_action_lists!(tournament_id: tournament.fetch(:id)) initial_payload = cable_client.wait_for_payload! - expect(initial_payload.fetch('team_action_lists').first.fetch('key')).to eq('group_stage_survivor_playoff_tokens') + expect(initial_payload.fetch('team_action_lists').first.fetch('key')).to eq(playoff_token_list_key) first_item = token_list.fetch(:team_action_items).first second_item = token_list.fetch(:team_action_items)[1] @@ -49,7 +50,7 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do expect(team_action_item_completed?(first_update, team_name: first_item.dig(:team, :name))).to eq(true) update_by_business_key = client.patch( - "/tournaments/#{tournament.fetch(:id)}/team_action_lists/group_stage_survivor_playoff_tokens/teams/#{second_item.dig(:team, :id)}", + "/tournaments/#{tournament.fetch(:id)}/team_action_lists/#{playoff_token_list_key}/teams/#{second_item.dig(:team, :id)}", body: { completed: true } ) expect(update_by_business_key[:status]).to eq(200) @@ -113,6 +114,17 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do expect(finish_stage[:status]).to eq(200) end + def create_team_action_list!(tournament_id) + response = client.post("/tournaments/#{tournament_id}/team_action_lists", body: { + name: 'Playoff token collection', + key: playoff_token_list_key, + action_name: 'Collect playoff token', + source: 'group_stage_survivors' + }) + expect(response[:status]).to eq(201) + response.fetch(:json) + end + def play_match!(match_id, first_score:, second_score:) match_response = anonymous_client.get("/matches/#{match_id}") expect(match_response[:status]).to eq(200) @@ -142,13 +154,17 @@ RSpec.describe 'Tournament team action lists websocket HTTP E2E' do def find_playoff_token_list!(tournament) tournament.fetch(:team_action_lists).find do |list| - list.fetch(:key) == 'group_stage_survivor_playoff_tokens' + list.fetch(:key) == playoff_token_list_key end || raise('playoff token list missing') end def team_action_item_completed?(payload, team_name:) - list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == 'group_stage_survivor_playoff_tokens' } + list = payload.fetch('team_action_lists').find { |candidate| candidate.fetch('key') == playoff_token_list_key } item = list.fetch('team_action_items').find { |candidate| candidate.dig('team', 'name') == team_name } item.fetch('completed') end + + def playoff_token_list_key + 'playoff_token_collection' + end end From d3589235d91fde7596be934bbb5ff8caa8d889d2 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 22:48:11 +0200 Subject: [PATCH 16/16] docs: update manual team action list API --- AGENTS.md | 1 + doc/team_action_lists_api.md | 117 +++++++++++++++++++++++++++-------- 2 files changed, 91 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a26dc23..bd49d17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,6 +176,7 @@ Practical rule: For team action lists specifically: +- create lists explicitly through owner-facing HTTP API; do not auto-generate them from stage transitions unless product requirements say so - subscribe by tournament id - channel name is `TournamentTeamActionListsChannel` - broadcast full current list snapshot for that tournament diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md index 9c1fd25..9adeeb9 100644 --- a/doc/team_action_lists_api.md +++ b/doc/team_action_lists_api.md @@ -2,13 +2,19 @@ ## Purpose -Team action lists let the backend expose checklist-style workflows for teams inside a tournament. +Team action lists let tournament owners create checklist-style workflows for teams inside a tournament. -Current example: +Examples: -- `group_stage_survivor_playoff_tokens` +- collect playoff tokens +- track team check-in +- hand out medals -That list contains all teams that advanced from the group stage and tracks whether each team already collected a playoff token. +Important: + +- lists are not auto-generated by backend tournament events +- owner creates a list explicitly through API +- backend materializes the initial team items from the chosen `source` ## Read Flow @@ -26,7 +32,7 @@ Example response shape: { "id": 9, "name": "Playoff token collection", - "key": "group_stage_survivor_playoff_tokens", + "key": "playoff_token_collection", "action_name": "Collect playoff token", "source": "group_stage_survivors", "team_action_items": [ @@ -45,6 +51,50 @@ Example response shape: } ``` +## Create Flow + +Only tournament owner may create lists. +Follower tournaments in `read_only_mode` reject creation with `423`. + +Endpoint: + +- `POST /tournaments/:tournament_id/team_action_lists` + +Request body: + +```json +{ + "name": "Playoff token collection", + "key": "playoff_token_collection", + "action_name": "Collect playoff token", + "source": "group_stage_survivors" +} +``` + +Response: + +- `201 Created` +- response body is the created `TeamActionList` including `team_action_items` + +Current supported sources: + +- `all_tournament_teams` +- `group_stage_survivors` + +Current source behavior: + +- `all_tournament_teams` uses all teams currently in tournament +- `group_stage_survivors` uses teams currently advancing from a finished group stage + +Creation is snapshot-style: + +- backend resolves teams at create time +- backend creates one item per resolved team +- backend does not auto-create lists later +- backend does not auto-refresh existing list membership later + +If selected source currently resolves to no teams, backend returns `422`. + ## Write Flow Only tournament owner may change item status. @@ -79,7 +129,7 @@ Endpoint: Example: -- `PATCH /tournaments/123/team_action_lists/group_stage_survivor_playoff_tokens/teams/55` +- `PATCH /tournaments/123/team_action_lists/playoff_token_collection/teams/55` Request body: @@ -93,7 +143,12 @@ Response body for both write endpoints is the updated `TeamActionItem`. ## Idempotency Rules -These endpoints are designed to be idempotent if frontend sends explicit target state. +Create endpoint is idempotent by unique business key per tournament: + +- backend enforces unique `(tournament_id, key)` +- repeated create with same key returns `422`, not a second list + +Item update endpoints are idempotent if frontend sends explicit target state. Good: @@ -103,12 +158,12 @@ Good: Avoid: - toggle-style client behavior such as "invert current state" +- whole-list writeback from stale client state -Why this is idempotent: +Why item updates are idempotent: - sending `completed=true` multiple times leaves item in same final state - sending `completed=false` multiple times leaves item in same final state -- backend does not create duplicate items - backend stores one unique item per `(team_action_list_id, team_id)` `completed_at` behavior: @@ -122,10 +177,10 @@ Why this is idempotent: Recommended frontend flow: 1. Load tournament via `GET /tournaments/:id` -2. Find desired list by stable `key` -3. Render `team_action_items` -4. On user action, send explicit target state -5. Update local UI from response or refetch tournament +2. Render current `team_action_lists` +3. If owner wants a new checklist, send `POST /tournaments/:id/team_action_lists` +4. For item changes, send explicit target state for one item only +5. Update local UI from response or websocket snapshot Recommended frontend identifiers: @@ -135,6 +190,7 @@ Recommended frontend identifiers: Recommended optimistic UI: +- create list only once per intended key - set checkbox immediately to requested target state - revert if API returns error - do not compute next state from stale cached data if multiple clients may edit @@ -143,11 +199,13 @@ Recommended optimistic UI: Expected statuses: -- `200` update accepted +- `201` list created +- `200` item update accepted +- `401` unauthenticated - `403` authenticated but not tournament owner - `404` tournament, list, team, or item not found +- `422` invalid create payload, duplicate key, unsupported source, or source with no teams - `423` follower tournament is read-only -- `401` unauthenticated ## Sync / Follower Behavior @@ -155,10 +213,12 @@ Team action lists are included in tournament sync snapshots. That means: -- source tournament owner updates item state +- source owner creates list on source tournament - backend enqueues tournament sync -- follower tournament receives updated list state -- follower users can view but not mutate item state while read-only +- follower tournament receives created list and all items +- source owner updates item state +- follower tournament receives updated completion state +- follower users can view but not mutate list state while read-only ## Websocket / Live Update Behavior @@ -168,7 +228,7 @@ This is a websocket flow, not a webhook flow. - backend pushes updates to subscribed clients over websocket - frontend does not register callback URLs -- writes still happen over normal HTTP `PATCH` requests +- writes still happen over normal HTTP `POST` and `PATCH` requests Endpoint: @@ -190,11 +250,12 @@ Subscription identifier example: Behavior: - subscription immediately receives current full team-action-list snapshot for that tournament -- later list/item changes broadcast updated full snapshot again +- later list creation and item changes broadcast updated full snapshot again - same broadcast path is used for: - - direct source updates + - list creation + - direct item updates - follower sync imports -- both write endpoints trigger the same backend update path before broadcast: +- both item update endpoints trigger the same backend update path before broadcast: - `PATCH /team_action_items/:id` - `PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id` @@ -208,7 +269,7 @@ Payload shape: { "id": 9, "name": "Playoff token collection", - "key": "group_stage_survivor_playoff_tokens", + "key": "playoff_token_collection", "action_name": "Collect playoff token", "source": "group_stage_survivors", "team_action_items": [ @@ -239,20 +300,22 @@ Practical frontend pattern: 1. load initial tournament state over HTTP 2. open websocket subscription for same tournament id 3. render incoming websocket payload as latest live snapshot -4. when user checks or unchecks one team, send one idempotent HTTP `PATCH` for that item only -5. let websocket keep other viewers in sync +4. if owner submits create form, send one HTTP `POST` to create the list +5. when user checks or unchecks one team, send one idempotent HTTP `PATCH` for that item only +6. let websocket keep other viewers in sync This avoids whole-list writebacks and reduces race-condition risk between multiple clients. ## Future Extension Guidance -When new lists are added, frontend should not hardcode backend internals beyond: +When new sources or list types are added, frontend should not hardcode backend internals beyond: - stable list `key` - user-facing `name` - user-facing `action_name` +- selected `source` -New list types should automatically fit the same frontend rendering model: +New list types should still fit same frontend rendering model: - list metadata - list items