add per-user named music playlists (server-side infra)

Mirrors the existing per-user music state (favorites / now-playing): Postgres
tables + query layer + REST endpoints on the music router, all scoped to the
caller's user id and served directly by the platform (not proxied to the
user-stateless sidecar). Item `key`s are opaque track homePaths, same contract
as favorites — the server never interprets them.

- schema: music_playlists (name unique per user) + music_playlist_items
  (0-based position, dupes allowed, cascade delete).
- queries: get/create/rename/delete playlists; add (append) / set (replace,
  covers reorder+remove) items; every mutation ownership-checked; item ops in a
  transaction that also bumps the playlist updatedAt.
- router (/api/music, before the catch-all proxy): GET/POST /playlists,
  GET/PATCH/DELETE /playlists/:id, POST/PUT /playlists/:id/items. 409 on
  name collision, 404 on a playlist that isn't the caller's.
- migration 0002 (also backfills music_favorites/now_playing into the snapshot,
  which were originally applied via a direct db:push). Applied to the DB.

Verified end-to-end against the live DB: create, dupes, ordering, append,
replace/reorder, ownership scoping, rename, counts, delete — all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 00:37:29 +00:00
co-authored by Claude Opus 4.8
parent e559c6c884
commit 7a966c780e
7 changed files with 2277 additions and 6 deletions
+87
View File
@@ -7,6 +7,13 @@ import {
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
type FavoriteKind,
} from 'officerdb';
@@ -80,6 +87,86 @@ musicRouter.delete('/now-playing', async (ctx) => {
return ctx.json({ ok: true });
});
// ── Named playlists ──────────────────────────────────────────────────────────────────────────────
// Playlist item `key`s are opaque track homePaths, same contract as favorites. Every route is scoped to
// the caller's user id (a playlist id that isn't theirs reads/writes as 404).
const MAX_NAME = 200;
const cleanName = (v: unknown): string | null => {
if (typeof v !== 'string') return null;
const n = v.trim();
return n && n.length <= MAX_NAME ? n : null;
};
const asKeys = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null;
// GET /playlists → [{ id, name, count, createdAt, updatedAt }] (most-recently-updated first).
musicRouter.get('/playlists', async (ctx) => {
return ctx.json(await getPlaylists(ctx.get('user').id));
});
// POST /playlists { name } → create; 409 if the name is already taken.
musicRouter.post('/playlists', async (ctx) => {
const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown };
const n = cleanName(name);
if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400);
const row = await createPlaylist(ctx.get('user').id, n);
if (!row) return ctx.json({ error: 'a playlist with that name already exists' }, 409);
return ctx.json(row, 201);
});
// GET /playlists/:id → { id, name, items: string[], createdAt, updatedAt }; 404 if not the user's.
musicRouter.get('/playlists/:id', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const pl = await getPlaylist(ctx.get('user').id, id);
return pl ? ctx.json(pl) : ctx.json({ error: 'not found' }, 404);
});
// PATCH /playlists/:id { name } → rename; 404 if not the user's, 409 on a name collision.
musicRouter.patch('/playlists/:id', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown };
const n = cleanName(name);
if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400);
const userId = ctx.get('user').id;
// Distinguish "not yours" (404) from "name collides" (409): confirm ownership first.
if (!(await getPlaylist(userId, id))) return ctx.json({ error: 'not found' }, 404);
const ok = await renamePlaylist(userId, id, n);
return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'a playlist with that name already exists' }, 409);
});
// DELETE /playlists/:id → delete (items cascade); 404 if not the user's.
musicRouter.delete('/playlists/:id', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const ok = await deletePlaylist(ctx.get('user').id, id);
return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'not found' }, 404);
});
// POST /playlists/:id/items { keys: string[] } → append to the end. Returns { count }.
musicRouter.post('/playlists/:id/items', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown };
const ks = asKeys(keys);
if (!ks) return ctx.json({ error: 'keys[] required' }, 400);
const count = await addPlaylistItems(ctx.get('user').id, id, ks);
return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count });
});
// PUT /playlists/:id/items { keys: string[] } → replace the whole ordered list (reorder / remove).
musicRouter.put('/playlists/:id/items', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown };
const ks = asKeys(keys);
if (!ks) return ctx.json({ error: 'keys[] required' }, 400);
const count = await setPlaylistItems(ctx.get('user').id, id, ks);
return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count });
});
const PREFIX = '/api/music';
musicRouter.all('/*', async (ctx) => {