11 KiB
Phase 13: Płytarium API (wishlist, notifications, CSV, credentials, public) - Pattern Map
Mapped: 2026-10-02 Files analyzed: 18 (groups) Analogs found: 16 / 18
Paths: APP = /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go, FW = /media/nvme/dev/golem15/summercms.io/summercms/summercms.go, PLG = APP/plugins/golem15/fonoteka. Every analog listed here is tracked source.
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
PLG/controllers/api/notifications_controller.go (new) |
controller | CRUD | PLG/controllers/api/invitations_controller.go |
exact |
PLG/controllers/api/credentials_controller.go (new; Winter 500 pages) |
controller | CRUD | invitations_controller.go + http_errors.go |
exact |
PLG/controllers/api/onboarding_controller.go, invitation_inspect_controller.go (new) |
controller | request-response | invitations_controller.go (InvitationAccept) |
exact |
PLG/controllers/api/wishlist_*_controller.go (new, split by share/settings/household/items/subscriptions/reservations) |
controller | CRUD | collection_share_controller.go, albums_controller.go |
exact |
PLG/controllers/api/csv_import_controller.go, csv_export_controller.go (new) |
controller | file-I/O + batch | collection_media_controller.go (multipart upload) + albums_controller.go |
role-match |
PLG/controllers/api/public_share_controller.go (new) |
controller | request-response (anonymous) | collection_share_controller.go + album_search_controller.go |
role-match |
PLG/classes/notification_service.go (modify: purchase/reveal/digest, wishlist branch of albumAddedCallback) |
service | event-driven | itself, lines 90-340 | exact |
PLG/classes/share_service.go (add ResolvePublic) |
service | CRUD | itself | exact |
PLG/classes/album_search.go (public mode, TEXT_FIELDS_PUBLIC) |
service | transform | itself lines 22-30, 317-320 | exact |
PLG/classes/credential_write_service.go (expand) |
service | CRUD | classes/collection_write_service.go |
role-match |
PLG/classes/wishlist_*.go, csv_* parser package (new) |
service/utility | transform | classes/tracklist_text_parser.go, collection_write_service.go |
role-match |
PLG/jobs.go + PLG/mail.go (purchase mail job + templates; digest/csv Dispatch with no worker) |
job/config | event-driven | PLG/jobs.go invitation mail |
exact |
PLG/routes.go (fill onboarding/public_invitation/public groups; D-14 per-route buckets) |
route | config | PLG/routes.go lines 150-200 |
exact |
PLG/plugin.go (register-event listener D-10) |
provider | event-driven | PLG/plugin.go lines 72-86 (GetApiArrayEvent listener) |
exact |
PLG/middleware/public_share_headers.go (no-store, private) |
middleware | request-response | itself | exact |
APP/plugins/golem15/user/classes/events.go + register path (additive exports D-09/D-11) |
model/event | event-driven | itself (RegisterEvent, line 19) |
exact |
PLG/routes_table_phase13_test.go, extend write_endpoints_fuzz_test.go |
test | - | routes_table_phase12_test.go, write_endpoints_fuzz_test.go |
exact |
FW/scripts/check-phase13.sh |
config/gate | batch | FW/scripts/check-phase12.sh |
exact |
FW surf overlap dispatch, conga unregistered-kind insert, lagoon prohibited, tide masks |
framework | - | (search in module; no single analog mapped) | none |
APP/parity/manifest.yaml, capture-rules.yaml, php_parity.sh |
config | - | existing entries (capture-rules.yaml:108-121 collection share) | exact |
Note: check-phase12.sh lives in FW/scripts/, not APP/scripts/. check-phase13.sh goes there too.
Pattern Assignments
Controllers (all new controllers/api/*.go)
Analog: PLG/controllers/api/collection_share_controller.go
Imports (lines 1-12):
import (
"encoding/json"
"net/http"
"git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/classes"
"git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
Rule table + handler factory (lines 14-68):
var collectionShareRules = []lagoon.RequestRule{
{Field: "enabled", Rules: lagoon.ParseRules("sometimes|boolean")},
{Field: "name", Rules: lagoon.ParseRules("sometimes|required|string|min:1|max:255")},
}
func CollectionShareUpdate(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, c, ok := ownedShareCollection(w, r, app)
if !ok { return }
input, ok := readInput(w, r)
if !ok { return }
if !validateInput(w, r, app, gdb, input, collectionShareRules) { return }
...
if err := classes.RenameShare(ctx, gdb, c, name); err != nil {
writeOpaque500(w)
return
}
Rules: one exported XxxYyy(app *backpack.App) http.HandlerFunc per route; only validated keys read from input (forged owner/token fields never reach writers); PHP bool via phpBoolCast / laravelBoolean (request.go:149).
Auth/scope (request.go:172-190) — use for every JWT+token route:
gdb, user, token, ok := requestScope(w, r, app) // token nil on JWT group
Owner checks: ownerCollection(...) (invitations_controller.go:132), resolve errors via writeResolveError (:153).
Error helpers (http_errors.go): writeWinterHTTPError(w, app, status) (line 48, Winter HTML page, Cache-Control: no-cache, private; credentials 500 pages use this), writeValidationFailed(w, errs, order) (line 78, {"error":"Validation failed","errors":{...}} in Laravel order), writeOpaque500(w). Add new Winter pages as winter_<status>.html next to the existing ones (loaded in init, line 26).
Uploads / CSV / private bucket
Copy multipart handling from controllers/api/collection_media_controller.go and uploadBucket(app) (request.go:192-201); add a parallel private-bucket lookup rather than reusing the public bucket.
classes/notification_service.go (modify)
Reuse WriteNotification(ctx, tx, svc, userID, typ, payload) (line 90) and the constants NotificationTypeWishlistItemAdded/Purchased (lines 26-27). Wishlist branch sits in albumAddedCallback (lines 291-331), which today returns early for wishlists; it runs in-transaction and fails the insert via db.AddError(err). Registration pattern (lines 334-339):
func init() {
RegisterHook(func(gdb *gorm.DB) error {
return gdb.Callback().Create().After("gorm:after_create").Before("gorm:commit_or_rollback_transaction").
Register("fonoteka:album_added_notification", albumAddedCallback)
})
}
If the digest River insert must happen after commit (D-08), do it in an explicit write-service call or an after-commit hook, not inside this callback.
jobs.go / mail.go (purchase mail + workerless Dispatch)
Analog PLG/jobs.go lines 28-58:
func (p *Plugin) Jobs() []pact.Job {
return []pact.Job{
conga.Job(p.sendInvitationMail,
conga.OnQueue(classes.InvitationMailQueue),
conga.MaxAttempts(classes.InvitationMailAttempts)),
}
}
func (p *Plugin) sendInvitationMail(ctx context.Context, args InvitationMailArgs) error {
gdb, ok := p.app.Lookup[*gorm.DB]() ...
mailer, ok := p.app.Lookup[postcard.Mailer]() ...
return deliverInvitationMail(ctx, gdb, mailer, appURL, log, args)
}
Delivery fn pattern (lines 67-121): skip with log.Info + return nil when row is gone; locale picks template (-en suffix); mailer.Send(ctx, postcard.Message{Template, To, Vars}); never log args/tokens. Template constants + MailTemplates() list in mail.go lines 16-29 — add wishlist_item_purchased / -en (vars albumName, wishlistName; layout plytarium). Digest and CSV jobs: only Dispatch on queues fonoteka.wishlist.digest, fonoteka.csv.import, fonoteka.csv.match; do not register workers or touch config/queue.yaml.
routes.go
Handler values built once at the top of Routes (lines 13-36) and shared between JWT group (line 37, surf.Use("jwt.auth","locale.from-principal","inv.must-change-password")) and token group (line 150, surf.Use("inv_token","throttle:fonoteka-api-token")). Token routes carry exactly one "inv.scope:read|write"; each {id} followed by g.Where("id","[0-9]+"). Inline throttles as trailing middleware string ("throttle:10,1"). Empty groups to fill: lines ~196-202 (onboarding, public_invitation, public). D-14: drop group-level throttle:fonoteka-public-token/-ip; put them on albums index/show routes only, throttle:10,1 on the resolve routes. Bucket definitions: plugin.go lines 307-320.
plugin.go register listener (D-10)
Copy the event listener shape (lines 74-86):
app.Events.Listen[*userclasses.GetApiArrayEvent]("golem15.fonoteka", func(_ context.Context, e *userclasses.GetApiArrayEvent) error { ... })
with *userclasses.RegisterEvent (user/classes/events.go:19-22, currently {User *models.User} with no listener). Additive user-plugin fields (e.g. invitation token) go on that struct; commit in the submodule via ssu first, then pointer bump.
middleware/public_share_headers.go
Change line dst.Set("Cache-Control", "private, no-store") to the PHP value per research; keep the 429 rewrite.
Shared Patterns
Route-table test
Source: PLG/routes_table_phase12_test.go lines 1-80: phase12Route{method, path, group, scope, throttle, line} with routes.php line numbers, jwtPrefix/tokenPrefix constants (reuse, do not redeclare in the same package). New phase13Route/phase13Routes table + TestRouteTablePhase13, also asserting the 4 Phase 14 routes are absent.
Write-endpoint fuzz
Source: PLG/write_endpoints_fuzz_test.go: writeSpec (lines 196-205: path, base, carol, upload, change, when, create, remove), table-name constants (207-225; tNotes exists, add wishlist/csv/credential tables), seedFuzzWorld (101-179), fuzzWriteSpecs map keyed "METHOD /path" (line 250). Add one entry per Phase 13 write route.
Phase gate
Source: FW/scripts/check-phase12.sh: env-overridable ROOT/APP/PHASE_DIR (lines 19-21), EXPECTED_PORTED=99 → 157, COVERAGE_FLOOR=80, modes --self-test --go --parity --named --removal --coverage --evidence --all, phase12_detect go-test-json detector, PHASE12_REQUIRE named tests (line 253). Rename prefixes to PHASE13_, require new flows (nuxt-wishlist, nuxt-csv, public-anonymous, onboarding).
Parity recording
APP/parity/capture-rules.yaml lines 108-121 (collection share token capture) → copy for share:wishlist on PUT/POST wishlist/share*. php_parity.sh:41 export QUEUE_CONNECTION=sync → "${QUEUE_CONNECTION:-sync}".
No Analog Found
| File | Role | Reason |
|---|---|---|
FW surf constraint-aware overlap dispatch |
framework router | New capability; read FW/modules/surf router internals directly |
conga insert of unregistered job kind |
framework queue | No existing workerless-dispatch path; follow RESEARCH.md |
CSV fputcsv port / parser truth tables |
utility | No CSV code yet; use RESEARCH.md PHP truth tables (closest style: classes/tracklist_text_parser.go) |
Metadata
Analog search scope: PLG/{controllers/api,classes,middleware,models}, PLG/*.go, APP/plugins/golem15/user/classes, FW/scripts
Pattern extraction date: 2026-10-02