From c7aeab5176209f266b6b82032059602fc1b0866a Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 00:38:06 +0200 Subject: [PATCH] feat(discovery): POST /api/discover/[id] follow/want/dismiss actions Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/api/discover/[id]/route.test.ts | 83 +++++++++++++++++++++ web/src/app/api/discover/[id]/route.ts | 81 ++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 web/src/app/api/discover/[id]/route.test.ts create mode 100644 web/src/app/api/discover/[id]/route.ts diff --git a/web/src/app/api/discover/[id]/route.test.ts b/web/src/app/api/discover/[id]/route.test.ts new file mode 100644 index 0000000..7b24d7e --- /dev/null +++ b/web/src/app/api/discover/[id]/route.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn() })); +import { browseReleaseGroups } from "@/lib/musicbrainz"; +import { prisma } from "@/lib/db"; +import { POST } from "./route"; + +const mockBrowse = vi.mocked(browseReleaseGroups); +afterEach(() => mockBrowse.mockReset()); + +function post(id: string, body: unknown) { + return POST( + new Request(`http://localhost/api/discover/${id}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); +} + +async function artist(mbid = "a1") { + return prisma.discoverySuggestion.create({ + data: { kind: "artist", artistMbid: mbid, artistName: "Cand", score: 1, seedCount: 1, + sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: `artist:${mbid}:` }, + }); +} +async function album(rg = "rg1") { + return prisma.discoverySuggestion.create({ + data: { kind: "album", artistMbid: "a9", artistName: "Band", rgMbid: rg, album: "Rec", + primaryType: "Album", score: 1, seedCount: 1, sources: ["listenbrainz"], + secondaryTypes: [], firstReleaseDate: "2023", dedupeKey: `album:a9:${rg}` }, + }); +} + +describe("discover action API", () => { + it("dismiss marks the suggestion dismissed", async () => { + const s = await artist("d1"); + const res = await post(s.id, { action: "dismiss" }); + expect(res.status).toBe(200); + expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("dismissed"); + }); + + it("follow creates a WatchedArtist + unmonitored discography and marks followed", async () => { + mockBrowse.mockResolvedValue([ + { rgMbid: "rg1", title: "Debut", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2019" }, + ]); + const s = await artist("f1"); + const res = await post(s.id, { action: "follow" }); + expect(res.status).toBe(200); + expect(await prisma.watchedArtist.findUnique({ where: { mbid: "f1" } })).not.toBeNull(); + const rel = await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg1" } }); + expect(rel).toMatchObject({ monitored: false, artistName: "Cand" }); + expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("followed"); + }); + + it("follow is idempotent when already following (no MB call, still marks followed)", async () => { + await prisma.watchedArtist.create({ data: { mbid: "f2", name: "Cand" } }); + const s = await artist("f2"); + const res = await post(s.id, { action: "follow" }); + expect(res.status).toBe(200); + expect(mockBrowse).not.toHaveBeenCalled(); + expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("followed"); + }); + + it("want upserts a monitored release from the suggestion and marks wanted", async () => { + const s = await album("rgW"); + const res = await post(s.id, { action: "want" }); + expect(res.status).toBe(200); + const rel = await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rgW" } }); + expect(rel).toMatchObject({ album: "Rec", monitored: true, artistName: "Band" }); + expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("wanted"); + }); + + it("rejects bad action, mismatched kind, and unknown id", async () => { + const a = await artist("m1"); + expect((await post(a.id, { action: "nope" })).status).toBe(400); + expect((await post(a.id, { action: "want" })).status).toBe(400); // want on artist + const al = await album("rgM"); + expect((await post(al.id, { action: "follow" })).status).toBe(400); // follow on album + expect((await post("missing", { action: "dismiss" })).status).toBe(404); + }); +}); diff --git a/web/src/app/api/discover/[id]/route.ts b/web/src/app/api/discover/[id]/route.ts new file mode 100644 index 0000000..3240bcf --- /dev/null +++ b/web/src/app/api/discover/[id]/route.ts @@ -0,0 +1,81 @@ +import { prisma } from "@/lib/db"; +import { browseReleaseGroups } from "@/lib/musicbrainz"; + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "invalid JSON" }, { status: 400 }); + } + const { action } = (body ?? {}) as { action?: unknown }; + if (action !== "follow" && action !== "want" && action !== "dismiss") { + return Response.json({ error: "action must be follow, want, or dismiss" }, { status: 400 }); + } + + const s = await prisma.discoverySuggestion.findUnique({ where: { id } }); + if (!s) return Response.json({ error: "not found" }, { status: 404 }); + + if (action === "dismiss") { + await prisma.discoverySuggestion.update({ where: { id }, data: { status: "dismissed" } }); + return Response.json({ id, status: "dismissed" }); + } + + if (action === "follow") { + if (s.kind !== "artist") { + return Response.json({ error: "follow applies to artist suggestions" }, { status: 400 }); + } + const existing = await prisma.watchedArtist.findUnique({ where: { mbid: s.artistMbid } }); + if (!existing) { + let releases; + try { + releases = await browseReleaseGroups(s.artistMbid); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } + const created = await prisma.watchedArtist.create({ + data: { mbid: s.artistMbid, name: s.artistName }, + }); + if (releases.length > 0) { + await prisma.monitoredRelease.createMany({ + data: releases.map((r) => ({ + watchedArtistId: created.id, + artistMbid: s.artistMbid, + artistName: s.artistName, + rgMbid: r.rgMbid, + album: r.title, + primaryType: r.primaryType, + secondaryTypes: r.secondaryTypes, + firstReleaseDate: r.firstReleaseDate, + monitored: false, + })), + skipDuplicates: true, + }); + } + } + await prisma.discoverySuggestion.update({ where: { id }, data: { status: "followed" } }); + return Response.json({ id, status: "followed" }); + } + + // action === "want" + if (s.kind !== "album" || !s.rgMbid) { + return Response.json({ error: "want applies to album suggestions" }, { status: 400 }); + } + await prisma.monitoredRelease.upsert({ + where: { rgMbid: s.rgMbid }, + create: { + artistMbid: s.artistMbid, + artistName: s.artistName, + rgMbid: s.rgMbid, + album: s.album ?? "", + primaryType: s.primaryType, + secondaryTypes: s.secondaryTypes, + firstReleaseDate: s.firstReleaseDate, + monitored: true, + }, + update: { monitored: true }, + }); + await prisma.discoverySuggestion.update({ where: { id }, data: { status: "wanted" } }); + return Response.json({ id, status: "wanted" }); +}