Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ Seller settings: `PLAYER_WITH_STRIPE` has ISO currency **GBP** (listings eligibl
| `SERIES_EJJ` | Euro Jigsaw Jam | Yes | - | Yes | one past, one upcoming |
| `SERIES_OFFLINE` | Puzzle Meetup Prague | No | cz | Yes | one upcoming |
| `SERIES_PAST_ONLY` | Berlin Puzzle Cup | No | de | Yes | only past — must never appear as "upcoming" |
| `SERIES_UNAPPROVED` | Pending Puzzle League | Yes | - | **No** (`approvedAt` null) | one upcoming — its edition must never be publicly visible (`IsCompetitionPubliclyVisible` false) |

### Series Editions (Competitions with series_id)

Expand All @@ -194,6 +195,7 @@ Seller settings: `PLAYER_WITH_STRIPE` has ISO currency **GBP** (listings eligibl
| `EDITION_EJJ_69` | SERIES_EJJ | EJJ #69 — May 2026 | +30 days |
| `EDITION_OFFLINE_1` | SERIES_OFFLINE | Puzzle Meetup #1 | +14 days |
| `EDITION_PAST_ONLY_1` | SERIES_PAST_ONLY | Berlin Puzzle Cup 2026 | -45 days |
| `EDITION_UNAPPROVED_1` | SERIES_UNAPPROVED | Pending Puzzle League #1 | +7 days (no rounds) |

### Competition Rounds

Expand Down
6 changes: 3 additions & 3 deletions docs/features/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ hand-typed `SOLVING_TIMES` variant silently matched nothing until 2026-08 (PR #1
| GET | `/api/v1/competitions?status=all\|live\|upcoming\|past&online=true&country=cz` | Any valid PAT or OAuth2 token (no specific scope) |
| GET | `/api/v1/competitions/{id}` | Any valid PAT or OAuth2 token (no specific scope) |

- **List** returns basic info for **approved, standalone** competitions only (mirrors the public website listing). Optional filters: `status` (default `all`), `online` (default `false`), `country` (ISO 3166-1 alpha-2). Response shape: `{ "count": N, "competitions": [ ... ] }`. Participants are never returned.
- **Detail** returns the competition metadata plus its `rounds`. Each round exposes `id`, `name`, `starts_at`, `minutes_limit`, `category`, and `puzzles`. **Participants are never returned.**
- **Unapproved or rejected competitions return `404`** — they must not leak through the API (the underlying `byId()` query does not filter on approval, so the provider gates on `approvedAt`).
- **List** returns basic info for **approved, standalone** competitions only (mirrors the public website listing). Series editions are not listed, but they are reachable by id through the detail endpoint. Optional filters: `status` (default `all`), `online` (default `false`), `country` (ISO 3166-1 alpha-2). Response shape: `{ "count": N, "competitions": [ ... ] }`. Participants are never returned.
- **Detail** returns the competition metadata plus its `rounds` and a `series` object. Each round exposes `id`, `name`, `starts_at`, `minutes_limit`, `category`, and `puzzles`. `series` is `{ "id", "name", "slug" }` for an edition of a competition series and `null` for a standalone competition (an edition's own `slug` is only unique within its series — build links as `/series/{series.slug}/{slug}`). **Participants are never returned.**
- **Unapproved or rejected competitions return `404`** — they must not leak through the API (the underlying `byId()` query does not filter on approval, so the provider gates on `IsCompetitionPubliclyVisible`). An **edition** is readable when its **series** is approved and not rejected; the edition's own `approved_at` is irrelevant (editions are never approved individually).
- **Puzzle-reveal privacy (critical):** a round puzzle flagged *hide until round starts* is governed by the same single-source-of-truth rule used on the website (`GetEditionRounds`). Until `round.startsAt + 10 minutes`:
- `hideMode = Entirely` → the puzzle is **omitted entirely** from the round's `puzzles`.
- `hideMode = ImageOnly` → the puzzle is returned but `image` is `null` (name, pieces count, manufacturer remain visible).
Expand Down
10 changes: 10 additions & 0 deletions docs/features/competitions-management/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ CompetitionSeries ("Euro Jigsaw Jam")
- Series appear in a dedicated "Recurring" section as single cards, showing the next upcoming edition date
- Editions (competitions with `series_id`) are excluded from Live/Upcoming/Past

### Event badge on solving times

A solving time may be linked to a standalone competition **or to a series edition** (`puzzle_solving_time.competition_id` points at the edition's `Competition` row). Every list that shows a time's event (player results, puzzle leaderboards, ladders, recent activity) renders the one partial `templates/_competition_badge.html.twig` (`{{ include('_competition_badge.html.twig', {time: x}) }}`, where `x` is a `SolvedPuzzle`, `PuzzleSolver`, `PuzzleSolversGroup` or `RecentActivityItem` — all carry `competitionName/Shortcut/Slug` plus `competitionSeriesName/Shortcut/Slug`, selected by every read model via `LEFT JOIN competition_series cs ON cs.id = competition.series_id`):

- **Label** — standalone: `shortcut ?? name` (e.g. `WJPC24`); edition: `<series shortcut ?? series name> · <edition name>` (e.g. `Euro Jigsaw Jam · EJJ #68 — February 2026`); when the edition is named exactly like its series (competitions converted to a series keep the name) only the series label is shown.
- **Link** — standalone: `event_detail` (`/en/events/{slug}`); edition: `edition_detail` (`/en/series/{seriesSlug}/{editionSlug}`) — **never** `event_detail`, an edition slug is only unique within its series. An edition whose series has no slug renders the badge unlinked.
- Nothing is rendered when the time has no competition.

**Public visibility of a competition row** (standalone or edition) is decided in one place, `IsCompetitionPubliclyVisible` (`check($competitionId)` + the reusable `SQL_CONDITION` fragment): a standalone competition is visible when approved and not rejected; an edition is visible iff its **series** is approved and not rejected — editions are never approved individually (their own `approved_at` stays `NULL`). The API competition detail uses this rule to decide what is readable.

## Round Management

A competition has multiple **rounds**, each with:
Expand Down
1 change: 1 addition & 0 deletions src/Api/V1/CompetitionDetailResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public function __construct(
public null|string $registrationLink,
public null|string $resultsLink,
array $rounds,
public null|CompetitionSeriesSummaryResponse $series = null,
) {
$this->rounds = $rounds;
}
Expand Down
33 changes: 26 additions & 7 deletions src/Api/V1/CompetitionDetailResponseProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
use ApiPlatform\State\ProviderInterface;
use SpeedPuzzling\Web\Exceptions\CompetitionNotFound;
use SpeedPuzzling\Web\Query\GetCompetitionEvents;
use SpeedPuzzling\Web\Query\GetCompetitionSeries;
use SpeedPuzzling\Web\Query\GetEditionRounds;
use SpeedPuzzling\Web\Query\IsCompetitionPubliclyVisible;
use SpeedPuzzling\Web\Results\EditionRoundDetail;
use SpeedPuzzling\Web\Results\EditionRoundPuzzle;

Expand All @@ -20,6 +22,8 @@
public function __construct(
private GetCompetitionEvents $getCompetitionEvents,
private GetEditionRounds $getEditionRounds,
private GetCompetitionSeries $getCompetitionSeries,
private IsCompetitionPubliclyVisible $isCompetitionPubliclyVisible,
) {
}

Expand All @@ -30,16 +34,30 @@ public function provide(Operation $operation, array $uriVariables = [], array $c

$competition = $this->getCompetitionEvents->byId($competitionId);

// Privacy gate: only approved, non-rejected competitions are publicly readable through
// the API. GetCompetitionEvents::byId() intentionally does NOT filter on approval (it
// serves the owner/admin web flows too), so an unapproved or rejected competition — and
// its not-yet-revealed puzzles — must 404 here instead of leaking. A competition can be
// both approved and rejected (approve() and reject() do not clear each other), so the
// rejected state must veto a stale approval.
if ($competition->approvedAt === null || $competition->rejectedAt !== null) {
// Privacy gate: only publicly visible competitions are readable through the API.
// GetCompetitionEvents::byId() intentionally does NOT filter on approval (it serves the
// owner/admin web flows too), so an unapproved or rejected competition — and its
// not-yet-revealed puzzles — must 404 here instead of leaking. A competition can be both
// approved and rejected (approve() and reject() do not clear each other), so the rejected
// state must veto a stale approval. Editions of a series are never approved individually
// (their own approved_at stays NULL) — they are visible iff their SERIES is approved and
// not rejected. IsCompetitionPubliclyVisible is the single source of truth for both rules.
if ($this->isCompetitionPubliclyVisible->check($competitionId) === false) {
throw new CompetitionNotFound();
}

$series = null;

if ($competition->seriesId !== null) {
$seriesOverview = $this->getCompetitionSeries->byId($competition->seriesId);

$series = new CompetitionSeriesSummaryResponse(
id: $seriesOverview->id,
name: $seriesOverview->name,
slug: $seriesOverview->slug,
);
}

// Rounds (and their puzzles) come from GetEditionRounds, the single source of truth for
// the puzzle-reveal rule: puzzles flagged hide-until-round-starts are omitted (Entirely)
// or stripped of their image (ImageOnly) until round.startsAt + 10 minutes. Participants
Expand All @@ -65,6 +83,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
registrationLink: $competition->registrationLink,
resultsLink: $competition->resultsLink,
rounds: $rounds,
series: $series,
);
}

Expand Down
19 changes: 19 additions & 0 deletions src/Api/V1/CompetitionSeriesSummaryResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Api\V1;

/**
* The parent series of an edition, as carried by a competition detail. Standalone competitions
* have no series (the detail's `series` is null).
*/
final class CompetitionSeriesSummaryResponse
{
public function __construct(
public string $id,
public string $name,
public null|string $slug,
) {
}
}
9 changes: 8 additions & 1 deletion src/Query/GetFastestGroups.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count
competition.id AS competition_id,
competition.shortcut AS competition_shortcut,
competition.slug AS competition_slug,
cs.name AS competition_series_name,
cs.shortcut AS competition_series_shortcut,
cs.slug AS competition_series_slug,
competition.name AS competition_name,
ps_main.skill_tier,
player.ranking_opted_out,
Expand All @@ -82,11 +85,12 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count
INNER JOIN player ON pst.player_id = player.id
INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id
LEFT JOIN competition ON pst.competition_id = competition.id
LEFT JOIN competition_series cs ON cs.id = competition.series_id
LEFT JOIN player_skill ps_main ON ps_main.player_id = player.id,
LATERAL json_array_elements(pst.team -> 'puzzlers') WITH ORDINALITY AS player_elem(player, ordinality)
LEFT JOIN player p ON p.id = (player_elem.player ->> 'player_id')::UUID
LEFT JOIN player_skill ps_member ON ps_member.player_id = p.id
GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, ps_main.skill_tier
GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, cs.id, ps_main.skill_tier
HAVING bool_or(p.is_private = false)
)
SELECT *
Expand Down Expand Up @@ -145,6 +149,9 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count
* competition_name: null|string,
* competition_shortcut: null|string,
* competition_slug: null|string,
* competition_series_name: null|string,
* competition_series_shortcut: null|string,
* competition_series_slug: null|string,
* skill_tier: null|int,
* ranking_opted_out: bool,
* } $row
Expand Down
9 changes: 8 additions & 1 deletion src/Query/GetFastestPairs.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count
competition.shortcut AS competition_shortcut,
competition.name AS competition_name,
competition.slug AS competition_slug,
cs.name AS competition_series_name,
cs.shortcut AS competition_series_shortcut,
cs.slug AS competition_series_slug,
ps_main.skill_tier,
player.ranking_opted_out,
JSON_AGG(
Expand All @@ -82,11 +85,12 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count
INNER JOIN player ON pst.player_id = player.id
INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id
LEFT JOIN competition ON pst.competition_id = competition.id
LEFT JOIN competition_series cs ON cs.id = competition.series_id
LEFT JOIN player_skill ps_main ON ps_main.player_id = player.id,
LATERAL json_array_elements(pst.team -> 'puzzlers') WITH ORDINALITY AS player_elem(player, ordinality)
LEFT JOIN player p ON p.id = (player_elem.player ->> 'player_id')::UUID
LEFT JOIN player_skill ps_member ON ps_member.player_id = p.id
GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, ps_main.skill_tier
GROUP BY puzzle.id, player.id, manufacturer.id, pst.id, competition.id, cs.id, ps_main.skill_tier
HAVING bool_or(p.is_private = false)
)
SELECT *
Expand Down Expand Up @@ -145,6 +149,9 @@ public function perPiecesCount(int $piecesCount, int $howManyPlayers, null|Count
* competition_name: null|string,
* competition_shortcut: null|string,
* competition_slug: null|string,
* competition_series_name: null|string,
* competition_series_shortcut: null|string,
* competition_series_slug: null|string,
* skill_tier: null|int,
* ranking_opted_out: bool,
* } $row
Expand Down
9 changes: 8 additions & 1 deletion src/Query/GetFastestPlayers.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c
competition.shortcut AS competition_shortcut,
competition.name AS competition_name,
competition.slug AS competition_slug,
cs.name AS competition_series_name,
cs.shortcut AS competition_series_shortcut,
cs.slug AS competition_series_slug,
ps.skill_tier,
player.ranking_opted_out
FROM FastestTimes
Expand All @@ -87,8 +90,9 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c
INNER JOIN player ON player.id = puzzle_solving_time.player_id
INNER JOIN manufacturer ON manufacturer.id = puzzle.manufacturer_id
LEFT JOIN competition ON puzzle_solving_time.competition_id = competition.id
LEFT JOIN competition_series cs ON cs.id = competition.series_id
LEFT JOIN player_skill ps ON ps.player_id = player.id
GROUP BY player.id, puzzle.id, manufacturer.id, puzzle_solving_time.id, competition.id, ps.skill_tier
GROUP BY player.id, puzzle.id, manufacturer.id, puzzle_solving_time.id, competition.id, cs.id, ps.skill_tier
ORDER BY puzzle_solving_time.seconds_to_solve
SQL;

Expand Down Expand Up @@ -130,6 +134,9 @@ public function perPiecesCount(int $piecesCount, int $limit, null|CountryCode $c
* competition_name: null|string,
* competition_shortcut: null|string,
* competition_slug: null|string,
* competition_series_name: null|string,
* competition_series_shortcut: null|string,
* competition_series_slug: null|string,
* skill_tier: null|int,
* ranking_opted_out: bool,
* } $row
Expand Down
Loading