feat(api): collaborative tracks — notifications, embed, profile + dashboard merge#932
Merged
Conversation
… + dashboard merge Consumes the ETL TrackCollaborator entity (go-openaudio #345/#346) to surface collaborative tracks. Ships inert until clients write the on-chain invites and accepts. - Migration 0220 mirrors the ETL track_collaborators table (IF NOT EXISTS) so it exists before the functions/ pass; handle_track_collaborator.sql adds a notification trigger (invite -> collaborator, accept -> owner) modeled on the manager-request trigger. - Track responses embed a `collaborators` array (accepted only), bulk-resolved in one query alongside the existing owner/user fetch. - Profile track list (GET /users/:id/tracks) UNIONs owned + accepted-collab tracks, fixing the artist-pick pin to reference the profile user (so a collaborator's track is never spuriously pinned by the owner's pick). - Dashboard monthly listens widen to co-owned tracks. Sales/revenue stays scoped to the seller (no revenue split, per the feature's scope). - New GET /users/:id/collaboration_invites lists a user's invites/credits, optionally filtered by status. - track_collaborator_invite / track_collaborator_accept added to the notifications type whitelist. Bumps the ETL module to 3904b9d. sqlc regenerated. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Collect data.inviter_user_id and data.collaborator_user_id when resolving notification-related users, so the track_collaborator_invite / _accept notifications hydrate their user entities (mirrors grantee_user_id for managers). Notification id hashing is already generic (HashifyJson).
# Conflicts: # go.mod # go.sum
raymondjacobson
added a commit
that referenced
this pull request
Jun 9, 2026
…ks (perf regression from #932) (#942) ## Incident hotfix After #932 deployed, `GET /users/:id/tracks` (and the dashboard monthly-listens query) started **sequential-scanning the entire `tracks` table on every request**, melting the DB on a top-QPS endpoint. ### Root cause #932 merged accepted-collaborator tracks into the profile query as: ```sql WHERE (t.owner_id = $1 OR t.track_id IN (SELECT track_id FROM track_collaborators WHERE collaborator_user_id = $1 AND status = 'accepted')) ``` That `OR` across two different access paths (an indexed column on `tracks` vs. a semi-join subquery) defeats the `tracks(owner_id)` index. With the subquery's poor row estimate, the planner falls back to a **seq scan of `tracks`** (+ the `aggregate_*` joins, then sort) — per request. Not feature-gated, so it hit 100% of profile loads immediately, despite **zero collaborations existing yet** (frontend isn't out). ### Fix Fetch the user's accepted-collaboration track ids **first** — a tiny index-only lookup on the covering `(collaborator_user_id, status, track_id)` index that returns nothing for ~every user — then: - **No collaborations (≈everyone):** run the **original owner-only query**, byte-identical to pre-#932 (same plan, same perf). The extra lookup is one cheap index probe. - **Has collaborations (rare):** widen via an explicit `t.track_id = ANY($ids)` array, which the planner handles with a bitmap OR of the `owner_id` index and the `track_id` PK — not a correlated subquery. Same treatment applied to `v1_users_listen_counts_monthly`. ### Correctness Behavior is unchanged. Passing: `TestGetUserTracks` (no-collab path), `TestAcceptedCollaborationAppearsOnProfile` (merge still works), `TestPendingCollaborationHiddenFromProfile`, `TestV1UsersListenCountsMonthly`, the collaborator embed/notification tests. ### Note This is the perf risk I'd flagged in the #932 review (the `OR … IN` pattern). If you need relief **before** this merges, reverting just `v1_users_tracks.go` + `v1_users_listen_counts_monthly.go` to their pre-#932 owner-only queries is a zero-risk instant mitigation — there are no real collaborations yet, so nothing is lost. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <[email protected]>
raymondjacobson
added a commit
that referenced
this pull request
Jun 9, 2026
) Follow-up to the collaborative-tracks feature: a collaborator can now **see a private (unlisted) track they've been invited to**, so the feature works end-to-end on private tracks (their profile, feeds, etc.). ### Background The single-track endpoint already sets `IncludeUnlisted: true`, so the track *page* and the invite notification's `useTrack` already loaded an unlisted track. But the **profile** and other `get_tracks` paths don't set that flag, so a private collaboration was hidden from the collaborator everywhere except the direct track link. ### Changes - **`get_tracks.sql`** — an unlisted track is also visible to a user who is an **active collaborator** on it (`status IN ('pending','accepted')`). Pending is included so an invited collaborator can view the track to decide. Scoped to `collaborator_user_id = @my_id`, so it **never leaks to other viewers**. - **`v1_users_tracks.go`** — surface a user's own unlisted **accepted** collaborations on **their own** profile (guarded by `@my_id = @user_id`); hidden from everyone else. ### Safety / performance - No leak: visibility is tied to the requesting user being the collaborator. A rejected collaborator loses access. Verified by test (non-collaborator and rejected both get nothing). - The `get_tracks` `EXISTS` only runs for **unlisted** tracks (the `OR` short-circuits public ones) and only over the already-bounded `@ids` set, served by the `track_collaborators` PK — not the kind of unbounded scan that bit #932. ### Tests `TestCollaboratorSeesPrivateTrack`: a pending collaborator sees the private track; a non-collaborator (user 2) does not; a rejected collaborator does not. Existing collaborator + user-tracks suites still pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <[email protected]>
raymondjacobson
added a commit
that referenced
this pull request
Jun 9, 2026
… spec (#946) Makes the SDK-generated `collaborators` field **durable**. ### Why The track response already returns `collaborators` (an array of accepted collaborator users, added in #932). But the embedded swagger spec (`api/swagger/swagger-v1.yaml`, served at `/v1/swagger.yaml`) never declared it. The apps SDK is generated from this spec via `openapi-generator`, and the generated models' `FromJSONTyped` only copy fields present in the spec — so `collaborators` was silently dropped during deserialization, and the field never reached the client even though the API returned it. (The apps PR currently hand-edits the generated `Track.ts`/`SearchTrack.ts` to add the field. That works, but the next `npm run gen` would overwrite it. This PR fixes the root: declare the field in the spec so codegen reproduces it.) ### Change Add `collaborators` (array of `user`) to the `track` and `search_track` schemas — the two schemas that produce the SDK `Track` and `SearchTrack` models the apps adapter consumes. The generated output (`collaborators?: Array<User>` + the standard array-map in `FromJSONTyped`/`ToJSON`) exactly matches the apps hand-edit, so a regen is a no-op for those files. ### Not included (deliberately) - **Notification types** (`track_collaborator_invite`/`_accept`): the apps SDK notification models are hand-authored as one shared `TrackCollaboratorNotification`, but generating two swagger schemas would emit two differently-named models — making this a rename + rewire that needs a verified generator run, not a clean spec addition. Tracked separately. - **`/collaboration_invites`**: no SDK consumer exists yet, so there's nothing for codegen to protect. Spec validates (`YAML OK`) and the binary builds (the spec is `//go:embed`-ed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of collaborative tracks (the api slice). Consumes the ETL
TrackCollaboratorentity (go-openaudio #345 / #346) and surfaces it through the API. Ships inert — nothing emits the on-chain invites/accepts until the client work lands, so this is safe to deploy dark.Recap
A track owner tags collaborator artists (in track metadata); each must accept (pending → accepted). Accepted collaborators get the track on their profile and merged into their dashboard analytics. Scope is display + analytics only — no revenue split, no edit rights.
What's here
Table + notifications
ddl/migrations/0220_track_collaborators.sqlmirrors the ETL table withCREATE TABLE IF NOT EXISTS(byte-compatible with go-openaudio migration 0033). It runs in themigrations/pass, beforefunctions/, so the trigger always has its table regardless of ETL-indexer timing.ddl/functions/handle_track_collaborator.sql— a trigger modeled onhandle_manager_request.sql: a new pending invite notifies the collaborator (track_collaborator_invite); a transition to accepted notifies the inviter/owner (track_collaborator_accept).Read path
collaboratorsarray (accepted only), bulk-resolved in a single indexed query folded into the existing user fetch — no N+1.GET /users/:id/tracksUNIONs the user's owned tracks with tracks they've accepted a credit on, preserving sort/pagination. The artist-pick pin now references the profile user, so a collaborator's track is never spuriously pinned by the owner's pick.Dashboard
GET /users/:id/listen_counts_monthlywidens to include co-owned tracks (engagement analytics).GET /users/:id/tracks, so it merges automatically.New endpoint
GET /users/:id/collaboration_invites?status=pending|accepted|rejectedlists a user's invites/credits with the inviter embedded as a user object (mirrors/managers). Powers the client's accept/decline UX.Performance
track_collaboratorsPK). The profile UNION uses the covering(collaborator_user_id, status, track_id)index.collaboratorsfield (empty array when none) — a small, constant payload addition.Tests
api/v1_track_collaborators_test.go: collaborators embedded on track responses, accepted credit appears on the collaborator's profile, pending invite stays hidden, the invites endpoint (status filter + validation), and the notification trigger firing for both invite and accept. Full./api/package suite passes.Next
Phase 3 (apps): SDK methods, upload tagging UI, push + in-app notifications, comma-separated artist line, dashboard — web + mobile, behind a flag.
🤖 Generated with Claude Code