Commit 47593d10 authored by Mahmoud Aglan's avatar Mahmoud Aglan

docs: the hosting contract every package codes against

The schema as applied (read back from production), the allowed values and the
conflict targets, the decisions taken where the spec left room, the JSON
shapes, the SQL functions and PHP hooks each package exposes, the API
contracts with one error shape and one Arabic error catalog, the manager
routes with their permission keys and the org-scope guard, file ownership,
the test harness, and notes for each package.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 41180847
# Big-tournament hosting: the shared contract
Every package (P1 to P14 of `docs/tournament-hosting-spec.md`) codes against this file. It records the schema as it was applied to production, the decisions taken where the spec left room, the SQL and PHP interfaces each package exposes, the API contracts, the manager routes and the file ownership.
When this file and the spec disagree, this file wins. To change something in here, change it here first and say so in your package's commit.
**Path notation.**
- `G/` is the player app, `/Users/mahmoudaglan/El3ab`.
- `M/` is the manager, `/Users/mahmoudaglan/El3ab-management`.
---
## 1. Status
| | |
|---|---|
| Schema migration | `G/migrations/2026-10-27-hosting-schema.sql`, applied to production on 2026-10-26 (run twice; the second run changed nothing) |
| Backup before it ran | `/root/backups/2026-10-27-hosting-schema/` on the server (root only): `public-schema-before.sql`, `touched-data-before.sql` and `.dump` (data of every touched table plus `storage.buckets`), `public-schema-after-run1.sql`, `public-schema-after-run2.sql`, `SHA256SUMS` |
| Rollback | The commented block at the end of the migration. It has been tested on production inside `BEGIN … ROLLBACK`, and on the test replica for real (the schema came back byte for byte) |
| Test replica | `G/tests/schema.sql`, plus `G/tests/run.sh`, which applies every `migrations/2026-10-27-hosting-*.sql` (the schema file first) and runs every `tests/test_hosting_*.php` |
| Manager routes | All 73 routes from spec 5.4 are in `M/config/routes.php`. Until a module merges, its routes answer 404 |
Owner decisions, applied as defaults:
1. **No-show fees are kept.** A `no_show` registration is never refunded, whether by close-check-in or by the refund sweep.
2. **Prize hold.** It is on for a prize pool of 1000 coins or more, or for any category prize (decision D7).
3. **Registration files** are deleted 30 days after the tournament ends (D8).
4. **Club coaches.** An org-scoped manager account sees and edits only its own club's team, lineups and registrants (section 7.3).
5. **Arena** is postponed. P14 is not started. The `arena` values in the checks stay, so it can land later without a schema change.
---
## 2. Schema as applied
Everything here was read back from production after the migration. The exact check expressions are in the migration file. This section lists the allowed values, which is what code needs.
### 2.1 Columns and tables
Read back from `information_schema` on production after the migration. Altered tables list only the columns this migration added.
#### `el3ab_tournaments` (columns added)
| column | type | null | default |
|---|---|---|---|
| `entrant_kind` | text | no | `'player'` |
| `team_size` | int | yes | |
| `team_reserves` | int | no | `0` |
| `team_scoring` | jsonb | no | `'{}'` |
| `allowed_org_ids` | uuid[] | yes | |
| `max_per_org` | int | yes | |
| `entry_conditions` | jsonb | no | `'{}'` |
| `access` | text | no | `'public'` |
| `approval_required` | boolean | no | `false` |
| `waitlist_enabled` | boolean | no | `true` |
| `waitlist_max` | int | yes | |
| `check_in_required` | boolean | no | `false` |
| `check_in_mode` | text | no | `'app'` |
| `check_in_opens_minutes` | int | no | `60` |
| `check_in_closes_minutes` | int | no | `5` |
| `check_in_closed_at` | timestamptz | yes | |
| `form_required_to_play` | boolean | no | `true` |
| `round_schedule` | jsonb | no | `'[]'` |
| `round_break_minutes` | int | no | `0` |
| `bye_value` | numeric(3,1) | no | `1.0` |
| `rules_ar` | text | yes | |
| `dispute_window_minutes` | int | no | `15` |
| `prize_review_required` | boolean | no | `false` |
| `prizes_status` | text | no | `'none'` |
| `prizes_released_at` | timestamptz | yes | |
| `prizes_released_by_admin` | uuid | yes | |
| `completed_at` | timestamptz | yes | |
#### `tournament_private_settings` (new table)
| column | type | null | default |
|---|---|---|---|
| `tournament_id` | uuid | no | |
| `venue_check_in_code` | text | yes | |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_phases` (columns added)
| column | type | null | default |
|---|---|---|---|
| `entrant_kind` | text | no | `'player'` |
| `time_control` | time_control | yes | |
| `custom_time_initial_ms` | int | yes | |
| `custom_time_increment_ms` | int | yes | |
| `rounds_planned` | int | yes | |
| `first_round` | int | yes | |
| `last_round` | int | yes | |
| `tiebreak_rules` | jsonb | yes | |
| `starts_at` | timestamptz | yes | |
| `cut_review_until` | timestamptz | yes | |
| `cut_confirmed_at` | timestamptz | yes | |
| `cut_confirmed_by` | text | yes | |
| `standings_snapshot` | jsonb | yes | |
#### `tournament_teams` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `tournament_id` | uuid | no | |
| `org_id` | uuid | yes | |
| `name` | text | no | |
| `name_ar` | text | yes | |
| `short_name` | text | yes | |
| `seed` | int | yes | |
| `status` | text | no | `'registered'` |
| `captain_player_id` | uuid | yes | |
| `roster_id` | uuid | yes | |
| `lineup_override` | jsonb | no | `'{}'` |
| `final_standing` | int | yes | |
| `created_by_admin` | uuid | yes | |
| `created_at` | timestamptz | no | `now()` |
| `updated_at` | timestamptz | no | `now()` |
#### `el3ab_tournament_rounds` (columns added)
| column | type | null | default |
|---|---|---|---|
| `phase_id` | uuid | yes | |
| `stage_round` | int | yes | |
| `kind` | text | no | `'swiss'` |
| `scheduled_start_at` | timestamptz | yes | |
| `published_at` | timestamptz | yes | |
| `version` | int | no | `0` |
#### `tournament_brackets` (columns added)
| column | type | null | default |
|---|---|---|---|
| `size` | int | yes | |
| `third_place` | boolean | no | `false` |
#### `bracket_matches` (columns added)
| column | type | null | default |
|---|---|---|---|
| `round_ref` | int | yes | |
| `pairing_id` | uuid | yes | |
| `match_id` | uuid | yes | |
| `games` | jsonb | no | `'[]'` |
| `best_of` | int | no | `1` |
| `points_a` | numeric(4,1) | no | `0` |
| `points_b` | numeric(4,1) | no | `0` |
| `tiebreak_stage` | text | yes | |
| `decided_by` | text | yes | |
| `team_a_id` | uuid | yes | |
| `team_b_id` | uuid | yes | |
| `winner_team_id` | uuid | yes | |
| `disputed` | boolean | no | `false` |
#### `tournament_team_matches` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `tournament_id` | uuid | no | |
| `phase_id` | uuid | yes | |
| `round_number` | int | no | |
| `table_no` | int | no | |
| `team_a_id` | uuid | no | |
| `team_b_id` | uuid | yes | |
| `team_a_white_odd` | boolean | no | `true` |
| `lineup_a` | jsonb | no | `'[]'` |
| `lineup_b` | jsonb | no | `'[]'` |
| `boards_total` | int | no | |
| `boards_decided` | int | no | `0` |
| `game_points_a` | numeric(5,1) | no | `0` |
| `game_points_b` | numeric(5,1) | no | `0` |
| `match_points_a` | numeric(4,1) | yes | |
| `match_points_b` | numeric(4,1) | yes | |
| `result` | text | yes | |
| `bracket_match_id` | uuid | yes | |
| `status` | text | no | `'in_progress'` |
| `completed_at` | timestamptz | yes | |
| `created_at` | timestamptz | no | `now()` |
#### `tournament_stage_entries` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `tournament_id` | uuid | no | |
| `phase_id` | uuid | no | |
| `player_id` | uuid | yes | |
| `team_id` | uuid | yes | |
| `entrant_id` | uuid | yes | generated `COALESCE(player_id, team_id)` |
| `seed` | int | yes | |
| `group_label` | text | yes | |
| `source_rank` | int | yes | |
| `carried_points` | numeric(6,2) | no | `0` |
| `status` | text | no | `'active'` |
| `final_rank` | int | yes | |
| `final_points` | numeric(6,2) | yes | |
| `tiebreaks` | jsonb | yes | |
| `qualified_by` | text | yes | |
| `updated_by` | text | yes | |
| `created_at` | timestamptz | no | `now()` |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_registrations` (columns added)
| column | type | null | default |
|---|---|---|---|
| `represent_org_id` | uuid | yes | |
| `team_id` | uuid | yes | |
| `board_order` | int | yes | |
| `team_role` | text | yes | |
| `checked_in_at` | timestamptz | yes | |
| `check_in_source` | text | yes | |
| `waitlisted_at` | timestamptz | yes | |
| `promoted_at` | timestamptz | yes | |
| `form_status` | text | no | `'none'` |
| `form_submitted_at` | timestamptz | yes | |
| `reviewed_by_admin` | uuid | yes | |
| `reviewed_at` | timestamptz | yes | |
| `review_note` | text | yes | |
| `requested_byes` | int4[] | no | `'{}'` |
| `late_entry_round` | int | yes | |
| `fair_play_status` | text | no | `'clear'` |
| `source` | text | no | `'app'` |
| `redemption_id` | uuid | yes | |
| `staff_note` | text | yes | |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_form_fields` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `tournament_id` | uuid | no | |
| `field_key` | text | no | |
| `type` | text | no | |
| `label_ar` | text | no | |
| `label_en` | text | yes | |
| `help_ar` | text | yes | |
| `help_en` | text | yes | |
| `placeholder_ar` | text | yes | |
| `required` | boolean | no | `false` |
| `options` | jsonb | no | `'[]'` |
| `validation` | jsonb | no | `'{}'` |
| `show_if` | jsonb | yes | |
| `visibility` | text | no | `'private'` |
| `editable_until` | text | no | `'check_in_close'` |
| `display_order` | int | no | `0` |
| `is_active` | boolean | no | `true` |
| `created_by_admin` | uuid | yes | |
| `created_at` | timestamptz | no | `now()` |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_registration_answers` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `tournament_id` | uuid | no | |
| `registration_id` | uuid | no | |
| `field_id` | uuid | no | |
| `value` | jsonb | no | |
| `value_text` | text | yes | |
| `file_path` | text | yes | |
| `updated_by_player` | uuid | yes | |
| `updated_by_admin` | uuid | yes | |
| `created_at` | timestamptz | no | `now()` |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_prize_categories` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `tournament_id` | uuid | no | |
| `key` | text | no | |
| `name_ar` | text | no | |
| `name_en` | text | yes | |
| `rule` | jsonb | no | |
| `prizes` | jsonb | no | `'[]'` |
| `exclusive` | boolean | no | `true` |
| `display_order` | int | no | `0` |
| `is_public` | boolean | no | `true` |
| `created_at` | timestamptz | no | `now()` |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_prize_payouts` (columns added)
| column | type | null | default |
|---|---|---|---|
| `category_id` | uuid | yes | |
| `team_id` | uuid | yes | |
| `status` | text | no | `'paid'` |
| `hold_reason` | text | yes | |
| `released_at` | timestamptz | yes | |
| `released_by_admin` | uuid | yes | |
#### `tournament_media` (columns added)
| column | type | null | default |
|---|---|---|---|
| `kind` | text | no | `'gallery'` |
| `storage_path` | text | yes | |
| `width` | int | yes | |
| `height` | int | yes | |
| `alt_ar` | text | yes | |
| `show_in_app` | boolean | no | `true` |
| `uploaded_by_admin` | uuid | yes | |
#### `sponsors` (columns added)
| column | type | null | default |
|---|---|---|---|
| `logo_path` | text | yes | |
| `description_ar` | text | yes | |
| `created_by_admin` | uuid | yes | |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_sponsorships` (columns added)
| column | type | null | default |
|---|---|---|---|
| `tier` | text | no | `'partner'` |
| `label_ar` | text | yes | |
| `link_url` | text | yes | |
| `logo_url` | text | yes | |
| `display_order` | int | no | `0` |
| `is_active` | boolean | no | `true` |
| `show_in_app` | boolean | no | `true` |
| `show_on_live` | boolean | no | `true` |
| `impressions` | int | no | `0` |
| `clicks` | int | no | `0` |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_ad_slots` (columns added)
| column | type | null | default |
|---|---|---|---|
| `sponsor_id` | uuid | yes | |
| `image_path` | text | yes | |
| `alt_ar` | text | yes | |
#### `tournament_announcements` (columns added)
| column | type | null | default |
|---|---|---|---|
| `title_ar` | text | yes | |
| `content_ar` | text | yes | |
| `audience` | text | no | `'all'` |
| `audience_ref` | uuid | yes | |
| `show_in_app` | boolean | no | `true` |
| `notify` | boolean | no | `false` |
| `notified_at` | timestamptz | yes | |
| `notified_count` | int | yes | |
| `expires_at` | timestamptz | yes | |
| `created_by_admin` | uuid | yes | |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_disputes` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `tournament_id` | uuid | no | |
| `round_number` | int | yes | |
| `pairing_id` | uuid | yes | |
| `match_id` | uuid | yes | |
| `game_key` | text | yes | |
| `reporter_id` | uuid | yes | |
| `against_player_id` | uuid | yes | |
| `kind` | text | no | |
| `message` | text | yes | |
| `evidence_path` | text | yes | |
| `status` | text | no | `'open'` |
| `blocks_progress` | boolean | no | `false` |
| `result_before` | text | yes | |
| `result_after` | text | yes | |
| `decision_note` | text | yes | |
| `decided_by_admin` | uuid | yes | |
| `decided_at` | timestamptz | yes | |
| `created_at` | timestamptz | no | `now()` |
| `updated_at` | timestamptz | no | `now()` |
#### `tournament_certificates` (new table)
| column | type | null | default |
|---|---|---|---|
| `id` | uuid | no | `gen_random_uuid()` |
| `code` | text | no | |
| `tournament_id` | uuid | no | |
| `player_id` | uuid | yes | |
| `team_id` | uuid | yes | |
| `kind` | text | no | |
| `place` | int | yes | |
| `category_id` | uuid | yes | |
| `name_snapshot` | text | no | |
| `issued_at` | timestamptz | no | `now()` |
| `revoked_at` | timestamptz | yes | |
| `revoke_reason` | text | yes | |
#### `tournament_notices_sent` (new table)
| column | type | null | default |
|---|---|---|---|
| `tournament_id` | uuid | no | |
| `player_id` | uuid | no | |
| `kind` | text | no | |
| `sent_at` | timestamptz | no | `now()` |
#### `tournament_forbidden_pairs` (new table)
| column | type | null | default |
|---|---|---|---|
| `tournament_id` | uuid | no | |
| `player_a` | uuid | no | |
| `player_b` | uuid | no | |
| `until_round` | int | yes | |
| `reason` | text | yes | |
| `created_by_admin` | uuid | yes | |
| `created_at` | timestamptz | no | `now()` |
#### `backgammon_matches` (columns added)
| column | type | null | default |
|---|---|---|---|
| `tournament_id` | uuid | yes | |
| `tournament_round` | int | yes | |
| `pairing_id` | uuid | yes | |
#### `domino_matches` (columns added)
| column | type | null | default |
|---|---|---|---|
| `tournament_id` | uuid | yes | |
| `tournament_round` | int | yes | |
| `pairing_id` | uuid | yes | |
#### `ludo_matches` (columns added)
| column | type | null | default |
|---|---|---|---|
| `tournament_id` | uuid | yes | |
| `tournament_round` | int | yes | |
| `pairing_id` | uuid | yes | |
#### `groups` (columns added)
| column | type | null | default |
|---|---|---|---|
| `org_id` | uuid | yes | |
| `kind` | text | no | `'player'` |
| `tournament_id` | uuid | yes | |
### 2.2 Allowed values (checks)
| Table | Column | Values |
|---|---|---|
| `el3ab_tournaments` | `status` | `draft` `registration` `in_progress` `paused` `completed` `cancelled` |
| | `entrant_kind` | `player` `team`. `team` needs `team_size` 1–12. `team_reserves` is 0–6 |
| | `access` | `public` `code_only` `hidden` |
| | `check_in_mode` | `app` `venue_code` `staff_only`. `check_in_opens_minutes` is 5–1440, `check_in_closes_minutes` is 0–120, and closes must be less than opens |
| | `bye_value` | `0` `0.5` `1` |
| | `prizes_status` | `none` `held` `released` |
| | others | `round_break_minutes` and `dispute_window_minutes` are 0–1440; `max_per_org` > 0; `waitlist_max` ≥ 0; `team_scoring` and `entry_conditions` are objects; `round_schedule` is an array |
| `tournament_phases` | `type` | `swiss` `round_robin` `group_stage` `single_elimination` `double_elimination` `heats` `arena` |
| | `status` | `pending` `in_progress` `cut_review` `completed` `cancelled` |
| | shape | `entrant_kind` is `player` or `team`; `config` and `advancement_rule` are objects (never a JSON string); `rounds_planned` is 1–40; `last_round` ≥ `first_round` |
| `el3ab_tournament_rounds` | `kind` | `swiss` `round_robin` `group` `knockout` `heats` `playoff` `arena` |
| | `status` | `pending` `in_progress` `completed`. `unpaired` is allowed only when `swiss_round_id` is set (D1) |
| `bracket_matches` | `status` | `pending` `ready` `in_progress` `tiebreak` `completed` `bye` |
| | `tiebreak_stage` | `rapid` `blitz` `armageddon` `berlin` `arbiter` |
| | `decided_by` | `games` `tiebreak` `armageddon` `berlin` `forfeit` `double_forfeit` `bye` `arbiter` `higher_seed` |
| | `best_of` | 1–7. `games` is an array |
| `tournament_team_matches` | `result` | `team_a_wins` `team_b_wins` `draw` `bye` `double_forfeit` |
| | `status` | `in_progress` `completed` |
| `tournament_stage_entries` | `status` | `active` `qualified` `eliminated` `withdrawn` `disqualified` |
| | `qualified_by` | `rank` `group_rank` `best_next` `points` `playoff` `arbiter` `wildcard` `winner` |
| `tournament_registrations` | `status` | `registered` `pending_approval` `waitlisted` `cancelled` `withdrawn` `no_show` `rejected` `disqualified` (`waitlisted` needs `waitlisted_at`) |
| | `form_status` | `none` `incomplete` `complete` |
| | `fair_play_status` | `clear` `flagged` `under_review` `cleared` `disqualified` |
| | `source` | `app` `link` `code` `staff` `import` `waitlist` |
| | `team_role` | `player` `reserve` `captain` |
| | `check_in_source` | `app` `venue_code` `staff` |
| | `requested_byes` | at most 5 entries |
| `tournament_form_fields` | `type` | `text` `textarea` `number` `phone` `email` `select` `multiselect` `date` `boolean` `consent` `file` `country` `region` |
| | others | `field_key` matches `^[a-z][a-z0-9_]{1,39}$`; `label_ar` is 1–120 characters; `help_ar` is at most 400; `options` is an array of at most 300 (at least 1 for a select); `visibility` is `private` or `public`; `editable_until` is `registration_close`, `check_in_close`, `start` or `never` |
| `tournament_registration_answers` | | `value_text` is at most 1000 characters; `file_path` starts with `tournament-uploads/` |
| `tournament_prize_categories` | | `key` follows the same pattern as `field_key`; `rule` is an object; `prizes` is an array |
| `tournament_prize_payouts` | `status` | `held` `paid` `void` |
| `tournament_media` | `kind` | `banner` `cover` `logo` `share_card` `gallery`. Anything but `gallery` needs `media_type='image'`. `storage_path` starts with `tournament-media/` |
| `sponsors` | `website` | null, or `https://…` |
| `tournament_sponsorships` | `tier` | `title` `presenting` `gold` `silver` `bronze` `partner` `media` `venue`. `link_url` is null or `https://…` |
| `tournament_ad_slots` | `position` | `hero_top` `hero_bottom` `sidebar_top` `sidebar_bottom` `between_sections` `footer` `ticker` `app_detail_top` `app_detail_mid` `app_round_break` `app_results` |
| | `slot_type` | `image` `html` (`script` was removed). An `app_*` position must be `image`. `link_url` is null or `https://…` |
| `tournament_announcements` | `type` | `info` `result` `alert` `commentary` `milestone` `schedule` `sponsor` |
| | `audience` | `all` `registered` `checked_in` `not_checked_in` `waitlisted` `team` `org`. `audience_ref` is set exactly when the audience is `team` or `org` |
| `tournament_disputes` | `kind` | `wrong_result` `result_mismatch` `fair_play` `connection` `clock` `conduct` `other` |
| | `status` | `open` `reviewing` `upheld` `rejected` `withdrawn`. `message` is at most 1000 characters; `evidence_path` starts with `tournament-uploads/` |
| `tournament_certificates` | `kind` | `participation` `placement` `category` `team` |
| `tournament_forbidden_pairs` | | `player_a < player_b`. Store the smaller uuid first |
| `groups` | `kind` | `player` `org` `tournament`. `org` needs `org_id`; `tournament` needs `tournament_id` |
### 2.3 Keys and conflict targets
| Key | Columns | Usable as PostgREST `on_conflict` |
|---|---|---|
| `tse_uq` | `tournament_stage_entries (phase_id, entrant_id)` | yes: `on_conflict=phase_id,entrant_id`. Never send `entrant_id`; it is generated |
| `tpp_uq` | `tournament_prize_payouts (tournament_id, player_id, category_id)`, NULLS NOT DISTINCT | yes, and in SQL as `ON CONFLICT (tournament_id, player_id, category_id)` |
| `tra_uq` | `tournament_registration_answers (registration_id, field_id)` | yes |
| `tff_key_uq` | `tournament_form_fields (tournament_id, field_key)` | yes |
| `tpc_uq` | `tournament_prize_categories (tournament_id, key)` | yes |
| `ttm_uq` | `tournament_team_matches (tournament_id, round_number, table_no)` | yes |
| `tcert_uq` | `tournament_certificates (tournament_id, player_id, team_id, kind, category_id)`, NULLS NOT DISTINCT | yes |
| `tournament_sponsorships_uq` | `(tournament_id, sponsor_id)` | yes |
| `tournament_notices_sent_pkey` | `(tournament_id, player_id, kind)` | yes (insert with `resolution=ignore-duplicates`) |
| `bracket_matches_slot_uq` | `(bracket_id, round_number, match_number)` | yes |
| `bracket_matches_pairing_uq`, `backgammon/domino/ludo_matches_pairing_uq` | `(pairing_id) WHERE pairing_id IS NOT NULL` | **no**: these are partial indexes. Insert plainly and treat a 409 (23505) as "it already exists, read it back", as chess does with its derived match id |
| `tournament_media_slot_uq` | `(tournament_id, kind) WHERE kind <> 'gallery'` | **no** (partial). Replace a slot by updating the row for that kind, or by deleting and then inserting |
| `tournament_teams_name_uq` | `(tournament_id, lower(name))` | **no** (expression). A clash comes back as 409 |
| `tournament_phases_first_round_uq` | `(tournament_id, first_round) WHERE first_round IS NOT NULL` | **no** (partial) |
| `tdisp_one_open_uq` | `(pairing_id, reporter_id) WHERE status IN ('open','reviewing')` | **no**: a 409 means `already_reported` |
| `groups_tournament_uq` | `(tournament_id) WHERE kind = 'tournament'` | **no**: one lobby group per tournament |
| existing | `el3ab_tournament_rounds (tournament_id, round_number)`, `tournament_registrations (tournament_id, player_id)`, `tournament_phases (tournament_id, phase_number)` | unchanged |
### 2.4 Access, storage, Realtime
- **RLS** is on for all 21 tables in the migration's section 14:
- the 11 new ones: `tournament_private_settings`, `tournament_teams`, `tournament_team_matches`, `tournament_stage_entries`, `tournament_form_fields`, `tournament_registration_answers`, `tournament_prize_categories`, `tournament_disputes`, `tournament_certificates`, `tournament_notices_sent`, `tournament_forbidden_pairs`;
- and `tournament_phases`, `tournament_brackets`, `bracket_matches`, `tournament_registrations`, `tournament_media`, `tournament_ad_slots`, `tournament_announcements`, `sponsors`, `tournament_sponsorships`, `tournament_prize_payouts`.
- **Grants.** anon and authenticated hold no grant at all on those 21 tables. `service_role` has SELECT, INSERT, UPDATE and DELETE (it bypasses RLS, so it needs no policy). Every read goes through PHP with the service key.
- **Checked on production:** an anon REST call to each of the 21 answers 401 with `42501`; a service call answers 200.
- **Unchanged:** `el3ab_tournaments` and `el3ab_tournament_rounds` keep their `SELECT USING (true)` policies, which Realtime needs. Both are world-readable, every new column included.
- Never put a secret on them. A secret goes in `tournament_private_settings`.
- **Buckets** (Supabase storage):
| Bucket | Public | Size limit | Types | Holds |
|---|---|---|---|---|
| `tournament-media` | yes | 5 MB | jpeg, png, webp | banners, covers, logos, share cards, gallery images, sponsor logos, ad images |
| `tournament-uploads` | no | 10 MB | jpeg, png, webp, pdf | registration form files, dispute evidence. Served only through signed links (10 minutes) |
- **Path rule.** A path stored in the DB is `<bucket>/<object key>`, so it starts with the bucket name, and the object key is what follows the first `/`.
- `tournament-media/<tid>/banner-<rand>.webp`
- `tournament-media/sponsors/<sponsor_id>/logo-<rand>.png`
- `tournament-media/ads/<tid>/<rand>.webp`
- `tournament-uploads/<tid>/pending/<player_id>/<field_key>-<rand>.jpg`, the upload before registering
- `tournament-uploads/<tid>/<registration_id>/<field_key>-<rand>.jpg`, optional once registered
- `tournament-uploads/<tid>/disputes/<dispute_id>/<rand>.jpg`
- The public URL of a media path is `SUPABASE_URL/storage/v1/object/public/<path>`.
---
## 3. Decisions
Where the spec was silent, ambiguous or wrong, this is what was decided. Each entry names the package it binds.
- **D1. Legacy `unpaired` rounds.** The manager's `unpairRound` (`M/modules/tournaments/controller.php`) still writes `status='unpaired'` and a JSON-string `pairings` for rounds of the old external Swiss service.
- The round status check allows `unpaired` only when `swiss_round_id` is set, so that path keeps working and no engine round can ever be `unpaired`.
- P8 may remove the path once nothing uses the Swiss service.
- **D2. Team events need a team size.** In the spec, `el3ab_t_entrant_chk` passed when `team_size` was NULL. It now requires `team_size` when `entrant_kind='team'`.
- **D3. `dispute_window_minutes`** is a column on `el3ab_tournaments`.
- It is `int NOT NULL DEFAULT 15`, from 0 to 1440, and 0 means disputes are off.
- The window is measured from the pairing's `completed_at`.
- It applies to P5 (report), P4 (payload) and P8 (form).
- **D4. `groups.kind` and `groups.tournament_id`.**
- **Values.** `kind` is `player` (made by a player in the app, the default), `org` (a club group, with `org_id`, made by P11's import) or `tournament` (a lobby group, with `tournament_id`, at most one per tournament).
- **Who uses it.** P13 reads and writes them; P11 creates `org` groups.
- **Club group size.** An `org` group's `max_members` is the org's `max_members`.
- **D5. The legacy string pairings were not unwrapped.**
- On 2026-10-26, 4 rounds held `pairings` as a JSON string, and all 4 decoded to an object (`{"count":16,"bye":null}`), not a list. So the unwrap step changed nothing, as intended.
- These rounds belong to cancelled or completed tournaments from the old Swiss service.
- **P1:** `tournament_pairing_set` must check `jsonb_typeof(v_pairings) = 'array'` after decoding a string, and answer `{ok:false, code:'bad_pairings'}` otherwise. `jsonb_array_elements` raises on an object.
- **D6. Who acted.** The engine's admin API receives two headers from the manager:
- `X-Actor: manager:<username>` (as today);
- `X-Actor-Id: <admin_users.id>` (new).
- Text columns (`cut_confirmed_by`, `updated_by`, the pairing's `set_by`) store the `X-Actor` string.
- uuid columns (`*_by_admin`) store `X-Actor-Id`, but only when the caller is the service and the value is a uuid. Otherwise they stay NULL.
- P8 sends the header (in `PlayerEngineService.php`); P5 reads it.
- **D7. Prize hold default** (owner decision 2). `prize_review_required` is a plain column with default `false`. The rule is applied when settings are saved:
- P8, on create or update, sets it to `true` when `prize_pool_coins >= 1000`, unless staff untick "مراجعة الجوائز قبل الصرف".
- P9 sets it to `true` when a category with at least one coin prize is saved. Staff can turn it off again afterwards.
- P5 obeys the column only.
- **The 24-hour reminder** is shown in the manager. P9's prizes page and P11's console show a `badge-warning` "الجوائز مستنية المراجعة من أكتر من ٢٤ ساعة" when `prizes_status='held'` and `completed_at` is more than 24 hours ago.
- Use the constant `TOURNAMENT_PRIZE_HOLD_MIN_POOL = 1000`.
- **D8. Deleting registration files** (owner decision 3).
- P3 provides `tournamentFilesPurgeTick($sdb): array`, and P1's cron calls it as a hook.
- It deletes every object under `tournament-uploads/<tid>/` for tournaments whose status is `completed` or `cancelled` and that ended more than 30 days ago. The end time is `completed_at`, or `updated_at` when that is NULL.
- It then sets the stored paths to NULL:
- on answers: `file_path = NULL`, `value = value - 'path' || {"purged":true}`;
- on disputes: `evidence_path = NULL`.
- It is idempotent and cheap when nothing is due, and it runs at most once an hour.
- Use the constant `TOURNAMENT_FILE_RETENTION_DAYS = 30`.
- **D9. `completed_at`** is stamped when a tournament reaches `completed` or `cancelled`. P1 stamps it in `tournamentFinish`; whoever cancels (P5 `cancel`, the manager's cancel through P5) stamps it too. Older tournaments keep NULL.
- **D10. `bracket_matches.match_id` holds chess games only.** It references `matches(id)`. For backgammon, domino and ludo it stays NULL, and `games[]` carries `{game_no, pairing_id, game_key, match_id}` with the game table's id (P2, P12).
- **D11. Notification types.** In-app notifications go to `notifications`, with `type`, `title`/`title_ar`, `body`/`body_ar` and `data` (`{tournament_id, round?, pairing_id?, link}`). The types are:
- `tournament_reminder` (t24h, t1h, t5m)
- `tournament_check_in` (check-in opens)
- `tournament_round` (your next opponent)
- `tournament_tiebreak` ("مباراة فاصلة")
- `tournament_waitlist` (promoted)
- `tournament_prize`
- `tournament_announcement`
- Each send is recorded in `tournament_notices_sent` under the kinds in the column comment, so it goes out once.
- P5 owns sending (`tournamentNotify`, section 6). P6 adds the sticker mapping for these types in `G/public/js/modules/social/scenes/notifications.js`; that file is assigned to P6 for the tournament types only.
- **D12. Errors.**
- Every API failure uses one shape (section 8.1).
- The Arabic text comes from the catalog in section 8.2, so the same refusal reads the same everywhere.
- The app shows its own string for `message_key` when it has one, and the server's `message` otherwise.
- **D13. RPCs from packages not merged yet.**
- P1 calls P3's `tournament_close_check_in` and `tournament_promote_waitlist`.
- Before P3 merges, PostgREST answers 404 (`function not found`) and the test shim answers `[]`. P1 treats both as "nothing to do" and logs once.
- In general, an RPC from a package that has not merged is always optional for the caller.
- **D14. Bracket game ids.**
- Game 1 of a slot keeps the existing derived ids: `tournamentPairingId(tid, round, board)` and `tournamentMatchIdFor(tid, round, board)`.
- Game *n* > 1 appends `:g<n>` to the seed (spec 3.3). P1 extends both functions with `int $gameNo = 1`.
- A tiebreak game takes the slot's board number, and the next free board numbers are never reused.
- **D15. The manager never computes standings.** P8, P10 and P11 read standings from `G/api/swiss.php` (P4), or from `tournament_phases.standings_snapshot` for a finished stage.
- **D16. Org-scoped access.** `OrgScope` does not look inside tournament modules, so every new manager controller enforces org scope itself (section 7.3).
---
## 4. JSON shapes
### 4.1 A pairing entry (`el3ab_tournament_rounds.pairings[]`)
```json
{"board":1,"pairing_id":"uuid","white_id":"uuid","black_id":"uuid","is_bye":false,
"bye_kind":"pairing|requested|late_entry","bye_points":1.0,
"result":null,"reason":null,"match_id":null,"completed_at":null,"set_by":null,"previous_result":null,
"game_key":"chess","group":"A",
"slot_id":"<bracket_matches.id>","game_no":1,"game_kind":"regular|rapid|blitz|armageddon|playoff",
"clock":{"white_ms":300000,"black_ms":240000,"increment_ms":0},"ko_winner":"uuid",
"team_match_id":"uuid","team_board":2,"white_team":"a|b",
"is_heat":true,"seats":["uuid","uuid","uuid","uuid"],"placements":{"uuid":1},
"reports":{"uuid":{"result":"white_wins","at":"2026-11-01T10:00:00Z"}}}
```
- `result` is one of `white_wins`, `black_wins`, `draw` or `aborted`.
- A bye has `is_bye:true`, `black_id:null`, and `bye_points` (0.5 for a requested bye, otherwise `bye_value`).
- Fields absent from a legacy pairing mean their defaults: `game_no` 1, `game_kind` `regular`, `game_key` the tournament's.
- A pairing is only ever written through `tournament_pairing_set` or `tournament_round_append_pairings`. A PATCH of the whole array is a bug.
### 4.2 Derived ids
| What | Seed passed to `tournamentUuidFrom` |
|---|---|
| pairing, game 1 | `"<tid>:<round>:<board>"` (unchanged) |
| match, game 1 | `"<tid>:<round>:<board>:match"` (unchanged) |
| pairing, game n > 1 | `"<tid>:<round>:<board>:g<n>"` |
| match, game n > 1 | `"<tid>:<round>:<board>:g<n>:match"` |
### 4.3 Stage `config` (validated by `tournamentStagesValidate`, P1)
```json
{"swiss":{"rounds":7,"accelerated":"none|baku","accel_rounds":2,"late_join_until_round":3,"half_point_byes_max":1,
"avoid_same_org_until_round":3,"carry_points":false},
"round_robin":{"cycles":1},
"group_stage":{"groups":8,"distribution":"snake|random|snake_avoid_org","cycles":1},
"single_elimination":{"bracket_size":8,"best_of":1,"third_place":true,"seeding":"standard|random|manual",
"colour_rule":"higher_seed_white|lot","tiebreak":["armageddon"],
"tiebreak_clocks":{"rapid":{"initial_ms":600000,"increment_ms":5000},"blitz":{"initial_ms":180000,"increment_ms":2000},
"armageddon":{"white_ms":300000,"black_ms":240000,"increment_ms":0}},
"armageddon_colours":"reverse_game1|lot","double_forfeit":"higher_seed|both_out","team_tiebreak":["berlin","armageddon_board1"]},
"double_elimination":{"bracket_size":16,"grand_final_reset":true,"best_of":1,"tiebreak":["armageddon"]},
"heats":{"table_size":4,"rounds":4,"placement_points":[3,2,1,0],"seat":"snake"},
"arena":{"duration_minutes":60,"berserk":false,"streak_bonus":true},
"manual_start":false,"break_minutes":15,
"match_length":1,"target_score":100}
```
- Only the block for the stage's own `type` is read.
- `late_join_until_round` and `half_point_byes_max` sit inside `swiss` (P3 reads `config->swiss->late_join_until_round`; the spec's SQL read `config->>'late_join_until_round'` at the top level, which is wrong).
- `match_length` (backgammon) and `target_score` (domino) are read by P12.
- `config` is always a JSON object, never a string.
### 4.4 Stage `advancement_rule`
```json
{"method":"top_n|top_per_group|score_at_least|winners|manual","count":8,"per_group":2,"plus_best_next":0,"points":4.5,
"tie_at_cut":"tiebreaks|playoff","seeding":"by_rank|by_rating|random|manual|snake_groups","carry":"none|points",
"min_games":0,"cut_review_minutes":5}
```
### 4.5 `team_scoring`, `entry_conditions`, category `rule`, category `prizes`
```json
{"mode":"none|top_n","top_n":4,"min_players":2}
{"mode":"match_points","win":2,"draw":1,"loss":0,"tiebreaks":["game_points","team_sb","direct_encounter"],"self_register":true}
```
```json
{"min_games":10,"account_age_days":7,"min_level":3,"countries":["EG"],
"answers":[{"field":"governorate","op":"in","values":["EG-C","EG-GZ"]},{"field":"birth_date","op":"age_lte","value":18}]}
```
```json
{"all":[{"source":"answer","field":"governorate","op":"in","values":["EG-ALX"]},
{"source":"rating","op":"lt","value":1400},{"source":"org","op":"in","values":["uuid"]},
{"source":"age","field":"birth_date","op":"lte","value":12,"on":"starts_at"},{"source":"unrated"}]}
```
`prizes` is written `[{"place":1,"coins":300,"label_ar":"أحسن لاعب من إسكندرية"}]`.
- Operators: `in`, `not_in`, `eq`, `lt`, `lte`, `gt` and `gte`, plus `age_lte` and `age_gte` for an answer that holds a date.
- A country in `entry_conditions.countries` is ISO alpha-2. `profiles.country_code` is `char(3)`: trim it and compare its first two letters, upper-case.
### 4.6 Answer values
| Type | `value` | `value_text` |
|---|---|---|
| text, textarea | `"…"` | the trimmed text |
| number | `12` | `"12"` |
| phone | `{"country":"EG","dial":"+20","national":"1001234567","e164":"+201001234567"}` | the E.164 number |
| email | `"a@b.c"` (lower-cased) | the same |
| select | `"u12"` | the same |
| multiselect | `["girls","u12"]` | `girls\|u12` (sorted) |
| date | `"2012-05-01"` | the same |
| boolean, consent | `true` | `"1"` |
| country | `{"code":"EG","label_ar":"مصر"}` | `EG` |
| region | `{"country":"EG","code":"EG-C","label_ar":"القاهرة","label_en":"Cairo"}` | `EG-C` |
| file | `{"path":"tournament-uploads/<tid>/…","mime":"image/jpeg","size":123456,"name":"id.jpg"}` | the file name |
The labels live inside each answer, so the manager and the exports never need the dataset.
`validation` keys:
- numbers: `min`, `max`, `integer`;
- text: `min_length`, `max_length` (text defaults to 200, textarea to 1000), `pattern`;
- dates: `min_date`, `max_date`, `min_age`, `max_age` (ages at `starts_at`);
- selects: `min_select`, `max_select`;
- country and region: `countries` (an allowed list), `country` (fixed), `depends_on` (the country field's key, for a region);
- files: `max_mb` (default 5), `accept`.
Option entries are `{"value":"u12","label_ar":"تحت ١٢","label_en":"Under 12"}`.
`show_if` is `{"field":"<key>","op":"eq|in","value":…|"values":[…]}`.
### 4.7 Regions dataset (`G/public/assets/data/arab-regions.json`)
```json
{"version":"2026-10-27","countries":[
{"code":"EG","dial":"+20","name_ar":"مصر","name_en":"Egypt","region_label_ar":"المحافظة",
"phone":{"min":10,"max":10,"trunk":"0"},
"regions":[{"code":"EG-C","name_ar":"القاهرة","name_en":"Cairo"}]}]}
```
- It covers the 22 Arab League countries, with ISO 3166-1 alpha-2 country codes and ISO 3166-2 region codes.
- Read it only through the adapters:
- `tournamentRegions()` in `G/includes/tournament-forms.php` (P3);
- `G/public/js/modules/tournaments/components/regions.js` (P6).
- When `phone` is missing: 6 to 12 national digits, with a leading `0` stripped.
---
## 5. SQL functions
Rules for every function below:
- `SET search_path = public, pg_temp`;
- then `REVOKE ALL ON FUNCTION … FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION … TO service_role;`;
- a business refusal returns `{ok:false, code:"<key>"}` and never raises;
- idempotent: `CREATE OR REPLACE`, with `DROP FUNCTION IF EXISTS` only for an overload being replaced;
- each package's functions live only in that package's own migration file.
| Pkg | File | Function | Returns (success) | Refusal codes |
|---|---|---|---|---|
| P1 | `2026-10-27-hosting-engine.sql` | `tournament_pairing_set(p_round_id uuid, p_pairing_id text, p_patch jsonb, p_mode text DEFAULT 'if_unset')`. The mode is `if_unset`, `override` or `clear` | `{ok:true, changed, pairing, previous, open, round_status}`, or `{ok:true, changed:false, code:'already_set', pairing}` | `bad_request`, `round_not_found`, `pairing_not_found`, `round_not_live`, `bad_pairings` (D5) |
| P1 | same | `tournament_round_append_pairings(p_round_id uuid, p_pairings jsonb)` | `{ok:true, appended}` (entries with a `pairing_id` already present are skipped) | `bad_request`, `round_closed` |
| P2 | `2026-10-27-hosting-bracket.sql` | `tournament_bracket_decide(p_match_id uuid, p_winner text, p_decided_by text, p_points_a numeric DEFAULT NULL, p_points_b numeric DEFAULT NULL)`. The winner is `a` or `b` | `{ok:true, winner_id, winner_team_id, next_match_id, next_ready, loser_next_match_id, loser_next_ready}`, or `{ok:true, already:true, winner_id}` | `bad_winner`, `not_found`, `empty_slot` |
| P2 | same | `tournament_team_match_recompute(p_team_match_id uuid)` | `{ok:true, boards_decided, game_points_a, game_points_b, result}` | `not_found` |
| P3 | `2026-10-27-hosting-registration.sql` | `tournament_register(p_tournament_id uuid, p_player_id uuid, p_opts jsonb DEFAULT '{}')`. It replaces and drops the 2-argument version. `p_opts` takes `source`, `waive_fee`, `represent_org_id`, `team_id`, `allow_waitlist`, `over_capacity`, `form_status`, `redemption_id` and `answers[]` (see the notes after this table) | `{ok:true, registration_id, status, form_status, player_count, waitlist_position, fee_charged, coins}`, or `{ok:true, already:true, registration_id, status}` | `bad_request`, `not_found`, `code_required`, `registration_closed`, `not_open_yet`, `not_allowed`, `org_quota_full`, `full`, `waitlist_full`, `insufficient_coins` |
| P3 | same | `tournament_promote_waitlist(p_tournament_id uuid, p_at_close boolean DEFAULT false)` | `{ok:true, promoted:[{player_id, registration_id, fee_charged}], skipped:[{player_id, reason}]}`. The skip reasons are `not_checked_in`, `not_approved` and `insufficient_coins` | `not_found` |
| P3 | same | `tournament_withdraw(p_tournament_id uuid, p_player_id uuid)` (replaced) | `{ok:true, status:'cancelled'\|'withdrawn', refunded, promoted[]}` | `not_found`, `not_registered`, `finished` |
| P3 | same | `tournament_check_in(p_tournament_id uuid, p_player_id uuid, p_source text DEFAULT 'app', p_code text DEFAULT NULL, p_undo boolean DEFAULT false)` | `{ok:true, checked_in_at}`, or `{ok:true, code:'check_in_not_required'}` | `not_registered`, `check_in_not_open` (+`opens_at`), `check_in_closed` (+`closes_at`), `staff_only`, `venue_code_required`, `venue_code_wrong`, `pending_approval`, `form_incomplete` (tested in this order) |
| P3 | same | `tournament_close_check_in(p_tournament_id uuid)` | `{ok:true, already?, no_shows[], rejected[], promoted[], skipped[]}` | `not_found` |
| P3 | same | `tournament_refund_due()` (replaced) | `{refunds, coins}`. It also refunds `rejected`, never `no_show` | — |
| P3 | same | `_redeem_tournament(p_player uuid, p_tid uuid, p_waive boolean)` (replaced) | the old shape plus `status`, `waitlisted`, `waitlist_position`, `form_status`. `full` and `waitlist_full` become `tournament_full` | as before |
| P3 | same | `tournament_promo_track(p_kind text, p_id uuid, p_event text)`. The kind is `ad` or `sponsor`; the event is `impression` or `click` | `{ok:true, counted:true\|false}` | `bad_request` |
| P5 | `2026-10-27-hosting-prizes.sql` | `tournament_settle(p_tournament_id uuid, p_results jsonb)` (replaced). Each row is `{player_id, rank, coins, category_id?, team_id?}` | `{ok:true, settled, paid, held, coins, prizes_status}` | `not_found`, `not_completed` |
| P5 | same | `tournament_prizes_reset_held(p_tournament_id uuid)` | `{ok:true, deleted}` | `not_held` |
| P5 | same | `tournament_release_prizes(p_tournament_id uuid, p_admin uuid, p_note text DEFAULT NULL)` | `{ok:true, paid, voided, coins}`, or `{ok:true, already:true}` | `not_found` |
Notes:
- In `p_opts`, `source` is `app`, `link`, `code`, `staff` or `import`. Each `answers[]` entry is `{field_id, value, value_text, file_path}`.
- P3's `tournament_register` reads `late_join_until_round` from `config->'swiss'->>'late_join_until_round'` (section 4.3).
- `tournament_promote_waitlist` writes `source` unchanged and `promoted_at=now()`. `tournament_check_in` compares venue codes through `redeem_fold()`.
- The functions this replaces, as they stand in production today, are in `G/tests/schema.sql` (`tournament_register`, `tournament_withdraw`, `tournament_refund_entry`, `tournament_refund_due`, `tournament_settle`, `_redeem_tournament`, `redeem_fold`, `complete_match`, `award_coins`). Build on those bodies.
---
## 6. PHP interfaces and hooks
Every function takes the service client (`$sdb`) first, as the engine already does.
- **Calling another package.** Call a function another package provides only behind `function_exists()`, and fall back to today's behaviour. That lets packages merge in any order.
- **Returns.** A function returns an array. A refusal looks like `['ok' => false, 'code' => '<key>', …]`.
- **Constants** are shared, but each package defines its own in its own file, guarded with `if (!defined(...))`:
- `TOURNAMENT_PRIZE_HOLD_MIN_POOL = 1000`
- `TOURNAMENT_FILE_RETENTION_DAYS = 30`
- `TOURNAMENT_STATUS_PAUSED = 'paused'`, which moves from `tournament-admin.php` into the engine (P1)
### 6.1 Hooks (P1 calls them; spec 8.1)
| Hook | Provided by | Called from | Signature | When it is missing |
|---|---|---|---|---|
| `tournamentBracketOnResult` | P2, `includes/tournament-bracket.php` | `tournamentAfterResult` (P1), after `tournament_pairing_set` changed a pairing that carries `slot_id` | `(SupabaseClient $sdb, array $t, array $round, array $pairing): void` | nothing happens |
| `tournamentTeamOnResult` | P2, `includes/tournament-teams.php` | the same, for a pairing that carries `team_match_id` | `(SupabaseClient $sdb, array $t, array $round, array $pairing): void` | nothing happens |
| `tournamentSettleAll` | P5, `includes/tournament-prizes.php` | `tournamentFinish` (P1) | `(SupabaseClient $sdb, array $t): array`, returning the `tournament_settle` RPC result | today's `tournamentSettle` |
| `tournamentNotifyTick` | P5, `includes/tournament-notify.php` | `api/cron.php` step 9 | `(SupabaseClient $sdb): array`, returning `{sent:int}` | nothing happens |
| `tournamentGamesRepairTick` | P12, `includes/tournament-games.php` | `api/cron.php` step 3 | `(SupabaseClient $sdb): array`, returning `{repaired:int}` | nothing happens |
| `tournamentFilesPurgeTick` | P3, `includes/tournament-forms.php` | `api/cron.php` after step 10 (D8) | `(SupabaseClient $sdb): array`, returning `{tournaments:int, files:int}` | nothing happens |
| `tournamentEligibilityV2` | P3, `includes/tournament-registration.php` | `api/tournaments.php` (P3) and the redeem path | `(SupabaseClient $sdb, array $t, ?string $playerId, array $opts = []): array` | the old `tournamentEligibility`, which P1 deletes once P3 has merged |
`tournamentAfterResult(SupabaseClient $sdb, array $t, array $round, array $pairing, array $setResult): void` is P1's; it is called after every recorded result. The order inside it is:
1. the bracket hook;
2. the team hook;
3. `tournamentMaybeAdvance`.
That order is why a round can't close between a drawn knockout game and its appended tiebreak.
### 6.2 Functions each package provides
**P1** (`tournament-engine.php`, `tournament-stages.php`, `tournament-pairing.php`):
| Function | Returns |
|---|---|
| `tournamentPairingSet($sdb, string $roundId, string $pairingId, array $patch, string $mode = 'if_unset')` | the RPC result |
| `tournamentRoundAppendPairings($sdb, string $roundId, array $pairings)` | the RPC result |
| `tournamentPairingId(string $tid, int $round, int $board, int $gameNo = 1)`, `tournamentMatchIdFor(…, int $gameNo = 1)` | string (D14) |
| `tournamentStages($sdb, array $t)` | list of stage rows. A legacy tournament gets one virtual stage `{virtual:true, phase_number:1, type, rounds_planned, first_round:1, …}` |
| `tournamentCurrentStage($sdb, array $t)` | ?array |
| `tournamentStagesValidate(array $t, array $stages)` | `{ok, stages, warnings[]}`, or `{ok:false, code:'invalid_stage', index, field, reason}` |
| `tournamentStagesSave($sdb, array $t, array $stages, string $actor)` | `{ok, stages}`, or `stages_locked` once the tournament has started |
| `tournamentCutPreview($sdb, array $t, int $phaseNumber)` | `{qualifiers:[{player_id\|team_id, rank, seed, points, tiebreaks}], boundary_ties[], playoff_needed}` |
| `tournamentCutAdjust($sdb, array $t, int $phaseNumber, array $add, array $remove, array $seeds, string $actor, string $reason)` | `{ok, qualifiers}`, or `not_in_cut_review` |
| `tournamentConfirmCut($sdb, array $t, int $phaseNumber, string $actor)` | `{ok, round}`, or `not_in_cut_review` |
| `tournamentStageStart($sdb, array $t, int $phaseNumber, string $actor)` | `{ok, round}`, for a stage with `manual_start` |
| `tournamentRoundSchedule($sdb, array $t, int $round, string $startsAtIso)` / `tournamentRoundDelay($sdb, array $t, int $minutes)` | `{ok, scheduled_start_at}` |
| `tournamentStageStandings($sdb, array $t, array $stage, array $opts = [])`. `$opts` takes `group`, and `view` = `individual`, `club` or `team` | list of rows |
| `tournamentCompositeRanking($sdb, array $t)` | `[{player_id, rank, points, team_id?}]`, the order of spec 4.5 |
| `tournamentClubTable($sdb, array $t)` | `[{org_id, rank\|null, points, counted:[…], tiebreaks}]` |
| `tournamentPlayers(…)` | chunked at 150 ids per `in.(…)` |
**P2** (`tournament-bracket.php`, `tournament-teams.php`):
| Function | Returns |
|---|---|
| `tournamentBracketBuild($sdb, array $t, array $stage, array $seeded)` | `{ok, bracket_ids[], byes}` |
| `tournamentBracketOpenRound($sdb, array $t, array $stage, int $stageRound, int $globalRound)` | the pairings for the round. It also writes each slot's `round_ref`, `pairing_id`, `games` and `status` |
| `tournamentBracketRoundComplete($sdb, array $t, array $round)` | bool |
| `tournamentBracketFinished($sdb, array $t, array $stage)` | bool |
| `tournamentBracketPlacements($sdb, array $t, array $stage)` | `[{entrant_id, place_from, place_to, eliminated_in}]`, for composite ranking |
| `tournamentBracketDecide($sdb, array $t, string $slotId, string $winner, string $decidedBy, string $actor)` | `{ok, next_ready}` |
| `tournamentBracketTiebreak($sdb, array $t, string $slotId, string $kind, string $actor)` | `{ok, pairing_id}` |
| `tournamentBracketReopen($sdb, array $t, string $slotId, string $actor)` | `{ok}`, or `next_started` |
| `tournamentTeamMatchesCreate($sdb, array $t, array $stage, int $globalRound, array $teamPairs)` | the board pairings. `$teamPairs` is `[{team_a_id, team_b_id\|null, table_no, team_a_white_odd}]`, from P1's team pairer |
| `tournamentTeamSave($sdb, array $t, array $team, string $actor)` | `{ok, team}`, or `player_in_other_team` / `not_org_member` |
| `tournamentTeamRemove($sdb, array $t, string $teamId, string $actor)` | `{ok}` |
| `tournamentTeamLineup($sdb, array $t, string $teamId, int $round, array $lineup, string $actor)` | `{ok}`, or `lineup_locked` |
| `tournamentTeamStandings($sdb, array $t, ?array $stage = null)` | list of team rows |
**P3** (`tournament-registration.php`, `tournament-forms.php`):
| Function | Returns |
|---|---|
| `tournamentEligibilityV2(…)` | `{ok, code?, rule?, field?, org_choices[], represent_org, requires_code, can_join_waitlist}` |
| `tournamentRegister($sdb, array $t, string $playerId, array $input, string $source)` | validates, then calls the RPC |
| `tournamentFormFields($sdb, string $tid, bool $activeOnly = true)` | list |
| `tournamentFormValidate(array $fields, array $answers, array $t, string $playerId)` | `{ok, answers:[{field_id, value, value_text, file_path}], errors:{key:reason}}` |
| `tournamentRegions()` | the dataset, normalised |
| `tournamentCheckIn($sdb, array $t, string $playerId, string $source, ?string $code, bool $undo)` | the RPC result |
| `tournamentFilesPurgeTick` | see 6.1 |
**P4** (`tournament-public.php`):
| Function | Returns |
|---|---|
| `tournamentPublicPayload($sdb, array $t, ?string $viewerId)` | the detail payload of 8.3 |
| `tournamentPresentation($sdb, array $t, string $surface = 'app')` | `{banner, cover, logo, sponsors, ads, gallery}`. `$surface` is `app` or `live` |
| `tournamentLink(array $t, ?string $code = null)` | `https://el3ab-player.caprover.al-arcade.com/t/<slug\|id>[?code=…]` |
| `tournamentMediaUrl(string $path)` | the public storage URL of a `tournament-media/…` path |
**P5** (`tournament-prizes.php`, `tournament-notify.php`):
| Function | Returns |
|---|---|
| `tournamentSettleAll` | see 6.1 |
| `tournamentPrizePreview($sdb, array $t)` | the plan, with the exclusive rule resolved |
| `tournamentNotify($sdb, array $t, array $playerIds, string $type, string $kind, array $payload)` | int, the number sent. `$kind` is the `tournament_notices_sent` kind; repeats are skipped |
| `tournamentNotifyTick` | see 6.1 |
Callers in P2 and P3 guard it with `function_exists`.
**P12** (`tournament-games.php`):
| Function | Returns |
|---|---|
| `tournamentGameOpen($sdb, array $t, array $round, array $pairing, string $playerId)` | `{game_key, match_id, seat, …}` |
| `tournamentGameReport($sdb, string $gameKey, array $gameRow, array $result)` | `void` |
| `tournamentGamesRepairTick` | see 6.1 |
---
## 7. Manager routes, permissions and access
### 7.1 Routes (all in `M/config/routes.php` now)
The Router answers 404 until the code exists:
- a route whose module has no `controller.php` yet;
- a route whose action method does not exist yet.
It answers 500 in one case only: a `controller.php` whose class is not named `<Module>Controller`. Name the class exactly. For example, `tournament-registrations` becomes `TournamentRegistrationsController`.
`{id}` is the tournament id unless the route is absolute.
| Route (`tournaments/{id}/…`) | Module@action | Key | Pkg |
|---|---|---|---|
| `stages` | `tournaments@stages` | `tournaments.stages` | P8 |
| `stages/save` | `tournaments@saveStages` | `tournaments.stages` | P8 |
| `stages/{n}/cut` | `tournaments@stageCut` | `tournaments.stages` | P8 |
| `stages/{n}/cut/adjust` | `tournaments@stageCutAdjust` | `tournaments.stages` | P8 |
| `stages/{n}/cut/confirm` | `tournaments@stageCutConfirm` | `tournaments.stages` | P8 |
| `stages/{n}/start` | `tournaments@stageStart` | `tournaments.stages` | P8 |
| `schedule`, `schedule/save` | `tournaments@schedule`, `@saveSchedule` | `tournaments.stages` | P8 |
| `bracket/matches/{matchId}/decide`, `/tiebreak`, `/reopen` | `tournaments@bracketDecide`, `@bracketTiebreak`, `@bracketReopen` | `tournaments.stages` | P8 |
| `form` | `tournament-forms@index` | `tournaments.forms` | P9 |
| `form/copy` | `tournament-forms@copy` | `tournaments.forms` | P9 |
| `form/fields/store` | `tournament-forms@storeField` | `tournaments.forms` | P9 |
| `form/fields/reorder` | `tournament-forms@reorder` | `tournaments.forms` | P9 |
| `form/fields/{fieldId}/update`, `/archive` | `tournament-forms@updateField`, `@archiveField` | `tournaments.forms` | P9 |
| `registrations` | `tournament-registrations@list` | `tournaments.registrations` | P9 |
| `registrations/bulk` | `tournament-registrations@bulk` | `tournaments.registrations` | P9 |
| `registrations/desk` | `tournament-registrations@desk` | `tournaments.registrations` | P9 |
| `registrations/seeds/save` | `tournament-registrations@saveSeeds` | `tournaments.registrations` | P9 |
| `registrations/{regId}` | `tournament-registrations@show` | `tournaments.registrations` | P9 |
| `registrations/{regId}/answers/update` | `tournament-registrations@updateAnswers` | `tournaments.registrations` | P9 |
| `registrations/{regId}/file/{fieldId}` | `tournament-registrations@file` | `tournaments.registrations` | P9 |
| `prizes` | `tournament-prizes@index` | `tournaments.prizes` | P9 |
| `prizes/release`, `prizes/recompute` | `tournament-prizes@release`, `@recompute` | `tournaments.prizes` | P9 |
| `prizes/categories/store` | `tournament-prizes@storeCategory` | `tournaments.prizes` | P9 |
| `prizes/categories/{catId}/update`, `/delete` | `tournament-prizes@updateCategory`, `@deleteCategory` | `tournaments.prizes` | P9 |
| `exports` | `tournament-exports@index` | `tournaments.exports` | P9 |
| `exports/{format}`. The format is `trf`, `pgn`, `standings.csv`, `registrations.csv`, `registrations.xlsx`, `teams.csv` or `crosstable` | `tournament-exports@download` | `tournaments.exports` | P9 |
| `live-settings/presentation` | `live-tournaments@adminPresentation` | `tournaments.update` | P10 |
| `live-settings/presentation/upload` | `live-tournaments@adminUploadPresentation` | `tournaments.update` | P10 |
| `live-settings/presentation/{kind}/delete` | `live-tournaments@adminDeletePresentation` | `tournaments.update` | P10 |
| `live-settings/sponsors` | `live-tournaments@adminSponsors` | `tournaments.update` | P10 |
| `live-settings/sponsors/attach` | `live-tournaments@adminAttachSponsor` | `tournaments.update` | P10 |
| `live-settings/sponsors/{sid}/update`, `/detach`. `{sid}` is a `tournament_sponsorships.id` | `live-tournaments@adminUpdateSponsorship`, `@adminDetachSponsor` | `tournaments.update` | P10 |
| `live-settings/links`, `live-settings/links/code` | `live-tournaments@adminLinks`, `@adminCreateLinkCode` | `tournaments.update` | P10 |
| `live-settings/announcements/{annId}/update`, `/notify` | `live-tournaments@adminUpdateAnnouncement`, `@adminNotifyAnnouncement` | `tournaments.update` | P10 |
| `teams` | `tournament-teams@list` | `tournaments.teams` | P11 |
| `teams/store`, `teams/from-orgs` | `tournament-teams@store`, `@fromOrgs` | `tournaments.teams` | P11 |
| `teams/{teamId}` | `tournament-teams@show` | `tournaments.teams` | P11 |
| `teams/{teamId}/update`, `/lineup`, `/withdraw` | `tournament-teams@update`, `@lineup`, `@withdraw` | `tournaments.teams` | P11 |
| `console` | `tournament-arbiter@console` | `tournaments.arbiter` | P11 |
| `console/action` | `tournament-arbiter@action` | `tournaments.arbiter` | P11 |
| `console/disputes`, `console/disputes/{did}/decide` | `tournament-arbiter@disputes`, `@decideDispute` | `tournaments.arbiter` | P11 |
| `console/fair-play`, `console/check-in`, `console/report`, `console/forbidden-pairs` | `tournament-arbiter@fairPlay`, `@checkIn`, `@report`, `@forbiddenPairs` | `tournaments.arbiter` | P11 |
| Absolute route | Module@action | Key | Pkg |
|---|---|---|---|
| `api/tournaments/{id}/console/boards` | `tournament-arbiter@apiBoards` | `tournaments.arbiter` | P11 |
| `sponsors` | `sponsors@list` | `sponsors.list` | P10 |
| `sponsors/create`, `sponsors/store` | `sponsors@create`, `@store` | `sponsors.create` | P10 |
| `sponsors/{id}/edit`, `sponsors/{id}/update` | `sponsors@edit`, `@update` | `sponsors.update` | P10 |
| `sponsors/{id}/archive` | `sponsors@archive` | `sponsors.delete` | P10 |
| `live/{id}/screen` | `live-tournaments@screen` | public, no login | P10 |
| `api/live/{id}/boards` | `live-tournaments@apiBoards` | public, no login | P10 |
| `organizations/import` | `org-import@index` | `organizations.import` | P11 |
| `organizations/import/preview`, `/commit` | `org-import@preview`, `@commit` | `organizations.import` | P11 |
| `organizations/import/{batch}/codes.csv`, `/posters` | `org-import@codesCsv`, `@posters` | `organizations.import` | P11 |
| `organizations/{id}/dashboard` | `org-dashboard@show` | `organizations.dashboard` | P11 |
Rules:
- **Check the key.** Every new action calls `Auth::requirePermission('<key>')` first. Apart from `OrgScope`, nothing else stops a viewer or sponsor account from reaching a module.
- **CSRF.** Every action that writes also calls `Auth::requireCsrf()`.
- **live-tournaments naming.** In `live-tournaments`, a staff action's name must start with `admin`. The Router serves every other action of that module publicly, without login.
- **Route order.** In `routes.php`, a static segment (`bulk`, `desk`, `store`, `from-orgs`, `reorder`) stays above its `{param}` sibling, because patterns match in array order.
- **The look.** New screens use the manager's existing kit only (spec 6.1). No new colours, fonts or global CSS.
- **Sidebar.** It now has "الرعاة" (`/sponsors`, in العمليات) and "استيراد أندية" (`/organizations/import`, in إدارة), both for superadmin and admin only. Everything for one tournament is reached from that tournament's tabs, never from the sidebar.
### 7.2 Permission matrix (`M/config/permissions.php`)
| Role | Hosting keys |
|---|---|
| superadmin | everything (`*`) |
| admin | everything through `tournaments.*` and `organizations.*`, plus `sponsors.*` (new) |
| tournament_organizer | `tournaments.stages`, `.forms`, `.registrations`, `.teams`, `.arbiter`, `.exports`. Not `.prizes`: releasing coins stays with platform staff |
| org_admin, org_manager (club accounts) | `tournaments.teams`, `tournaments.registrations`, `organizations.dashboard`, plus `tournament-teams.own`, `tournament-registrations.own`, `org-dashboard.own` |
| moderator, sponsor, charity, viewer | none |
`OrgScope` admits an org-scoped account to a module only when its role lists a key that starts with the module's own name. That is what the `<module>.own` keys are for. It already refuses a club account on `form`, `console`, `prizes`, `exports`, `sponsors` and `organizations/import` (checked: 403), and on another org's dashboard (403).
### 7.3 Org-scoped access (D16; owner decision 4)
For an account with `Auth::isOrgScoped()` (whose role is neither superadmin nor admin), every action in `tournament-teams`, `tournament-registrations`, `tournament-forms`, `tournament-prizes`, `tournament-exports`, `tournament-arbiter`, the new `live-tournaments` actions and P8's new `tournaments` actions does this, in order:
```php
$t = $this->db->selectOne('el3ab_tournaments', ['id' => 'eq.' . $params['id'], 'select' => 'id,org_id,allowed_org_ids']);
if (!$t) { http_response_code(404); View::render('errors/404'); return; }
$own = Auth::orgId();
$allowed = Auth::isOrgScoped() && !in_array(Auth::role(), ['superadmin', 'admin'], true)
? (($t['org_id'] ?? null) === $own || in_array($own, (array)($t['allowed_org_ids'] ?? []), true))
: true;
if (!$allowed) { http_response_code(403); View::render('errors/403'); exit; }
```
Then, inside teams and registrations, such an account lists and edits only rows where `tournament_teams.org_id = own` or `tournament_registrations.represent_org_id = own`:
- for a list, `OrgScope::filter($query, ['org_id'])` or `['represent_org_id']`;
- for a single row, compare and refuse with 403.
It never changes stages, forms, prizes or results.
---
## 8. API contracts
### 8.1 Conventions
- Every new response carries `ok`.
- A failure is `{"ok":false,"error":"<key>","code":"<key>","message_key":"tournament.err_<key>","message":"<Arabic>", …details}`. `code` repeats the key for older clients.
- **HTTP status**:
| Status | Means |
|---|---|
| 403 | not allowed for this caller (also `account_required`; never 401 for a guest, which would sign them out in `net.js`) |
| 404 | no such thing for this caller |
| 409 | conflicts with the current state: timing, capacity, already done |
| 413 | file too large |
| 422 | bad input |
| 429 | rate limited |
| 502 | an upstream failed (the database, storage) |
- Times are ISO 8601 UTC with a `Z`. The app and the manager show them in Africa/Cairo.
- Numbers are numbers: `points: 4.5`, never `"4.5"`.
### 8.2 Error catalog
Use this Arabic text verbatim in `message`. P6 and P7 put the same text in their `strings-*.js` under `tournament.err_<key>`.
| Key | HTTP | `message` |
|---|---|---|
| `not_found` | 404 | البطولة دي مش موجودة |
| `account_required` | 403 | اعمل حساب الأول عشان تسجل |
| `code_required` | 403 | البطولة دي بالكود بس |
| `not_open_yet` | 409 | التسجيل لسه مفتحش |
| `registration_closed` | 409 | التسجيل اتقفل |
| `full` | 409 | البطولة كملت |
| `waitlist_full` | 409 | قائمة الانتظار كملت |
| `insufficient_coins` | 409 | رصيدك مش مكفي |
| `not_allowed` | 403 | مينفعش تسجل في البطولة دي |
| `not_eligible` | 403 | مش مستوفي شروط البطولة |
| `members_only` | 409 | البطولة دي لأعضاء النادي بس |
| `org_not_allowed` | 409 | ناديك مش من الأندية المشاركة |
| `org_quota_full` | 409 | عدد لاعبي ناديك كمل |
| `rating_too_low` | 409 | تقييمك أقل من المطلوب |
| `rating_too_high` | 409 | تقييمك أعلى من المسموح |
| `invalid_answers` | 422 | راجع البيانات اللي باللون الأحمر |
| `edit_closed` | 409 | وقت التعديل خلص |
| `invalid_file` | 422 | الملف ده مش مقبول |
| `file_too_large` | 413 | الملف كبير، أقصى حجم {max_mb} ميجا |
| `upload_failed` | 502 | الرفع ماكملش، جرّب تاني |
| `unavailable` | 502 | في مشكلة دلوقتي، جرّب تاني كمان شوية |
| `bad_request` | 422 | الطلب ناقص حاجة |
| `not_registered` | 404 | انت مش مسجل في البطولة دي |
| `check_in_not_open` | 409 | تأكيد الحضور لسه مفتحش |
| `check_in_closed` | 409 | تأكيد الحضور اتقفل |
| `staff_only` | 403 | المنظمين هما اللي بيسجلوا حضورك |
| `venue_code_required` | 422 | اكتب كود المكان |
| `venue_code_wrong` | 422 | الكود ده مش صح |
| `pending_approval` | 409 | طلبك مع المنظمين |
| `form_incomplete` | 409 | كمّل بياناتك الأول |
| `not_member` | 403 | انت مش عضو في النادي ده |
| `locked` | 409 | مينفعش تغير النادي دلوقتي |
| `byes_disabled` | 409 | الاستراحة مش متاحة في البطولة دي |
| `too_many_byes` | 409 | خدت كل الاستراحات المسموحة |
| `round_started` | 409 | الجولة دي اتوزّعت خلاص |
| `finished` | 409 | البطولة خلصت |
| `report_window_closed` | 409 | وقت البلاغ خلص |
| `not_your_game` | 403 | دي مش مباراتك |
| `already_reported` | 409 | بلغت عن المباراة دي قبل كده |
| `rate_limited` | 429 | استنى شوية وجرّب تاني |
Staff-facing keys (tournament-admin.php to the manager):
| Key | HTTP | `message` |
|---|---|---|
| `invalid_stage` | 422 | إعدادات المرحلة فيها غلط |
| `stages_locked` | 409 | مينفعش تغير المراحل بعد البداية |
| `not_in_cut_review` | 409 | مفيش مراجعة تأهل دلوقتي |
| `next_started` | 409 | المباراة اللي بعدها بدأت |
| `player_in_other_team` | 409 | اللاعب ده في فريق تاني |
| `not_org_member` | 409 | اللاعب مش عضو في النادي |
| `lineup_locked` | 409 | التشكيلة اتقفلت للجولة دي |
| `not_held` | 409 | الجوائز مش متعلقة |
| `not_completed` | 409 | البطولة لسه مخلصتش |
Field reasons inside `invalid_answers` (`{fields:{key:reason}}`; the app words them):
`required`, `too_short`, `too_long`, `invalid_number`, `out_of_range`, `invalid_phone`, `invalid_email`, `invalid_option`, `too_few`, `too_many`, `invalid_date`, `too_young`, `too_old`, `invalid_country`, `invalid_region`, `must_accept`, `invalid_file`.
### 8.3 Player app
**`G/api/tournaments.php`** (P3)
| Action | Request | Success | Errors |
|---|---|---|---|
| `GET list` | `org_id, game, status, q, mine` | the existing fields plus `slug`, `banner_thumb`, `org{id,name,name_ar,tag,logo_url,hue}`, `access`, `entrant_kind`, `check_in_required` and `my{status,checked_in,waitlist_position}`. A `hidden` event is listed only to its registrants | — |
| `GET eligibility` | `id` | the existing fields plus `status`, `waitlist_position`, `checked_in_at`, `check_in{state:'not_open'\|'open'\|'closed'\|'not_required', opens_at, closes_at, mode}`, `form_status`, `represent_org`, `org_choices[]`, `team`, `requires_code`, `can_join_waitlist`, `requested_byes` | — |
| `GET form` | `tournament_id` | `{fields:[{id,key,type,label,help,placeholder,required,options,validation,show_if}], answers:{key:value}, editable, regions_version}`. `label` follows the app language | `not_found` |
| `POST upload-answer` | multipart `tournament_id, field_key, file` | `{ok, path, name, size}`. The file is stored under `tournament-uploads/<tid>/pending/<uid>/` | `invalid_file`, `file_too_large`, `upload_failed` |
| `POST register` | `{tournament_id, answers:{key:value}, represent_org_id?, join_waitlist?:true, code?}` | 200 `{ok, status:'registered', registration_id, player_count, fee_charged, coins, form_status}`; 202 `{ok, status:'waitlisted', waitlist_position}`; 202 `{ok, status:'pending_approval'}` | `invalid_answers {fields}`, `not_eligible {rule, field?}`, `members_only`, `org_not_allowed`, `org_quota_full`, `rating_too_low`, `rating_too_high`, `full`, `waitlist_full`, `insufficient_coins`, `registration_closed`, `not_open_yet`, `code_required`, `account_required`, `unavailable` |
| `POST update-answers` | `{tournament_id, answers}` | `{ok, form_status}` | `edit_closed`, `invalid_answers` |
| `POST check-in` | `{tournament_id, venue_code?, undo?}` | `{ok, checked_in_at}` | the `tournament_check_in` codes |
| `POST set-org` | `{tournament_id, org_id}` | `{ok, represent_org}` | `not_member`, `org_not_allowed`, `locked` |
| `POST request-bye` | `{tournament_id, round, cancel?}` | `{ok, requested_byes}` | `byes_disabled`, `too_many_byes`, `round_started` |
| `POST withdraw` | `{tournament_id}` | as today, plus `status` | as today |
| `POST promo-event` | `{kind:'ad'\|'sponsor', id, event:'impression'\|'click'}` | `{ok}`. Throttled like `ads.php` | — |
No player endpoint ever returns a file or a file URL. A path the caller did not upload is refused with `invalid_file`.
**`G/api/swiss.php`** (P4; read-only; no account needed)
`tournament&id=|slug=` returns exactly these fields:
```json
{"id","slug","name","name_ar","description_ar","rules_ar","game_key","format","entrant_kind","status","time_control",
"starts_at","registration_opens_at","registration_closes_at","max_players","player_count","waitlist_count",
"entry_fee_coins","prize_pool_coins","prize_distribution","min_rating","max_rating","members_only","access",
"check_in":{"required":true,"opens_at","closes_at","mode":"app"},"current_phase","current_round","is_rated",
"dispute_window_minutes",
"link":"https://el3ab-player.caprover.al-arcade.com/t/cairo-open","live_url",
"org":{"id","name","name_ar","tag","logo_url","hue","is_verified"},
"presentation":{"banner":{"url","width","height","alt_ar"},"cover":null,"logo":null,
"sponsors":[{"id","tier","name","name_ar","label_ar","logo_url","link_url"}],
"ads":[{"id","position","image_url","link_url","alt_ar"}],"gallery":[{"url","thumbnail_url","caption"}]},
"announcements":[{"id","type","title","content","is_pinned","created_at"}],
"stages":[{"phase_number","name_ar","type","entrant_kind","status","rounds_planned","first_round","last_round",
"time_control","advancement":{"method","count"},"starts_at"}],
"schedule":[{"round","phase_number","starts_at","status"}],
"categories":[{"key","name_ar","prizes":[{"place","coins","label_ar"}]}],
"form":{"has_fields":true,"required_count":3}}
```
- **Never sent:** `live_custom_css`, `live_branding`, `sponsor_branding`, `created_by`, Swiss ids, private settings, `*_by_admin`.
- **Images** come only from the storage host; **links** are only `https`.
- **Sponsors** are in tier order: `title`, `presenting`, `gold`, `silver`, `bronze`, `partner`, `media`, `venue`, then `display_order`.
- **Ads** are only active, in-window, image-only ads at `app_*` positions.
- **Announcements** are filtered to `show_in_app` and not expired, pinned first. `title` and `content` are the Arabic text when present.
The other `swiss.php` reads:
- `standings&tournament_id&phase=&group=&view=individual|club|team` adds `represent_org` per row.
- `rounds` adds `phase_number`, `kind` and `scheduled_start_at`. `pairings` adds the slot, game and team fields.
- `bracket&tournament_id&phase=` is the shape in spec 5.2. `play` is `{tournament_id, round_number, pairing_id}`, set only on the caller's own open slot.
- `teams&tournament_id`, `team&id`, `team-match&id`, `schedule&tournament_id` and `entrants&tournament_id` (seed, name, rating, club).
**`G/api/tournament-match.php`** (P2)
- `create-or-join` picks the caller's lowest unfinished `game_no`. It applies `pairing.clock`, else the stage's `time_control`, else the tournament's.
- It answers `{"waiting":true,"starts_at":"…"}` while the round is `pending`.
- A non-chess game (from P12) answers `{"game_key","match_id","seat",…}`.
- `my-pending` adds `kind`, `slot_id`, `game_kind`, `stage_name_ar`, `starts_at` and `board`.
- `report-result` is unchanged. `confirm-result` comes from P12.
**`G/api/tournament-disputes.php`** (P5, new)
- `POST report {tournament_id, pairing_id, kind, message?, evidence?}` returns `{ok, dispute_id}`.
- Errors: `report_window_closed`, `not_your_game`, `already_reported`, `rate_limited` (5 per hour per player).
- `GET mine&tournament_id` returns the caller's disputes, with `status` and `decision_note`.
**Links and assets** (P4)
- `/t/{slug|uuid}[?code=]` sets `window.__EL3AB_LINK__={kind:'tournament', slug, code?}`. Its OG title is "{name_ar} على العب".
- `/v/{code}` verifies a certificate. `G/api/certificate.php?code=` returns a PNG. `G/api/calendar.php?tournament_id=` returns an `.ics` file.
### 8.4 Engine admin API: `G/api/tournament-admin.php` (P5)
Callers:
- the manager, with `X-Service-Key`, `X-Actor` and `X-Actor-Id` (D6);
- a player holding a tournament role (`hasTournamentRole`).
All existing actions stay. `set-result`, `forfeit` and `advance force` now write through `tournament_pairing_set`. The success shapes are spec 5.3's, normalised:
| Action | Body | Success | Refusals |
|---|---|---|---|
| `stages-save` | `{stages:[{phase_number,name_ar,type,entrant_kind,time_control,rounds_planned,config,advancement_rule,tiebreak_rules,starts_at}]}` | `{ok, stages, warnings[]}` | `invalid_stage {index, field, reason}`, `stages_locked` |
| `cut-preview` | `{phase_number}` | `{ok, qualifiers, boundary_ties, playoff_needed}` | `not_found` |
| `cut-adjust` | `{phase_number, add[], remove[], seeds:[{id,seed}], reason}` | `{ok, qualifiers}` | `not_in_cut_review` |
| `cut-confirm`, `stage-start` | `{phase_number}` | `{ok, round}` | `not_in_cut_review` |
| `bracket-decide` | `{bracket_match_id, winner:'a'\|'b', reason}` | `{ok, next_ready}` | `bad_winner`, `empty_slot` |
| `bracket-tiebreak` | `{bracket_match_id, kind:'blitz'\|'armageddon'}` | `{ok, pairing_id}` | `round_closed` |
| `bracket-reopen` | `{bracket_match_id}` | `{ok}` | `next_started` |
| `round-schedule`, `round-delay` | `{round_number, starts_at}`, `{minutes}` | `{ok, scheduled_start_at}` | `round_started` |
| `check-in-player`, `check-in-close` | `{player_id, undo?}`, `{}` | the RPC result | the RPC codes |
| `registration-review` | `{player_id, decision:'approve'\|'reject', note}` | `{ok, status, refunded}` | `not_registered` |
| `waitlist-promote`, `capacity-changed` | `{player_id}`, `{}` | `{ok, promoted[]}` | — |
| `registration-set-org`, `seeds-save` | `{player_id, org_id}`, `{seeds:[{player_id,seed}]}` | `{ok}` | `not_member`, `locked` |
| `team-save`, `team-remove`, `team-lineup` | `{team:{id?,org_id,name,name_ar,members:[{player_id,board_order,role}]}}`, `{team_id}`, `{team_id, round, lineup[]}` | `{ok, team}` | `player_in_other_team`, `not_org_member`, `lineup_locked` |
| `forbidden-pair` | `{player_a, player_b, until_round?, remove?}` | `{ok}` | — |
| `dispute-decide` | `{dispute_id, decision:'upheld'\|'rejected', new_result?, note}` | `{ok, applied}` | `not_found` |
| `fair-play-set` | `{player_id, status, note}` | `{ok}` (a `disqualified` during a hold recomputes the prizes) | — |
| `prizes-preview`, `prizes-recompute`, `prizes-release` | `{}`, `{}`, `{note?}` | the plan, or the RPC result | `not_completed`, `not_held` |
| `announce` | `{announcement_id}` | `{ok, notified}` | `not_found` |
| `certificates-issue` | `{kinds:['placement','category','participation']}` | `{ok, issued}` | `not_completed` |
| `cancel` (existing) | as today | as today, plus `completed_at` stamped (D9) | as today |
---
## 9. File ownership
Each file has exactly one owner. Touch nothing outside your list. If you need a change in a file you don't own, ask its owner, or write it down here first.
| Pkg | Owns |
|---|---|
| **P0** | `G/migrations/2026-10-27-hosting-schema.sql`, `G/tests/schema.sql`, `G/tests/run.sh`, `G/tests/pgshim.php`, `G/tests/router.php`, `G/tests/test_e2e.mjs`, this file; `M/config/routes.php`, `M/config/permissions.php`, `M/layouts/partials/sidebar.php` |
| **P1** | `G/includes/tournament-engine.php`, new `G/includes/tournament-stages.php`, new `G/includes/tournament-pairing.php`, `G/api/cron.php`, `G/migrations/2026-10-27-hosting-engine.sql`, `G/tests/test_swiss.php`, `G/tests/engine_shim.php`, new `G/tests/test_hosting_stages.php` |
| **P2** | new `G/includes/tournament-bracket.php`, new `G/includes/tournament-teams.php`, `G/api/tournament-match.php`, `G/migrations/2026-10-27-hosting-bracket.sql`, new `G/tests/test_hosting_knockout.php`, `G/tests/test_hosting_teams.php` |
| **P3** | `G/api/tournaments.php`, new `G/includes/tournament-registration.php`, new `G/includes/tournament-forms.php`, `G/migrations/2026-10-27-hosting-registration.sql`, new `G/tests/test_hosting_forms.php`, `G/tests/play/tournament-registration.mjs` |
| **P4** | `G/api/swiss.php`, `G/index.php`, `G/includes/link-preview.php`, `G/public/js/core/deeplink.js`, new `G/includes/tournament-public.php`, new `G/api/certificate.php`, new `G/api/calendar.php`, `G/tests/play/tournament-links.mjs` |
| **P5** | `G/api/tournament-admin.php`, new `G/api/tournament-disputes.php`, new `G/includes/tournament-prizes.php`, new `G/includes/tournament-notify.php`, `G/migrations/2026-10-27-hosting-prizes.sql`, `G/tests/test_hosting_prizes.php`, `G/tests/play/tournament-admin.mjs` |
| **P6** | `G/public/js/modules/rank/scenes/{tournament-detail,tournaments}.js`, `G/public/js/modules/tournaments/scenes/hub.js`, `G/public/js/core/tournament-session.js`, new `G/public/js/modules/tournaments/components/{presentation,register-sheet,form-fields,phone-field,region-field,regions,check-in,countdown}.js`, new `…/tournaments/strings-hosting.js`, `G/public/js/modules/social/scenes/notifications.js` (tournament types only, D11), `G/tests/play/tournament-detail-ui.mjs` |
| **P7** | `G/public/js/modules/rank/scenes/{tournament-bracket,tournament-live}.js`, `G/public/js/modules/tournaments/mod.js`, new `…/tournaments/scenes/{stages,teams,team}.js`, new `…/tournaments/components/{slip,dispute-sheet}.js`, new `…/tournaments/strings-stages.js`, `G/tests/play/tournament-bracket-ui.mjs` |
| **P8** | `M/modules/tournaments/controller.php`, `views/{form,show,_bracket,_group_stage,_native_rounds,_native_arbiter}.php`, new `views/_stages.php`, `services/{PhaseManager,BracketEngine,PlayerEngineService}.php`, `assets/{tournaments.js,tournaments.css,bracket.js}`, `M/tools/verify-stages.mjs` |
| **P9** | new `M/modules/{tournament-forms,tournament-registrations,tournament-prizes,tournament-exports}/*`, `M/modules/tournaments/services/ExportService.php`, new `M/core/Xlsx.php`, `M/Dockerfile` (the `zip` extension), `M/tools/verify-registrations.mjs` |
| **P10** | `M/modules/live-tournaments/*`, new `M/modules/sponsors/*`, `M/tools/verify-presentation.mjs` |
| **P11** | new `M/modules/{tournament-teams,tournament-arbiter,org-import,org-dashboard}/*`, `M/tools/verify-teams.mjs`, `M/tools/verify-arbiter.mjs` |
| **P12** | `G/api/{backgammon-match,domino-match,ludo-match}.php`, new `G/includes/tournament-games.php`, the tournament entry points in `G/public/js/modules/{backgammon,domino,ludo}/scenes/*`, `G/tests/play/tournament-games.mjs` |
| **P13** | new `G/api/org-feed.php`, `G/api/groups.php`, the feed section of `G/public/js/modules/org/scenes/home.js` |
| **P14** | new `G/includes/tournament-arena.php`, plus an arena action added through P5 when it starts |
`G/public/assets/data/arab-regions.json` is built outside the packages (spec 3.4); P3 and P6 only read it, through their adapters.
Frozen for everyone: `G/public/js/core/i18n.js`, `G/public/js/core/engine.js` and `G/public/js/modules/tournaments/strings.js`. Each package adds its own `strings-<pkg>.js` through `extend()`.
The new manager modules each live in their own folder with a `controller.php` whose class is named after the folder (section 7.1).
---
## 10. Test harness
- `G/tests/run.sh` runs in a throwaway local Postgres. It runs these steps:
1. it loads `tests/schema.sql`, failing loudly if that errors;
2. it applies `migrations/2026-10-27-hosting-schema.sql` and then every other `2026-10-27-hosting-*.sql` in name order;
3. it checks that a second run of all of them leaves the schema byte-identical;
4. it checks that the 21 tables keep RLS on, the service role only, and the two Realtime read policies;
5. it runs the existing suites;
6. it runs every `tests/test_hosting_*.php`.
- A failing migration, a non-idempotent migration or a leaked grant turns the suite red.
- **The replica** (`tests/schema.sql`) is production as it stood on 2026-10-26, before this work, generated from read-only dumps. It has the tables the tournament system touches, their checks, keys, indexes, RLS, policies and grants. It also has these production functions, verbatim:
- `tournament_register`, `tournament_withdraw`, `tournament_refund_entry`, `tournament_refund_due`, `tournament_settle`, `_redeem_tournament`;
- `redeem_fold`, `redeem_canonical`, `org_members_sync`;
- `complete_match`, `award_coins`.
- The header lists the few differences from Supabase.
- A package changes the schema only through its own migration, never in `schema.sql`.
- **The shim** (`tests/pgshim.php`) now behaves like PostgREST:
- `rpc()` runs the real SQL function, with named arguments and the client's return shapes (a decoded json, `['result'=>v]` for a scalar, a list for a set, `[]` for void, `['error'=>…, 'code'=>…]` on failure). A function the replica doesn't have still answers `[]`;
- `upsert()` exists (`on_conflict`, merge-duplicates);
- jsonb, json, arrays and numerics come back typed;
- a PHP list written to an array column (`uuid[]`, `int[]`) becomes a Postgres array.
- The test router's `supabaseRpc()` goes through it too.
- **Test rows** use the `ZZZ-HOST-*` prefix, and each test cleans up after itself.
- **Status at handover:** 8 passed, 0 failed.
---
## 11. Notes for each package
- **P1**
- Guard `tournament_pairing_set` against a string or object `pairings` (D5). Four legacy rounds hold an object.
- Stamp `completed_at` (D9).
- Call the RPCs from packages that have not merged as optional (D13).
- The engine's `TOURNAMENT_STATUS_PAUSED` lives in `tournament-admin.php` today; move it into the engine.
- `bye_value` now exists in production (`numeric(3,1)`, default 1.0). The replica used to carry it while production didn't.
- **P2**
- `bracket_matches.match_id` is for chess only (D10).
- The partial unique indexes can't be PostgREST conflict targets (2.3).
- `tournament_bracket_decide` is the only writer of a slot's winner.
- **P3**
- Read `late_join_until_round` from `config->'swiss'` (4.3).
- `tournament_refund_due` must never refund `no_show` (owner decision 1).
- Provide `tournamentFilesPurgeTick` (D8).
- `profiles.country_code` is `char(3)`; compare its first two letters (4.5).
- **P4**
- `el3ab_tournaments` is world-readable over REST and Realtime, so the allow-listed payload is about what the API sends, not about secrecy.
- Media URLs are built from `storage_path` through `tournamentMediaUrl`. The old free-text `banner_url` stays a fallback.
- **P5**
- Read `X-Actor-Id` (D6).
- `prize_review_required` is decided when settings are saved, not when settling (D7).
- `tournamentNotify` and the notification types are D11.
- Releasing prizes is the only path that pays held coins.
- **P6, P7**
- Error text: the catalog in 8.2.
- Times: Africa/Cairo.
- Media only inside the existing frames.
- P6 owns the tournament types in `notifications.js` (D11).
- **P8**
- `PhaseManager` writes `json_encode`d strings into `config` and `advancement_rule`, which the new shape check refuses. Replace it with `stages-save`.
- `unpairRound` (D1) is legacy.
- Send `X-Actor-Id` (D6).
- Set `prize_review_required` (D7) and `dispute_window_minutes` (D3) in the wizard.
- **P9**
- Category prizes switch the prize hold on (D7).
- Show the 24-hour held-prizes badge.
- File links are signed for 10 minutes and logged to `audit_log`.
- **P10**
- Ad `link_url` and sponsor links must be `https`; validate them in the form, because the check refuses anything else and today's ad form doesn't check.
- App positions take images only.
- `script` slots are gone.
- Replace a media slot by updating its row, not by upserting (2.3).
- Staff actions keep the `admin` prefix (7.1).
- **P11**
- Club groups are `groups.kind='org'` with `org_id` and the org's `max_members` (D4).
- The org-scope guard is in 7.3.
- Show the 24-hour held-prizes badge on the console (D7).
- **P12**
- Make game creation idempotent through the partial `pairing_id` unique index: insert, and on a 409 read the row back (2.3).
- `match_length` and `target_score` come from the stage config (4.3).
- **P13**
- Use `groups.kind` and `groups.tournament_id` (D4).
- There is at most one `tournament` group per tournament (`groups_tournament_uq`).
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment