15 KiB
Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes - Context
Gathered: 2026-10-02 Status: Ready for planning
## Phase BoundaryPort the remaining core Płytarium API surface to fonoteka.go with byte-compatible request/response shapes: wishlist, notifications, CSV import session and export, per-user/org credentials CRUD, onboarding, invitation inspection and the anonymous public-share views (collection and wishlist) with their rate-limit buckets.
The manifest lists 62 pending routes in this area, every one with a recorded PHP fixture. After the decisions below, 58 are ported in this phase and 4 move to Phase 14 (wishlist match/apply-release, ai-credential/test, discogs-credential/test). Breakdown of the 62: wishlist 36 (28 JWT, 5 personal-token group, 3 public), notifications 4, CSV 7 (6 import + export), credentials 9, onboarding/invitation/public collection 6.
Repos: fonoteka.go, plus an additive change to sm-user-plugin (mounted at fonoteka.go/plugins/golem15/user). summercms.go only if a framework gap turns up.
Phase 13/14 boundary
- D-01:
POST wishlist/albums/{id}/matchandapply-release(WishlistReleaseMatchController) move to Phase 14 with the Discogs client (INTG-01), next to the album match/apply-release routes. Their manifest entries staypending(P6 D-15, no 501 shells). Success criterion 1 and API-03 are reworded at plan time. - D-02: Credentials CRUD lands in Phase 13:
GET/POST ai-credential,GET/POST/DELETE org-ai-credential,GET/POST discogs-credential(including theshared=true|falsemirror into the org credential, owner/admin only, else 403), encrypted storage,FONOTEKA_AI_ORG_LOCKorg-lock and the PHP resolution order (AI: admin → backend Settings global model, then org-lock tier, user, org, else error; Discogs: admin → envDISCOGS_TOKEN, then user, org). The two live-call routesai-credential/testanddiscogs-credential/teststaypendingfor Phase 14 (Discogs client and AI adapters). Success criterion 4 and API-06 are reworded at plan time. - D-03: All 7 CSV routes are ported in Phase 13 (
POST import/csv202 with throttle:10,1,GET import/csv/{id},PATCH .../mapping,PATCH .../rows/{rowId},POST .../commit,POST .../cancel,GET export/csvon both groups).commit(after the preview→importing compare-and-swap, idempotent 202 on replay) and non-canonicalmappingenqueue real River jobs whose kinds, queues (fonoteka.csv.import,fonoteka.csv.match) and args mirror PHP'sAlbumCsvImportJob/AlbumCsvMatchJob. The job bodies are JOBS-02 in Phase 14.cancelmarks the job rows canceled as PHP does;showreads progress from the job table. — Reversibility: costly — the job kind names and args become the contract that Phase 14 workers consume and that queued rows in a live DB already carry. - D-04: Until Phase 14, no worker is registered for the CSV import/match kinds. The queued rows simply wait and
showreports the state PHP reports before its worker picks the job up. No fake success, no stub worker. - D-05: The row-edit branch with
selected_discogs_id(PHP callsDiscogsClient::getReleaseinline) is held behind a narrow seam that Phase 14 fills. The rest of the row-edit route is ported. How that branch behaves in the meantime (and which recorded case stays pending) is decided at planning, with the constraint that it never fabricates release data. - D-06: Notifications: PHP has no prune route; pruning is the
fonoteka:prune-notificationsconsole command, already in Phase 14. Success criterion 2 and API-04 drop "prune" at plan time. The 4 routes (notificationscapped at 50 newest-first,unread-count,read-all,{id}/read) are ported.
Wishlist mail and digest
- D-07:
wishlist/albums/{id}/purchasekeeps PHP's DB effects (move viacollection_idupdate,releaseForAlbum, bell notifications, reserver notification) but subscriber mail is sent by a River job enqueued in the purchase transaction, sent only after commit (P12 D-13 pattern). PHP sends it inline; response bodies and DB state still match. - D-08: Adding a wishlist item writes bell notifications immediately and upserts the
wishlist_digest_queuerow exactly asWishlistDigestQueue::enqueuedoes (first enqueue in a window creates the row and enqueues the 1800 s delayed digest job; later ones bumpitem_count). The delayed River job is enqueued with the PHP-equivalent kind and args but has no worker until Phase 14, which adds the worker, the digest mail and the queue-row delete (JOBS-03).
User plugin register hook (core plugin change, signed off)
- D-09:
sm-user-plugin'sRegisterEventgains a genericPayload map[string]anycarrying the raw register input, mirroring Winter'sgolem15.user.registerpayload. Additive and non-breaking: existing listeners compile unchanged, the user plugin learns nothing about invitations, and its README is updated in the same change. The user signed off on this core-plugin change on 2026-10-02. — Reversibility: costly — once other plugins readPayload, removing or reshaping it breaks them across every project using the user plugin. - D-10: fonoteka registers a listener that ports
Plugin.php:180: hashPayload["invitation_token"]; if it matches a valid invitation for the user's email, upsertPendingInvitationRegistration(consumed later by the existingguardPendingInvitation); otherwise provision a collection. - D-11:
onboarding/bootstrapregisters the first user through the user plugin's exported registration path so password hashing, the register event and JWT issuance are identical to/register. If a needed function is not exported, it is exported additively (same sign-off as D-09). Bootstrap keeps PHP's validation (org_name,email,password), the cache-lock race guard and the 409 on replay.
Parity evidence
- D-12: New recordings against the isolated PHP instance with the Phase 2
tidecapture rules (private 0600 vars, no live tokens in git):nuxt-wishlist: create item → share show/update/regenerate → subscribe by token and by collection → reserve as a second user → reveal → purchase → settings → household → peer albums.nuxt-csv: store → show → mapping → row edit → commit (to its 202 and the queued job row) → cancel, then CSV export on both groups. Jobs do not run (D-04).public-anonymous:public/{token}andpublic-wishlist/{token}resolve, albums, album detail, a bad token, and thepubfail:<ip>anti-enumeration 429 after 10 failures.mcp-wishlist: the MCP token-group wishlist CRUD (fonoteka-mcp/src/client.ts:295-307).onboarding: status and bootstrap on an empty DB, then register withinvitation_token.- Plus every distinct error status/body per route, with envelopes reproduced per endpoint (P7 D-13).
- D-13: Side effects of purchase and item-added are verified by diffing recorded
notificationsandwishlist_digest_queuerows and the Centrifugo publications (P11 D-10 tooling,timestamp/actornormalised). Mail recipients, locale and template are asserted in Go tests through postcard'smemorydriver.
Fixes without a decision (parity rule)
- D-14: The public group's
resolveroutes (public/{token},public-wishlist/{token}) get PHP's inlinethrottle:10,1only.fonoteka-public-token+fonoteka-public-ipapply only to the albums index/show routes. Currentlyroutes.goapplies both buckets at group level. - D-15: The manifest case for
GET invitations/{token}expects 404 while its recorded fixture is 200{"data":{"state":"unavailable"}}. The manifest is corrected to the fixture.
Carried forward (locked earlier)
- C-01: One handler per route, mounted on both groups where PHP mirrors it, with PHP's exact
->where()constraints, inline throttles and exactly oneinv.scope:read|writeper token-group route (P12 D-01, D-10, D-26). - C-02: Error bodies as production PHP serves them (
APP_DEBUG=false), including Winter HTML 404 pages where PHP throwsHttpException(notifications{id}/read, reserve DELETE, reveal, subscriptions) (P12 D-21). CSV errors keep{"result":"error","code","message"}; public 404/429 keep{"error":"Not found"}/{"error":"Too many requests"}. - C-03: Response DTOs from ported
Serialize*functions,[]notnull, Carbon+00:00, pagination envelope withoutlinks(P5 D-08, P6 D-17). - C-04: Write paths go through ported fill boundaries; the request-DTO fuzz covers every write endpoint of this phase (P12 C-02).
- C-05: Mail via River job in the write transaction; raw tokens in job args encrypted with the app key (P12 D-13, D-23).
- C-06: Public tokens: 16 chars
[0-9a-zA-Z],LOWER()lookup plus constant-time compare, no route regex,PublicShareHeaders(X-Robots-Tag: noindex, nofollow,Cache-Control: no-store, private). public-wishlist mirrors public collection withkind='wishlist'and no reservations. - C-07: Unported routes stay
pending; pending never counts as passing (P2, P6 D-15).
Claude's Discretion
- Whether the wishlist item-added branch extends the existing
albumAddedCallbackGORM hook or is an explicit write-service call, provided every create path (JWT, token-group POST, any bulk path) triggers it exactly once and only after commit. - Handler file layout and how the ~1,480 lines of wishlist controllers and the 578-line CSV controller are split across Go files.
- Job kind names for purchase mail and digest (must be stable once chosen, see D-03 reversibility).
- The interim behaviour of the
selected_discogs_idrow-edit branch (D-05), within its constraint. - Which error cases are recorded versus Go-tested with bodies from PHP source where recording is impractical. Recording is the default.
- Plan count and split, subject to the plan-count checkpoint, "unit tests are the last plan" and the security-review agent (this phase touches public tokens, credentials encryption and authorization).
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Contract source (PHP)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php— every Phase 13 route, group, middleware and throttle/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php—eloquent.createdwishlist listener (lines ~87-111),golem15.user.registerlistener (~180)/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers and classes:NotificationApiController,Wishlist*Controller,CsvImportApiController,CsvExportApiController,AiCredentialController,OrgAiCredentialController,DiscogsCredentialController,OnboardingController,InvitationApiController@inspect,PublicShareApiController,PublicShareHeaders; servicesWishlistSubscriptionService,AlbumReservationService,ActiveWishlistResolver,WishlistProvisioner,CollectionShareService,NotificationService,WishlistDigestQueue,AiConfigResolver,DiscogsConfigResolver,AiGate,DiscogsGate,OrgAccess,classes/csv/*,config/fonoteka.php/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/andstores/fonoteka.ts,stores/auth.ts:374— Nuxt callers; their requests define the contractfonoteka-mcp/src/client.ts:295-307— MCP wishlist calls
Go port
../fonoteka.go/parity/manifest.yamland../fonoteka.go/parity/fixtures/— route status and recorded fixtures../fonoteka.go/plugins/golem15/fonoteka/routes.go— empty onboarding/public_invitation/public groups (~207-212)../fonoteka.go/plugins/golem15/fonoteka/plugin.go(~275) — rate buckets../fonoteka.go/plugins/golem15/user/classes/events.go—RegisterEvent
Planning
.planning/ROADMAP.md— Phase 13 and Phase 14 sections (criteria to reword per D-01, D-02, D-06).planning/REQUIREMENTS.md— API-03..API-07, JOBS-02, JOBS-03.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md— D-01, D-08, D-10, D-13, D-21, D-23, D-25, D-26 carried forward
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
- Models in
fonoteka.go/plugins/golem15/fonoteka/models/:notification,wishlist_subscription,wishlist_digest_queue,album_reservation,csv_import,csv_import_row, the four credential models (lagoon.Encrypted,json:"-"),pending_invitation_registration. classes/notification_service.go:WriteNotification(write + realtime publish), the five type constants,NotifyAlbumAdded, andalbumAddedCallback, which currently returns early for wishlists. Purchase, reveal and digest paths are missing.classes/share_service.go: token generation, enable/regenerate,/w/paths.ResolvePublicis missing.classes/gates.go:IsSiteAdmin,CanManageOrg,AIAllowed,aiOrgLock,ResolveDiscogsConfig,DiscogsAllowed. No full AI config resolver yet.classes/credential_write_service.go: only theCredentialFillFieldsallow-list.classes/active_collection.go:guardPendingInvitation(consume side) andProvisionCollection.middleware/public_share_headers.go;jobs.go(invitation-mail job pattern to copy for purchase mail).
Established Patterns
- Mail as a River job enqueued in the write transaction, encrypted secrets in args (P12).
- Route-table tests for group membership and
inv.scopeper route (P12 D-10). - Parity flows recorded under
parity/fixtures/nuxt/*.yaml; currently onlynotifications,unread-count,discogs-credentialandonboarding/statusexist for this scope.
Integration Points
sm-user-pluginRegisterEvent(D-09) and its registration service (D-11).schedule.goalready schedulesfonoteka:prune-notifications, whose command ships in Phase 14.- Phase 14 workers consume the CSV and digest job kinds defined here (D-03, D-08).
</code_context>
## Specific Ideas- Interim honesty: queued jobs without workers are acceptable; fabricated results are not (D-04, D-05).
- Reserve semantics: 422
cannot_reserve_own_wishlist, 409reservations_disabled, 201 on success. Reveal is owner-only and one-way, bell only, a repeat is a no-op, no reservation returns{"data":{"reserved":false}}. - Onboarding
statusreturnsneeds_onboardingtrue when there are zero users.
- Wishlist
match/apply-release, credential/testroutes, CSV job workers, theselected_discogs_idrow-edit branch, the digest worker and mail, andfonoteka:prune-notifications— all Phase 14.
Reviewed Todos (not folded)
redacting-slog-handler.md— relevant to credentials but a framework logging concern; stays a standalone todo.backend-admin-api-tokens.md,refresh-fonoteka-readme.md,rewrite-summercms-readme.md,per-module-readmes-after-nest.md,readme-go-fences-src.md— keyword matches only, unrelated to this phase's routes.
Phase: 13-p-ytarium-api-wishlist-notifications-csv-credentials-public Context gathered: 2026-10-02