From fa83923bf29da1a6bd78f524efe32c511c652063 Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 10:08:19 +0200 Subject: [PATCH] docs: add team action list frontend guide --- .../team_action_lists_controller.rb | 38 ++++ config/routes.rb | 1 + doc/team_action_lists_api.md | 176 ++++++++++++++++++ .../team_action_lists_controller_spec.rb | 74 ++++++++ .../routing/team_action_lists_routing_spec.rb | 17 ++ 5 files changed, 306 insertions(+) create mode 100644 app/controllers/team_action_lists_controller.rb create mode 100644 doc/team_action_lists_api.md create mode 100644 spec/controllers/team_action_lists_controller_spec.rb create mode 100644 spec/routing/team_action_lists_routing_spec.rb diff --git a/app/controllers/team_action_lists_controller.rb b/app/controllers/team_action_lists_controller.rb new file mode 100644 index 0000000..06c13c5 --- /dev/null +++ b/app/controllers/team_action_lists_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +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 :set_team_action_item, only: %i[update_item] + + # PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id + def update_item + if @team_action_item.update(team_action_item_params) + push_sync_if_needed!(@tournament) + render json: @team_action_item + else + render json: @team_action_item.errors, status: :unprocessable_entity + end + end + + private + + def set_tournament + @tournament = Tournament.find(params[:tournament_id]) + end + + def set_team_action_item + list = @tournament.team_action_lists.find_by!(key: params[:key]) + @team_action_item = list.team_action_items.find_by!(team_id: params[:team_id]) + end + + def team_action_item_params + params.slice(:completed).permit(:completed) + end + + def push_sync_if_needed!(tournament) + TournamentSyncEnqueue.call(tournament) + end +end diff --git a/config/routes.rb b/config/routes.rb index 1bd4721..e4fac80 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,7 @@ Rails.application.routes.draw do resources :teams, only: %i[show update] resources :team_action_items, only: %i[update] resources :tournaments do + patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' resources :statistics, only: %i[index] resources :matches, only: %i[index] resources :beamers, only: %i[index show create update destroy] do diff --git a/doc/team_action_lists_api.md b/doc/team_action_lists_api.md new file mode 100644 index 0000000..24a29d6 --- /dev/null +++ b/doc/team_action_lists_api.md @@ -0,0 +1,176 @@ +# Team Action Lists API + +## Purpose + +Team action lists let the backend expose checklist-style workflows for teams inside a tournament. + +Current example: + +- `group_stage_survivor_playoff_tokens` + +That list contains all teams that advanced from the group stage and tracks whether each team already collected a playoff token. + +## Read Flow + +Frontend reads team action lists from the normal tournament payload: + +- `GET /tournaments/:id` + +Example response shape: + +```json +{ + "id": 123, + "name": "My Tournament", + "team_action_lists": [ + { + "id": 9, + "name": "Playoff token collection", + "key": "group_stage_survivor_playoff_tokens", + "action_name": "Collect playoff token", + "source": "group_stage_survivors", + "team_action_items": [ + { + "id": 41, + "completed": false, + "completed_at": null, + "team": { + "id": 55, + "name": "Team A" + } + } + ] + } + ] +} +``` + +## Write Flow + +Only tournament owner may change item status. +Everyone may read status. +Follower tournaments in `read_only_mode` reject updates with `423`. + +There are two supported write patterns. + +### Option 1: update by item id + +Use this when frontend already has the item id from tournament payload. + +- `PATCH /team_action_items/:id` + +Request body: + +```json +{ + "completed": true +} +``` + +### Option 2: update by list key + team id + +Use this when frontend wants a business-key style request like: + +- "tick off team X from list Y in tournament Z" + +Endpoint: + +- `PATCH /tournaments/:tournament_id/team_action_lists/:key/teams/:team_id` + +Example: + +- `PATCH /tournaments/123/team_action_lists/group_stage_survivor_playoff_tokens/teams/55` + +Request body: + +```json +{ + "completed": true +} +``` + +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. + +Good: + +- send `{"completed": true}` to mark complete +- send `{"completed": false}` to mark incomplete + +Avoid: + +- toggle-style client behavior such as "invert current state" + +Why this is 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: + +- first successful transition to `completed=true` sets timestamp if none exists +- sending `completed=true` again keeps same completed state +- sending `completed=false` clears `completed_at` + +## Frontend Implementation Guidance + +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 + +Recommended frontend identifiers: + +- use list `key` as stable frontend reference +- use `team.id` as stable team reference +- use `team_action_item.id` when available for direct item updates + +Recommended optimistic UI: + +- 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 + +## Error Handling + +Expected statuses: + +- `200` update accepted +- `403` authenticated but not tournament owner +- `404` tournament, list, team, or item not found +- `423` follower tournament is read-only +- `401` unauthenticated + +## Sync / Follower Behavior + +Team action lists are included in tournament sync snapshots. + +That means: + +- source tournament owner updates item state +- backend enqueues tournament sync +- follower tournament receives updated list state +- follower users can view but not mutate item state while read-only + +## Future Extension Guidance + +When new lists are added, frontend should not hardcode backend internals beyond: + +- stable list `key` +- user-facing `name` +- user-facing `action_name` + +New list types should automatically fit the same frontend rendering model: + +- list metadata +- list items +- explicit `completed` state +- idempotent set-state requests diff --git a/spec/controllers/team_action_lists_controller_spec.rb b/spec/controllers/team_action_lists_controller_spec.rb new file mode 100644 index 0000000..fdb9322 --- /dev/null +++ b/spec/controllers/team_action_lists_controller_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamActionListsController, type: :controller do + 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 + context 'as owner' do + before do + apply_authentication_headers_for tournament.owner + end + + it 'updates item by list key and team id' do + patch :update_item, params: { + tournament_id: tournament.to_param, + key: list.key, + team_id: team.to_param, + completed: true + } + + expect(response).to be_successful + expect(team_action_item.reload.completed).to eq(true) + end + + it 'is idempotent when same state sent repeatedly' do + 2.times do + patch :update_item, params: { + tournament_id: tournament.to_param, + key: list.key, + team_id: team.to_param, + completed: true + } + expect(response).to be_successful + end + + expect(team_action_item.reload.completed).to eq(true) + end + + it 'enqueues tournament sync' do + allow(TournamentSyncEnqueue).to receive(:call) + + patch :update_item, params: { + tournament_id: tournament.to_param, + key: list.key, + team_id: team.to_param, + completed: true + } + + expect(TournamentSyncEnqueue).to have_received(:call).with(tournament) + end + end + + context 'as another user' do + before do + apply_authentication_headers_for create(:user) + end + + it 'returns forbidden' do + patch :update_item, params: { + tournament_id: tournament.to_param, + key: list.key, + team_id: team.to_param, + completed: true + } + + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/routing/team_action_lists_routing_spec.rb b/spec/routing/team_action_lists_routing_spec.rb new file mode 100644 index 0000000..121778e --- /dev/null +++ b/spec/routing/team_action_lists_routing_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamActionListsController, type: :routing do + describe 'routing' do + it 'routes to #update_item via PATCH' do + expect(patch: '/tournaments/1/team_action_lists/group_stage_survivor_playoff_tokens/teams/2') + .to route_to( + 'team_action_lists#update_item', + tournament_id: '1', + key: 'group_stage_survivor_playoff_tokens', + team_id: '2' + ) + end + end +end