From fc973104ebdd67ee448c62ba9cbcf4603ef6454f Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 10 Apr 2026 08:31:22 +0530 Subject: [PATCH] feat: add artist delete endpoint (#421) * feat: add artist delete endpoint * refactor: validate artist delete access in request validator --------- Co-authored-by: Sweets Sweetman --- app/api/artists/[id]/route.ts | 30 ++++ .../__tests__/deleteArtistHandler.test.ts | 62 +++++++++ .../validateDeleteArtistRequest.test.ts | 128 ++++++++++++++++++ lib/artists/deleteArtist.ts | 40 ++++++ lib/artists/deleteArtistHandler.ts | 53 ++++++++ lib/artists/validateDeleteArtistRequest.ts | 69 ++++++++++ .../deleteAccountArtistId.ts | 27 ++++ lib/supabase/accounts/deleteAccountById.ts | 19 +++ 8 files changed, 428 insertions(+) create mode 100644 app/api/artists/[id]/route.ts create mode 100644 lib/artists/__tests__/deleteArtistHandler.test.ts create mode 100644 lib/artists/__tests__/validateDeleteArtistRequest.test.ts create mode 100644 lib/artists/deleteArtist.ts create mode 100644 lib/artists/deleteArtistHandler.ts create mode 100644 lib/artists/validateDeleteArtistRequest.ts create mode 100644 lib/supabase/account_artist_ids/deleteAccountArtistId.ts create mode 100644 lib/supabase/accounts/deleteAccountById.ts diff --git a/app/api/artists/[id]/route.ts b/app/api/artists/[id]/route.ts new file mode 100644 index 00000000..7376882d --- /dev/null +++ b/app/api/artists/[id]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { deleteArtistHandler } from "@/lib/artists/deleteArtistHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * DELETE /api/artists/{id} + * + * Removes the authenticated account's direct artist link and deletes the artist + * account if that link was the last remaining owner association. + * + * @param request - The request object + * @param options - Route options containing params + * @param options.params - Route params containing the artist account ID + * @returns A NextResponse with the delete result + */ +export async function DELETE(request: NextRequest, options: { params: Promise<{ id: string }> }) { + return deleteArtistHandler(request, options.params); +} diff --git a/lib/artists/__tests__/deleteArtistHandler.test.ts b/lib/artists/__tests__/deleteArtistHandler.test.ts new file mode 100644 index 00000000..ac452668 --- /dev/null +++ b/lib/artists/__tests__/deleteArtistHandler.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { deleteArtistHandler } from "../deleteArtistHandler"; +import { validateDeleteArtistRequest } from "../validateDeleteArtistRequest"; +import { deleteArtist } from "../deleteArtist"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateDeleteArtistRequest", () => ({ + validateDeleteArtistRequest: vi.fn(), +})); + +vi.mock("../deleteArtist", () => ({ + deleteArtist: vi.fn(), +})); + +describe("deleteArtistHandler", () => { + const artistId = "550e8400-e29b-41d4-a716-446655440000"; + const requesterAccountId = "660e8400-e29b-41d4-a716-446655440000"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the validation response when request validation fails", async () => { + const validationError = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateDeleteArtistRequest).mockResolvedValue(validationError); + + const request = new NextRequest(`http://localhost/api/artists/${artistId}`, { + method: "DELETE", + }); + + const response = await deleteArtistHandler(request, Promise.resolve({ id: artistId })); + + expect(response).toBe(validationError); + expect(deleteArtist).not.toHaveBeenCalled(); + }); + + it("returns success when the artist is deleted", async () => { + vi.mocked(validateDeleteArtistRequest).mockResolvedValue({ + artistId, + requesterAccountId, + }); + vi.mocked(deleteArtist).mockResolvedValue(artistId); + + const request = new NextRequest(`http://localhost/api/artists/${artistId}`, { + method: "DELETE", + }); + + const response = await deleteArtistHandler(request, Promise.resolve({ id: artistId })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + artistId, + }); + }); +}); diff --git a/lib/artists/__tests__/validateDeleteArtistRequest.test.ts b/lib/artists/__tests__/validateDeleteArtistRequest.test.ts new file mode 100644 index 00000000..c0585f9f --- /dev/null +++ b/lib/artists/__tests__/validateDeleteArtistRequest.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateDeleteArtistRequest } from "../validateDeleteArtistRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { checkAccountArtistAccess } from "../checkAccountArtistAccess"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ + selectAccounts: vi.fn(), +})); + +vi.mock("../checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +describe("validateDeleteArtistRequest", () => { + const validArtistId = "550e8400-e29b-41d4-a716-446655440000"; + const authenticatedAccountId = "660e8400-e29b-41d4-a716-446655440000"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a 400 response when the artist id is invalid", async () => { + const request = new NextRequest("http://localhost/api/artists/not-a-uuid", { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + }, + }); + + const result = await validateDeleteArtistRequest(request, "not-a-uuid"); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns the auth error when authentication fails", async () => { + const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + }, + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(result).toBe(authError); + expect(validateAuthContext).toHaveBeenCalledWith(request); + }); + + it("returns 404 when the artist does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: authenticatedAccountId, + authToken: "test-token", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([]); + + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + }, + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(404); + expect(checkAccountArtistAccess).not.toHaveBeenCalled(); + }); + + it("returns 403 when the requester cannot access the artist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: authenticatedAccountId, + authToken: "test-token", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([{ id: validArtistId }] as never); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + }, + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns the validated artist and requester account ids", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: authenticatedAccountId, + authToken: "test-token", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([{ id: validArtistId }] as never); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, { + method: "DELETE", + headers: { + Authorization: "Bearer test-token", + }, + }); + + const result = await validateDeleteArtistRequest(request, validArtistId); + + expect(result).toEqual({ + artistId: validArtistId, + requesterAccountId: authenticatedAccountId, + }); + }); +}); diff --git a/lib/artists/deleteArtist.ts b/lib/artists/deleteArtist.ts new file mode 100644 index 00000000..330826b3 --- /dev/null +++ b/lib/artists/deleteArtist.ts @@ -0,0 +1,40 @@ +import { deleteAccountArtistId } from "@/lib/supabase/account_artist_ids/deleteAccountArtistId"; +import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccountArtistIds"; +import { deleteAccountById } from "@/lib/supabase/accounts/deleteAccountById"; + +export interface DeleteArtistParams { + artistId: string; + requesterAccountId: string; +} + +/** + * Deletes an artist for an already validated requester. + * + * The validator is responsible for existence and access checks. This helper + * only removes the direct owner link and deletes the artist account if that + * link was the last remaining association. + * + * @param params - Delete artist parameters + * @param params.artistId - Artist account ID to remove + * @param params.requesterAccountId - Authenticated account performing the delete + * @returns The deleted artist account ID + */ +export async function deleteArtist({ + artistId, + requesterAccountId, +}: DeleteArtistParams): Promise { + const deletedLinks = await deleteAccountArtistId(requesterAccountId, artistId); + if (!deletedLinks.length) { + throw new Error("Failed to delete artist link"); + } + + const remainingLinks = await getAccountArtistIds({ + artistIds: [artistId], + }); + + if (remainingLinks.length === 0) { + await deleteAccountById(artistId); + } + + return artistId; +} diff --git a/lib/artists/deleteArtistHandler.ts b/lib/artists/deleteArtistHandler.ts new file mode 100644 index 00000000..10aad609 --- /dev/null +++ b/lib/artists/deleteArtistHandler.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { deleteArtist } from "@/lib/artists/deleteArtist"; +import { validateDeleteArtistRequest } from "@/lib/artists/validateDeleteArtistRequest"; + +/** + * Handler for DELETE /api/artists/{id}. + * + * Removes the authenticated account's direct link to an artist. If that link + * was the last remaining owner link, the artist account is deleted as well. + * + * @param request - The incoming request + * @param params - Route params containing the artist account ID + * @returns A NextResponse with the delete result or an error + */ +export async function deleteArtistHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + + const validated = await validateDeleteArtistRequest(request, id); + if (validated instanceof NextResponse) { + return validated; + } + + const artistId = await deleteArtist(validated); + + return NextResponse.json( + { + success: true, + artistId, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] deleteArtistHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artists/validateDeleteArtistRequest.ts b/lib/artists/validateDeleteArtistRequest.ts new file mode 100644 index 00000000..a69832e4 --- /dev/null +++ b/lib/artists/validateDeleteArtistRequest.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; + +export interface DeleteArtistRequest { + artistId: string; + requesterAccountId: string; +} + +/** + * Validates DELETE /api/artists/{id} path params and authentication. + * + * @param request - The incoming request + * @param id - The artist account ID from the route + * @returns The validated artist ID plus requester context, or a NextResponse error + */ +export async function validateDeleteArtistRequest( + request: NextRequest, + id: string, +): Promise { + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) { + return validatedParams; + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const artistId = validatedParams.id; + const requesterAccountId = authResult.accountId; + + const existingArtist = await selectAccounts(artistId); + if (!existingArtist.length) { + return NextResponse.json( + { + status: "error", + error: "Artist not found", + }, + { + status: 404, + headers: getCorsHeaders(), + }, + ); + } + + const hasAccess = await checkAccountArtistAccess(requesterAccountId, artistId); + if (!hasAccess) { + return NextResponse.json( + { + status: "error", + error: "Unauthorized delete attempt", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return { + artistId, + requesterAccountId, + }; +} diff --git a/lib/supabase/account_artist_ids/deleteAccountArtistId.ts b/lib/supabase/account_artist_ids/deleteAccountArtistId.ts new file mode 100644 index 00000000..294763fa --- /dev/null +++ b/lib/supabase/account_artist_ids/deleteAccountArtistId.ts @@ -0,0 +1,27 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Deletes a direct account-artist relationship. + * + * @param accountId - The requester account ID + * @param artistId - The artist account ID + * @returns Deleted account_artist_ids rows + */ +export async function deleteAccountArtistId( + accountId: string, + artistId: string, +): Promise[]> { + const { data, error } = await supabase + .from("account_artist_ids") + .delete() + .eq("account_id", accountId) + .eq("artist_id", artistId) + .select("*"); + + if (error) { + throw new Error(`Failed to delete account-artist relationship: ${error.message}`); + } + + return data || []; +} diff --git a/lib/supabase/accounts/deleteAccountById.ts b/lib/supabase/accounts/deleteAccountById.ts new file mode 100644 index 00000000..c5b513a6 --- /dev/null +++ b/lib/supabase/accounts/deleteAccountById.ts @@ -0,0 +1,19 @@ +import supabase from "../serverClient"; + +/** + * Deletes an account by ID. + * + * Related rows are removed by the database's foreign key cascade rules. + * + * @param accountId - The account ID to delete + * @returns True when the delete succeeds + */ +export async function deleteAccountById(accountId: string): Promise { + const { error } = await supabase.from("accounts").delete().eq("id", accountId); + + if (error) { + throw new Error(`Failed to delete account: ${error.message}`); + } + + return true; +}