Skip to content

feat(api): collaborative tracks — notifications, embed, profile + dashboard merge#932

Merged
raymondjacobson merged 3 commits into
mainfrom
claude/collab-api
Jun 9, 2026
Merged

feat(api): collaborative tracks — notifications, embed, profile + dashboard merge#932
raymondjacobson merged 3 commits into
mainfrom
claude/collab-api

Conversation

@raymondjacobson

Copy link
Copy Markdown
Member

Phase 2 of collaborative tracks (the api slice). Consumes the ETL TrackCollaborator entity (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.sql mirrors the ETL table with CREATE TABLE IF NOT EXISTS (byte-compatible with go-openaudio migration 0033). It runs in the migrations/ pass, before functions/, so the trigger always has its table regardless of ETL-indexer timing.
  • ddl/functions/handle_track_collaborator.sql — a trigger modeled on handle_manager_request.sql: a new pending invite notifies the collaborator (track_collaborator_invite); a transition to accepted notifies the inviter/owner (track_collaborator_accept).
  • Both types added to the notifications request whitelist.

Read path

  • Track responses now carry a collaborators array (accepted only), bulk-resolved in a single indexed query folded into the existing user fetch — no N+1.
  • GET /users/:id/tracks UNIONs 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_monthly widens to include co-owned tracks (engagement analytics).
  • The dashboard track table is powered by GET /users/:id/tracks, so it merges automatically.
  • Sales/revenue intentionally left scoped to the seller — collaborators don't share revenue (per the no-split scope). Easy to widen later if desired; flagging for a decision.

New endpoint

  • GET /users/:id/collaboration_invites?status=pending|accepted|rejected lists a user's invites/credits with the inviter embedded as a user object (mirrors /managers). Powers the client's accept/decline UX.

Performance

  • The collaborators embed is one extra indexed query per track-list response (returns 0 rows for the common no-collaborator case; backed by the track_collaborators PK). The profile UNION uses the covering (collaborator_user_id, status, track_id) index.
  • Every track response now includes a collaborators field (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.

Note: sql/01_schema.sql carries the table + trigger inline so fresh/test DBs have them; make test-schema (which needs an exclusive local pg) will canonicalize the placement on the next regen.

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

… + 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).
@raymondjacobson raymondjacobson merged commit cdedb5f into main Jun 9, 2026
5 checks passed
@raymondjacobson raymondjacobson deleted the claude/collab-api branch June 9, 2026 21:07
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant