316 lines
50 KiB
Markdown
316 lines
50 KiB
Markdown
---
|
|
phase: 12-p-ytarium-api-collections-and-albums
|
|
plan: 04
|
|
type: execute
|
|
wave: 4
|
|
depends_on: ["12-03"]
|
|
files_modified:
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/style_resolver.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/tracklist_text_parser.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/added_date_parser.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/duplicate_matcher.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_sync.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_stats.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/realtime.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_bulk_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_stats_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_sync_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_search_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/ratings_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/lookups_controller.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go
|
|
- ../fonoteka.go/parity/manifest.yaml
|
|
- ../fonoteka.go/parity/fixtures/routes/
|
|
- ../fonoteka.go/parity/fixtures/files/
|
|
- ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml
|
|
- ../fonoteka.go/parity/fonoteka_seed_test.go
|
|
- ../fonoteka.go/parity/fonoteka_flows_test.go
|
|
- ../fonoteka.go/parity/broadcast_goldens_test.go
|
|
- ../fonoteka.go/parity/parity_test.go
|
|
- ../fonoteka.go/parity/README.md
|
|
- ../fonoteka.go/README.md
|
|
- scripts/check-phase10.sh
|
|
- scripts/check-phase10.1.sh
|
|
- scripts/check-phase11.sh
|
|
autonomous: true
|
|
requirements: [API-02]
|
|
estimate:
|
|
tokens: 380000
|
|
raw_tokens: 380000
|
|
tasks: 3
|
|
confidence: low
|
|
must_haves:
|
|
truths:
|
|
- "Per C-02 and RESEARCH Summary, the album write service ports AlbumWriteService create/apply: AlbumFillFields only, ArtistResolver (numeric ids, Discogs ids and names resolved by name_key firstOrCreate, ordered pivot, displayFor artist_display), styles resolved or created by name/slug, `tracklist_text` parsed by the TracklistTextParser port (max 200 lines), `created_at` through the AddedDateParser port, barcode normalization, completeness sync, and cover import; unknown and server-owned keys never persist."
|
|
- "Per RESEARCH Pattern 4 and P11 D-08, POST albums and PUT albums/{id} run the whole write under `lighthouse.WithoutBroadcasting[models.Album]` and emit exactly one `created.fonoteka.album` or `updated.fonoteka.album` on the album's kind=collection channel with the full serialized album (genre, styles, artists, photos), and the created and updated broadcast goldens are assertions that pass (the gate scripts no longer expect them to skip)."
|
|
- "Per D-02, POST albums/bulk validates `albums` required|array|min:1 plus every album rule under `albums.*.`, creates every row in one transaction under suppression, returns 201 `{\"data\":[...],\"duplicates\":[...]}`, and emits exactly one `collection.bulk_updated` `{\"reason\":\"bulk_create\",\"count\":N}` only when at least one album was created in a kind=collection collection."
|
|
- "Per RESEARCH Pitfall 6, every album created in a kind=collection collection writes an `album_added` notification for each owner and editor except the actor, with notification:new and notification:count emitted in the write transaction (published after commit)."
|
|
- "Per D-05, `cover_urls` (at most 5, https only, host discogs.com or a subdomain, fetchguard AllowHosts with the configured byte cap and timeout) are imported after the album row commits; each failed URL is recorded in cover_import_failures; a failed cover never fails or rolls back the album."
|
|
- "Per D-07 and D-11, POST albums/{id}/photos accepts exactly one of a multipart `file` (Laravel image/mimes/max:10240 plus the ImageContentGuard sniff-and-decode, webp included) or a `cover_url` fetched with fetchguard PublicOnly, maps each fetch failure reason to PHP's exact message, carries throttle:20,1 on the JWT route only, and answers 201 `{\"data\":PhotoDTO}`."
|
|
- "Per D-16, D-17 and D-19, albums/search uses the Typesense path only when the search_use_typesense gate is on, the engine is configured, the sort is not rating, name, artist or price, and no rating filter is set: one SearchPage with PHP's query_by order `name,artist_display,style_names,genre_name,track_titles,notes,label,catalog_number` and weights `10,10,5,5,3,1,3,3`, items re-gated in SQL by AlbumsAccessibleBy plus collection_id plus filters in engine order, and meta.total the re-gated count over at most 1000 engine ids; an engine error logs a warning and falls back to the SQL path, which uses ILIKE with % and _ escaped over the authenticated text fields and artists.name."
|
|
- "Per D-12, search replays are recorded and replayed with search disabled; Typesense behaviour is covered by Go tests with a fake engine (full leak suite in 12-05)."
|
|
- "Per D-02, albums/stats, value, missing and sync (sync JWT only with throttle:60,1, PHP cursor `base64(<sync_version ISO>|<id>)`, per_page default 100 and max 200, tombstones in delta mode) return PHP's bodies for the active collection."
|
|
- "Per D-01 and D-10, the albums, rating, photos, stats, value, search, bulk routes and the lookups (GET artists, GET and POST styles, POST genres) are mounted once and reused on the token group exactly where routes.php mirrors them, each with exactly one inv.scope; the cover-price and Discogs match/apply/recognize/import routes stay pending (D-04)."
|
|
- "Per D-08, the `nuxt-albums` flow (create with and without cover_urls, update, rate, unrate, photo upload and delete, search, stats, value, missing, sync, bulk, delete) is recorded from PHP and replays green, and expectedPortedRoutes reaches 99."
|
|
- "Edge (API-02 boundary): year accepts 1889 and 2100 and rejects 1888 and 2101; per_page is clamped to 1..50 for index and search and 1..200 for sync with PHP (int) prefix parsing; cover_urls accepts five URLs and rejects six; rating accepts 1 and 5 and rejects 0 and 6; search meta.total never exceeds 1000 on the Typesense path."
|
|
- "Edge (API-02 precision): market_price_stored round-trips as a fixed four-decimal string (never float64) with min:0 and max:999999.9999 compared exactly; albums/value totals are exact SQL decimal sums formatted like PHP sprintf('%.2f') with the rounding of tie values pinned against php -r output."
|
|
- "Edge (API-02 concurrency): two concurrent PUT albums/{id}/rating calls by the same user leave exactly one rating row (unique user and album) holding one of the two values; a bulk create interrupted by a failing row rolls back every row and emits nothing."
|
|
artifacts:
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go"
|
|
provides: "CreateAlbum, UpdateAlbum (create/apply ports), SaveAlbum kept for existing callers"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go"
|
|
provides: "SearchAlbums with the Scout-exact Typesense path and the SQL path"
|
|
contains: "SearchPage"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go"
|
|
provides: "CoverImporter with an injectable fetch func"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go"
|
|
provides: "IsAllowedImage"
|
|
- path: "../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml"
|
|
provides: "recorded PHP Nuxt albums flow"
|
|
key_links:
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go"
|
|
to: "summercms.go modules/lighthouse/suppress.go"
|
|
via: "WithoutBroadcasting[models.Album] then one Service.Emit"
|
|
pattern: "WithoutBroadcasting"
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go"
|
|
to: "summercms.go modules/beachcomber/searchable.go"
|
|
via: "beachcomber.SearchPage for ids and found, then SQL recount"
|
|
pattern: "SearchPage\\("
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go"
|
|
to: "../fonoteka.go/plugins/golem15/fonoteka/classes/access.go"
|
|
via: "AlbumsAccessibleBy re-gates every engine id"
|
|
pattern: "AlbumsAccessibleBy"
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go"
|
|
to: "summercms.go modules/fetchguard/fetch.go"
|
|
via: "Fetch with AllowHostsMode discogs.com"
|
|
pattern: "AllowHosts"
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go"
|
|
to: "summercms.go modules/fetchguard/fetch.go"
|
|
via: "Fetch with PublicOnlyMode"
|
|
pattern: "PublicOnly"
|
|
prohibitions:
|
|
- requirement_id: API-02
|
|
category: privacy
|
|
statement: "Search MUST NOT return or count an album the caller cannot access, whatever the search index contains"
|
|
status: resolved
|
|
verification: test
|
|
- requirement_id: API-02
|
|
category: safety
|
|
statement: "A failed or slow cover download MUST NOT lose the album the user just saved"
|
|
status: resolved
|
|
verification: test
|
|
- requirement_id: API-02
|
|
category: privacy
|
|
statement: "An album-added notification MUST NOT go to the actor or to anyone outside the collection's owner and editors"
|
|
status: resolved
|
|
verification: test
|
|
---
|
|
|
|
## Phase Goal
|
|
|
|
ROADMAP Phase 12 goal (verbatim, not in user-story form): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search. (12-01 Task 4 rewords it per D-03/D-04/D-06.)
|
|
|
|
This plan's slice: the Nuxt Albums UI and the MCP server can add, browse, edit, rate, photograph, bulk-add, sync, count, value and search albums and look up artists, styles and genres, with PHP's bodies and Centrifugo events (API-02; ROADMAP SC-2, SC-3 code path, SC-4).
|
|
|
|
<objective>
|
|
Port the album write path and its helpers, the full album serializer, all album routes in scope, album search with the Scout-exact recount, the artists/styles/genres lookups, the Nuxt albums flow, and flip the created/updated broadcast goldens.
|
|
|
|
Purpose: albums are the bulk of the app; search is the security-critical read path (Pitfall 15). Decisions implemented: C-02, C-03, C-04, D-01, D-02, D-05, D-07, D-08, D-09, D-10, D-11, D-12, D-16, D-17, D-19, D-24 (consumer).
|
|
Output: classes, controllers, routes, recordings and the nuxt-albums flow, broadcast golden assertions, gate script updates.
|
|
|
|
Repos: fonoteka.go, plus summercms.go `scripts/check-phase10.sh`, `scripts/check-phase10.1.sh` and `scripts/check-phase11.sh` (their pending lists for the two goldens). Never add co-author tags.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
|
@~/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md
|
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md
|
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-02-SUMMARY.md
|
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-03-SUMMARY.md
|
|
@../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
|
@../fonoteka.go/plugins/golem15/fonoteka/realtime.go
|
|
@../fonoteka.go/plugins/golem15/fonoteka/search.go
|
|
|
|
<interfaces>
|
|
- From 12-01: `lagoon.ValidateRequest`, `lagoon.ParseRules`, `lagoon.CustomRule`, `lagoon.UploadedFile`; `beachcomber.SearchPage(ctx, engine, index, Query{Q, QueryBy, QueryByWeights, FilterBy, SortBy, Page, PerPage}) (SearchResult{IDs, Found}, error)`; `attach.PublicURL`, `(*attach.File).URL()`, webp decoding; tide `Request.Parts`.
|
|
- From 12-02: `classes.Resolve`, `classes.AlbumsAccessibleBy(userID, token)`, `classes.SerializePhoto`, `PhotoDTO`, `writeValidationFailed`, `writeWinterHTTPError`, `decodeInput`, `phpInt`, `laravelBoolean`, `requestScope`, `classes.CollectionKey`, seed hook `fonoteka`.
|
|
- From 12-03: `classes.WriteNotification`, `classes.NotificationTypeAlbumAdded`, the 409 guard mapping.
|
|
- fonoteka today: `classes.SaveAlbum(ctx, gdb, album, requested, artistIDs)` (fill, validate, lagoon.Transaction, ResolveArtists numeric-only, tx.Save, syncArtists), `AlbumFillFields`, `classes.SerializeAlbum` (8-key map; realtime.go albumPayload calls it and `albumBroadcast.Album` is `map[string]any`), `models.Album` (AlbumFormats, AlbumConditions, AlbumMarketCurrencies, MediumFamily, BeforeSave market stamping, track_titles), `models.AlbumArtist`, `models.AlbumRating`, `models.Artist`, `models.Style`, `models.Genre`, `models.AlbumSearchQueryBy` (Go order differs from PHP; the search uses PHP's order), settingsGate in search.go, lighthouse Album binding in realtime.go (alias fonoteka.album, albumChannels), controllers.ListGenres and polishOrderColumns/polishCollation `pl-x-icu` in controllers/genre_controller.go.
|
|
- fetchguard: `Fetch(ctx, rawURL, Policy{Mode: AllowHostsMode or PublicOnlyMode, AllowHosts, MaxBytes, Timeout}, cfg) (*Result, error)` with `*fetchguard.Error{Reason}` reasons invalid_url, scheme, unresolvable, private_ip, network_error, too_large; dotted-suffix host match.
|
|
- lighthouse: `WithoutBroadcasting[T](ctx, fn func(ctx) error) error` (writes must use the ctx handed to fn), `(*Service).Emit(ctx, db, Broadcast{Channels, Event, Payload})`.
|
|
- Parity: broadcast goldens `fixtures/broadcasts/{created,updated,deleted,bulk}.yaml`, `TestBroadcastGoldens` (created/updated skip with `pending: Phase 12 ...`), gate scripts' `APP_PENDING_SKIPS`/`GOLDEN_SKIPS` in scripts/check-phase10.sh, check-phase10.1.sh, check-phase11.sh (they refuse an expected skip that passes).
|
|
- PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/AlbumApiController.php, controllers/api/AlbumSyncController.php, controllers/api/RatingApiController.php, controllers/api/ArtistApiController.php, controllers/api/StyleApiController.php, controllers/api/GenreApiController.php, classes/AlbumWriteService.php, classes/ArtistResolver.php, classes/AlbumCompletenessService.php, classes/AlbumDuplicateMatcher.php, classes/TracklistTextParser.php, classes/AddedDateParser.php, classes/ImageContentGuard.php, classes/ManualCoverUrlFetcher.php, classes/discogs/CoverImporter.php, classes/discogs/DiscogsInputParser.php (normalizeBarcode), classes/AlbumSearchService.php, classes/AlbumSyncService.php, classes/PolishOrder.php, classes/NotificationService.php (notifyAlbumAdded), Plugin.php (registerNotificationListeners), models/Album.php, traits/SerializesFonoteka.php, config/fonoteka.php}; /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Builder.php (lines 533-556), Engines/TypesenseEngine.php.
|
|
</interfaces>
|
|
</context>
|
|
|
|
## Artifacts this phase produces
|
|
|
|
(This plan's share.)
|
|
|
|
- classes: `CreateAlbum(ctx, gdb, svc, collectionID uint, input map[string]any, opts AlbumWriteOptions) (*models.Album, error)`, `UpdateAlbum(ctx, gdb, svc, album, input, opts) (*models.Album, error)`, `AlbumWriteOptions{CoverURLs []string}`, `ResolveArtistInputs`, `ArtistDisplayFor`, `ResolveStyleIDs`, `ParseTracklistText`, `ParseAddedDate`, `FindDuplicateAlbum`, `SyncCompletion`, `MissingTags`, `NotifyAlbumAdded`, `IsAllowedImage`, `CoverImporter{Fetch func(ctx, url string) ([]byte, string, error)}`, `ImportCovers`, `FetchManualCover`, `ManualCoverMessage(reason string) string`, `SearchAlbums(ctx, gdb, engineSvc, params AlbumSearchParams) (AlbumPage, error)`, `AlbumSyncDelta`, `AlbumTombstones`, `AlbumStats`, `AlbumValue`, `MissingAlbums`, `AlbumDTO`, `SerializeAlbum`, `SerializeArtist`, `SerializeArtistAggregate`, `SerializeGenre`, `SerializeStyle`.
|
|
- controllers/api: `AlbumsIndex`, `AlbumsStore`, `AlbumsShow`, `AlbumsUpdate`, `AlbumsDestroy`, `AlbumsBulk`, `AlbumPhotoUpload`, `AlbumPhotoDelete`, `AlbumRatingUpdate`, `AlbumRatingDestroy`, `AlbumsStats`, `AlbumsValue`, `AlbumsMissing`, `AlbumsSync`, `AlbumsSearch`, `ArtistsIndex`, `StylesIndex`, `StylesStore`, `GenresStore`.
|
|
- Routes: JWT `albums/search`, `albums/stats`, `albums/value`, `albums/sync` (throttle:60,1), `albums/missing`, `albums/bulk`, `albums`, `albums/{id}`, `albums/{id}/photos` (throttle:20,1), `albums/{id}/photos/{fileId}`, `albums/{id}/rating`, `genres` POST, `styles`, `artists`; token twins per routes.php.
|
|
- Config keys: `golem15.fonoteka.discogs.max_covers` (5), `.cover_max_bytes` (10485760), `.cover_timeout_seconds` (10), `.cover_host_suffix` (".discogs.com"), `golem15.fonoteka.covers.manual_url_max_bytes` (10485760), `.manual_url_timeout_seconds` (10).
|
|
- Parity: `fixtures/nuxt/nuxt-albums.yaml`, `TestFonotekaNuxtFlows/nuxt-albums`, created/updated goldens asserted.
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer">
|
|
<name>Task 1: Adding an album from the Nuxt form or an MCP token stores it like PHP, notifies the household and publishes one created event</name>
|
|
<reversibility rating="reversible">New write path beside SaveAlbum; SaveAlbum stays callable for existing tests and admin code.</reversibility>
|
|
<precondition>Plan 12-03 is executed: `go -C ../fonoteka.go doc ./plugins/golem15/fonoteka/classes WriteNotification` exits 0.</precondition>
|
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/style_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/tracklist_text_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/added_date_parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/duplicate_matcher.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/parity_test.go, scripts/check-phase10.sh, scripts/check-phase10.1.sh, scripts/check-phase11.sh</files>
|
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (store, show, albumRules, duplicateAttrs, serializeDuplicate, context, embedsFor, publishAlbumBroadcast), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php (create, apply, applyFields, applyAddedAt, syncArtists, resolveStyleIds, syncCompletion, maybeImportCovers), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ArtistResolver.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/TracklistTextParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AddedDateParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumDuplicateMatcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumCompletenessService.php (syncCompletion, missingTags), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/DiscogsInputParser.php (normalizeBarcode), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/NotificationService.php (notifyAlbumAdded), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php (registerNotificationListeners, resolveNotificationActor), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (getBroadcastPayload, mediumFamily, beforeSave), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (serializeAlbum, serializeOwnRating, serializeArtist), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go, ../fonoteka.go/plugins/golem15/fonoteka/realtime.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album.go, ../fonoteka.go/plugins/golem15/fonoteka/models/artist.go, ../fonoteka.go/plugins/golem15/fonoteka/models/style.go, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_albums_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/POST__api_v1_fonoteka_albums_personal_token.yaml, scripts/check-phase11.sh (GOLDEN_SKIPS, refuse rule), scripts/check-phase10.sh and scripts/check-phase10.1.sh (APP_PENDING_SKIPS)</read_first>
|
|
<action>(1) Helpers (ports, each a small file): ArtistResolver (resolve: numeric id existing; `discogs:` or Discogs id inputs as PHP; a name trimmed and keyed by Artist name_key, firstOrCreate with slug; returns ordered ids without duplicates; `ArtistDisplayFor(ids)` joining names as PHP displayFor), `ResolveStyleIDs` (numeric existing id, else name or slug match, else create, as SerializesFonoteka resolveStyleIds), `ParseTracklistText` (TracklistTextParser: line split on CRLF/LF/CR, max 200 lines, autoNumber, position/title/artist/duration), `ParseAddedDate` (AddedDateParser FORMATS and MIN_DATE, returns nil for refused formats), `FindDuplicateAlbum(collectionID, attrs)` (AlbumDuplicateMatcher with its POLISH_FOLD map), `SyncCompletion` and `MissingTags` (AlbumCompletenessService BASE_TAGS, cond, price, rating rules), barcode normalization (normalizeBarcode). Keep every PHP constant and string verbatim.
|
|
|
|
(2) album_write_service.go: `CreateAlbum` and `UpdateAlbum` porting create/apply inside one lagoon.Transaction: Fill with AlbumFillFields, apply genre_id, styles, artists (pivot sort_order), tracklist or tracklist_text, created_at via ParseAddedDate, completion sync, dismiss_missing handling as applyFields does, collection_id server-set from the resolved active collection, market_price_source preserved as SaveAlbum does today. Strings are stored as sent (no trimming beyond what PHP does). Covers (`cover_urls`) are NOT imported in this task (Task 2 adds them after commit). SaveAlbum stays exported and green for its existing tests.
|
|
|
|
(3) Notifications (Pitfall 6): register a GORM after-create callback for models.Album in classes (RegisterHook pattern, Before gorm:commit_or_rollback_transaction as lighthouse/beachcomber do) that, for an album whose collection kind is `collection`, calls `NotifyAlbumAdded(ctx, tx, svc, album, actor)` writing `album_added` {album_id, album_name, collection_id, actor_name} to the owner and every editor except the actor (actor from the frontend principal in ctx, nil for none or a backend admin, per resolveNotificationActor); the wishlist branch is Phase 13. This fires for any album insert, as PHP's eloquent.created listener does.
|
|
|
|
(4) serialize.go: `AlbumDTO` ordered as serializeAlbum (id, name, artists ordered by pivot sort_order, artist_display, year, format, condition, medium, genre {id,name} or null, styles [{id,name}], shelf, barcode, discogs_id, edition, label, catalog_number, country, market_price_stored string or null, market_price_currency, market_price_checked_at, market_price_source, tracklist (never null), cover_import_failures (never null), notes, rating (the caller's own rating or null), photos (PhotoDTO list ordered as attachMany), created_at, updated_at); no `reservation` key (Phase 13). `SerializeArtist`, `SerializeArtistAggregate`, `SerializeGenre`, `SerializeStyle`. realtime.go: albumPayload uses the DTO (change `albumBroadcast.Album` to `*AlbumDTO` with omitempty) built from a fresh read with genre, styles, artists and photos.
|
|
|
|
(5) albums_controller.go: `AlbumsStore` (Resolve; ValidateRequest with albumRules(true) ported verbatim including the tracklist_text 200-line closure via CustomRule with PHP's literal message, the created_at date rules, cover_urls array|max:5 and cover_urls.* string|url|max:2048, tracklist.*.duration regex; FindDuplicateAlbum before create; CreateAlbum inside `lighthouse.WithoutBroadcasting[models.Album]`; then one `svc.Emit` of `created.fonoteka.album` on albumChannels with the albumPayload shape when the channel list is non-empty; respond 201 `{"data":AlbumDTO,"duplicate":DuplicateDTO or null}`), and `AlbumsShow` (AlbumsAccessibleBy plus collection_id = active id, embeds plus own rating, else 404 `{"error":"Album not found"}`). Routes: JWT and token `POST /albums` (token write) and `GET /albums/{id}` (token read) with `[0-9]+`.
|
|
|
|
(6) Goldens (D-09): rework TestBroadcastGoldens `created` to drive POST albums through the assembled handler on the memory/fake Centrifugo driver and assert the recorded created golden (timestamps and actor normalized by tide); remove its pending skip. Clear `TestBroadcastGoldens/created` from APP_PENDING_SKIPS in scripts/check-phase10.sh and scripts/check-phase10.1.sh and from GOLDEN_SKIPS in scripts/check-phase11.sh (keep `updated` until Task 2), updating their comments, and make each script's `--self-test` still pass.
|
|
|
|
(7) Recordings: POST albums 201 (minimal create, create with styles and tracklist_text, create that triggers `duplicate`), 422 cases (missing name and artists, year 1888, six cover_urls, bad duration, numeric name), token 201; GET albums/{id} 200 own, 200 shared to bob, 404 foreign, token pinned 404 for an album outside the pin. Flip the four routes to ported and raise expectedPortedRoutes by 4.
|
|
|
|
(8) Smoke tests: `TestAlbumStoreSingleCreatedEvent` (one publication, payload album has artists in order, no duplicate events), `TestAlbumAddedNotifiesHousehold` (bob gets the notification when alice creates; alice does not), `TestAlbumWriteHelpersMatchPHP` (table cases for the parsers and the duplicate matcher taken from the PHP sources' behaviour; run php -r to confirm any ambiguous case before pinning it).</action>
|
|
<verify>
|
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumStoreSingleCreatedEvent|TestAlbumAddedNotifiesHousehold|TestAlbumWriteHelpersMatchPHP)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestBroadcastGoldens|TestParityCorpus)$' -count=1 -v && scripts/check-phase11.sh --self-test</automated>
|
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "DATA RACE", or lacks "--- PASS: TestBroadcastGoldens/created" or "--- PASS: TestParityCorpus/coverage"; "--- SKIP: TestBroadcastGoldens/created" appears; the gate self-test reports a failed detector.</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c 'WithoutBroadcasting\[models.Album\]' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go` prints at least 1.
|
|
- `grep -n 'GOLDEN_SKIPS=' scripts/check-phase11.sh` no longer lists `TestBroadcastGoldens/created`.
|
|
- `grep -c 'album_added' ../fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go` prints at least 1.
|
|
- The PHP tracklist_text closure message appears verbatim in albums_controller.go (`grep -c 'more than 200 lines' ...` prints at least 1).
|
|
- `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes -run 'TestSaveAlbum|FuzzSaveAlbum' -count=1` still passes (old callers intact).
|
|
</acceptance_criteria>
|
|
<done>A created album matches PHP's response, DB rows, household notifications and single Centrifugo event, on both auth groups, and the created golden is an assertion.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Users edit, rate, photograph, bulk-add, delete, sync, count and value their albums with PHP's bodies and events</name>
|
|
<reversibility rating="reversible">Handlers and helpers are additive; the cover import runs after commit and can be disabled by config max_covers.</reversibility>
|
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_sync.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_stats.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/completeness.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_bulk_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_stats_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_sync_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/ratings_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/files/, ../fonoteka.go/parity/broadcast_goldens_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/README.md, scripts/check-phase10.sh, scripts/check-phase10.1.sh, scripts/check-phase11.sh</files>
|
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (index, update, destroy, bulk, stats, value, missing, uploadPhoto, coverUrlErrorMessage, deletePhoto, paginated), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/RatingApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumSyncController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSyncService.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumCompletenessService.php (applyMissingConstraints, applyListFilters, counters), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ImageContentGuard.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/ManualCoverUrlFetcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/discogs/CoverImporter.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php, summercms.go modules/fetchguard/fetch.go and policy.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go (12-02 upload pattern), ../fonoteka.go/parity/fixtures/broadcasts/updated.yaml, ../fonoteka.go/parity/fixtures/broadcasts/bulk.yaml, ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_albums_stats_jwt.yaml, GET___fonoteka_api_v1_albums_value_jwt.yaml, GET___fonoteka_api_v1_albums_missing_jwt.yaml, GET___fonoteka_api_v1_albums_sync_jwt.yaml, POST___fonoteka_api_v1_albums_bulk_jwt.yaml (all under ../fonoteka.go/parity/fixtures/routes/)</read_first>
|
|
<action>(1) Covers (D-05, D-07, T-12-09): `CoverImporter` ports CoverImporter.import with a `Fetch` field defaulting to fetchguard.Fetch with AllowHostsMode, AllowHosts ["discogs.com"] (PHP suffix `.discogs.com`), MaxBytes and Timeout from the new config keys; https only; at most `discogs.max_covers`; each URL that fails (scheme, host, fetch error, not an image per IsAllowedImage) goes into cover_import_failures in input order; success attaches a photo. Import runs AFTER the album write commits (never hold the write tx across downloads), then saves cover_import_failures, then serializes and emits; this applies to POST albums (extend Task 1's store), PUT albums/{id} and each bulk row. `IsAllowedImage` ports ImageContentGuard (sniff ALLOWED_IMAGE_MIMES, decode config succeeds, webp included per D-24). `FetchManualCover` uses fetchguard PublicOnlyMode with `covers.manual_url_*` limits and adds the app-level checks PHP does (2xx status, image content type, IsAllowedImage), returning a reason; `ManualCoverMessage` maps invalid_url, scheme, unresolvable, private_ip, network_error, http_status, content_type, too_large, invalid_image to PHP's exact English strings.
|
|
|
|
(2) Album routes: `AlbumsIndex` (per_page phpInt default 20 clamped 1..50, page min 1, AlbumsAccessibleBy plus active collection, order by name then id, lagoon pagination envelope without links), `AlbumsUpdate` (albumRules(false), UpdateAlbum under WithoutBroadcasting, one `updated.fonoteka.album` Emit, cover_urls after commit, 200 `{"data":AlbumDTO}`), `AlbumsDestroy` (soft delete through the model so the automatic deleted broadcast and index removal fire; PHP response body), ratings (`PUT albums/{id}/rating` validates rating required|integer|min:1|max:5, upserts the caller's row on the unique (user, album) key, returns `{"data":{"rating":n}}`; DELETE returns `{"data":{"rating":null}}`; 404 `{"error":"Album not found"}`), photos (`POST albums/{id}/photos`: exactly one of file or cover_url else 422 `{"error":"Validation failed","errors":{"file":["Provide exactly one of file or cover_url."]}}`; file branch: ValidateRequest file rules, then IsAllowedImage else `The file is not a valid image.`, then attach as 12-02 does; cover_url branch: `cover_url` required|string|url|max:2048 then FetchManualCover, failure 422 under `cover_url`; JWT route `throttle:20,1`, token route none; `DELETE albums/{id}/photos/{fileId}` with `Photo not found`), `AlbumsBulk` (rules prefixed `albums.*.`, one lagoon.Transaction under WithoutBroadcasting creating each row via CreateAlbum with duplicates computed first, rollback on any failure, then cover imports per row after commit, then one `collection.bulk_updated` Emit only when created rows exist and the active kind is collection; 201 `{"data":[...],"duplicates":[...]}`). Stats (`AlbumStats`: totals albums, all_count, vinyl, cd, cassette, box, artists, missing_albums and genres aggregates with album_count ordered as PHP plus an id tiebreak), value (`AlbumValue`: per-currency SUM in SQL as decimal, PHP sprintf('%.2f') formatting confirmed with php -r for tie values, albums_total, albums_valued, albums_without_price), missing (filters page, per_page 1..50, include_condition, include_price, include_rating, dismissed as laravelBoolean; MissingTags per album and the counters), sync (`AlbumSyncDelta` and `AlbumTombstones` ports with the cursor, checkpoint clamped to now, `mode`, `collection_key` (CollectionKey), `total_estimate`, `changed` rows carrying `sync_version`; JWT only with `throttle:60,1`). Token twins exactly as routes.php (search later, stats read, value read, bulk write, index read, update write, destroy write, photos POST and DELETE write, rating PUT and DELETE write); every `{id}` and `{fileId}` gets its Where right after its route.
|
|
|
|
(3) Goldens: drive PUT albums/{id} through the handler in TestBroadcastGoldens `updated`, drop its skip and its remaining entries in the three gate scripts (now empty pending lists; self-tests pass); keep `deleted` and `bulk` green through the real handlers.
|
|
|
|
(4) Config keys in plugin config.yaml and ../fonoteka.go/README.md: discogs.max_covers, cover_max_bytes, cover_timeout_seconds, cover_host_suffix; covers.manual_url_max_bytes, manual_url_timeout_seconds (PHP defaults).
|
|
|
|
(5) Recordings: index (two pages, per_page=abc clamps), update 200 and 422 and 404, destroy 200 and 404, rating PUT 200, 422 (0 and 6) and DELETE 200, photos multipart 201 (png and webp), 422 both-or-neither, 422 non-image, cover_url 422 for `http://` (scheme) and a private literal address (private_ip) without network, photo delete 200 and 404, bulk 201 with two rows, 422 `{"albums":[]}` (one message), cover_urls with a non-https and an off-list host producing cover_import_failures (network-free, Pitfall 10), stats, value, missing (with each include flag), sync full and delta with a cursor; token twins each with their own case. Flip every route of this task and raise expectedPortedRoutes.
|
|
|
|
(6) Smoke tests: `TestCoverImportAfterCommit` (injected Fetch: one success, one failure recorded; a fetch panic or timeout leaves the album committed), `TestManualCoverReasons` (every reason maps to PHP's text; private IPv4, IPv6 loopback and a NAT64-embedded private address refused by fetchguard), `TestAlbumPhotoUpload` (row, blob, thumb, webp accepted, polyglot rejected, 10240 KB plus one byte rejected), `TestBulkSingleSummaryEvent` (rollback emits nothing), `TestRatingUpsertConcurrent`, `TestAlbumValueFormatting` (php -r pinned values).</action>
|
|
<verify>
|
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCoverImportAfterCommit|TestManualCoverReasons|TestAlbumPhotoUpload|TestBulkSingleSummaryEvent|TestRatingUpsertConcurrent|TestAlbumValueFormatting)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestBroadcastGoldens|TestParityCorpus)$' -count=1 -v && scripts/check-phase10.sh --self-test && scripts/check-phase10.1.sh --self-test && scripts/check-phase11.sh --self-test</automated>
|
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestBroadcastGoldens/updated, TestBroadcastGoldens/bulk, TestCoverImportAfterCommit and "--- PASS: TestParityCorpus/coverage"; a gate self-test reports a failed detector.</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c 'PublicOnly' ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go` and `grep -c 'AllowHosts' ../fonoteka.go/plugins/golem15/fonoteka/classes/cover_importer.go` each print at least 1.
|
|
- `grep -c 'Provide exactly one of file or cover_url.' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go` prints 1.
|
|
- `grep -n 'throttle:20,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` matches only the JWT photos route and `grep -n 'throttle:60,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` only the sync route.
|
|
- `grep -n 'APP_PENDING_SKIPS=' scripts/check-phase10.sh scripts/check-phase10.1.sh` and `grep -n 'GOLDEN_SKIPS=' scripts/check-phase11.sh` show empty lists.
|
|
- The upload fixtures carry `parts:` with sha256 and their url/thumb_url bodies replay through the tide upload mask.
|
|
- `grep -c 'cover_max_bytes' ../fonoteka.go/README.md` prints at least 1.
|
|
</acceptance_criteria>
|
|
<done>The whole album management surface the Albums UI uses is ported with PHP's bodies, single-event broadcasting, guarded cover fetching and exact upload handling, and all four album broadcast goldens are assertions.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Users search their albums and look up artists, styles and genres, and the Nuxt albums journey replays end to end</name>
|
|
<files>../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_search_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/lookups_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/albums_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md</files>
|
|
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (whole file), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php (search rules, RATING_FILTERS, SORTS), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Builder.php (paginate, getTotalCount lines 533-556), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Engines/TypesenseEngine.php (maxPerPage, performPaginatedSearch), /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Searchable.php (queryScoutModelsByIds), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/ArtistApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/StyleApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/GenreApiController.php (store), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/PolishOrder.php, /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useAlbumsQuery.ts, useBulkAlbumTape.ts, useRealtimeSync.ts (request order for the flow), ../fonoteka.go/plugins/golem15/fonoteka/search.go (settingsGate), ../fonoteka.go/plugins/golem15/fonoteka/models/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (aggregate and Polish ordering precedent), ../fonoteka.go/parity/fonoteka_flows_test.go (12-03 flow test), ../fonoteka.go/parity/genres_seed_test.go and ../fonoteka.go/parity/README.md (the temporary genres seed hook note)</read_first>
|
|
<action>(1) album_search.go `SearchAlbums` (D-16, D-17, D-19): validation as PHP search (rating in RATING_FILTERS, sort in SORTS, dir asc|desc, format in AlbumFormats, medium in mediums, artist nullable|integer|exists:golem15_fonoteka_artists,id, decade nullable|integer|between:1880,2100 plus the multiple-of-ten CustomRule with PHP's literal message, genre and style nullable|integer). Typesense path conditions as resolveSort/searchWithScout: settingsGate on, engine Configured, q non-empty or as PHP requires, sort not in rating/name/artist/price and no rating filter. Query: Q, QueryBy `name, artist_display, style_names, genre_name, track_titles, notes, label, catalog_number` (PHP order, not models.AlbumSearchQueryBy), QueryByWeights 10,10,5,5,3,1,3,3, FilterBy `collection_id:=[<id>]` joined with genre/style/format/medium/artist/decade filters by ` && ` exactly as PHP, SortBy `_text_match:desc,` prefix when q is set then `year` or `created_at` with dir, Page and PerPage. Items: SQL `id IN ids` plus AlbumsAccessibleBy(user, token) plus collection_id = active plus the same filters plus embeds, ordered by engine position. Total (Scout v10.25.0): when len(page ids) is below found, fetch min(found, 1000) ids with SearchPage at per_page found (if under 250) or pages of 250 up to 1000, then COUNT the re-gated rows; else count the re-gated page ids; last_page = max(ceil(total/per_page), 1). Engine error: slog warning without query text, then the SQL path. SQL path: text search ILIKE with `%`, `_` and `\` escaped (ESCAPE '\') over name, notes, artist_display, track_titles, label, catalog_number OR EXISTS artists.name, filters, rating filter joined to the caller's rating, sorts with pl-x-icu collation for name and artist, rating and price sorts as PHP, id tiebreak always, lagoon pagination envelope.
|
|
|
|
(2) album_search_controller.go `AlbumsSearch` on JWT and token (read). Replays use search disabled (D-12): the parity config keeps search_use_typesense off.
|
|
|
|
(3) lookups_controller.go: `ArtistsIndex` (aggregate with album_count over the active collection's accessible albums, PolishOrder by artists.name with pl-x-icu and id tiebreak, PHP validation if any), `StylesIndex` (orderBy name with collation decision documented and id tiebreak), `StylesStore` and `GenresStore` (PHP validation and bodies, including the existing-name branch). Mount JWT and token twins (index read, store write). The temporary `genres` seed hook stays: it now seeds identities for ported routes; update the parity README sentence that promised to delete it once POST genres is ported, explaining why it stays.
|
|
|
|
(4) Flow (D-08): record `nuxt-albums` from PHP with a request spec in the composables' order (create without and with cover_urls using a network-free failing URL, update, rate, unrate, photo upload multipart and delete, search with q and filters, stats, value, missing, sync full then delta, bulk, delete) and add subtest `nuxt-albums` to TestFonotekaNuxtFlows replaying it against Go on the `fonoteka` seed.
|
|
|
|
(5) Recordings: search (q match, no match, each filter, each sort, rating filter, per_page clamp, 422 for decade 1995, unknown artist id, bad sort), token search pinned; artists, styles GET (jwt and token), styles POST 201/200-existing/422, genres POST 201/existing/422 (jwt and token). Flip all remaining Phase 12 routes; expectedPortedRoutes becomes 99. Confirm with check_corpus that no Phase 12 route of CONTEXT's domain list is still pending and that the D-04 Discogs routes and every wishlist/public/onboarding route are still pending.
|
|
|
|
(6) Smoke tests: `TestAlbumSearchSQLEscaping` (q `50%` and `a_b` match literally), `TestAlbumSearchTypesenseRecount` (fake engine registered through beachcomber.RegisterEngine with scripted ids and found: a stale id shortens the page and is not counted; found 3000 caps the recount at 1000; an engine error falls back to SQL with a warning).</action>
|
|
<verify>
|
|
<automated>go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAlbumSearchSQLEscaping|TestAlbumSearchTypesenseRecount)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestFonotekaNuxtFlows|TestParityCorpus|TestBroadcastGoldens)$' -count=1 -v && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets</automated>
|
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestFonotekaNuxtFlows/nuxt-albums", "--- PASS: TestFonotekaNuxtFlows/nuxt-collections" and "--- PASS: TestParityCorpus/coverage"; check_corpus reports a secret or an unrecorded route.</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go` shows the value 99.
|
|
- `grep -c '10,10,5,5,3,1,3,3' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` prints at least 1 (or the weights appear as the equivalent int slice with a comment naming PHP's string).
|
|
- `grep -c '1000' ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` prints at least 1 (Scout max_total_results cap).
|
|
- `grep -c "ESCAPE" ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go` prints at least 1.
|
|
- The manifest still marks `POST /api/v1/fonoteka/albums/{id}/cover-price/discogs personal_token`, `POST /_fonoteka/api/v1/albums/recognize jwt` and every `wishlist/` route as pending.
|
|
- `test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-albums.yaml` succeeds.
|
|
</acceptance_criteria>
|
|
<done>Search, lookups and the full albums journey match PHP; every Phase 12 route is ported and the corpus stands at 99 ported routes.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Caller → album ids in paths and bulk bodies | Ids and bodies are untrusted; tenancy is server-resolved |
|
|
| User-supplied URLs → outbound fetch | cover_urls and cover_url make the server fetch remote content |
|
|
| Uploaded bytes → blob storage and decoders | Photos are stored and thumbnailed |
|
|
| Search engine → SQL | Engine ids and found counts are candidates only |
|
|
| Write transaction → Centrifugo and notifications | Album data and notifications leave for subscribers |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-12-33 | Information Disclosure | albums/{id}, photos/{fileId}, rating | high | mitigate | AlbumsAccessibleBy plus collection_id = active on every lookup; photo lookup scoped to the album's attachments; one 404 body for foreign and missing (Tasks 1-2). |
|
|
| T-12-02 | Information Disclosure | albums/search items and meta.total | high | mitigate | Every engine id re-gated in SQL; total recounted in SQL over at most 1000 ids (D-19); engine errors fall back to SQL (Task 3; leak suite in 12-05). |
|
|
| T-12-34 | Elevation of Privilege | token pin on albums, stats, value, search, lookups | high | mitigate | Resolve honours the pin; AlbumsAccessibleBy narrows; sync is JWT only (Tasks 1-3). |
|
|
| T-12-09 | Tampering / Information Disclosure | cover_urls and cover_url fetches (SSRF) | high | mitigate | fetchguard AllowHosts discogs.com for imports and PublicOnly for manual URLs: dial-time private-address refusal, no redirects, byte cap, timeout; https only; never inside the write tx (Task 2). |
|
|
| T-12-10 | Tampering / Denial of Service | photo upload | high | mitigate | body limit, max:10240 rule, ImageContentGuard sniff and decode, throttle:20,1 on JWT (Task 2). |
|
|
| T-12-11 | Tampering | mass assignment on albums and bulk | high | mitigate | AlbumFillFields only; collection_id and market_price_source server-controlled; fuzz in 12-05 (Tasks 1-2). |
|
|
| T-12-23 | Information Disclosure | album_added notifications and broadcasts | medium | mitigate | Recipients are the collection's owner and editors minus the actor; broadcasts only on the kind=collection channel; published after commit (Task 1). |
|
|
| T-12-24 | Denial of Service | search recount and bulk size | medium | mitigate | Recount capped at 1000 ids in pages of 250; bulk bounded by body limits and validation; cover imports capped at max_covers per album (Tasks 2-3). |
|
|
| T-12-SC | Tampering | package installs | low | accept | No new dependency in this plan (golang.org/x/image arrived in 12-01). |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; `go vet ./... && go test ./...` green in summercms.go (gate scripts only changed there).
|
|
- Parity corpus at 99 ported and passing; TestFonotekaNuxtFlows (both flows) and TestBroadcastGoldens (all four) pass; check_corpus --require-recorded --check-secrets green.
|
|
- `scripts/check-phase10.sh --self-test`, `scripts/check-phase10.1.sh --self-test`, `scripts/check-phase11.sh --self-test` pass.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- All 36 album and lookup routes in Phase 12 scope are ported on the right groups with PHP bodies.
|
|
- One broadcast per store/update, one summary per bulk, household notifications on create, covers imported after commit through fetchguard.
|
|
- Search re-gates items and total; the Nuxt albums journey replays green.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-04-SUMMARY.md` when done.
|
|
</output>
|