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
17 changes: 16 additions & 1 deletion docs/features/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ hand-typed `SOLVING_TIMES` variant silently matched nothing until 2026-08 (PR #1
| GET | `/api/v1/me/results?type=solo\|duo\|team` | PAT or `results:read`. Each result also carries the puzzle's `statistics` (public) and `difficulty` (members, else `null`) - see Insights on lists below |
| GET | `/api/v1/me/puzzles/{puzzleId}/predicted-time` | PAT or `results:read` |
| GET | `/api/v1/me/statistics` | PAT or `statistics:read` |
| POST | `/api/v1/me/solving-times` | PAT or `solving-times:write` |
| POST | `/api/v1/me/solving-times` | PAT or `solving-times:write`. The response carries the parsed `time_seconds` and `prediction` - the time prediction that applied *before* this solve (solo times, token owner a member who has not opted out, PAT or `results:read`; else `null`) - see the POST section below |
| PUT | `/api/v1/me/solving-times/{timeId}` | PAT or `solving-times:write` |
| GET | `/api/v1/me/collections` | PAT or `collections:read` |
| GET | `/api/v1/me/collections/{id}/items` | PAT or `collections:read`. Each item also carries `statistics` (public), `difficulty` (members), `prediction` (members, not opted out, PAT or `results:read`) and `solves` (own history, PAT or `results:read`) - see Insights on lists below |
Expand Down Expand Up @@ -253,6 +253,21 @@ The three blocks the profile page shows, under the page's own gates (`templates/
- `round_id`: optional, nullable. When set, the time is linked to that competition round and automatically to its competition. An invalid or unknown `round_id` returns 404.
- Photo uploads not supported via API (use the website)

Response (`SolvingTimeResponse`, shared with `PUT …/solving-times/{timeId}`):

```json
{
"time_id": "uuid", "puzzle_id": "uuid", "time_seconds": 5025, "finished_at": "2025-12-01T14:30:00+00:00",
"first_attempt": true, "unboxed": false, "comment": "Optional comment", "round_id": null,
"prediction": { "predicted_seconds": 1890, "range_low_seconds": 1607, "range_high_seconds": 2174,
"is_personalized": true, "personal_solve_count": 1, "predicted_attempt_number": 2, "last_time_seconds": 2100 }
}
```

- `time_seconds` - the submitted `time` parsed with the same parser the handler stores from (`SolvingTime::fromUserInput`), so it is the number that lands in the database (`"1:23:45"` ⇒ `5025`, `"25:10"` ⇒ `1510`). Filled on `POST` since PR 4 of the expansion plan (it used to be `null`); still `null` on `PUT`.
- `prediction` - the time prediction that applied **before** this solve, the one the website shows on the added-time recap page (`AddedTimeRecapController`): the new time is excluded from the prediction query, so `personal_solve_count` is the count before it, `predicted_attempt_number` the attempt this time was, and `last_time_seconds` the previous solve - an app can show "12% faster than predicted" right after submitting. Same shape as the `prediction` object on puzzle cards (Puzzles above). Gates, exactly as on the recap page plus the API's own read rule: **solo** time (`group_players` empty), a time present, the token owner a **member** who has **not opted out** of time predictions, and PAT or `results:read` on the token (`solving-times:write` alone writes, it does not read insights) - `null` otherwise; a `client_credentials` token never reaches `/me/*` at all. When present, the object is always complete: the statistical (baseline × difficulty) estimate comes with `is_personalized: false` and the three `personal_*`/`last_*` fields `null`; every field `null` when there is nothing to predict from. Always `null` on `PUT` (the "before" prediction is a property of creating a time).
- **Query cost** (asserted in `CreateSolvingTimePredictionEndpointTest`, request only, PAT): the create itself already runs the `PuzzleSolved` event's synchronous recalculations (statistics, intelligence, wishlist removal, notifications), so its count depends on the data - measured 2026-08-19 with the prediction switched off: 28-35 for a solo time, 21 for a duo time. On top of that the feature adds the owner profile (1, `ApiTokenOwner`, only when the time is solo and the token may read results) and `GetPlayerPrediction::forPuzzle` (≤ 5: personal - solves, pieces, player ratio, global ratio(s); statistical - solves + 1) only for an eligible request; a group time adds nothing.

### Privacy

- `/api/v1/me/*` always returns full data for the token owner
Expand Down
4 changes: 2 additions & 2 deletions docs/features/api/v1-expansion-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ The motivating case: a collection with 500+ puzzles where the app wants, per ite

## 7. PR 4 — `prediction` in the `POST /me/solving-times` response

After dispatch, when the time is **solo** (`group_players` empty/null), parsed time is non-null, owner is a member and not opted out: `prediction = TimePredictionResponse` from `GetPlayerPrediction::forPuzzle($playerId, $puzzleId, excludeTimeId: $timeId)` (the prediction *before* this solve — what the added-time recap shows); else `null`. Also fill `time_seconds` using the same parser the handler uses (today it is always `null` in the create response — filling it is additive). `SolvingTimeResponse` gains trailing `prediction: ?TimePredictionResponse = null` (the PUT response keeps `null`). Budget: + profile 1 + prediction ≤5, only for eligible requests. Tests: member solo with history (seeded) ⇒ object, `personal_solve_count` equals the count *before* this time; non-member ⇒ null; group time ⇒ null; opted-out ⇒ null; `time_seconds` filled; existing create tests unchanged.
After dispatch, when the time is **solo** (`group_players` empty/null), parsed time is non-null, owner is a member and not opted out, and the token may read results (PAT or `results:read` - §2's rule, the write scope alone does not read insights; corrected when PR 4 shipped): `prediction = TimePredictionResponse` from `GetPlayerPrediction::forPuzzle($playerId, $puzzleId, excludeTimeId: $timeId)` (the prediction *before* this solve — what the added-time recap shows); else `null`. Also fill `time_seconds` using the same parser the handler uses (today it is always `null` in the create response — filling it is additive). `SolvingTimeResponse` gains trailing `prediction: ?TimePredictionResponse = null` (the PUT response keeps `null`). Budget: + profile 1 + prediction ≤5, only for eligible requests. Tests: member solo with history (seeded) ⇒ object, `personal_solve_count` equals the count *before* this time; non-member ⇒ null; group time ⇒ null; opted-out ⇒ null; `time_seconds` filled; existing create tests unchanged.

## 7b. PR 5 — the puzzle library: wishlist, lend/borrow, unsolved, sell/swap (read-only) + library summary

Expand Down Expand Up @@ -232,7 +232,7 @@ Waves (dependencies): **wave 1** PR 0 ∥ PR 1 (independent) → **wave 2** PR 2
- [x] PR 2 `GET /puzzles/{id}` (2026-08-19; the detail is `PuzzleResponseFactory::card()` of the overview - no separate `PuzzleInsightsAssembler`, the factory from PR 1 already is that assembler; `MyPredictedTimeResponseProvider` now gates through `ApiTokenOwner` and flattens `TimePredictionResponse`/`PuzzleDifficultyResponse` via `PredictedTimeResponse::fromInsights()`, output byte-identical; `PuzzleOverview::hideUntil` added (loaded by `GetPuzzleOverview::byId` only) for the N5 404; measured: cc 2-3, non-member 5 PAT / 7 OAuth2, member 9-10 PAT / 11-12 OAuth2 - ceilings 4 / 6 / 8 / 11 / 13)
- [x] PR 3 insights & solves on lists (2026-08-19; `PuzzleResponseFactory::insightsFor()` + `PuzzleInsightsBatch` shared by cards, collection items and result lists; the `POST /me/collections/{id}/items` item answers in the same shape; measured: collection items cc 4-5 / non-member 5-6 PAT, 7-8 OAuth2 / member 9-11 PAT, 13 OAuth2 on `/me`, 9 on `/players`; result lists today + ≤ 3; `solves` on `/me/*` needs PAT or `results:read` like everywhere else - §6 table corrected from "always")
- [x] PR 3b `/players/{id}/collections/{cid}/items`: collection visibility as on the web (private profile / private custom collection / private system collection ⇒ zeroed for everyone but the owner; +1 query for custom collections) and `prediction` = the token owner's own forecast there, like the website's collection page (2026-08-19)
- [ ] PR 4 `prediction` on `POST /me/solving-times`
- [x] PR 4 `prediction` on `POST /me/solving-times` (2026-08-19; `time_seconds` filled from `SolvingTime::fromUserInput` - the handler's parser; gate = solo + time + `ApiTokenOwner::isMember()` + not opted out **+ PAT / `results:read`** (§2's rule - the write scope alone does not read insights, so an auth-code token with only `solving-times:write` gets `null`); measured request-only counts, PAT: the create's own write path is 28-35 (solo, data-dependent: the `PuzzleSolved` event runs the statistics/intelligence recalculations, wishlist removal and notifications synchronously) / 21 (duo), the feature adds profile 1 + prediction 4 (personal) or 2 (statistical) - ceilings pinned per scenario as write path + 1 + 5 / + 1 / + 0 in `CreateSolvingTimePredictionEndpointTest`; one plan wrinkle: the synchronous recalculation rewrites the posted puzzle's `puzzle_difficulty` row inside the request, so a seeded difficulty does not survive the POST - the statistical-prediction test seeds four other players' first attempts instead so the puzzle is scored from real data)
- [ ] PR 5 puzzle library lists (wishlist, unsolved, lend/borrow, sell/swap, library summary)
- [x] PR 5a medians in `puzzle_statistics` — code (2026-08-19: `median_time(_solo/_duo/_team)` columns, computed with `percentile_cont(0.5)` over the same per-player-best population as the averages, `median_seconds` on every `statistics` group); **backfill on the box still to do after deploy: `php bin/console myspeedpuzzling:recalculate-puzzle-statistics` once — until then `median_seconds` is `null`**
- [x] PR 6 profile insights on `/me` + `GET /players/{id}` (2026-08-19; measured budgets: `/me` member 7 OAuth2 / 5 PAT - the §8 "+3" on top of auth 3 + profile; `/players/{id}` member viewer 8, non-member 7, cc 4, masked 5 (auth-code) / 2 (cc). The owner's own private profile on `/players/{id}` and on `/me` returns the rating (plan §8, "the full one"), while the web's own-private-profile view replaces both blocks with an explanation - the API follows the plan)
Expand Down
13 changes: 12 additions & 1 deletion src/Api/V1/CreateSolvingTimeInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@
operations: [
new Post(
uriTemplate: '/v1/me/solving-times',
openapi: new OpenApiOperation(tags: ['My Results & Solving Times']),
openapi: new OpenApiOperation(
tags: ['My Results & Solving Times'],
summary: 'Add a solving time for the token owner',
description: 'time is HH:MM:SS or MM:SS; the response carries it parsed as time_seconds. '
. 'group_players (player codes prefixed with #, or plain names for guests) makes the time a duo/team one. '
. 'The response also carries prediction - the time prediction that applied *before* this solve, '
. 'what the website shows on the added-time recap: the new time is excluded, so personal_solve_count is the count before it. '
. 'Puzzle Insights are members-only and self-only, exactly as on the website: prediction is null for a group time, '
. 'a non-member, an owner who opted out of time predictions, and for an OAuth2 token without results:read '
. '(the write scope alone does not read insights; a personal access token always can). '
. 'When present, every field inside is null and is_personalized false if there is nothing to predict from.',
),
security: "is_granted('ROLE_PAT') or is_granted('ROLE_OAUTH2_SOLVING-TIMES:WRITE')",
output: SolvingTimeResponse::class,
processor: CreateSolvingTimeProcessor::class,
Expand Down
45 changes: 44 additions & 1 deletion src/Api/V1/CreateSolvingTimeProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use SpeedPuzzling\Web\Message\AddPuzzleSolvingTime;
use SpeedPuzzling\Web\Query\GetPlayerPrediction;
use SpeedPuzzling\Web\Repository\CompetitionRoundRepository;
use SpeedPuzzling\Web\Security\ApiUser;
use SpeedPuzzling\Web\Services\Api\ApiTokenOwner;
use SpeedPuzzling\Web\Value\SolvingTime;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
Expand All @@ -24,6 +27,8 @@ public function __construct(
private Security $security,
private MessageBusInterface $messageBus,
private CompetitionRoundRepository $competitionRoundRepository,
private ApiTokenOwner $tokenOwner,
private GetPlayerPrediction $getPlayerPrediction,
) {
}

Expand Down Expand Up @@ -71,15 +76,53 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
),
);

// The same parser the handler stores from (SolvingTime::fromUserInput);
// the input regex guarantees the HH:MM:SS / MM:SS shape it asserts.
$timeSeconds = SolvingTime::fromUserInput($data->time)->seconds;

return new SolvingTimeResponse(
time_id: $timeId->toString(),
puzzle_id: $data->puzzle_id,
time_seconds: null,
time_seconds: $timeSeconds,
finished_at: $finishedAt?->format('c'),
first_attempt: $data->first_attempt,
unboxed: $data->unboxed,
comment: $data->comment,
round_id: $data->round_id,
prediction: $this->predictionBefore($data, $timeSeconds, $timeId->toString()),
);
}

/**
* The prediction that applied *before* this solve - what the website's added-time
* recap shows (AddedTimeRecapController: solo, time present, owner not opted out;
* the template reveals it to members only). The new time is excluded from the
* prediction query, so personal_solve_count is the count before it. Null when the
* token is not entitled to one: group time, no time, not a member, opted out, or an
* OAuth2 token without results:read (the write scope alone does not grant reading
* insights - the same PAT / results:read rule as every other prediction object).
* The cheap checks come first so a non-eligible request costs at most the one
* owner-profile query.
*/
private function predictionBefore(CreateSolvingTimeInput $data, null|int $timeSeconds, string $timeId): null|TimePredictionResponse
{
// A non-empty group_players list always makes a duo/team time (or fails in the handler)
if ($data->group_players !== [] || $timeSeconds === null) {
return null;
}

if ($this->tokenOwner->canReadResults() === false || $this->tokenOwner->isMember() === false) {
return null;
}

$profile = $this->tokenOwner->profile();

if ($profile === null || $profile->timePredictionsOptedOut) {
return null;
}

return TimePredictionResponse::fromResult(
$this->getPlayerPrediction->forPuzzle($profile->playerId, $data->puzzle_id, excludeTimeId: $timeId),
);
}
}
6 changes: 6 additions & 0 deletions src/Api/V1/SolvingTimeResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public function __construct(
public bool $unboxed,
public null|string $comment,
public null|string $round_id = null,
/**
* POST only: the time prediction that applied *before* this solve (the one the
* added-time recap page shows) - solo times, token owner a member who has not
* opted out, PAT or results:read. Null otherwise, and always null on PUT.
*/
public null|TimePredictionResponse $prediction = null,
) {
}
}
Loading