Fans the frozen ETag/If-Match/412 recipe (Block reference, #253) onto the
Playlist aggregate:
- ReplacePlaylistItems command carries ExpectedVersion; the handler runs
CheckVersion as a standalone Either after validation (so a stale write
survives as 412, not flattened to 422 by Apply/Join), bumps Version
unconditionally before saving, and persists via
SaveChangesWithConcurrencyGuard (EF concurrency-token backstop).
- PlaylistViewModel carries Version; the items GET sets a strong ETag and
the PUT parses If-Match, threads it into the command, and returns the
refreshed ETag on success (400 on a malformed If-Match).
- Sibling item-adding handlers (AddItemsToPlaylist, AddMovie/Episode/
Season/ShowToPlaylist) bump Version too, since they mutate the same
editor-visible item list.
- SPA: playlists.ts exposes getPlaylistItemsWithMeta and an
If-Match-aware updatePlaylist; PlaylistEditor holds the ETag in a ref,
round-trips it on save, and opens a "changed elsewhere" ConfirmDialog on
412 (mirrors BlockEditor).
Tests: new ReplacePlaylistItemsHandlerConcurrencyTests (stale/match/
force-write/no-op-bump/racing-save), new PlaylistController tests
(ETag on GET items, 400/412/thread-version/force-write on PUT), and a
vitest 412-conflict-dialog test for PlaylistsScreen. dotnet test:
1304/1304 green. web: npm run typecheck clean, npm run build clean,
vitest 664/664 green.
Ref #253 PR2.
Hardening from adversarial review of the #153 playlist API:
- PUT /api/playlists/{id}: guard IsSystem in the controller after the
existence pre-check -> 422, so a system (generated) playlist can no
longer be renamed/wiped. ReplacePlaylistItems is never sent for it.
- PUT /api/playlists/groups/{id}: add controller existence pre-check
(404 for missing, mirroring DeleteGroup) plus an IsSystem 422 guard;
RenamePlaylistGroupHandler also gains a system guard (defense-in-depth
for the Blazor path). Missing/system are now distinct outcomes despite
LanguageExtensions.Apply collapsing NotFoundError to a plain BaseError.
- POST /api/playlists/preview: validate each draft item at the controller
boundary (the id required for its collection type must be present) ->
422 before the shared PreviewPlaylistPlayoutHandler runs, preventing a
NRE/500 in the playout builder. Logic lives in ReplacePlaylistRequest so
it stays parallel with ReplacePlaylistItemsHandler's PUT-path check.
Tests: controller cases for system-playlist PUT, system-group PUT,
missing-group 404, and invalid-preview 422 (each asserting the handler is
not invoked); handler tests for RenamePlaylistGroup system/missing/success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add write endpoints to PlaylistController for the playlist editor: group
create/rename/delete, playlist create/read/update/delete, item list read,
and draft playout preview. Introduces a RenamePlaylistGroup command/handler
(the one missing Application-layer operation) plus request/response DTOs.
Endpoints (verb / route / route-Name):
- POST /api/playlists/groups CreatePlaylistGroup
- PUT /api/playlists/groups/{id} UpdatePlaylistGroup (rename)
- DELETE /api/playlists/groups/{id} DeletePlaylistGroup
- GET /api/playlists/{id} GetPlaylistById
- GET /api/playlists/{id}/items GetPlaylistItems
- POST /api/playlists CreatePlaylistInGroup
- PUT /api/playlists/{id} UpdatePlaylist (rename + replace items)
- DELETE /api/playlists/{id} DeletePlaylist
- POST /api/playlists/preview PreviewPlaylist
404-vs-422: unknown-id on GET items / PUT / DELETE returns 404 via a
controller-side existence pre-check (mirrors TemplateController.DeleteGroup),
leaving existing shared handlers untouched; validation failures return 422.
RenamePlaylistGroup returns NotFoundError -> 404 for a missing group.
Regenerated wwwroot/openapi/v1.json, docs/endpoint-index.md, and the SPA
client types (web/src/api/generated/v1.d.ts). No SPA screen in this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>