58 KiB
Purpose: this is the densest single vertical slice in the phase — it proves the pivot-write pattern, the money/jsonable cast pattern, the validation engine, and every DATA-03 lifecycle hook this slice's models declare, all at once on real data, so every later plan (03, 05) reuses proven code instead of open questions.
Output: 6 new fonoteka migrations; widened Album/Collection (with TrackTitles) plus new Artist, Style, AlbumArtist, AlbumRating, AlbumReservation models; lagoon.Jsonable[T], MoneyString, lagoon.Validate; a shared slugify/normalizeNameKey helper; Artist.BeforeValidate, Genre.BeforeValidate+AfterDelete, Style.BeforeValidate+AfterDelete, Collection.BeforeDelete (cascade), Album.BeforeSave (refreshTrackTitles+stampMarketPrice); AlbumWriteService/CollectionWriteService fill-boundary functions and ArtistResolver's callback.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md @.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md @.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.mdFrom summercms.go/lagoon/fill.go (Plan 05-01):
func Fill(model any, allowed []string, requested map[string]any, production bool) error
type HasFillable interface { Fillable() []string }
type HasHidden interface { Hidden() []string }
From summercms.go/lagoon/lifecycle.go (Plan 05-01):
type HasBeforeValidate interface { BeforeValidate(tx *gorm.DB) error }
type HasBeforeSave interface { BeforeSave(tx *gorm.DB) error }
type HasBeforeDelete interface { BeforeDelete(tx *gorm.DB) error }
type HasAfterDelete interface { AfterDelete(tx *gorm.DB) error }
func WithSoftDeleteCascade(tx *gorm.DB, cascade func(tx *gorm.DB) error) error
From summercms.go/lagoon/relations.go (Plan 05-01):
func RegisterJoinTable(db *gorm.DB, owner any, field string, joinModel any) error
// Pivot-write contract: never Association().Append/Replace for a pivot with business columns.
From fonoteka.go/plugins/golem15/fonoteka/classes/registry.go (Plan 05-01):
func RegisterHook(fn func(*gorm.DB) error)
func RegisterHooks(gdb *gorm.DB) error
From fonoteka.go/plugins/golem15/fonoteka/models/registry.go and updates/registry.go (Plan 05-01):
func Register(models ...any) // package models
func All() []any // package models
func Register(ms ...*gormigrate.Migration) // package updates
func All() []*gormigrate.Migration // package updates
End the file with `func init() { updates.Register(albumSliceMigrations...) }` — this is the only wiring needed; `plugin.go` (Plan 05-01) already returns `updates.All()`.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./parity/... -run TestMigrateSeedsCanonicalGenres
- A new `TestAlbumSliceMigrationsUpDown` (added in this task or Task 3's test file) migrates `updates.All()` against a fresh testcontainers-backed database, asserts `golem15_fonoteka_artists`, `golem15_fonoteka_album_artists`, `golem15_fonoteka_styles`, `golem15_fonoteka_album_styles`, `golem15_fonoteka_album_ratings`, `golem15_fonoteka_album_reservations` all exist, then calls `lagoon.RollbackLast` six times and asserts each drops only that migration's own table(s) while `golem15_fonoteka_genres`/`users` (Phase 3 base) remain untouched.
- A `various-artists`-keyed row exists in `golem15_fonoteka_artists` after migrate, and running `Migrate()` twice does not duplicate it (idempotent, per D-04).
- `golem15_fonoteka_collections.public_token` has a plain (non-partial) `UNIQUE` constraint, confirmed via `\d golem15_fonoteka_collections` or an `information_schema` query in the test.
- `golem15_fonoteka_albums.track_titles` (`TEXT`, nullable) exists after `widen_albums` runs, confirmed via `information_schema.columns`.
- `information_schema.columns` reports `golem15_fonoteka_albums.discogs_id` and `golem15_fonoteka_artists.discogs_artist_id` as a character/text type (not integer/bigint), and both have the PHP indexes (`discogs_id` on albums, `discogs_artist_id` and `slug` on artists).
All 6 migrations exist, run up/down individually and together, the Various Artists seed is idempotent, `track_titles` exists for Task 3's `refreshTrackTitles` hook to write to, and the widened tables match RESEARCH.md's live-verified column facts.
Task 2 (summercms.go, fonoteka.go): lagoon.Jsonable[T] + MoneyString casts; widen Album/Collection/CollectionEditor and add Artist/Style/AlbumArtist/AlbumRating/AlbumReservation models; port Artist/Genre/Style/Collection's beforeValidate/afterDelete/beforeDelete hooks
summercms.go/lagoon/jsonable.go, summercms.go/lagoon/jsonable_test.go,
fonoteka.go/plugins/golem15/fonoteka/models/album.go, models/collection.go, models/collection_editor.go, models/genre.go,
fonoteka.go/plugins/golem15/fonoteka/models/artist.go, models/style.go, models/album_artist.go, models/album_rating.go, models/album_reservation.go,
fonoteka.go/plugins/golem15/fonoteka/models/money_string.go, models/slug.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php (lines ~85-134 for attachMany/attachOne AND `beforeDelete` — leave the attachMany/attachOne relations as unimplemented struct fields/TODO comments this plan, Plan 05-04 wires those; `beforeDelete`'s cascade-soft-delete-every-album behavior on lines ~127-134 IS this plan's job)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Artist.php (all of it — `beforeValidate` slug+name_key defaulting, `normalizeNameKey`)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Genre.php (all of it — `beforeValidate` slug default, `afterDelete` unassign-not-cascade)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Style.php (all of it — `beforeValidate` collision-safe `generateSlug`, `afterDelete` pivot detach)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/casts/MarketPriceCast.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/add_music_fields_to_albums.php (confirms `track_titles` is a plain nullable text column, not fillable)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 1, 5, 8, 11, 20 and their Hooks column; "Pattern: Money cast"; "Pattern: GORM many-to-many with pivot business columns"; Pitfall "go-playground/validator's min/max tags don't parse a custom string-backed Money type")
.planning/research/PITFALLS.md (Pitfall 4, Pitfall 5, Pitfall 12)
fonoteka.go/plugins/golem15/fonoteka/models/registry.go (Plan 05-01)
summercms.go/lagoon/relations.go (Plan 05-01, RegisterJoinTable)
summercms.go/lagoon/lifecycle.go (Plan 05-01, HasBeforeValidate/HasAfterDelete/HasBeforeDelete/WithSoftDeleteCascade)
- `lagoon.Jsonable[[]string]` scans SQL `NULL` into a Go `nil` slice and SQL `'[]'`/`'["a"]'` into `nil`/`[]string{"a"}` respectively, matching `Album.php`'s `cover_import_failures` default (an accumulated list of Discogs cover URLs — confirmed a flat string array, unlike `tracklist`).
- `lagoon.Jsonable[[]TrackEntry]` (`TrackEntry` a `map[string]any`, matching `tracklist`'s free-form per-track associative-array shape in PHP — each entry has at least a `title` key, per `Album::refreshTrackTitles()`) round-trips `NULL`/`[]`/a populated array without ever collapsing to `[]string`.
- `lagoon.Jsonable[T]`'s `Value()` never returns a `float64`-shaped intermediate; it returns `driver.Value` as a JSON-text string or `nil`.
- `MoneyString.Scan(nil)` and `MoneyString.Scan("")` both produce the empty string; `MoneyString.Scan(decimalFromPostgres)` produces a fixed 4-decimal string (e.g. `"12.5000"` from Postgres `12.5`); `MoneyString` never implements `float64`-shaped arithmetic — normalization is the caller's job (`Album.BeforeSave`, added in Task 3).
- `Artist{Name: "Nirvana"}.BeforeValidate(tx)` (new record, no slug/name_key set) sets `Slug` to a deterministic non-empty slug and `NameKey` to a lowercased, whitespace-collapsed key; an existing record or one with `Slug`/`NameKey` already set is left untouched.
- `Genre{Name: "Rock"}.BeforeValidate(tx)` defaults `Slug` the same way, only for a new record with no slug; `Genre.AfterDelete(tx)` sets `genre_id = NULL` on every Album that referenced the deleted genre, and never deletes those Albums.
- `Style{Name: "R&B"}.BeforeValidate(tx)` (a name that slugifies to fewer than 3 characters) produces a deterministic slug at least 3 characters long by appending a name-derived suffix, never a bare too-short slug; `Style.AfterDelete(tx)` removes only that style's rows from `golem15_fonoteka_album_styles`, never touching the Albums themselves.
- `Collection.BeforeDelete(tx)` against a Collection with 2+ real Albums, run inside a real Postgres transaction, soft-deletes every one of those Albums (`deleted_at` set) in the same transaction as the Collection's own delete; if the cascade callback returns an error, neither the Collection nor any Album is deleted (transaction rolls back) — this must be proven with real Postgres rows, not just a fixture.
In `lagoon/jsonable.go`: implement `type Jsonable[T any] struct { Value T; Valid bool }` (or a simpler `type Jsonable[T any] T` if a zero-alloc named-type approach round-trips cleanly through `database/sql/driver.Valuer`/`sql.Scanner` for both slice and map `T` — pick whichever compiles cleanly against both `[]string` and `map[string]any` without reflection surprises, and document the choice) with `Scan(src any) error` (JSON-decode a `[]byte`/`string` src, or leave the zero value on `nil`) and `Value() (driver.Value, error)` (JSON-encode, returning `nil` for a nil/zero underlying value so the column round-trips SQL `NULL`, per Pitfall 4's "`[]` vs `null` differs per column" — expose a per-instance `NullOnEmpty bool` or a documented convention for the caller to pick empty-slice-vs-null explicitly rather than lagoon guessing).
In `models/money_string.go` (package `models`, Płytarium-specific per the layout note): implement `type MoneyString string` with `func (m *MoneyString) Scan(src any) error` (Postgres `numeric` arrives as `[]byte` or `string`; format to exactly 4 decimal places using `strconv`/`big.Rat` — never round-trip through `float64` for the final string, per Pitfall 5 — `NULL` becomes `""`) and `func (m MoneyString) Value() (driver.Value, error)` (pass the string through verbatim — per RESEARCH.md's division of labor, `MarketPriceCast::set()` in PHP is a pure passthrough; `Album`'s `BeforeSave` owns blank-to-NULL normalization, added in Task 3, not this cast).
In `models/slug.go` (package `models`, shared by Artist/Genre/Style — pure string manipulation, no DB/service calls, so it stays a leaf-safe helper): implement `func slugify(name string) string` (stdlib-only: `strings.ToLower`, then a `regexp.MustCompile("[^a-z0-9]+")` replace with `"-"`, then `strings.Trim(s, "-")` — this is a deterministic, URL-safe slug generator; it does not need to reproduce PHP `Str::slug`'s exact Unicode-transliteration table byte-for-byte, since no test in this phase asserts that — only that the same input always produces the same non-empty, `unique`-safe slug) and `func normalizeNameKey(name string) string` (per `Artist::normalizeNameKey`: `strings.ToLower` + collapse repeated whitespace via a regexp replace on `\s+` to a single space + `strings.TrimSpace` — full ASCII-folding of accented characters is not required this phase, only the same collapse-and-lowercase dedup behavior D-04 needs).
Widen `models/album.go`'s `Album` struct to the full column set from RESEARCH.md's Full Per-Model Inventory row 1 and `Album.php`: add every fillable column (`Shelf *string`, `GenreID *uint` already present, `Quantity int`, `Notes *string`, `ArtistDisplay *string`, `Year *int`, `Format *string`, `Condition *string`, `Barcode *string`, `DiscogsID *string` (nullable TEXT per `add_music_fields_to_albums.php`'s `$table->string('discogs_id')->nullable()->index()` — never `*uint`; AlbumCoverFetcher treats discogs_id as free-text that may be non-numeric), `Edition *string`, `Tracklist lagoon.Jsonable[[]TrackEntry]` where `type TrackEntry = map[string]any` (a free-form per-track record — PHP's `tracklist` entries are associative arrays, not bare strings; `refreshTrackTitles` in Task 3 reads each entry's `"title"` key), `CoverImportFailures lagoon.Jsonable[[]string]` (a flat list of Discogs cover URLs), `Label *string`, `CatalogNumber *string`, `Country *string`, `MarketPriceStored models.MoneyString`, `MarketPriceCurrency *string`, `MarketPriceSource *string`) plus the non-fillable `TrackTitles *string` `gorm:"column:track_titles"` (server-computed by `BeforeSave`, never in `Fillable()`), `DeletedAt gorm.DeletedAt` (soft delete, per row 1's SD=Yes) and `CreatedAt`/`UpdatedAt time.Time`. Add `func (Album) Fillable() []string` returning exactly the 20-name list from RESEARCH.md row 1 (verbatim, snake_case DB column names matching `lagoon.Fill`'s tag-matching convention — `track_titles` is deliberately excluded), `func (Album) Rules() map[string]string` with the 6 rule strings from RESEARCH.md's Rules() inventory table copied verbatim (`name: "required"`, `year: "nullable|integer|between:1889,2100"`, `market_price_stored: "nullable|numeric|min:0|max:999999.9999"`, `format`/`condition`/`market_price_currency`/`market_price_source` each with their `Rule::in(...)` lists — copy the exact allowed-value lists from `Album.php`'s constants, do not invent them). Add `func (Album) Hidden() []string { return nil }` (Album has no hidden columns per row 1's H column). Wire `belongsToMany` relations for `Artists`/`Styles` via GORM `many2many` tags referencing the two new join tables, and register `AlbumArtist` as the pivot for `Artists` via `lagoon.RegisterJoinTable` (call site added in Task 3's Boot wiring, not here — this task only adds the struct field and tag). Do not add the `BeforeSave` method here — Task 3 adds it alongside the money-normalization logic it depends on.
Widen `models/collection.go`'s `Collection` struct: add `PublicToken *string` (nullable, unique), `ReservationsAllowed bool`, `DeletedAt gorm.DeletedAt` if not already present from Phase 3. Add `Editors []usermodels.User` with `gorm:"many2many:golem15_fonoteka_collection_editors;joinForeignKey:collection_id;joinReferences:user_id"` — CollectionEditor is the join model carrying `role`/`granted_at`/`granted_by` (DATA-04, RESEARCH.md row 8). Import User from `git.golem15.com/golem15/fonoteka/plugins/golem15/user/models` (models importing models is leaf-legal; do not import `classes/`). Add the user plugin module as a `require` of the fonoteka plugin `go.mod` if it is not already one (they share the app `go.work`). Do not call `lagoon.RegisterJoinTable` from this models file (models-leaf rule); Task 3's `classes/join_tables.go` is the call site. Add `Fillable()` returning `{"name","description","owner_id"}` per row 8, `Rules()` returning `{"name":"required"}`, `Hidden()` returning `{"public_token"}` per row 8's H column. Add `func (c *Collection) BeforeDelete(tx *gorm.DB) error { return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error { return tx.Where("collection_id = ?", c.ID).Delete(&Album{}).Error }) }` — ports `Collection::beforeDelete()`'s `\DB::transaction` wrapper around `foreach ($this->albums as $album) { $album->delete(); }`: GORM's own soft-delete `Delete` call (Album has `DeletedAt`) inside `WithSoftDeleteCascade` runs in the same transaction as the Collection's own delete, so no explicit `\DB::transaction`-equivalent wrapper is needed beyond the one `WithSoftDeleteCascade` already documents.
Widen `models/collection_editor.go`'s `CollectionEditor` struct to add `Role string`, `GrantedAt *time.Time`, `GrantedBy *uint` (the table already has these columns per RESEARCH.md's "collection_editors ... needs no widening" note — this is a Go-struct-only change, no migration). `models.Register` was already called for this type in Plan 05-01; do not double-register.
Add lifecycle hooks to `models/genre.go` (Phase 3 model — the model *file* may gain methods, but its shipped migration is never touched, per P3 D-17 and the checker note that this is fine): `func (g *Genre) BeforeValidate(tx *gorm.DB) error { if g.ID == 0 && (g.Slug == nil || *g.Slug == "") { s := slugify(g.Name); g.Slug = &s }; return nil }` (verify against the Go `Genre` struct's actual `Slug` field type from Phase 3 — adjust pointer/value handling to match, do not introduce a second slug field) and `func (g *Genre) AfterDelete(tx *gorm.DB) error { return tx.Model(&Album{}).Where("genre_id = ?", g.ID).Update("genre_id", nil).Error }` — ports `Genre::afterDelete()`'s "unassign, never cascade-delete" behavior exactly. Add `func (Genre) Rules() map[string]string { return map[string]string{"name": "required"} }` (RESEARCH.md's Rules() inventory row 4).
Create `models/artist.go` (`Artist` struct per row 5: `Name string`, `NameKey string`, `Slug *string`, `DiscogsArtistID *string` (nullable TEXT per `create_artists_tables.php`'s `$table->string('discogs_artist_id')->nullable()->index()` — never `*uint`), `IsVarious bool`; `Fillable()` = `{"name","name_key","slug","discogs_artist_id","is_various"}`; `Rules()` = `{"name":"required"}`) with `func (a *Artist) BeforeValidate(tx *gorm.DB) error { if a.ID == 0 && (a.Slug == nil || *a.Slug == "") { s := slugify(a.Name); a.Slug = &s }; if a.NameKey == "" && a.Name != "" { a.NameKey = normalizeNameKey(a.Name) }; return nil }` (ports `Artist::beforeValidate()` verbatim). Tests must not assume numeric Discogs IDs; persist and reload a non-numeric `discogs_artist_id` such as `"artist-x"` if any test sets the field.
Create `models/style.go` (`Style` struct per row 20: `Name string`, `Slug *string`, `Description *string`; `Fillable()` = `{"name","slug","description"}`; `Rules()` = `{"name":"required","slug":"required|between:3,64|unique:golem15_fonoteka_styles"}`) with `func (s *Style) BeforeValidate(tx *gorm.DB) error { if s.ID == 0 && (s.Slug == nil || *s.Slug == "") { g := generateStyleSlug(s.Name); s.Slug = &g }; return nil }` and a private `func generateStyleSlug(name string) string` porting `Style::generateSlug()`: compute `slug := slugify(name)`; if its rune length is >= 3, return it as-is; otherwise fall back to `base` (the slug, or the literal string `"style"` if the slug is empty) plus a `"-"` plus the first 6 hex characters of an `md5` digest of the original `name` (stdlib `crypto/md5`/`encoding/hex` — deterministic, name-derived, collision-safe per the PHP docblock, matching `substr(md5($name), 0, 6)`). Add `func (s *Style) AfterDelete(tx *gorm.DB) error { return tx.Exec("DELETE FROM golem15_fonoteka_album_styles WHERE style_id = ?", s.ID).Error }` (ports `Style::afterDelete()`'s pivot detach, never touching Albums).
Create `models/album_artist.go` (`AlbumArtist` pivot struct exactly as RESEARCH.md's verified GORM example: `AlbumID uint` primaryKey, `ArtistID uint` primaryKey, `SortOrder int` default 0, `TableName() "golem15_fonoteka_album_artists"`), `models/album_rating.go` (`AlbumRating`: `AlbumID`, `UserID`, `Rating int`; `Fillable()` = `{"album_id","user_id","rating"}`), `models/album_reservation.go` (`AlbumReservation`: `AlbumID`, `UserID`, `ReservedAt *time.Time`, `RevealedAt *time.Time`; `Fillable()` = `{"album_id","user_id","reserved_at","revealed_at"}`). Every new file's `init()` calls `models.Register(TheType{})`, following Plan 05-01's established pattern exactly.
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestJsonable && cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run 'TestArtistBeforeValidate|TestGenreHooks|TestStyleHooks|TestCollectionBeforeDeleteCascadesAlbums'
- `lagoon.Jsonable[[]string]` unit test proves both the null-preserving and empty-array-preserving cases work for `cover_import_failures`; a separate `lagoon.Jsonable[[]TrackEntry]` test proves `tracklist` round-trips a `[]map[string]any` with a `"title"` key without collapsing to `[]string`.
- `MoneyString` unit test proves `"12.5000"` from a Postgres `numeric(10,4)` value, `""` from `NULL`, and that `grep -rn "float64" fonoteka.go/plugins/golem15/fonoteka/models/money_string.go` returns no matches.
- `Album.Fillable()` returns exactly the 20 names in RESEARCH.md row 1, verified by a table-driven test comparing against a literal `[]string` copied from RESEARCH.md; `grep -n "track_titles" <(printf '%s\n' "$(go doc ...)")`-equivalent test asserts `track_titles` is never in `Album.Fillable()`'s output.
- `TestArtistBeforeValidate` proves a new Artist with no slug/name_key gets both defaulted deterministically; an Artist that already has them is untouched.
- `TestGenreHooks` proves `BeforeValidate` defaults a slug and `AfterDelete` nulls `genre_id` on a real Album row without deleting it (real Postgres).
- `TestStyleHooks` proves a short name (e.g. `"R&B"`) gets a >=3-character deterministic slug and `AfterDelete` removes only that style's `golem15_fonoteka_album_styles` rows (real Postgres).
- `TestCollectionBeforeDeleteCascadesAlbums` proves a real Collection with 2+ real Albums has every Album soft-deleted inside the same transaction as the Collection's delete, and a forced cascade error rolls back both (real Postgres, not a fixture).
- `go build ./...` succeeds in `fonoteka.go` with the widened structs and no `AutoMigrate` call anywhere (`grep -rn "AutoMigrate" fonoteka.go/plugins` returns no matches).
- `grep -n "DiscogsID \*uint\|DiscogsArtistID \*uint" fonoteka.go/plugins/golem15/fonoteka/models` returns no matches; both fields are `*string`.
`lagoon.Jsonable[T]` and `MoneyString` exist and are tested; `Album`/`Collection` are widened to their final column sets with `Fillable`/`Hidden`/`Rules`; `Artist`, `Style`, `AlbumArtist`, `AlbumRating`, `AlbumReservation` exist and self-register; Artist/Genre/Style/Collection's PHP lifecycle hooks are ported and proven against real Postgres.
Task 3 (summercms.go, fonoteka.go): lagoon.Validate rule-string engine; Album.BeforeSave (refreshTrackTitles+stampMarketPrice); AlbumWriteService/CollectionWriteService fill boundary; ArtistResolver callback; minimal Serialize*
summercms.go/lagoon/validate.go, summercms.go/lagoon/validate_test.go,
fonoteka.go/plugins/golem15/fonoteka/models/album.go,
fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go,
fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go,
fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go,
fonoteka.go/plugins/golem15/fonoteka/classes/serialize.go,
fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumWriteService.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php (lines ~250-394: `beforeSave`, `refreshTrackTitles`, `stampMarketPrice`, `normalizeCurrencyOnlyChange`, `marketCurrency` — read the full block, this task ports it verbatim onto `models/album.go`)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/casts/MarketPriceCast.php (the `get()`/`set()` division of labor this hook completes: `set()` is a pure passthrough, `beforeSave()` does all the normalization)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php (photo payload section is Plan 05-04's job; port only the album/collection list-field shaping this phase's own tests need)
fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go (transaction shape to copy, moved here by Plan 05-01)
fonoteka.go/plugins/golem15/fonoteka/genre_handler.go / controllers/genre_controller.go (422 envelope shape: `{"error":"Validation failed","errors":{"field":["message"]}}`)
summercms.go/phrasebook/translator.go (Get/GetIn for message translation)
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("lagoon/validate.go (D-09)" pattern section, full rule-string->validator-tag table, the two documented pitfalls: min/max on MoneyString, oneof quoting for `EP 7"`)
.planning/research/ARCHITECTURE.md (Pattern 4, rule-string validation without struct tags)
- `lagoon.Validate` translates `"nullable|integer|between:1889,2100"` to a `go-playground/validator` tag equivalent to `omitempty,min=1889,max=2100` and rejects `year=1700` with a translated message, accepts `year=nil`.
- `lagoon.Validate` translates `Rule::in` lists into `oneof=...` with single-quoted multi-word/quoted values (`'EP 7"'`) and correctly accepts the literal value `EP 7"`.
- `lagoon.Validate` validates `market_price_stored`'s `numeric|min:0|max:999999.9999` against the pre-cast numeric value (a custom validation func, not the built-in `min`/`max` on the `MoneyString` type), rejecting `1000000.0000`.
- `lagoon.Validate`'s `unique:golem15_fonoteka_styles` check for `Style.Slug` rejects a duplicate live row and accepts a value matching only a soft-deleted row's slug.
- An untranslatable rule string (a made-up rule not in the translation table) causes `lagoon.Validate` to fail loudly (return an error, or panic if called from a package-level registration path) rather than silently passing.
- `syncArtists` on a 3+ artist album round-trips `sort_order` after a full re-save with a different order; `Association("Artists").Append` is never called anywhere in `classes/album_write_service.go` (grep-checkable).
- After `lagoon.RegisterJoinTable` for `Collection.Editors`, a CollectionEditor row's `role`/`granted_at`/`granted_by` survive insert and `Preload("Editors")` reload.
- `Album.BeforeSave` against a blank string, an explicit `nil`, and a non-numeric string all normalize `MarketPriceStored`/`MarketPriceCurrency`/`MarketPriceSource` to their zero/NULL values (`""`/`nil`); a numeric value with more than 4 fractional digits formats to exactly 4 decimals using PHP `number_format`-equivalent rounding (e.g. `"12.55555"` rounds to `"12.5556"` — this is the case exercised as "the PHP ceiling case" in the round-trip test); a valid price with no currency set gets `Album.marketCurrency()`'s default; a valid price whose save also sets `MarketPriceSource` keeps that source, otherwise it is reset to `nil`.
- A full round-trip test through `SaveAlbum` proves each of: blank string in, `NULL` out; explicit `nil` in, `NULL` out; a non-numeric string in, `NULL` out; the PHP ceiling case in, `"12.5556"` out; a normal value (`"25"`) in, `"25.0000"` out — read back from real Postgres, never asserted only in-memory, and confirmed to marshal as a JSON string (never a bare number) through `Serialize*`.
In `lagoon/validate.go`: implement `func Validate(ctx context.Context, tx *gorm.DB, model any, rules map[string]string, values map[string]any, tr *phrasebook.Translator) (map[string][]string, error)`. Build a private rule-string-to-validator-tag translator covering exactly the vocabulary RESEARCH.md verified (`required`, `nullable`->`omitempty`, `integer`, `numeric`, `between:X,Y`->`min=X,max=Y`, `min:X`, `max:X`, `in:...`/`Rule::in([...])`->`oneof=...` with single-quoting for any value containing whitespace or a `"`), calling `validator.New().Var(value, tag)` per field via the go-playground/validator `Var()` API (never `Struct()`, per ARCHITECTURE.md Pattern 4). For any rule string containing `numeric` combined with `min`/`max` bounds intended for a string-backed type (detected by the caller passing the pre-cast numeric value alongside the money-typed field — accept a `map[string]any` of *pre-cast* values for this reason, not the model's post-cast fields directly), register and use a dedicated `moneyRange(min, max float64)` custom validation func instead of the built-in tag (per the documented Pitfall). For `unique:` tokens, run a direct `tx.Table(table).Where(column+" = ?", value)` count query that additionally appends `.Where("deleted_at IS NULL")` when `tx.Migrator().HasColumn(table, "deleted_at")` returns true (Winter/PHP-equivalent soft-delete scoping, D-09). On an unrecognized rule token, return a non-nil error naming the exact unrecognized token (fail loudly, never silently skip). Translate each failing field's message through `tr.Get(ctx, key, params)` using a small conventional key shape (e.g. `lagoon.validate.`) falling back to a hardcoded Laravel-shaped English string when no phrasebook entry exists yet (this phase does not need to ship new `lang/` YAML files — Phase 4's `phrasebook` machinery is reused as-is); assemble the final return value as `map[string][]string` keyed by field name, matching `controllers/genre_controller.go`'s existing `{"error":"Validation failed","errors": ...}` envelope shape (this function returns just the `errors` map — the HTTP envelope itself is a Phase 6/12 concern).
On `models/album.go`, add `func (a *Album) BeforeSave(tx *gorm.DB) error { a.refreshTrackTitles(); return a.stampMarketPrice() }` and its two private methods, porting `Album::beforeSave()`/`refreshTrackTitles()`/`stampMarketPrice()`/`normalizeCurrencyOnlyChange()`/`marketCurrency()` from `Album.php` lines ~250-394: `refreshTrackTitles` reads `a.Tracklist.Value` (`[]TrackEntry`), and if it is empty/nil sets `a.TrackTitles = nil`, otherwise collects each entry's `"title"` key (type-asserted to `string`, trimmed, skipped if empty) and joins the non-empty titles with a single space into `a.TrackTitles` (a `*string`, `nil` if the joined result is empty) — matches PHP's `implode(' ', $titles)` / `null` on an empty result. `stampMarketPrice` operates on the already-`Fill`-ed struct fields (Go's GORM does not expose Eloquent-style per-save `isDirty` tracking the way this port needs, so this simplifies PHP's dirty-gated behavior to "normalize whatever `MarketPriceStored` currently holds on every save," which preserves every one of the money-value invariants DATA-07 tests — blank/null/non-numeric-in-means-NULL-out, valid-in-means-fixed-4-decimal-out, currency default, source reset-unless-set-same-save — while dropping only the "don't re-stamp `checked_at` on a no-op save" micro-optimization, which no test in this phase's `05-VALIDATION.md` map exercises): if `string(a.MarketPriceStored)` is blank, unparseable as a number, the model's zero value, or the caller explicitly cleared it, set `a.MarketPriceStored = ""`, `a.MarketPriceCurrency = nil`, `a.MarketPriceSource = nil`, and clear the `market_price_checked_at` column (add a `MarketPriceCheckedAt *time.Time` field mirroring PHP's `$dates` entry if not already present from the widen migration); otherwise format the parsed value to exactly 4 decimals using PHP `number_format`-equivalent half-away-from-zero rounding (stdlib `strconv.ParseFloat` + a helper that rounds at the 4th decimal before formatting — never leave the extra digits to `%.4f`'s own rounding without verifying it matches half-away-from-zero, which it does for `strconv.FormatFloat` with `'f', 4, 64` on Go's IEEE754 double in every case this phase's test vectors exercise), default `a.MarketPriceCurrency` to `Album.marketCurrency()` (a small package-level function reading a configured default — hardcode `"EUR"` as the fallback per `Album.php`'s own default when config is unset/unrecognized, since this phase does not need the full Discogs-config plumbing) when blank, reset `a.MarketPriceSource` to `nil` unless the caller's `requested` map for this save explicitly included `market_price_source` (thread this through `SaveAlbum`'s call site below rather than re-deriving dirty state inside the hook), and stamp `a.MarketPriceCheckedAt` to `time.Now()`.
In `classes/artist_resolver.go`: port `ArtistResolver` as a `func ResolveArtists(tx *gorm.DB, album *models.Album, requestedArtistIDs []uint) error`-shaped function (read `Album.php`'s `beforeSave` call to `ArtistResolver` for its exact contract — resolving/creating artist rows from free-text or IDs) and register it as a GORM callback via `func init() { classes.RegisterHook(func(gdb *gorm.DB) error { gdb.Callback().Create().Before("gorm:create").Register("fonoteka:album_artist_resolver", albumBeforeSaveCallback); gdb.Callback().Update().Before("gorm:update").Register("fonoteka:album_artist_resolver_update", albumBeforeSaveCallback); return nil }) }` where `albumBeforeSaveCallback(tx *gorm.DB) error` type-asserts `tx.Statement.Schema.ModelType == reflect.TypeOf(models.Album{})` before doing anything (per RESEARCH.md's cross-plugin-callback pattern, applied intra-plugin here per the layout note's "service-calling hook becomes a callback registered from classes/" rule). This callback runs independently of and in addition to `Album.BeforeSave`'s own native hook method — GORM dispatches both.
In `classes/album_write_service.go`: declare `var AlbumFillFields = []string{...}` as the strict subset of `Album.Fillable()` from `AlbumWriteService.php`'s `FILL_FIELDS` constant (read the PHP file directly and copy verbatim — RESEARCH.md confirms it excludes at least `collection_id` and `market_price_source`); implement `func SaveAlbum(ctx context.Context, gdb *gorm.DB, album *models.Album, requested map[string]any, artistIDs []uint) error` wrapping `gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ... })` (copy `active_collection.go`'s transaction shape) that calls `lagoon.Fill(album, AlbumFillFields, requested, production)`, `tx.Save(album)`, then `syncArtists(tx, album.ID, artistIDs)` — pass whether `requested` contained `market_price_source` down into the save call so `Album.BeforeSave`'s `stampMarketPrice` can distinguish "this save set the source" from "this save didn't," per the paragraph above (a package-level or context-carried flag is acceptable; document the exact mechanism chosen). Implement `func syncArtists(tx *gorm.DB, albumID uint, artistIDsInOrder []uint) error` as an explicit delete-then-bulk-insert against `golem15_fonoteka_album_artists` (`tx.Where("album_id = ?", albumID).Delete(&models.AlbumArtist{})` then a single multi-row `tx.Create(&rows)` with `SortOrder` set from each artist's position in `artistIDsInOrder`) inside the same transaction as the caller — never `tx.Model(album).Association("Artists")`.
In `classes/collection_write_service.go`: declare `var CollectionFillFields = []string{"name", "description"}` (or the PHP equivalent's exact narrower list if `CollectionWriteService.php`/equivalent exists — check the canonical PHP source; if Collection has no dedicated write service in PHP, use `Collection.Fillable()` itself as the boundary and note that in a comment) and a `SaveCollection` function mirroring `SaveAlbum`'s transaction shape.
In `classes/serialize.go`: port only `SerializeAlbum(a *models.Album) map[string]any` and `SerializeCollection(c *models.Collection) map[string]any` — the minimal subset `SerializesFonoteka.php` builds that this phase's own tests need (list-field shaping: `market_price_stored` as `string(a.MarketPriceStored)` (never a bare model field cast to a number), `tracklist`/`cover_import_failures` as their jsonable `.Value` with the correct `[]`/`null` behavior). Do not port the photo/thumb payload section (Plan 05-04) or any field requiring an HTTP request context.
Create `classes/join_tables.go` (package `classes`, models-leaf: this file lives in `classes/` not `models/`) with `func init() { classes.RegisterHook(func(gdb *gorm.DB) error { if err := lagoon.RegisterJoinTable(gdb, &models.Album{}, "Artists", &models.AlbumArtist{}); err != nil { return err }; if err := lagoon.RegisterJoinTable(gdb, &models.Collection{}, "Editors", &models.CollectionEditor{}); err != nil { return err }; return nil }) }`. Plan 05-01's `plugin.go` Boot already calls `classes.RegisterHooks(gdb)` — do not edit `plugin.go` or any `models/` file for this wiring. `RegisterJoinTable` is for Preload of pivot columns (`sort_order`, `role`/`granted_at`/`granted_by`); it is not a replacement for `syncArtists`, which keeps writing `golem15_fonoteka_album_artists` directly (RESEARCH.md pivot-write gap). Add `TestCollectionEditorsPivotRoundTrip` (real Postgres): insert a Collection plus a CollectionEditor row with `Role`, `GrantedAt`, `GrantedBy` set to known values, reload via `Preload("Editors")` on Collection (after Boot/`RegisterHooks` has run) and via a direct `Take` of CollectionEditor, and assert all three pivot columns survive the round-trip.
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestValidate && cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./... -run 'TestAlbumArtistsOrderRoundTrip|TestCollectionEditorsPivotRoundTrip|TestSaveAlbum|TestSaveAlbumMoneyNormalization'
- `lagoon.Validate` unit tests cover: `between`, `oneof` with a quoted-space value, the custom money-range func, `unique:table` respecting `deleted_at`, and an unrecognized-rule error.
- `TestAlbumArtistsOrderRoundTrip` (integration, real Postgres) saves a 3-artist album via `SaveAlbum`, re-reads with `Preload("Artists").Order(...)` on the pivot's `sort_order`, and asserts the order survived a subsequent re-save with a shuffled `artistIDsInOrder`.
- `TestCollectionEditorsPivotRoundTrip` (integration, real Postgres) inserts a CollectionEditor with `role`/`granted_at`/`granted_by` set and reloads those three columns via `Preload("Editors")` after `lagoon.RegisterJoinTable` has run.
- `grep -n "RegisterJoinTable" fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go` shows both `Artists` and `Editors` call sites; `grep -n "RegisterJoinTable" fonoteka.go/plugins/golem15/fonoteka/models` returns no matches (models-leaf).
- `TestSaveAlbumMoneyNormalization` (integration, real Postgres) round-trips through `SaveAlbum` for: blank string, explicit `nil`, non-numeric string, the PHP ceiling case (`"12.55555"` -> `"12.5556"`), and a normal value (`"25"` -> `"25.0000"`) — every case re-read from the database, not asserted only against the in-memory struct.
- `grep -rn "Association(\"Artists\")" fonoteka.go/plugins/golem15/fonoteka/classes` returns no matches.
- A Go test asserts `AlbumFillFields` excludes both `collection_id` and `market_price_source`, which are present in `Album.Fillable()`.
`lagoon.Validate` exists and is tested against this slice's 5 rule-bearing models; `Album.BeforeSave` ports `refreshTrackTitles`/`stampMarketPrice` with the blank/null/non-numeric/ceiling/currency/source/checked_at semantics proven via a real-Postgres round-trip; `AlbumWriteService`/`CollectionWriteService` fill boundaries and `syncArtists` exist and round-trip pivot order on real Postgres; `ArtistResolver` fires as a registered callback, not inline model code.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| caller -> AlbumWriteService/CollectionWriteService | untrusted requested map reaches persisted columns through the two-layer fillable boundary |
caller -> lagoon.Validate unique:table |
untrusted field value reaches a raw SQL WHERE column = ? — parameterized, but the table/column names driving the query come from a model's own Rules() map, not request input |
| caller -> Album.BeforeSave's stampMarketPrice | an untrusted market_price_stored string reaches numeric parsing; a non-numeric/blank value must resolve to NULL, never a parse panic or a silently-truncated number |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-05-04 | Tampering / Elevation of Privilege | AlbumWriteService.FILL_FIELDS |
mitigate | Narrower service-level allow-list excludes collection_id/market_price_source; Plan 05-06's fuzz test asserts nothing outside the list is ever persisted (D-05/D-07) |
| T-05-05 | Tampering | syncArtists pivot write |
mitigate | Delete-then-bulk-insert runs inside the same transaction as the parent save; a failed insert rolls back the delete too, so a partial write can never leave an album with zero artists |
| T-05-06 | Information Disclosure | lagoon.Validate's unique:table query |
accept | Table/column names are sourced from the model's own compiled Rules() map, never from request input; the value is parameterized |
| T-05-07 | Tampering (data integrity) | golem15_fonoteka_collections.public_token unique constraint |
accept | Matches PHP's actual plain-unique constraint exactly (no partial index) per the D-02 audit; the delete-then-recreate behavioral test lives in Plan 05-06, this plan only creates the column |
| T-05-23 | Denial of Service | Album.BeforeSave's money-string parsing |
mitigate | Non-numeric/blank input is normalized to NULL via a checked strconv.ParseFloat, never an unguarded numeric conversion that could panic on malformed input |
| T-05-24 | Repudiation | Collection.BeforeDelete's cascade |
mitigate | Runs inside the same transaction as the parent delete via lagoon.WithSoftDeleteCascade; a cascade failure rolls back the parent delete too, so a Collection can never end up deleted with orphaned non-cascaded Albums |
| </threat_model> |
<success_criteria>
- All 6 migrations run up/down individually and the Various Artists seed is idempotent;
track_titlesexists ongolem15_fonoteka_albums. Album/Collectionare at their final column set withFillable/Hidden/Rules;Artist/Style/AlbumArtist/AlbumRating/AlbumReservationexist and self-register.- Artist/Genre/Style's
BeforeValidate/AfterDeletehooks and Collection'sBeforeDeletecascade are ported verbatim from the PHP source and proven against real Postgres, not left as unwired framework primitives. lagoon.Jsonable[T],MoneyString, andlagoon.Validateexist, are tested, and never route a money value throughfloat64.Album.BeforeSaveportsrefreshTrackTitles+stampMarketPriceand a real-Postgres round-trip proves the blank/null/non-numeric/ceiling/currency/source cases.- A 3+ artist album's
sort_orderround-trips throughsyncArtists, neverAssociation().Append; Collection.Editors pivot columns round-trip afterlagoon.RegisterJoinTable. discogs_idanddiscogs_artist_idare nullable TEXT/*stringwith the PHP indexes, never INTEGER/*uint.AlbumWriteService/CollectionWriteServicefill boundaries exist and are narrower than their models'Fillable(). </success_criteria>