357 lines
58 KiB
Markdown
357 lines
58 KiB
Markdown
---
|
|
phase: 05-data-layer-full-fidelity
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["05-01"]
|
|
files_modified:
|
|
- fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/album.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/collection.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/collection_editor.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/genre.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/artist.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/style.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/album_artist.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/album_rating.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/album_reservation.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/money_string.go
|
|
- fonoteka.go/plugins/golem15/fonoteka/models/slug.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
|
|
- summercms.go/lagoon/jsonable.go
|
|
- summercms.go/lagoon/jsonable_test.go
|
|
- summercms.go/lagoon/validate.go
|
|
- summercms.go/lagoon/validate_test.go
|
|
autonomous: true
|
|
requirements: [DATA-03, DATA-04, DATA-05, DATA-06, DATA-07, DATA-09]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "widen_collections, widen_albums, create_artists (with Various Artists seed), create_styles, create_album_ratings and create_album_reservations each run up and down individually against golem15.fonoteka's per-plugin history table (DATA-09, D-01, D-04 seed)"
|
|
- "A 3+ artist album round-trips golem15_fonoteka_album_artists.sort_order through an explicit sync function, never Association().Append/Replace (DATA-04, RESEARCH.md pivot gap)"
|
|
- "Collection.Editors is a belongsToMany registered via lagoon.RegisterJoinTable so CollectionEditor.role/granted_at/granted_by round-trip on Preload; Album.Artists is registered the same way for sort_order Preload while syncArtists still writes the join table directly (DATA-04)"
|
|
- "Artist.BeforeValidate defaults slug+name_key, Genre.BeforeValidate defaults slug and Genre.AfterDelete unassigns (not cascade-deletes) its albums, Style.BeforeValidate defaults a collision-safe min-length-3 slug and Style.AfterDelete detaches its album pivot rows, and Collection.BeforeDelete cascades a soft-delete of every one of its albums inside the same transaction via lagoon.WithSoftDeleteCascade — all five hooks read verbatim from the PHP source and exercised against real Postgres, not left as unwired primitives (DATA-03)"
|
|
- "Album, Collection, Artist, Style and Genre's Rules() validate via lagoon.Validate producing a Laravel-shaped {field:[message]} 422 map with phrasebook-translated messages, and Style's unique:golem15_fonoteka_styles check excludes soft-deleted rows generically (DATA-05, D-09)"
|
|
- "AlbumWriteService.FILL_FIELDS is a strict subset of Album.Fillable() that excludes collection_id and market_price_source; CollectionWriteService has its own narrower list (D-05, D-07)"
|
|
- "Album.MarketPriceStored never appears as float64 anywhere in the Go type graph; Album.Tracklist/CoverImportFailures round-trip [] vs null through lagoon.Jsonable[T]; Album.BeforeSave's stampMarketPrice normalizes a blank/null/non-numeric price to NULL (clearing currency/source/checked_at too), formats a valid price to a fixed 4-decimal string with PHP number_format-equivalent rounding, defaults an unset currency, and resets market_price_source unless the same save also set it, exactly matching Album.php/MarketPriceCast.php's division of labor (DATA-07, Pitfall 4/5)"
|
|
artifacts:
|
|
- path: fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go
|
|
provides: "6 migrations: widen_collections, widen_albums (incl. track_titles), create_artists+seed, create_styles, create_album_ratings, create_album_reservations"
|
|
- path: fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
|
provides: "AlbumWriteService fill boundary + syncArtists pivot writer"
|
|
- path: fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go
|
|
provides: "classes/ RegisterHook calling lagoon.RegisterJoinTable for Album.Artists and Collection.Editors"
|
|
- path: summercms.go/lagoon/validate.go
|
|
provides: "Validate(model, rules, values, tx) with rule-string-to-validator.Var() translation"
|
|
- path: summercms.go/lagoon/jsonable.go
|
|
provides: "Jsonable[T] generic Scanner/Valuer cast"
|
|
key_links:
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/classes/artist_resolver.go
|
|
to: fonoteka.go/plugins/golem15/fonoteka/classes/registry.go
|
|
via: "init() calls classes.RegisterHook(...) so plugin.go's Boot never edits"
|
|
pattern: "classes\\.RegisterHook\\("
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
|
|
to: summercms.go/lagoon/relations.go
|
|
via: "syncArtists writes golem15_fonoteka_album_artists directly, never Association mode"
|
|
pattern: "syncArtists|golem15_fonoteka_album_artists"
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/classes/join_tables.go
|
|
to: summercms.go/lagoon/relations.go
|
|
via: "init RegisterHook calls lagoon.RegisterJoinTable for Album.Artists/AlbumArtist and Collection.Editors/CollectionEditor (models-leaf: not from models/)"
|
|
pattern: "lagoon\.RegisterJoinTable"
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/models/album.go
|
|
to: summercms.go/lagoon/validate.go
|
|
via: "Album.Rules() consumed by lagoon.Validate in a BeforeSave-style call site"
|
|
pattern: "func \\(Album\\) Rules\\(\\) map\\[string\\]string"
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/models/collection.go
|
|
to: summercms.go/lagoon/lifecycle.go
|
|
via: "Collection.BeforeDelete wraps its album cascade in lagoon.WithSoftDeleteCascade"
|
|
pattern: "func \\(.*Collection\\) BeforeDelete"
|
|
- from: fonoteka.go/plugins/golem15/fonoteka/models/album.go
|
|
to: fonoteka.go/plugins/golem15/fonoteka/models/album.go (own BeforeSave)
|
|
via: "Album.BeforeSave calls its own refreshTrackTitles/stampMarketPrice methods, matching Album.php's beforeSave"
|
|
pattern: "func \\(.*Album\\) BeforeSave"
|
|
---
|
|
|
|
<objective>
|
|
Port the Album/Collection vertical slice: widen the two Phase-3 stub tables to their final shape (including the `track_titles` denormalized column `refreshTrackTitles` writes to), add Artists/Styles/ratings/reservations with the ordered `album_artists` pivot, wire the money and jsonable casts, ship `lagoon.Validate` (the rule-string engine six models across this phase depend on) wired for this slice's five rule-bearing models, port every PHP lifecycle hook named in RESEARCH.md's per-model inventory for this slice's five models (Artist, Genre, Style, Collection, Album — `beforeValidate`/`afterDelete`/`beforeDelete`/`beforeSave`), and port the PHP write services' fill boundary that D-07's service-level fuzz test (Plan 05-06) targets.
|
|
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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.md
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Contracts Plan 05-01 shipped that this plan's code consumes directly -->
|
|
|
|
From summercms.go/lagoon/fill.go (Plan 05-01):
|
|
```go
|
|
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):
|
|
```go
|
|
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):
|
|
```go
|
|
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):
|
|
```go
|
|
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):
|
|
```go
|
|
func Register(models ...any) // package models
|
|
func All() []any // package models
|
|
func Register(ms ...*gormigrate.Migration) // package updates
|
|
func All() []*gormigrate.Migration // package updates
|
|
```
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1 (fonoteka.go): Migrations — widen_collections, widen_albums, create_artists (+ seed), create_styles, create_album_ratings, create_album_reservations</name>
|
|
<files>fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go</files>
|
|
<read_first>
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.5/add_public_share_to_collections.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.4/add_reservations_allowed_to_collections.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/add_music_fields_to_albums.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.1/add_cover_import_failures_to_albums.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.4/add_album_catalog_completeness_fields.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.8/add_album_sync_indexes.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.5/add_market_price_to_albums.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.6/add_market_price_source_to_albums.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/create_artists_tables.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/seed_genre_and_various_artist_taxonomy.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/create_album_ratings_table.php
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.2.4/create_album_reservations_table.php
|
|
fonoteka.go/plugins/golem15/fonoteka/updates/00_base.go (moved by Plan 05-01 — the exact shape/ID-naming convention to follow, plus the seed-migration Rollback-by-natural-key pattern in its second migration)
|
|
.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Squashed Migration List" rows 3-8, "Full Per-Model Inventory" rows 1, 5, 8, 20; "D-02 Verified" section for the live-verified column facts already known: `market_price_stored`/`api_key`/`token` are `numeric(10,4)`/`text`)
|
|
</read_first>
|
|
<action>
|
|
Create `updates/10_album_slice.go` (package `updates`) with a package-doc comment naming every PHP file it folds (copy `00_base.go`'s comment-block discipline exactly), holding one `var albumSliceMigrations = []*gormigrate.Migration{...}` with 6 entries, IDs following the existing `YYYYMMDDNNNN_description` convention with today's date and the next sequential `NNNN` after `00_base.go`'s two IDs:
|
|
1. `widen_collections` — `ALTER TABLE golem15_fonoteka_collections ADD COLUMN ...` for every column the two folded PHP files add (read them directly for exact names/types/defaults: expect a nullable `public_token TEXT` with a plain `UNIQUE` constraint — confirmed live in RESEARCH.md, not a partial index — and a `reservations_allowed BOOLEAN NOT NULL DEFAULT true`-shaped column; verify the exact default against the PHP migration, do not guess). Rollback drops each added column (and its unique constraint) in reverse order.
|
|
2. `widen_albums` — one `ALTER TABLE golem15_fonoteka_albums ADD COLUMN` per column across all six folded files, translating each Laravel column-builder call verbatim (`string`->`TEXT`, `integer`->`INTEGER`, `decimal(10,4)`->`NUMERIC(10,4)`, `json`/array-cast columns -> `TEXT` since D-07's jsonable cast stores JSON text not a native `jsonb` column per the PHP source's own `jsonable` mechanism, `boolean`->`BOOLEAN`, `timestamp`->`TIMESTAMPTZ`); the fillable columns already named in RESEARCH.md's Full Per-Model Inventory row 1 (`shelf, quantity, notes, artist_display, year, format, condition, barcode, discogs_id, edition, tracklist, cover_import_failures, label, catalog_number, country, market_price_stored, market_price_currency, market_price_source`) must all be present with `market_price_stored NUMERIC(10,4)` nullable, **plus** the non-fillable, server-computed `track_titles TEXT` nullable column from `v1.1.0/add_music_fields_to_albums.php` (denormalized whitespace-joined track titles — never user-writable, but `Album.BeforeSave`'s `refreshTrackTitles` in Task 3 writes to it, so the column must exist); `discogs_id` is Laravel `string` in `add_music_fields_to_albums.php` (`$table->string('discogs_id')->nullable()->index()`) so it is nullable `TEXT` with that index, never INTEGER — AlbumCoverFetcher treats it as free-text that may be non-numeric; literally copy the `v1.1.8` sync-index `CREATE INDEX` statements plus the PHP `discogs_id` index. Rollback drops every added column (including `track_titles`) and index in reverse order.
|
|
3. `create_artists` — copy types verbatim from `create_artists_tables.php` (Laravel `string` -> Postgres TEXT, never INTEGER): `CREATE TABLE golem15_fonoteka_artists (id SERIAL PRIMARY KEY, name TEXT NOT NULL, name_key TEXT NOT NULL, slug TEXT, discogs_artist_id TEXT, is_various BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fonoteka_artist_name_key_unique UNIQUE (name_key))` plus `CREATE INDEX` on `slug` and on `discogs_artist_id` matching `$table->string('slug')->nullable()->index()` and `$table->string('discogs_artist_id')->nullable()->index()` (nullable TEXT, not INTEGER — PHP stores Discogs identifiers as free-text strings); plus `CREATE TABLE golem15_fonoteka_album_artists (album_id INTEGER NOT NULL REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, artist_id INTEGER NOT NULL REFERENCES golem15_fonoteka_artists(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (album_id, artist_id))` (verify exact column list/constraints against `create_artists_tables.php`); in the same `Migrate` func, idempotently insert the "Various Artists" seed row (`name_key='various-artists'` or the PHP file's actual natural key — read `seed_genre_and_various_artist_taxonomy.php`'s artist half directly for the exact name/name_key/is_various values) guarded by a `SELECT COUNT(*) WHERE name_key = ?` check, following `00_base.go`'s seed-migration shape exactly (D-04). Rollback drops `golem15_fonoteka_album_artists` then `golem15_fonoteka_artists` (seed row goes with the table, no separate delete needed since Rollback drops the whole table).
|
|
4. `create_styles` — `CREATE TABLE golem15_fonoteka_styles (id SERIAL PRIMARY KEY, name TEXT NOT NULL, slug TEXT, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` plus `CREATE TABLE golem15_fonoteka_album_styles (album_id INTEGER NOT NULL REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, style_id INTEGER NOT NULL REFERENCES golem15_fonoteka_styles(id) ON DELETE CASCADE, PRIMARY KEY (album_id, style_id))` (per RESEARCH.md this pair is authored fresh at final shape, not folded from a PHP file — Style has no pivot business columns, ordering is `ORDER BY name` at read time per row 20's Relations column, so no `SetupJoinTable`/business-column pivot struct is needed here, a plain `many2many` tag is correct). Rollback drops both tables.
|
|
5. `create_album_ratings` — `CREATE TABLE golem15_fonoteka_album_ratings (id SERIAL PRIMARY KEY, album_id INTEGER NOT NULL REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (album_id, user_id))` (verify against the PHP file for the exact rating column type/check-constraint, if any). Rollback drops the table.
|
|
6. `create_album_reservations` — `CREATE TABLE golem15_fonoteka_album_reservations (id SERIAL PRIMARY KEY, album_id INTEGER NOT NULL UNIQUE REFERENCES golem15_fonoteka_albums(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, reserved_at TIMESTAMPTZ, revealed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (per RESEARCH.md row 3, `album_id` unique enforces the PHP `hasOne` cardinality — verify against the PHP file). Rollback drops the table.
|
|
|
|
End the file with `func init() { updates.Register(albumSliceMigrations...) }` — this is the only wiring needed; `plugin.go` (Plan 05-01) already returns `updates.All()`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go vet ./... && go test ./parity/... -run TestMigrateSeedsCanonicalGenres</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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).
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>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</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<read_first>
|
|
/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)
|
|
</read_first>
|
|
<behavior>
|
|
- `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.
|
|
</behavior>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>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'</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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`.
|
|
</acceptance_criteria>
|
|
<done>`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.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3 (summercms.go, fonoteka.go): lagoon.Validate rule-string engine; Album.BeforeSave (refreshTrackTitles+stampMarketPrice); AlbumWriteService/CollectionWriteService fill boundary; ArtistResolver callback; minimal Serialize*</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<read_first>
|
|
/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)
|
|
</read_first>
|
|
<behavior>
|
|
- `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*`.
|
|
</behavior>
|
|
<action>
|
|
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:<table>` 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.<rule>`) 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.
|
|
</action>
|
|
<verify>
|
|
<automated>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'</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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()`.
|
|
</acceptance_criteria>
|
|
<done>`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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
`cd summercms.go && go vet ./... && go test ./lagoon/... -short` then the full (non-short) `go test ./lagoon/...`; `cd ../fonoteka.go && go vet ./... && go test ./...` (real Postgres via testcontainers) covering the new migrations, models, hooks, validation, and write services.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- All 6 migrations run up/down individually and the Various Artists seed is idempotent; `track_titles` exists on `golem15_fonoteka_albums`.
|
|
- `Album`/`Collection` are at their final column set with `Fillable`/`Hidden`/`Rules`; `Artist`/`Style`/`AlbumArtist`/`AlbumRating`/`AlbumReservation` exist and self-register.
|
|
- Artist/Genre/Style's `BeforeValidate`/`AfterDelete` hooks and Collection's `BeforeDelete` cascade are ported verbatim from the PHP source and proven against real Postgres, not left as unwired framework primitives.
|
|
- `lagoon.Jsonable[T]`, `MoneyString`, and `lagoon.Validate` exist, are tested, and never route a money value through `float64`.
|
|
- `Album.BeforeSave` ports `refreshTrackTitles`+`stampMarketPrice` and a real-Postgres round-trip proves the blank/null/non-numeric/ceiling/currency/source cases.
|
|
- A 3+ artist album's `sort_order` round-trips through `syncArtists`, never `Association().Append`; Collection.Editors pivot columns round-trip after `lagoon.RegisterJoinTable`.
|
|
- `discogs_id` and `discogs_artist_id` are nullable TEXT/`*string` with the PHP indexes, never INTEGER/`*uint`.
|
|
- `AlbumWriteService`/`CollectionWriteService` fill boundaries exist and are narrower than their models' `Fillable()`.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/05-data-layer-full-fidelity/05-02-SUMMARY.md` when done
|
|
</output>
|