Files
summercms/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-03-SUMMARY.md
2026-10-03 08:24:27 +02:00

26 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status, plan_head_before, plan_head_after
phase plan subsystem tags requires provides affects actuals tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status plan_head_before plan_head_after
13-p-ytarium-api-wishlist-notifications-csv-credentials-public 03 api
wishlist
reservations
subscriptions
notifications
digest
conga
river
postcard
parity
tide
broadcasts
phase provides
13-01 surf overlap families, conga workerless kinds, lagoon prohibited, tide notification publication masks, job contract, php_parity.sh rows and QUEUE_CONNECTION, share:wishlist capture
phase provides
13-02 WriteNotification usage, notification registry routes, seed-state pattern (fonotekaRouteExtras), {{secret:...}} vars
phase provides
12 album write path (CreateAlbum/UpdateAlbum, covers, broadcast suppression), share service, invitation mail job pattern, Winter error pages
Wishlist resolver and scopes: ActiveWishlist/ProvisionWishlist (users-row lock), WishlistsVisibleTo, HouseholdPeerIDs, ReservableWishlistIDs
Optional reservation key on AlbumDTO (WithReservation, WithoutArtists serializer options) with stateForViewer's three-way mask
30 wishlist routes ported (26 JWT, 4 personal-token): own list/show/store/update/destroy/similar, share, settings, household, subscriptions by token and by collection, subscribers, reservations, reveal, purchase, peer albums
Item-added branch of albumAddedCallback: bell rows plus an atomic digest upsert that dispatches one delayed wishlist_digest job per window (no worker until Phase 14)
SetJobDispatcher/JobDispatcher/ErrNoJobDispatcher wired at boot; jobDB fresh-statement handle for job queues inside GORM callbacks
ResolvePublic(ctx, db, token, kind) in share_service.go for 13-05
Purchase: MoveToCollection in one transaction (updated album event, release, bells, one wishlist_purchased_mail job per email subscriber) and the mail worker with the pl/en templates
Parity: wishlist seed state on both sides, 30 routes re-recorded (143 ported), nuxt-wishlist (+ rows golden), mcp-wishlist, three publication goldens
13-04
13-05
13-06
14
tokens tasks commits
106635 4 4
added patterns
Serializer options: SerializeAlbums(..., WithReservation(rc), WithoutArtists()) per PHP call site; no option keeps every existing body byte-identical
Job queues called from inside a GORM callback get jobDB(tx, ctx) (a chained fresh statement), never a bare cleanSession handle
Multi-publication broadcast goldens are compared order-insensitively (Go sends each publication as its own River job) on a database of their own
Parity cases that need two states use PARITY_CASE=albums,wishlist on both sides via fonotekaCaseExtras
created modified
fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_resolver.go
fonoteka.go/plugins/golem15/fonoteka/classes/reservations.go
fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_subscriptions.go
fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go
fonoteka.go/plugins/golem15/fonoteka/classes/similarity_finder.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_albums_controller.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_share_settings_controller.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_subscriptions_controller.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_reservations_controller.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/wishlist_peer_controller.go
fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased.htm
fonoteka.go/plugins/golem15/fonoteka/views/mail/wishlist_item_purchased-en.htm
fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go
fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go
fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go
fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.yaml
fonoteka.go/parity/fixtures/nuxt/nuxt-wishlist.rows.json
fonoteka.go/parity/fixtures/mcp/mcp-wishlist.yaml
fonoteka.go/parity/fixtures/broadcasts/flows/wishlist.yaml
fonoteka.go/parity/fixtures/broadcasts/wishlist-item-added.yaml
fonoteka.go/parity/fixtures/broadcasts/reservation-revealed.yaml
fonoteka.go/parity/fixtures/broadcasts/wishlist-purchased.yaml
fonoteka.go/plugins/golem15/fonoteka/classes/serialize_album.go
fonoteka.go/plugins/golem15/fonoteka/classes/notification_service.go
fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go
fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go
fonoteka.go/plugins/golem15/fonoteka/routes.go
fonoteka.go/plugins/golem15/fonoteka/realtime.go
fonoteka.go/plugins/golem15/fonoteka/jobs.go
fonoteka.go/plugins/golem15/fonoteka/mail.go
fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
fonoteka.go/parity/manifest.yaml
fonoteka.go/parity/fixtures/routes/ (30 routes, 108 route fixtures)
fonoteka.go/parity/fonoteka_seed_test.go
fonoteka.go/parity/fonoteka_reset.php
fonoteka.go/parity/fonoteka_flows_test.go
fonoteka.go/parity/broadcast_goldens_test.go
fonoteka.go/parity/parity_test.go
fonoteka.go/parity/parity_contract_test.go
fonoteka.go/parity/README.md
The item-added discretion is resolved by extending albumAddedCallback (RESEARCH recommendation): the hook loads the collection kind once and calls NotifyAlbumAdded or NotifyWishlistItemAdded, so every insert path (JWT, token group, direct CreateAlbum, later CSV) notifies exactly once on the insert's transaction.
The digest upsert is one INSERT ... ON CONFLICT ... RETURNING (xmax = 0); only the row-creating insert dispatches WishlistDigestArgs on fonoteka_wishlist_digest with label wishlist_digest and the 1800 s delay. A missing dispatcher fails the write with ErrNoJobDispatcher.
A freshly provisioned wishlist is returned with ReservationsAllowed false in memory (the row holds the default true), because PHP answers that request from the unsaved model; GET wishlist/settings for a new user reports false once, as recorded.
Reserve and reveal bodies serialize artists as [] and rating as null: PHP loads only reservation.user there and serializeAlbum reads artists only when eager-loaded. Expressed as the WithoutArtists serializer option.
MoveToCollection saves the album under broadcast suppression and emits its one updated.fonoteka.album event before releasing the reservation and writing the bells, matching PHP's save-time broadcast order; it takes the upload bucket for the payload.
Purchase mail is queued only for subscribers whose user row still exists (PHP returns early for a missing user); the worker also skips a subscriber deleted before it runs, logging the id only.
Wishlist routes guard path ids above the Postgres integer range with int4PathID (PHP's 404); the general pathID gap stays logged.
A route family whose cases need wishlist data maps to the wishlist seed state in fonotekaRouteExtras; PHP and Go apply it identically, including the notifications sequence lift on the Go side
Flows recorded with QUEUE_CONNECTION=database dump their row goldens with php_parity.sh rows using an id-free SELECT the Go replay runs verbatim on Postgres
API-03
id description requirement verification human_judgment
D1 Own wishlist list (both groups) and show (JWT): provisioned once under a users-row lock, PHP's pagination envelope, owner reservation mask without reserved_by before reveal, 404 JSON for foreign/missing/out-of-range ids; existing album bodies carry no reservation key API-03
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go#TestWishlistOwnListAndShow pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (GET wishlist/albums jwt + personal_token, GET wishlist/albums/{id}) pass
false
id description requirement verification human_judgment
D2 Wishlist item store/update/destroy on both groups through the album write path (condition/shelf prohibited, 422 JSON with validation.prohibited, duplicate check against the real collection) and similar (ILIKE, literal %, _ and ) API-03
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go#TestWishlistShareSettingsHousehold/similar pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (POST/PUT/DELETE wishlist/albums on both groups, GET wishlist/albums/similar) pass
false
id description requirement verification human_judgment
D3 Item-added side effects: one bell row per ws-enabled non-actor subscriber (household peers included) and one digest upsert per email subscriber on every insert path; one dispatched digest job per window (1800 s, unserved queue, label wishlist_digest); rollback leaves nothing; matching notification publications API-03
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go#TestWishlistItemAddedOncePerPath pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go#TestDigestCoalescing pass
kind ref status
integration fonoteka.go/parity/broadcast_goldens_test.go#TestBroadcastGoldens/wishlist-item-added pass
false
id description requirement verification human_judgment
D4 Wishlist share (/w/ paths, regenerate under the row lock, throttle:10,1), settings (reservations_allowed required|boolean, 422 JSON, follower count) and household (8 previews by name, household vs subscribed), JWT only API-03
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/wishlist_smoke_test.go#TestWishlistShareSettingsHousehold pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (wishlist share, share/regenerate, settings, household) pass
false
id description requirement verification human_judgment
D5 Subscriptions by token (ResolvePublic: shape, LOWER() lookup, constant-time compare) and by visible collection, roster with synthetic household peers, subscriptions list in PHP order, Winter 404 pages API-03
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go#TestWishlistSubscriptions pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus (subscribers, subscriptions, token and collection subscribe routes) pass
false
id description requirement verification human_judgment
D6 Reservations: first wins under the album row lock (the loser gets the 409 page), 422 own, 409 disabled, reserver-only cancel, owner-only idempotent reveal writing one bell row, owner never learns the reserver before reveal API-03
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go#TestReserveConcurrent pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go#TestRevealIdempotent pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go#TestReservationMask pass
kind ref status
integration fonoteka.go/parity/broadcast_goldens_test.go#TestBroadcastGoldens/reservation-revealed pass
false
id description requirement verification human_judgment
D7 Purchase (D-07): move to the real collection, release, bells for every ws subscriber (actor included) and the reserver once, one purchase mail job per email subscriber in the transaction; the worker sends the pl/en template with PHP's subject; rollback leaves no move, job, mail or row API-03
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go#TestPurchaseSideEffects pass
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_notifications_test.go#TestPurchaseMailAfterCommit pass
kind ref status
integration fonoteka.go/parity/broadcast_goldens_test.go#TestBroadcastGoldens/wishlist-purchased pass
false
id description requirement verification human_judgment
D8 Peer wishlist albums index/show through reservableWishlistIds with the viewer's mask, and the four routes.php overlap pairs dispatching to the real handlers through the assembled router; nothing social on the token group, match/apply-release unmounted API-03
kind ref status
integration fonoteka.go/plugins/golem15/fonoteka/wishlist_reservations_test.go#TestWishlistOverlapRoutesAssembled pass
kind ref status
unit fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go#TestOAuthTokenSurfaceIsolationCoverage pass
kind ref status
integration fonoteka.go/parity/parity_test.go#TestParityCorpus/coverage (143 ported, 143 passing) pass
false
id description requirement verification human_judgment
D9 Recorded client journeys: nuxt-wishlist (database queue) with its digest-rows golden and one digest job per window, and mcp-wishlist with the pinned token; corpus secrets check green API-03
kind ref status
e2e fonoteka.go/parity/fonoteka_flows_test.go#TestFonotekaNuxtFlows/nuxt-wishlist pass
kind ref status
e2e fonoteka.go/parity/fonoteka_flows_test.go#TestFonotekaNuxtFlows/mcp-wishlist pass
kind ref status
other go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes .../routes.php --require-recorded --check-secrets pass
false
51min 2026-10-03 complete ec054a2c8ee2d887dd4899bb07fb01cda720ce3d 37405f014878e249bd501ec93b1b7ffaa632772b

Phase 13 Plan 03: Wishlist Summary

All 30 Phase 13 wishlist routes now run in Go with PHP's bodies, error pages and reservation mask. That covers the own list, item CRUD, similar, share, settings, household, subscriptions, secret reservations, reveal, purchase and peer views. Item-added bells and a coalesced digest job hang off the album insert hook, and purchase mail goes through a River job that is sent only after commit. The corpus is at 143 ported, and both real clients' journeys and three publication goldens replay green.

Performance

  • Duration: 51 min
  • Started: 2026-10-03T05:31:59Z
  • Completed: 2026-10-03T06:23:00Z
  • Tasks: 4 of 4
  • Files modified: 132 in fonoteka.go: 25 source/test files, 108 route fixtures and 7 flow, rows and golden files

Accomplishments

  • Resolver and scopes.

    • ActiveWishlist/ProvisionWishlist lock the users row FOR UPDATE, reuse the own wishlist or create Moja lista życzeń. Concurrent first reads create one row.
    • WishlistsVisibleTo and HouseholdPeerIDs derive peers as PHP does: owners and live editors of every collection the user belongs to.
    • ReservableWishlistIDs adds the subscribed wishlists, re-checked as live wishlists.
  • Serializer. AlbumDTO.Reservation uses json:"reservation,omitempty". SerializeAlbums takes options:

    • WithReservation(rc): stateForViewer's mask. The owner before reveal gets reserved, is_mine and revealed only.
    • WithoutArtists(): the artists are [], as in PHP's reserve and reveal bodies.

    With no option, every existing album body is unchanged; the album broadcast goldens and corpus confirm it.

  • Item writes. Store, update and destroy work on both groups through the Phase 12 write path:

    • condition and shelf are prohibited. A2 is settled by recording: the message is validation.prohibited.
    • The duplicate check runs against the real collection.
    • similar uses ILIKE ... ESCAPE '\' with %, _ and \ escaped.
  • Item-added (D-08). albumAddedCallback branches on the collection kind, and NotifyWishlistItemAdded uses SubscribersOf:

    • persisted rows come first, then synthetic household peers with both channels on;
    • the actor is skipped;
    • ws subscribers get wishlist_item_added with {album_id, album_name, collection_id, owner_name};
    • email subscribers go through EnqueueWishlistDigest (the atomic upsert, with the dispatch on the first insert only).
  • Share, settings, household.

    • Share mirrors collection/share on the own wishlist (/w/ paths).
    • Settings validate before resolving, write the flag directly and report followers_count.
    • Household groups subscriptionsFor with album counts and eight name-ordered previews.
  • Subscriptions. Subscribe is an upsert that stamps subscribed_at. Unsubscribe is also ported. SubscriptionStateFor has forced for peers. Token routes use the new ResolvePublic; collection routes use WishlistsVisibleTo. Every miss is the Winter 404 page.

  • Reservations.

    • ReserveAlbum checks 422 own and 409 disabled, then locks the album row; an existing reservation gives the Winter 409 page.
    • CancelReservation works for the reserver only.
    • RevealReservation is one-way and writes a bell row only.
    • ReleaseForAlbum is also ported.
  • Purchase (D-07). MoveToCollection runs in one transaction:

    1. the save under suppression, then one updated album event on the new collection channel;
    2. the reservation release;
    3. bells for every ws subscriber (the actor too) and for the reserver once;
    4. WishlistPurchasedMailArgs enqueued on mail per email subscriber.

    The worker sends wishlist_item_purchased(-en) by preferred_locale with albumName and wishlistName.

  • Peer views. Index and show go through ReservableWishlistIDs with the viewer's mask, is_owner and owner. The four overlap pairs dispatch correctly through the assembled router.

  • Parity.

    • The wishlist seed state exists on both sides.
    • 30 routes are re-recorded (108 fixtures), so 143 routes are ported.
    • nuxt-wishlist was recorded with QUEUE_CONNECTION=database. Its rows golden is bob's digest at item_count 2, with one digest job.
    • mcp-wishlist replays.
    • The wishlist-item-added, reservation-revealed and wishlist-purchased goldens replay.
    • The README documents the recipes.

Task Commits

fonoteka.go (master, not pushed):

  1. Task 1: own wishlist read path and reservation mask - 338f518 (feat)
  2. Task 2: item writes, item-added bells and digest, share/settings/household - ff38bad (feat)
  3. Task 3: subscriptions, reservations, peer views, purchase and its mail - 791daad (feat)
  4. Task 4: nuxt-wishlist and mcp-wishlist flows, rows golden, publication goldens - 37405f0 (test)

Decisions Made

See key-decisions above. The user may want to confirm one of them: the purchase emits the album updated event before the bells. That matches the order PHP records under sync. In production the PHP broadcast is queued and would arrive after the bells. No client depends on the cross-channel order.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Job dispatch inside the album insert hook inserted extra album rows

  • Found during: Task 2 (TestWishlistItemAddedOncePerPath)
  • Issue: cleanSession (Session{NewDB: true}) keeps the callback's album statement until the first chained call. conga.Dispatch called WithContext, which clones that statement, and its Create(&record) re-ran the album insert. Each digest dispatch added two album rows.
  • Fix: job queues get jobDB(tx, ctx) (cleanSession(...).Scopes(), a fresh statement). The latent hazard is logged in deferred-items.md.
  • Files modified: classes/wishlist_notifications.go
  • Commit: ff38bad

2. [Rule 1 - Parity] A provisioned wishlist's settings answer reservations_allowed: false

  • Found during: Task 2 (GET wishlist/settings provision case)
  • Issue: PHP serializes the unsaved model, whose flag was never set, while the row holds the default true.
  • Fix: ProvisionWishlist returns the in-memory flag as false; later reads see true.
  • Commit: ff38bad

3. [Rule 1 - Test seed] New notifications sorted below the seeded one in Go

  • Issue: a Postgres sequence does not advance past an explicit id the way SQLite's AUTOINCREMENT does.
  • Fix: seedParityWishlist lifts the notifications sequence.
  • Commit: ff38bad

4. [Rule 3 - Blocking] Phase 8 isolation subtests asserted the whole wishlist family absent

  • Fix: they now assert the real invariant through assertWishlistSurfaces:
    • token-group wishlist routes are exactly the four CRUD routes, each with its one scope;
    • every other wishlist route is JWT only;
    • match and apply-release are absent;
    • the share subtests allow /wishlist/share.
  • Commits: 338f518, ff38bad

5. [Rule 1 - Ordering] Purchase publications

  • Found during: Task 4 (the wishlist-purchased golden)
  • Issue: PHP publishes the moved album's updated event inside the save, before the bells.
  • Fix: MoveToCollection now emits it right after the save under suppression; it gained a bucket parameter, and the handler no longer emits separately.
  • Commit: 37405f0

6. [Rule 3 - Test harness] Broadcast goldens with several publications

  • Issue 1: small Go ids collided in the normaliser (a user id equal to a collection id).
  • Issue 2: River delivers publications in no fixed order.
  • Issue 3: seeding the shared database broke TestOAuthFlows, which runs later.
  • Fix: the wishlist subtests run on a database of their own with lifted collection ids, and compare order-insensitively (assertMatchesGoldenUnordered).
  • Commit: 37405f0

7. [Rule 2 - Correctness] Path ids above the int4 range

  • Fix: the wishlist routes use int4PathID, so ids above the int4 range get PHP's 404 (an out-of-range case is recorded).
  • Commit: 338f518

8. [Scope] Small refactor outside the plan's file list

  • albums_controller.go's createAlbumWithCovers now delegates to createAlbumIn(collectionID), so the wishlist store reuses the exact write path.
  • Commit: ff38bad

9. [Recording] The wishlist share 422 case was not recorded

  • Issue: the share:wishlist capture rule on PUT wishlist/share needs a token in the response, so a 422 cannot be recorded.
  • Coverage instead: the 422 path shares collectionShareRules with the collection share and is asserted in TestWishlistShareSettingsHousehold/share.

Total deviations: 9: 4 bugs or parity fixes, 2 blocking test inventories or harness issues, 1 correctness hardening, 1 refactor, 1 recording limitation. Impact: every response body and side effect matches PHP. The interface changes from the plan are:

  • MoveToCollection(ctx, tx, svc, bucket, album, target);
  • serializer options rather than a SerializeAlbum parameter;
  • ReserveAlbum takes the wishlist so it can perform the controller's 422/409 checks.

Issues Encountered

  • TestPhase09SecurityRoutes (pre-existing, Phase 12.2 cabana routes) still fails in the fonoteka plugin package; it is logged in deferred-items.md. Every other suite is green:
    • the root module;
    • parity (including TestParityCorpus 143/143, TestFonotekaNuxtFlows, TestBroadcastGoldens, TestOAuthFlows);
    • the plugin's subpackages.
  • The FORCE_COLOR shell variable was unset for test runs, as in 13-01/13-02.
  • The isolated PHP server was started for recording (sync, then QUEUE_CONNECTION=database) and stopped before returning.

Known Stubs

None. The digest job kind has no worker by design (D-04/D-08, Phase 14 JOBS-03). The match and apply-release routes stay pending (D-01).

Threat Flags

None beyond the plan's register:

  • T-13-04, T-13-05: TestReservationMask, TestReserveConcurrent and TestRevealIdempotent; no bell or publication names the reserver to the owner.
  • T-13-06: scoped lookups with identical 404 pages.
  • T-13-07: assertWishlistSurfaces plus the acceptance greps.
  • T-13-28: the rollback cases in TestDigestCoalescing and TestPurchaseMailAfterCommit.
  • T-13-29: ResolvePublic with disabled, regenerated, case-folded and malformed tokens tested.
  • T-13-30: the purchase mail args carry an id and two names only, and the worker logs the subscriber id only.

User Setup Required

None.

Next Phase Readiness

  • 13-05 can reuse classes.ResolvePublic(ctx, db, token, "wishlist") for public-wishlist/{token}. The wishlist seed state already holds a shared wishlist (share:wishlist) with three albums.
  • 13-06 should add the wishlist write routes to write_endpoints_fuzz_test.go and the wishlist routes to the Phase 13 route-table test.
  • Phase 14 consumes the queued golem15.fonoteka.wishlist_digest jobs (fonoteka_wishlist_digest) and the digest-queue rows.

Self-Check: PASSED

  • Created files exist: all 22 key-files.created paths checked with test -f.
  • Commits exist in fonoteka.go: 338f518, ff38bad, 791daad, 37405f0.
  • Plan verification:
    • fonoteka.go go vet ./... (root, plugin, parity) is clean.
    • go test is green for the root, parity and the plugin's subpackages; the plugin package is green except the pre-existing TestPhase09SecurityRoutes.
    • The corpus has 143 routes ported and passing.
    • nuxt-wishlist, mcp-wishlist and the three new broadcast goldens pass.
    • check_corpus --require-recorded --check-secrets is green.