Skip to content

feat(artists): POST /api/artists resolve-or-create by spotify_artist_id (chat#1889 row 8) - #793

Merged
sweetmantech merged 1 commit into
mainfrom
feat/artists-resolve-or-create
Jul 29, 2026
Merged

feat(artists): POST /api/artists resolve-or-create by spotify_artist_id (chat#1889 row 8)#793
sweetmantech merged 1 commit into
mainfrom
feat/artists-resolve-or-create

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Row 8 of the canonical-artist trilogy on chat#1889 — dedup at creation. Contract: docs#278. Merge order: docs#278 → this → chat#TBD.

Why

The chat add path (POST /api/artists name-only, then a client PATCH for the social) creates a new artist row per signup, so multiple rows exist per Spotify id and every canonical lookup downstream is ambiguous — the root cause of the roster doubles. api#791 deduped the valuation fallback (necessary, insufficient); api#792 tried account-scoped preference and was closed unmerged on the 2026-07-29 decision: canonical artists exist across all accounts; a canonical lookup should not need an accountId. Creation is the only place dedup can be enforced.

What changed

  • validateCreateArtistBody: optional spotify_artist_id (min 1), threaded as spotifyArtistId.
  • New resolveOrCreateArtist (SRP): canonical exists → insertAccountArtistId (skip if rostered) + return it, created: false; else createArtistInDb + attach the SPOTIFY social server-side (non-fatal, same contract as chat#1892's guard), created: true.
  • createArtistPostHandler: 200 on reuse, 201 on create; response shape { artist } unchanged.

Verification

TDD red → green, three units (validator 2 new tests, resolver 5, handler rewritten + 2 new — all RED first: two files failed collection, validator failed assertions). Full api suite 786 files / 4262 tests pass; tsc at the 236 baseline; eslint clean.

Preview verification to follow on this PR (200-reuse against a known canonical, 201-create for a fresh artist, cross-account link assertion via SQL).

Reviewer notes

  • Org linking on the reuse path links the account only — an existing canonical is not added to artist_organization_ids when organization_id is passed alongside a resolved spotify_artist_id. Flagging rather than silently deciding; happy to add if you want org parity.
  • Until the row-9 cleanup migration lands, the canonical picked among existing duplicates is newest-first and can drift (row 10 makes it deterministic). New adds stop creating duplicates as of this PR regardless.

Tracked in chat#1889 (matrix row 8).


Summary by cubic

POST /api/artists now resolves or creates by spotify_artist_id to prevent duplicate artist rows and keep one canonical per Spotify id. If a canonical exists it’s linked to the account (200); otherwise a new artist is created and enriched with the SPOTIFY social (best‑effort) (201).

  • New Features
    • Accepts optional spotify_artist_id (must be non-empty).
    • When a canonical exists, link it via account_artist_ids and skip if already rostered.
    • On create, attach the SPOTIFY URL server-side; failures don’t block creation.
    • Response shape is unchanged: { artist }.

Written for commit 293d712. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Artist creation now accepts an optional Spotify artist ID.
    • Existing canonical artists are reused and linked instead of creating duplicates.
    • Spotify information is attached automatically when available.
  • Bug Fixes

    • Non-critical Spotify information failures no longer prevent artist creation.
    • Responses now distinguish between newly created artists and existing artists that were linked.

…r a spotify_artist_id

One canonical artist per Spotify id, enforced where rows are born
(chat#1889 rows 8-10, decision 2026-07-29). The chat add path created a
new artist row per signup, so every downstream canonical lookup was
ambiguous -- the root cause of the roster doubles that api#791 patched
downstream and api#792 (closed unmerged) tried to patch with account
context.

Optional spotify_artist_id on the create body: canonical exists -> link
to the account, return 200; none -> create + attach the SPOTIFY social
server-side (non-fatal, same contract as chat#1892), return 201.
Contract: docs#278.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 29, 2026 1:11pm

Request Review

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Artist creation now accepts an optional Spotify ID, resolves existing canonical artists and roster links, creates missing artists, and reports 201 for new records or 200 for existing canonical artists.

Changes

Artist resolve-or-create

Layer / File(s) Summary
Spotify identifier request contract
lib/artists/validateCreateArtistBody.ts
Request validation accepts optional non-empty spotify_artist_id and returns it as spotifyArtistId.
Canonical lookup and artist creation
lib/artists/resolveOrCreateArtist.ts
Adds the resolve-or-create workflow, including canonical lookup, account linking, artist creation, and non-fatal Spotify social attachment.
POST response status integration
lib/artists/createArtistPostHandler.ts
Delegates artist handling to resolveOrCreateArtist and returns 201 for created artists or 200 for linked existing artists.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant createArtistPostHandler
  participant validateCreateArtistBody
  participant resolveOrCreateArtist
  participant ArtistDB
  participant account_artist_ids
  Client->>createArtistPostHandler: POST artist request
  createArtistPostHandler->>validateCreateArtistBody: validate body
  validateCreateArtistBody-->>createArtistPostHandler: validated fields
  createArtistPostHandler->>resolveOrCreateArtist: resolve or create artist
  resolveOrCreateArtist->>ArtistDB: find canonical artist by Spotify ID
  alt Existing canonical artist
    resolveOrCreateArtist->>account_artist_ids: link artist to account
    resolveOrCreateArtist-->>createArtistPostHandler: artist, created=false
    createArtistPostHandler-->>Client: HTTP 200
  else New artist
    resolveOrCreateArtist->>ArtistDB: create artist row
    resolveOrCreateArtist-->>createArtistPostHandler: artist, created=true
    createArtistPostHandler-->>Client: HTTP 201
  end
Loading

Possibly related PRs

  • recoupable/api#791: Both changes reuse canonical artists for Spotify-linked artist creation and manage account roster associations.

Poem

A Spotify seed enters the gate,
Canonical artists find their state.
New rows bloom, old links return,
Two hundred or two-oh-one in turn.
Clean paths now resolve the fate.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Solid & Clean Code ✅ Passed PASS: The PR keeps a clear handler/validator/resolver split, adds a single cohesive resolve-or-create helper, and file names match the exported primary functions.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/artists-resolve-or-create

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
lib/artists/resolveOrCreateArtist.ts (2)

57-64: 🩺 Stability & Availability | 🔵 Trivial

Keep non-fatal social failures observable.

The intentional catch preserves the successful create response, but an empty catch makes failed Spotify attachments indistinguishable from successful ones. Emit a structured warning or metric without logging raw request data; keep the operation non-fatal as documented.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/artists/resolveOrCreateArtist.ts` around lines 57 - 64, Update the catch
block around updateArtistSocials in resolveOrCreateArtist to emit a structured
warning or metric identifying the failed Spotify attachment without including
raw request data. Preserve the documented non-fatal behavior and successful
artist creation response.

35-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the artist request flow small and focused.

All three functions combine multiple responsibilities and exceed the 20-line guideline. Extract focused helpers for validation/auth, canonical resolution/linking, social attachment, and HTTP response mapping.

  • lib/artists/resolveOrCreateArtist.ts#L35-L67: split lookup/linking, creation, and social attachment.
  • lib/artists/validateCreateArtistBody.ts#L35-L68: split body validation from authorization and result mapping.
  • lib/artists/createArtistPostHandler.ts#L29-L47: split response/error mapping from route orchestration.

As per coding guidelines: “Flag functions longer than 20 lines” and “Keep functions small and focused.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/artists/resolveOrCreateArtist.ts` around lines 35 - 67, Reduce the
oversized artist request flow by extracting focused helpers: in
lib/artists/resolveOrCreateArtist.ts lines 35-67, separate canonical
lookup/linking, artist creation, and Spotify social attachment while preserving
behavior; in lib/artists/validateCreateArtistBody.ts lines 35-68, separate
request-body validation, authorization, and result mapping; in
lib/artists/createArtistPostHandler.ts lines 29-47, separate response/error
mapping from route orchestration. Keep each public function under the 20-line
guideline and reuse the existing symbols and behavior.

Source: Coding guidelines

lib/artists/validateCreateArtistBody.ts (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the request contract from the Zod schema.

ValidatedCreateArtistRequest is handwritten, so the schema and TypeScript contract can drift. Export a z.infer<typeof createArtistBodySchema> type and compose it with the normalized authorization fields as needed.

As per path instructions: “For validation functions, ensure: Use Zod for schema validation … Export inferred types for validated data.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/artists/validateCreateArtistBody.ts` around lines 16 - 21, Update
ValidatedCreateArtistRequest to derive its request fields from
createArtistBodySchema using z.infer, then compose that inferred input with the
normalized authorization fields accountId and organizationId as needed. Remove
the duplicated handwritten request properties while preserving the optional
spotifyArtistId contract and export the resulting validated type.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/artists/resolveOrCreateArtist.ts`:
- Around line 40-55: Make the Spotify artist resolution and creation flow in
resolveOrCreateArtist atomic: enforce a unique canonical Spotify social identity
in the schema, then use a transaction or atomic upsert to combine artist
creation with Spotify social attachment so concurrent requests cannot create
duplicate artists. Replace the selectAccountArtistId/insertAccountArtistId
sequence with an idempotent or conflict-safe roster-link insert, preserving the
existing return behavior for the canonical artist.

In `@lib/artists/validateCreateArtistBody.ts`:
- Line 9: Update the spotify_artist_id schema in validateCreateArtistBody to
trim input before validation, reject values that become empty after trimming,
and pass the trimmed value through to resolveOrCreateArtist so whitespace-only
IDs are not treated as present during social-link creation.

---

Nitpick comments:
In `@lib/artists/resolveOrCreateArtist.ts`:
- Around line 57-64: Update the catch block around updateArtistSocials in
resolveOrCreateArtist to emit a structured warning or metric identifying the
failed Spotify attachment without including raw request data. Preserve the
documented non-fatal behavior and successful artist creation response.
- Around line 35-67: Reduce the oversized artist request flow by extracting
focused helpers: in lib/artists/resolveOrCreateArtist.ts lines 35-67, separate
canonical lookup/linking, artist creation, and Spotify social attachment while
preserving behavior; in lib/artists/validateCreateArtistBody.ts lines 35-68,
separate request-body validation, authorization, and result mapping; in
lib/artists/createArtistPostHandler.ts lines 29-47, separate response/error
mapping from route orchestration. Keep each public function under the 20-line
guideline and reuse the existing symbols and behavior.

In `@lib/artists/validateCreateArtistBody.ts`:
- Around line 16-21: Update ValidatedCreateArtistRequest to derive its request
fields from createArtistBodySchema using z.infer, then compose that inferred
input with the normalized authorization fields accountId and organizationId as
needed. Remove the duplicated handwritten request properties while preserving
the optional spotifyArtistId contract and export the resulting validated type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d8bb0c6b-ac5c-4fe8-9454-226a05cb9f8b

📥 Commits

Reviewing files that changed from the base of the PR and between 0b222d1 and 293d712.

⛔ Files ignored due to path filters (3)
  • lib/artists/__tests__/createArtistPostHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/artists/__tests__/resolveOrCreateArtist.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/artists/__tests__/validateCreateArtistBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (3)
  • lib/artists/createArtistPostHandler.ts
  • lib/artists/resolveOrCreateArtist.ts
  • lib/artists/validateCreateArtistBody.ts

Comment on lines +40 to +55
if (spotifyArtistId) {
const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
if (canonicalId) {
const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
if (!alreadyRostered) {
await insertAccountArtistId(accountId, canonicalId);
}
const artist = await selectAccountWithSocials(canonicalId);
return {
artist: artist ? { ...artist, account_id: artist.id } : null,
created: false,
};
}
}

const created = await createArtistInDb(name, accountId, organizationId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
rg -n -C 5 'findCanonicalArtistBySpotifyId|createArtistInDb|selectAccountArtistId|insertAccountArtistId' lib
rg -n -C 3 'spotify|account_artist_ids|unique' supabase lib

Repository: recoupable/api

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files containing resolveOrCreateArtist:"
fd -a 'resolveOrCreateArtist\.ts$' . | sed 's#^\./##'

file="$(fd 'resolveOrCreateArtist\.ts$' . | head -n 1)"
echo "---- $file ----"
wc -l "$file"
ast-grep outline "$file" --view expanded || true
sed -n '1,180p' "$file" | cat -n

echo "---- Supabase table definitions around artists/socials/account_artist_ids ----"
rg -n -C 8 'artists|spotify_id|account_artist|account_social|i[._-]?new|index|unique|constraint|account_socials' supabase lib | head -n 260

Repository: recoupable/api

Length of output: 23226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Candidate artifact files:"
git ls-files | rg '(^|/)(artist|artists|account_artist|artist_social|account_social|account_socials|spotify).*(sql|ts)$|supabase/.*/.*(artist|spotify|social).*' | head -n 300

for f in $(git ls-files | rg '(^|/)(artist|artists|spotify|socials?).*\.(sql|ts)$' | head -n 80); do
  echo "---- $f ----"
  wc -l "$f"
  sed -n '1,220p' "$f" | cat -n
done

Repository: recoupable/api

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate Supabase schema and migrations:"
git ls-files | rg '(^|/)(supabase|migrations|.*\.sql$)' | head -n 200

echo "---- Files directly named around artists/socials/account_artist_ids sql ----"
git ls-files | rg '(artist|artists|account_artist|artist_social|account_social|socials).*\.sql$' | sort

echo "---- Relevant SQL snippets ----"
for f in $(git ls-files | rg '(artist|artists|account_artist|artist_social|account_social|socials).*\.sql$' | sort | head -n 80); do
  echo "---- $f ----"
  rg -n -C 8 'artists|spotify|unique|unique .*spotify|account_artist|account_social|unique .*account.*social|constraint.*account.*social|i[._-]?new|create table' "$f" || true
done

Repository: recoupable/api

Length of output: 11060


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- candidate value/spotify canonical files ----"
git ls-files | rg '(^|/)findCanonicalArtistBySpotifyId\.ts$|spotify|artist' | head -n 200

echo "---- implementation files ----"
for f in $(git ls-files | rg '(^|/)findCanonicalArtistBySpotifyId\.ts$|(^|/)createArtistInDb\.ts$|(^|/)(insertAccountArtistId|selectAccountArtistId|updateArtistSocials|insertAccountSocial|socials).*\.ts$' | sort); do
  echo "---- $f ----"
  wc -l "$f"
  ast-grep outline "$f" --view expanded || true
  sed -n '1,220p' "$f" | cat -n
done

echo "---- schema searches for SPOTIFY/social/account_artist constraints ----"
rg -n -C 6 'SPOTIFY|account_socials|socials|account_artist_ids|spotify|create table|constraint|unique|create unique|i[_-]?new|unique constraint|unique' lib/supabase/lib/supabase | head -n 350

Repository: recoupable/api

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- target implementation files ----"
for f in \
  lib/valuation/findCanonicalArtistBySpotifyId.ts \
  lib/artists/createArtistInDb.ts \
  lib/supabase/account_artist_ids/selectAccountArtistId.ts \
  lib/supabase/account_artist_ids/insertAccountArtistId.ts \
  lib/supabase/accounts/selectAccountWithSocials.ts \
  lib/supabase/account_socials/insertAccountSocial.ts \
  lib/supabase/socials/selectSocials.ts \
  lib/supabase/socials/upsertSocials.ts
do
  if [ -f "$f" ]; then
    echo "---- $f ----"
    wc -l "$f"
    sed -n '1,220p' "$f" | cat -n
  else
    echo "MISSING: $f"
  fi
done

echo "---- schema/migration SQL files ----"
git ls-files \*.{sql|ddl}| head -n 200
for f in $(git ls-files \*.{sql|ddl} | head -n 200); do
  echo "---- $f: relevant schema matches ----"
  rg -n -C 5 'create table|account_socials|socials|account_artist_ids|spotify|unique constraint|unique|SPOTIFY|profile_url' "$f" | head -n 180
done

Repository: recoupable/api

Length of output: 12821


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- all SQL files ----"
git ls-files | rg '\.sql$' | sort

echo "---- schema/migration relevant constraints ----"
for f in $(git ls-files | rg '\.sql$' | sort); do
  matches=$(rg -n -C 5 'account_socials|socials|account_artist_ids|spotify|unique constraint|unique|SPOTIFY|profile_url' "$f" || true)
  if [ -n "$matches" ]; then
    echo "---- $f ----"
    echo "$matches"
  fi
done

echo "---- deterministic race-order probe ----"
python3 - <<'PY'
sequence = [
    ("A", "findCanonical", None),
    ("B", "findCanonical", None),
    ("A", "createArtistInDb", "artist-A"),
    ("B", "createArtistInDb", "artist-B"),
    ("A", "updateArtistSocials", "artist-A"),
    ("B", "updateArtistSocials", "artist-B"),
]
for step in sequence:
    print(f"{step[0]}: {step[1]} -> {step[2]}")

print("\nTwo artist rows exist after interleaved createArtistInDb calls, each with same Spotify social: duplicate canonical artist rows.")
PY

Repository: recoupable/api

Length of output: 176


Make canonical artist lookup, social attach, and roster linking atomic.

findCanonicalArtistBySpotifyId() only searches socials by substring, while the artist/social row may not exist until after createArtistInDb() returns. Concurrent requests can each miss the canonical artist, insert separate artist rows, then attach the same Spotify social. The later selectAccountArtistId() / insertAccountArtistId() sequence has the same issue and can also generate duplicate roster links.

Enforce one canonical Spotify artist in the schema, use an atomic upsert/transaction for create + social attach, and use an idempotent or conflict-safe insert for account_artist_ids.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/artists/resolveOrCreateArtist.ts` around lines 40 - 55, Make the Spotify
artist resolution and creation flow in resolveOrCreateArtist atomic: enforce a
unique canonical Spotify social identity in the schema, then use a transaction
or atomic upsert to combine artist creation with Spotify social attachment so
concurrent requests cannot create duplicate artists. Replace the
selectAccountArtistId/insertAccountArtistId sequence with an idempotent or
conflict-safe roster-link insert, preserving the existing return behavior for
the canonical artist.


export const createArtistBodySchema = z.object({
name: z.string({ message: "name is required" }).min(1, "name cannot be empty"),
spotify_artist_id: z.string().min(1, "spotify_artist_id cannot be empty").optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'validateCreateArtistBody\.ts$' . || true

echo "== file outline / contents =="
file="$(fd 'validateCreateArtistBody\.ts$' . | head -n1 || true)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '1,140p' "$file" | cat -n
fi

echo "== search related spotify_artist_id usages =="
rg -n "spotify_artist_id|artist.socials|socials|spotify|createArtist|create" -S --glob '*.ts' --glob '*.js' lib app 2>/dev/null || true

echo "== deterministic zod-like whitespace probe if zod/schema is present (readonly) =="
(node - <<'JS' || true)
const candidate = `  spotify_artist_id: z.string().min(1, "spotify_artist_id cannot be empty").optional(),`;
console.log("has min1_optional:", !!candidate.match(/z\.string\(\)\.min\(1[^\n]*\.optional\(\)/));
console.log("has trim:", !!candidate.includes(".trim()"));
JS

Repository: recoupable/api

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'validateCreateArtistBody\.ts$' . | tail -n1)"
echo "== schema and validation body =="
sed -n '1,90p' "$file" | cat -n

echo "== handler pass-through =="
sed -n '1,70p' lib/artists/createArtistPostHandler.ts | cat -n

echo "== resolveOrCreateArtist implementation =="
sed -n '1,90p' lib/artists/resolveOrCreateArtist.ts | cat -n

echo "== tests around allowed empty ids and whitespace =="
sed -n '213,290p' lib/artists/__tests__/validateCreateArtistBody.test.ts | cat -n

Repository: recoupable/api

Length of output: 11836


Reject whitespace-only Spotify IDs.

.min(1) still allows " "; this value reaches resolveOrCreateArtist() as a present spotifyArtistId and is interpolated into the Spotify URL rather than being treated as missing. Trim in the schema and pass the trimmed value through so empty/whitespace IDs are rejected before social-link creation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/artists/validateCreateArtistBody.ts` at line 9, Update the
spotify_artist_id schema in validateCreateArtistBody to trim input before
validation, reject values that become empty after trimming, and pass the trimmed
value through to resolveOrCreateArtist so whitespace-only IDs are not treated as
present during social-link creation.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 6 files

Confidence score: 2/5

  • In lib/artists/resolveOrCreateArtist.ts, the canonical lookup/create flow is not atomic, so concurrent requests can create multiple artist accounts for the same entity and fragment ownership/linking state — switch this path to a single transactional upsert or DB-enforced atomic recheck.
  • In lib/artists/resolveOrCreateArtist.ts (and input handling in lib/artists/validateCreateArtistBody.ts), spotifyArtistId can be treated as an ILIKE pattern (%/_ or whitespace-only), which can resolve and attach the wrong artist; this is a concrete mis-link risk for callers — enforce strict Spotify ID format and trim before validation/querying.
  • In lib/artists/resolveOrCreateArtist.ts, the roster existence-check then insert can race under concurrent reuse requests, leading to duplicate account_artist_ids rows or a uniqueness-conflict 500 — make the link step idempotent with insert ... on conflict/upsert semantics.
  • In lib/artists/createArtistPostHandler.ts, database error text can leak directly to clients on 500s, and related tests in lib/artists/__tests__/resolveOrCreateArtist.test.ts and lib/artists/__tests__/createArtistPostHandler.test.ts no longer verify key behavior, increasing regression risk — return a fixed generic error message and tighten assertions around social-attach attempts and organizationId forwarding.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/artists/createArtistPostHandler.ts">

<violation number="1" location="lib/artists/createArtistPostHandler.ts:29">
P2: A failed canonical-to-account link can now return raw database error text to API clients through this new resolver call. The 500 path should log the exception server-side and return a fixed generic message such as `Internal server error`.</violation>
</file>

<file name="lib/artists/resolveOrCreateArtist.ts">

<violation number="1" location="lib/artists/resolveOrCreateArtist.ts:41">
P1: A non-empty `spotifyArtistId` containing `%` or `_` is treated as an `ILIKE` wildcard, so a caller can resolve and link an unrelated artist instead of the requested ID. Restrict the ID to the accepted Spotify-ID format or escape `ILIKE` metacharacters before lookup and URL construction.</violation>

<violation number="2" location="lib/artists/resolveOrCreateArtist.ts:41">
P1: Concurrent requests can both observe no canonical and create separate artist accounts because the lookup and creation are not atomic; the later social upsert does not make the artist rows unique. An atomic database resolve/create operation with a unique Spotify identity is needed to preserve the one-canonical-per-ID contract.</violation>

<violation number="3" location="lib/artists/resolveOrCreateArtist.ts:43">
P2: Concurrent reuse requests can race between the roster existence check and insert, causing duplicate `account_artist_ids` rows or a 500 on a uniqueness conflict. An idempotent database upsert/insert-on-conflict operation would make the reuse path safe.</violation>
</file>

<file name="lib/artists/__tests__/createArtistPostHandler.test.ts">

<violation number="1" location="lib/artists/__tests__/createArtistPostHandler.test.ts:56">
P3: Test 'passes organization_id to createArtistInDb' title now references a function that is no longer the public API, and the assertion no longer verifies organizationId was forwarded. The old test explicitly checked the org UUID in the third arg; the new assertion only checks name and accountId, so it would pass even if organizationId were dropped. Either add organizationId to the objectContaining matcher or rename the test to reflect what it actually validates.</violation>
</file>

<file name="lib/artists/validateCreateArtistBody.ts">

<violation number="1" location="lib/artists/validateCreateArtistBody.ts:9">
P2: Add `.trim()` to `spotify_artist_id` validation to reject whitespace-only strings — without it a whitespace-only value would pass `.min(1)`, produce a broad `ilike` query that could match unrelated social rows, and create a broken Spotify social URL.</violation>
</file>

<file name="lib/artists/__tests__/resolveOrCreateArtist.test.ts">

<violation number="1" location="lib/artists/__tests__/resolveOrCreateArtist.test.ts:51">
P2: Test "returns the created artist when the social attach fails" verifies resilience to a rejected updateArtistSocials but doesn't assert the social attach was attempted. If a regression causes the function to silently skip updateArtistSocials (no call), the mock rejection never fires and the test passes trivially because it only checks the return value. Add `expect(updateArtistSocials).toHaveBeenCalledWith("new-1", { SPOTIFY: \`https://open.spotify.com/artist/${SPOTIFY_ID}\` });` to confirm the action was attempted before the catch.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant API as createArtistPostHandler
    participant Validator as validateCreateArtistBody
    participant Resolver as resolveOrCreateArtist
    participant DB as Database

    Note over Client,DB: POST /api/artists – Resolve-or-create by Spotify ID

    Client->>API: POST /api/artists<br/>{name, spotify_artist_id?, account_id?, organization_id?}
    API->>Validator: validateCreateArtistBody(request)
    Validator->>DB: Validate auth context & body
    DB-->>Validator: Account + parsed body
    alt Validation fails
        Validator-->>API: NextResponse (400)
        API-->>Client: Error response
    else Valid
        Validator-->>API: {name, accountId, organizationId, spotifyArtistId?}
        API->>Resolver: resolveOrCreateArtist({name, accountId, organizationId, spotifyArtistId})
        alt spotifyArtistId provided
            Resolver->>DB: findCanonicalArtistBySpotifyId(spotifyArtistId)
            alt Canonical exists
                DB-->>Resolver: canonicalId
                Resolver->>DB: selectAccountArtistId(accountId, canonicalId)
                opt Not already rostered
                    DB-->>Resolver: null
                    Resolver->>DB: insertAccountArtistId(accountId, canonicalId)
                end
                Resolver->>DB: selectAccountWithSocials(canonicalId)
                DB-->>Resolver: artist data
                Resolver-->>API: {artist, created: false}
            else No canonical exists
                DB-->>Resolver: null
                Resolver->>DB: createArtistInDb(name, accountId, organizationId)
                alt Artist created
                    DB-->>Resolver: artist
                    opt spotifyArtistId present
                        Resolver->>DB: updateArtistSocials(artist.id,<br/>{SPOTIFY: "https://..."})
                        alt Update fails
                            DB->>Resolver: error
                            Note over Resolver: Non‑fatal – swallow
                        end
                    end
                    Resolver-->>API: {artist, created: true}
                else Creation fails
                    DB-->>Resolver: null
                    Resolver-->>API: {artist: null, created: true}
                end
            end
        else No spotifyArtistId (legacy plain create)
            Resolver->>DB: createArtistInDb(name, accountId, organizationId)
            DB-->>Resolver: artist
            Resolver-->>API: {artist, created: true}
        end

        alt artist is null
            API-->>Client: 500 – "Failed to create artist"
        else created === false
            API-->>Client: 200 – {artist}
        else created === true
            API-->>Client: 201 – {artist}
        end
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const { name, accountId, organizationId, spotifyArtistId } = params;

if (spotifyArtistId) {
const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A non-empty spotifyArtistId containing % or _ is treated as an ILIKE wildcard, so a caller can resolve and link an unrelated artist instead of the requested ID. Restrict the ID to the accepted Spotify-ID format or escape ILIKE metacharacters before lookup and URL construction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/resolveOrCreateArtist.ts, line 41:

<comment>A non-empty `spotifyArtistId` containing `%` or `_` is treated as an `ILIKE` wildcard, so a caller can resolve and link an unrelated artist instead of the requested ID. Restrict the ID to the accepted Spotify-ID format or escape `ILIKE` metacharacters before lookup and URL construction.</comment>

<file context>
@@ -0,0 +1,68 @@
+  const { name, accountId, organizationId, spotifyArtistId } = params;
+
+  if (spotifyArtistId) {
+    const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
+    if (canonicalId) {
+      const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
</file context>

const { name, accountId, organizationId, spotifyArtistId } = params;

if (spotifyArtistId) {
const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Concurrent requests can both observe no canonical and create separate artist accounts because the lookup and creation are not atomic; the later social upsert does not make the artist rows unique. An atomic database resolve/create operation with a unique Spotify identity is needed to preserve the one-canonical-per-ID contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/resolveOrCreateArtist.ts, line 41:

<comment>Concurrent requests can both observe no canonical and create separate artist accounts because the lookup and creation are not atomic; the later social upsert does not make the artist rows unique. An atomic database resolve/create operation with a unique Spotify identity is needed to preserve the one-canonical-per-ID contract.</comment>

<file context>
@@ -0,0 +1,68 @@
+  const { name, accountId, organizationId, spotifyArtistId } = params;
+
+  if (spotifyArtistId) {
+    const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
+    if (canonicalId) {
+      const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
</file context>

validated.accountId,
validated.organizationId,
);
const { artist, created } = await resolveOrCreateArtist({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A failed canonical-to-account link can now return raw database error text to API clients through this new resolver call. The 500 path should log the exception server-side and return a fixed generic message such as Internal server error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/createArtistPostHandler.ts, line 29:

<comment>A failed canonical-to-account link can now return raw database error text to API clients through this new resolver call. The 500 path should log the exception server-side and return a fixed generic message such as `Internal server error`.</comment>

<file context>
@@ -26,11 +26,12 @@ export async function createArtistPostHandler(request: NextRequest): Promise<Nex
-      validated.accountId,
-      validated.organizationId,
-    );
+    const { artist, created } = await resolveOrCreateArtist({
+      name: validated.name,
+      accountId: validated.accountId,
</file context>

if (spotifyArtistId) {
const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
if (canonicalId) {
const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent reuse requests can race between the roster existence check and insert, causing duplicate account_artist_ids rows or a 500 on a uniqueness conflict. An idempotent database upsert/insert-on-conflict operation would make the reuse path safe.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/resolveOrCreateArtist.ts, line 43:

<comment>Concurrent reuse requests can race between the roster existence check and insert, causing duplicate `account_artist_ids` rows or a 500 on a uniqueness conflict. An idempotent database upsert/insert-on-conflict operation would make the reuse path safe.</comment>

<file context>
@@ -0,0 +1,68 @@
+  if (spotifyArtistId) {
+    const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
+    if (canonicalId) {
+      const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
+      if (!alreadyRostered) {
+        await insertAccountArtistId(accountId, canonicalId);
</file context>


export const createArtistBodySchema = z.object({
name: z.string({ message: "name is required" }).min(1, "name cannot be empty"),
spotify_artist_id: z.string().min(1, "spotify_artist_id cannot be empty").optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Add .trim() to spotify_artist_id validation to reject whitespace-only strings — without it a whitespace-only value would pass .min(1), produce a broad ilike query that could match unrelated social rows, and create a broken Spotify social URL.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/validateCreateArtistBody.ts, line 9:

<comment>Add `.trim()` to `spotify_artist_id` validation to reject whitespace-only strings — without it a whitespace-only value would pass `.min(1)`, produce a broad `ilike` query that could match unrelated social rows, and create a broken Spotify social URL.</comment>

<file context>
@@ -6,6 +6,7 @@ import { z } from "zod";
 
 export const createArtistBodySchema = z.object({
   name: z.string({ message: "name is required" }).min(1, "name cannot be empty"),
+  spotify_artist_id: z.string().min(1, "spotify_artist_id cannot be empty").optional(),
   account_id: z.uuid({ message: "account_id must be a valid UUID" }).optional(),
   organization_id: z.uuid({ message: "organization_id must be a valid UUID" }).optional(),
</file context>
Suggested change
spotify_artist_id: z.string().min(1, "spotify_artist_id cannot be empty").optional(),
spotify_artist_id: z.string().trim().min(1, "spotify_artist_id cannot be empty").optional(),

expect(updateArtistSocials).toHaveBeenCalledWith("new-1", {
SPOTIFY: `https://open.spotify.com/artist/${SPOTIFY_ID}`,
});
expect(result).toEqual({ artist: created, created: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Test "returns the created artist when the social attach fails" verifies resilience to a rejected updateArtistSocials but doesn't assert the social attach was attempted. If a regression causes the function to silently skip updateArtistSocials (no call), the mock rejection never fires and the test passes trivially because it only checks the return value. Add expect(updateArtistSocials).toHaveBeenCalledWith("new-1", { SPOTIFY: \https://open.spotify.com/artist/${SPOTIFY_ID}\` });` to confirm the action was attempted before the catch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/__tests__/resolveOrCreateArtist.test.ts, line 51:

<comment>Test "returns the created artist when the social attach fails" verifies resilience to a rejected updateArtistSocials but doesn't assert the social attach was attempted. If a regression causes the function to silently skip updateArtistSocials (no call), the mock rejection never fires and the test passes trivially because it only checks the return value. Add `expect(updateArtistSocials).toHaveBeenCalledWith("new-1", { SPOTIFY: \`https://open.spotify.com/artist/${SPOTIFY_ID}\` });` to confirm the action was attempted before the catch.</comment>

<file context>
@@ -0,0 +1,107 @@
+    expect(updateArtistSocials).toHaveBeenCalledWith("new-1", {
+      SPOTIFY: `https://open.spotify.com/artist/${SPOTIFY_ID}`,
+    });
+    expect(result).toEqual({ artist: created, created: true });
+  });
+
</file context>

Comment on lines +56 to 58
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id" }),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Test 'passes organization_id to createArtistInDb' title now references a function that is no longer the public API, and the assertion no longer verifies organizationId was forwarded. The old test explicitly checked the org UUID in the third arg; the new assertion only checks name and accountId, so it would pass even if organizationId were dropped. Either add organizationId to the objectContaining matcher or rename the test to reflect what it actually validates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/__tests__/createArtistPostHandler.test.ts, line 56:

<comment>Test 'passes organization_id to createArtistInDb' title now references a function that is no longer the public API, and the assertion no longer verifies organizationId was forwarded. The old test explicitly checked the org UUID in the third arg; the new assertion only checks name and accountId, so it would pass even if organizationId were dropped. Either add organizationId to the objectContaining matcher or rename the test to reflect what it actually validates.</comment>

<file context>
@@ -45,18 +45,16 @@ describe("createArtistPostHandler", () => {
-      "Test Artist",
-      "api-key-account-id",
-      undefined,
+    expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
+      expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id" }),
     );
</file context>
Suggested change
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id" }),
);
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id", organizationId: "660e8400-e29b-41d4-a716-446655440001" }),
);

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — all three contract paths live, SQL cross-checked

  • Preview: https://api-alr31xfzr-recoup.vercel.app, deployment sha confirmed = PR head 293d712d
  • Credential: Privy bearer (user-supplied) for the account already rostering Del Water Gap 7647f901 + Delacey 29797518
  • Contract under test: docs#278, merged 2026-07-29
# Path Documented Actual
1 Reuse, already rostered — Delacey (single canonical) 200, { artist } 200, artist.account_id = 29797518 (the canonical), roster unchanged
2 Create — Grace Ives 4TZieE5978SbTInJswaay2, SQL-verified 0 existing socials before the call 201, social attached server-side 201, new 0d5e37f8…; roster shows open.spotify.com/artist/4TZieE5978SbTInJswaay2 attached — via curl, so no client PATCH was involved
3 Idempotent re-add — same POST again 200, same artist 200, same 0d5e37f8…, roster stays 3
4 No credential 401 401 Exactly one of x-api-key or Authorization must be provided
5 Empty spotify_artist_id 400 400 spotify_artist_id cannot be empty
6 Missing name 400 400 name is required

SQL ground truth (prod — api previews share the prod DB)

artist rows carrying Grace Ives' spotify id : 1   ← one canonical, created once across two POSTs
roster links to Grace Ives                  : 1
roster links to Delacey (29797518)          : 2   ← two accounts share ONE artist row

That last number is the model working end to end: Delacey is rostered by this account and by the …1713 account (linked during api#791's verification) — one canonical, many rosters, which is exactly the 2026-07-29 decision.

Docs ↔ API ↔ live reconciliation

Field-by-field match with the merged contract: { artist } envelope on both codes, 200 only on resolve, 201 with the server-side social attach, 400/401 shapes unchanged. No drift found — nothing to patch back into docs.

Known and already flagged in the PR body: with existing duplicates (Del Water Gap, Ana Bárbara) the canonical picked is newest-first until database#49 collapses them and api#794 makes the pick oldest-first — new adds stop duplicating as of this PR regardless.

Ready to merge. Next in order: this → chat#1901 (after a test-branch sync so chat previews see the new param).

@sweetmantech
sweetmantech merged commit 42cfedf into main Jul 29, 2026
6 checks passed
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