Skip to content

feat(vps): port music albums/tracks/playlists CRUD (Step 6c)#191

Merged
guidefari merged 2 commits into
migration/effect-http-apifrom
migration/6c-music-albums-tracks-playlists
Jul 12, 2026
Merged

feat(vps): port music albums/tracks/playlists CRUD (Step 6c)#191
guidefari merged 2 commits into
migration/effect-http-apifrom
migration/6c-music-albums-tracks-playlists

Conversation

@guidefari

Copy link
Copy Markdown
Owner

Why

Fixes a live production regression: /api/music/albums, /api/music/tracks, and /api/music/playlists have been 404ing since commit d052ce82 ("port search to HttpApiBuilder.group, Step 6"), which deleted routes/music/* claiming the code was "dead Hono code... already superseded" by http/music.handlers.ts. That claim was false — the new Effect handler only ever implemented artist CRUD + artist-junction endpoints, never albums/tracks/playlists. This has been breaking apps/www's admin UI (useAdminAlbums/useAdminTracks in routes/admin/music.tsx) since that commit landed.

Flagged as tracked follow-up in docs/migration-effect-http-api.md after the Hono-removal PR (#190); this PR resolves it.

What

Extends the existing music HttpApiGroup (packages/api/src/music.ts) and MusicHandlersLive (apps/vps/src/http/music.handlers.ts) with:

  • Album CRUD (list/get/create/update/delete)
  • Track CRUD (list/get/create/update/delete)
  • Playlist CRUD (list/get/create/update/delete)
  • Playlist-tracks management (list tracks, add, remove, reorder)
  • Spotify integration (add-track-by-URL, import-playlist [fire-and-forget via runAppFork], sync-links)

All backed by the existing, already-wired MusicEntityService — no service-layer changes, pure route-layer work.

Deliberately out of scope (separate stacked PR to follow): entity-links CRUD, resolveMusicEntity, scrapeEntityLinks, listPendingLinks. These are a distinct concern (link-review workflow vs entity CRUD) with their own real apps/www consumers (useAdminEntityLinks, useResolveMusicEntity in -MusicEntityDetailPage.tsx/-TweetCapturePage.tsx).

Admin-role enforcement follows the same convention as the already-merged artist group in this file: AuthMiddleware only requires login; requireAdmin inside the handler returns Forbidden for non-admins, keeping the schema layer's declared error union honest without a hard admin gate baked into middleware.

Test evidence

Blackbox tests (18 new, all passing against a real ephemeral Postgres — 102/102 total in the file):

$ bun vitest run src/http/routes.blackbox.test.ts
 Test Files  1 passed (1)
      Tests  102 passed (102)

Live curl verification against dev server with real data:

$ curl -s http://localhost:3003/api/music/albums | head -c 300
[{"id":"7a0fea96-...","title":"Konnichiwa","artistNames":["Skepta"],"releaseDate":null,
  "coverImageUrl":"https://cdn.goosebumps.fm/...","genres":null,"albumType":null,
  "slug":"konnichiwa-18a11cb6",...}]

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3003/api/music/tracks
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3003/api/music/playlists
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3003/api/music/albums/00000000-0000-0000-0000-000000000000
404
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3003/api/music/albums -d '{"title":"x","slug":"x"}'
401

Response shape matches apps/www's existing MusicAlbum/MusicTrack interfaces (apps/www/src/lib/http.ts) field-for-field, so the existing useAdminAlbums/useAdminTracks hooks (plain fetcher(apiUrl(...)), not the typed client) work against this without any www-side changes.

I attempted a full browser screenshot of the admin UI but couldn't get past the admin-role auth gate without either a known admin credential or writing a role grant to an unconfirmed database — declined to guess/bypass either, so this PR ships with API-contract-level evidence (curl + blackbox tests) rather than a UI screenshot. The UI code itself is unchanged by this PR; only the backend contract it already expects is being restored.

Test plan

  • bun run typecheck clean (packages/api, apps/vps, apps/www)
  • bun precommit clean (format, lint, typecheck)
  • 18 new blackbox tests, 102/102 passing
  • Live curl verification of all new GET/POST/PATCH/DELETE routes against dev server with real data
  • Auth gating verified (401 for all admin-mutation routes without session)

…group (Step 6c)

Fixes the /api/music/albums (and tracks/playlists) 404 regression from
d052ce8, which deleted routes/music/* claiming it was dead code already
superseded -- it wasn't. This breaks apps/www admin UI's Albums/Tracks
tabs (useAdminAlbums/useAdminTracks in routes/admin/music.tsx).

Ports: album/track/playlist CRUD, playlist-tracks management, spotify
track-add/playlist-import/sync-links. Entity-links, resolve, scrape, and
the pending-links review queue are deliberately out of scope for this PR
(separate stacked PR to follow) -- they're a distinct concern (link
review workflow vs entity CRUD) with their own real apps/www consumers.

Follows the same convention as the already-merged artist group in this
file: AuthMiddleware only requires login, admin-role enforcement happens
in-handler via requireAdmin so it can return Forbidden without baking a
hard admin gate into the schema layer.

Backed entirely by the existing, already-wired MusicEntityService -- no
service-layer changes needed, this is pure route-layer work.
…aylist port

- addSpotifyTrackToPlaylist now maps a bad-URL SpotifyError (statusCode
  400) to HttpApiError.BadRequest instead of dying on it as a 500 --
  matches the sibling importSpotifyPlaylist handler's existing behavior
  for the same failure mode.
- CreateAlbumInput/CreateTrackInput/CreatePlaylistInput now reject an
  empty title/slug (NonEmptyString), matching the old Zod schemas'
  .min(1) -- a plain Schema.String let '' through to a NOT NULL varchar
  column.
- UpdateAlbumInput/UpdateTrackInput/UpdatePlaylistInput now accept null
  (not just absent) on every optional field -- the admin edit forms
  submit full form state on every save, so an unset field arrives as
  null. Schema.optional alone rejected that, breaking every edit of an
  album/track with any unset field.
- AddSpotifyTrackToPlaylistInput/ImportSpotifyPlaylistInput.url now
  validate as a URL, matching the old Zod schemas' .url().
- packages/api/src/music.test.ts pins the two schema-level fixes
  directly so they can't silently regress.
@guidefari

Copy link
Copy Markdown
Owner Author

Addressed 3 findings from adversarial review (commit ff48fa0):

  1. addSpotifyTrackToPlaylist swallowed a client-fixable 400 as a 500 — a bad Spotify track URL now correctly maps to HttpApiError.BadRequest, matching the sibling importSpotifyPlaylist handler's existing behavior for the same failure.
  2. UpdateAlbumInput/UpdateTrackInput/UpdatePlaylistInput rejected null — the admin edit forms submit full form state (not a diff) on every save, so an unset field arrives as null, not absent. Every optional field now accepts null in addition to the value/absent cases. Reproduced and pinned with a decoder test in packages/api/src/music.test.ts.
  3. CreateAlbumInput/CreateTrackInput/CreatePlaylistInput accepted an empty title/slug — a plain Schema.String let '' through to a NOT NULL varchar column, unlike the old Zod schemas' .min(1). Fixed with a shared NonEmptyString check, also pinned with a test. Also restored .url()-equivalent validation on the two Spotify URL input fields that had silently dropped it.

Full blackbox suite (102/102) and the new music.test.ts (6/6) pass. bun precommit clean.

@guidefari
guidefari merged commit 225e43d into migration/effect-http-api Jul 12, 2026
@guidefari
guidefari deleted the migration/6c-music-albums-tracks-playlists branch July 12, 2026 11:54
guidefari added a commit that referenced this pull request Jul 12, 2026
… 6d)

Completes the music album/track/playlist port (#191) with the remaining
groups: entity-link CRUD (list/add/update-status/delete), resolveMusicEntity
(paste-a-URL flow), scrapeEntityLinks (trigger link discovery), and
listPendingLinks (admin review queue).

Real apps/www consumers: useAdminEntityLinks/useAddAdminEntityLink/
useUpdateAdminEntityLinkStatus/useDeleteAdminEntityLink (used in both
-MusicEntityDetailPage.tsx and -TweetCapturePage.tsx) and
useResolveMusicEntity (-TweetCapturePage.tsx's paste-a-link flow).

listEntityLinks is intentionally public (no AuthMiddleware), matching the
old Hono route -- everything else (add/update-status/delete/resolve/
scrape/pending-queue) requires admin via the same requireAdmin in-handler
check used throughout this file.

resolveMusicEntity ports the cover-image-copy side effect from the old
handler verbatim: on a successful scrape with a coverImageUrl, best-effort
fetch+reupload to our own S3 bucket so entity images don't depend on a
third-party URL's long-term availability; a failed copy falls back to the
original URL rather than failing the whole request.
guidefari added a commit that referenced this pull request Jul 12, 2026
…record #191/#193 lessons

- migration.md: mark the previously-flagged music-group follow-up
  resolved (#191, #193), no longer tracked as outstanding.
- process.md: record two recurring bug patterns from adversarial review
  on both PRs (Zod-constraint-not-carried-forward, Update*Input
  null-rejection), and the PR-auto-close-on-deleted-base-branch gotcha
  hit when #191 merged while #192 was still open.
github-actions Bot pushed a commit that referenced this pull request Jul 18, 2026
# [2.66.0](v2.65.4...v2.66.0) (2026-07-18)

### Bug Fixes

* **email:** tighten input validation ([ca45177](ca45177))
* password reset flow - when passwords arent matching ([89fb3fa](89fb3fa))
* **upload:** partNumber must decode from a string in multipart bodies ([1b19994](1b19994)), closes [#187](#187)
* **vps:** add missing packages/api copy to Dockerfile ([9ad0050](9ad0050))
* **vps:** address adversarial review findings on music album/track/playlist port ([ff48fa0](ff48fa0))
* **vps:** address adversarial review findings on music entity-links port ([4191370](4191370))
* **vps:** auto-default OTel endpoint to localhost in dev stages ([acbf6ea](acbf6ea))
* **vps:** avatar upload 400 from Schema.File multipart mismatch ([6a8d78c](6a8d78c))
* **vps:** complete resolve HttpApi cutover (Step 6) ([88921f5](88921f5)), closes [#161](#161)
* **vps:** confirmInvite as a real HttpApiEndpoint, not a raw route ([8f326b0](8f326b0))
* **vps:** correct music/albums gap attribution, document middleware ordering ([76704a6](76704a6)), closes [#189](#189)
* **vps:** remove no-op type assertion flagged by oxlint ([0c402d0](0c402d0)), closes [#151](#151)
* **vps:** restore email/UUID format validation and await on newsletter unsubscribe ([7157dfe](7157dfe))
* **vps:** restore non-empty-string validation on file-manager fields ([36983bc](36983bc)), closes [#165](#165) [#166](#166)
* **vps:** restore UUID format validation on favorites audioId/showId ([72d40fc](72d40fc))
* **vps:** restore UUID validation on shows subscribe/unsubscribe id param ([a5c19d0](a5c19d0))
* **vps:** tighten email validation to match zod's z.email() ([bf6b12f](bf6b12f)), closes [#170](#170)
* **vps:** use real URL parsing for spotify enrich endpoint ([b2dbadb](b2dbadb))
* **www:** convert Date fields before sending artist metadata updates ([df07f39](df07f39)), closes [#184](#184)
* **www:** make 'Resend email' link visibly clickable ([77cdbe1](77cdbe1))
* **www:** wire SocialLinksCard into the actual profile route ([8a7eb11](8a7eb11))

### Features

* **api:** scaffold packages/api with health group contract ([c9b5235](c9b5235))
* **email:** port email vertical slice ([36913ab](36913ab))
* **vps:** add unused Effect toWebHandler + Hono fallback (Step 2a) ([9899df5](9899df5))
* **vps:** dual-export traces to motel alongside Jaeger ([3a59df8](3a59df8))
* **vps:** move CORS/rate-limit/logging/Sentry to Effect global middleware, remove HonoFallback (Step 8) ([cfb13e8](cfb13e8))
* **vps:** port admin to HttpApiBuilder.group (Step 6) ([b0ec807](b0ec807))
* **vps:** port audio group to HttpApiBuilder (step 6) ([0539002](0539002))
* **vps:** port auth middleware to HttpApiMiddleware (Step 3b) ([28ffae7](28ffae7))
* **vps:** port better-auth route onto the Effect router (Step 2c) ([300b58c](300b58c))
* **vps:** port favorites to HttpApiBuilder.group (Step 6) ([e82ef4f](e82ef4f))
* **vps:** port file-manager to HttpApiBuilder.group (Step 6) ([54ba078](54ba078))
* **vps:** port health to HttpApiBuilder.group (Step 3a) ([bca15fb](bca15fb))
* **vps:** port invite to HttpApiBuilder.group (Step 6) ([65a091a](65a091a))
* **vps:** port label group to HttpApiBuilder (step 6) ([d438195](d438195))
* **vps:** port music albums/tracks/playlists CRUD to HttpApiBuilder.group (Step 6c) ([40873cf](40873cf))
* **vps:** port music entity-links/resolve/scrape/pending-queue (Step 6d) ([7d9ad0c](7d9ad0c)), closes [#191](#191)
* **vps:** port music-artists CRUD to HttpApiBuilder.group (Step 4) ([18dafca](18dafca))
* **vps:** port music-reminders to HttpApiBuilder.group (Step 6) ([5cc0c45](5cc0c45))
* **vps:** port newsletter to HttpApiBuilder.group (Step 6) ([eb1a2a6](eb1a2a6))
* **vps:** port post group to HttpApiBuilder (step 6) ([374a480](374a480))
* **vps:** port profile to HttpApiBuilder.group (Step 6) ([095ab24](095ab24))
* **vps:** port release group to HttpApiBuilder (step 6) ([1db4dd8](1db4dd8))
* **vps:** port rss/seo/share routes to plain HttpRouter (Step 7) ([24e57e9](24e57e9))
* **vps:** port search to HttpApiBuilder.group (Step 6) ([d052ce8](d052ce8))
* **vps:** port shows to HttpApiBuilder.group (Step 6) ([b8c5d91](b8c5d91))
* **vps:** port spotify to HttpApiBuilder.group (Step 6) ([34ea0a5](34ea0a5)), closes [#165](#165) [#166](#166) [#167](#167)
* **vps:** port upload + upload-multipart to HttpApiBuilder.group (Step 7) ([e3b78d4](e3b78d4)), closes [175/#184](#184)
* **vps:** port user group to HttpApiBuilder (step 6) ([0fe5c29](0fe5c29))
* **vps:** swap entry point to Effect toWebHandler (Step 2b) ([5454eea](5454eea))
* **vps:** wire newsletter group into routes.ts/api.ts, delete old Hono trio ([98f8d47](98f8d47))
* **vps:** wire OtlpLive into AppLayer to export traces ([732e625](732e625))
* **www:** add music reminder deletion ([92d6dbb](92d6dbb))
* **www:** add typed HttpApiClient for the music-artists group ([7aa6dd0](7aa6dd0))
* **www:** improve auth navigation ([4a70fd6](4a70fd6))
* **www:** replace useAdminArtists' fetcher with the typed client ([90b6622](90b6622))
* **www:** self-service social links editor in dashboard ([ccb2a38](ccb2a38))
* **www:** swap admin-group hooks to typed HttpApiClient (step 6b) ([35bfb5b](35bfb5b))
* **www:** swap audio-group hooks to typed HttpApiClient (step 6b) ([1779b7f](1779b7f)), closes [#176](#176) [#177](#177) [#175](#175)
* **www:** swap favorites hooks to typed HttpApiClient (step 6b) ([c98bc94](c98bc94))
* **www:** swap label+release hooks to typed HttpApiClient (step 6b) ([150031a](150031a)), closes [171/#172](#172)
* **www:** swap music-artist hooks to typed HttpApiClient (step 6b) ([fd0cd90](fd0cd90))
* **www:** swap newsletter hooks to typed HttpApiClient (step 6b) ([6843733](6843733))
* **www:** swap newsletter hooks to typed HttpApiClient (step 6b) ([15b4240](15b4240))
* **www:** swap post-group hooks to typed HttpApiClient (step 6b) ([9e86ca7](9e86ca7)), closes [#176](#176)
* **www:** swap resolve slug consumer to typed Effect client (step 6b) ([d8ebe7d](d8ebe7d))
* **www:** swap search + profile consumers to typed Effect client (step 6b) ([d1179a8](d1179a8))
* **www:** swap shows-group hooks to typed HttpApiClient (step 6b) ([0c1f9bb](0c1f9bb))
* **www:** swap spotify hooks to typed HttpApiClient (step 6b) ([fb238c7](fb238c7))
* **www:** swap user-group hooks to typed HttpApiClient (step 6b) ([7bce140](7bce140))
* **www:** toast feedback for dashboard profile settings ([11b8edd](11b8edd))
* **www:** warn when reset-password link is opened with an active session ([b71cb61](b71cb61))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.66.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant