Skip to content

Personal Songs (Plan 3)#2

Merged
jwhumphries merged 24 commits into
mainfrom
personal-songs
Jun 11, 2026
Merged

Personal Songs (Plan 3)#2
jwhumphries merged 24 commits into
mainfrom
personal-songs

Conversation

@jwhumphries

@jwhumphries jwhumphries commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • The personal song library, implementing the spec's identity + annotation-layers model: songs are identity-only (title/artist + owner); status, notes, resources, and practice events live in subject-keyed rows (band columns pre-provisioned in the schema but unwritten until Plan 4)
  • Practice logging: day-granularity events deduped per (song, subject, date) via per-subject partial unique indexes (plain composite unique indexes silently don't fire with NULL subject columns — caught and fixed during review), one-tap "Practiced" with a 6-second undo toast, date backfill with error surfacing, minimal stats (last practiced + total days)
  • Playlist-style folders: a song can live in many folders, drag-reorder of folders and of songs within a folder (dnd-kit), checkbox folder picker on the song detail page, full-replace entry semantics
  • Library page with Fuse.js fuzzy search over title/artist, color-coded status badges, add-song modal, delete-with-confirmation
  • 16 new API endpoints behind RequireAuth + CSRF; review-loop fixes included: validate-all-before-write updates, resource↔song URL binding, DB errors surfaced as 500s (not 400s), rename double-fire guard

Test Plan

  • just check green (all six gates)
  • Repository tests: cascade delete across all metadata tables, practice idempotency, folder ordering/ownership, subject-uniqueness enforcement at the schema level
  • Handler tests: CRUD with cross-user 404 isolation, practice stats responses, URL validation, folder entry dedupe/visibility
  • Frontend: 22 tests across library/search/practice-undo/detail/folder components
  • CI run on this PR

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Personal song library: create, organize, and track individual songs with titles and artists.
    • Practice tracking: log and undo practice sessions with date and statistics.
    • Folders: organize songs into reorderable playlists with drag-and-drop support.
    • Status tracking: mark songs as "Not Learned," "Learning," "Learned," or "Nailed."
    • Resources: attach links and notes to songs for reference material.
    • Search: filter songs by title or artist in real time.
  • Chores

    • Added drag-and-drop dependencies for folder management.

jwhumphries and others added 23 commits June 11, 2026 07:30
Replace single composite unique indexes (broken for NULLable band_id/user_id
due to SQLite NULL semantics) with two partial unique indexes per table:
idx_annotation_user/idx_annotation_band on SongAnnotation and
idx_practice_user_day/idx_practice_band_day on PracticeEvent, each guarded
by a WHERE IS NOT NULL clause so duplicates are correctly rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reorder imports to match goimports canonical grouping (produced by just fmt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restructure UpdateSong to stage-validate all fields (title, artist,
status, notes) before writing any, preventing partial saves on mixed
valid/invalid payloads. Split CreateSong validation messages for blank
title vs oversized artist. Add regression test asserting no partial
identity write when status is invalid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enforce that UpdateResource and DeleteResource verify the resource belongs
to the song in the URL path parameter. Updated repository signatures to
accept songID and filter by it, preventing access to resources from
different songs. Added regression test to verify mismatched song URLs
return 404.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Map genuine DB failures (ErrRecordNotFound) through error wrapping in
ReorderFolders and SetFolderEntries repository methods, so handlers can
distinguish not-found errors (400) from DB errors (500).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jwhumphries, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 40 minutes and 9 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4c1cebcb-284b-4a0c-ac7d-6c7a3f975545

📥 Commits

Reviewing files that changed from the base of the PR and between ad84d58 and 9ebc85a.

📒 Files selected for processing (5)
  • frontend/src/components/songs/ResourceList.tsx
  • frontend/src/hooks/songs.ts
  • frontend/src/pages/HomePage.tsx
  • frontend/src/pages/SongPage.tsx
  • frontend/vitest.config.ts
📝 Walkthrough

Walkthrough

This PR implements a complete personal song library feature for BandWidth, adding songs with practice tracking, folder-based organization, linked resources, and full-stack support including drag-and-drop folder reordering.

Changes

Personal Song Library Feature

Layer / File(s) Summary
Backend domain models
internal/model/songs.go
Introduces Song (title/artist with user/band ownership), SongAnnotation (per-subject status/notes), Resource (song attachments), PracticeEvent (per-day practice tracking), Folder (ordered collections), and FolderEntry (song placement) with GORM constraints and conditional uniqueness indexes.
Repository persistence layer
internal/repository/songs.go, practice.go, resources.go, folders.go
Implements song lifecycle (create, list with annotations and practice aggregation, fetch, update, delete with cascades), idempotent practice logging by day, resource CRUD with positional ordering, and folder management with ordered entries and transactional reordering.
Repository tests and schema
internal/repository/*_test.go, repository.go, repository_test.go
Test coverage for song CRUD with cross-user isolation, practice idempotency, resource lifecycle, folder ownership and ordering, and database-level uniqueness constraint enforcement.
HTTP handlers and API routing
internal/handlers/songs.go, practice.go, resources.go, folders.go, cmd/bandwidth/server.go
Song/practice/resource/folder endpoints with ownership verification, staged validation, and error mapping; route registration under authenticated /api/songs and /api/folders groups.
Handler tests
internal/handlers/*_test.go
Integration tests for song CRUD, practice logging/undo, resource management, and folder operations with cross-user authorization checks.
Frontend types and hooks
frontend/src/lib/types.ts, frontend/src/hooks/songs.ts, frontend/src/hooks/folders.ts, frontend/src/lib/api.ts, frontend/src/lib/dates.ts
TypeScript data types (SongStatus, SongListItem, Folder, etc.); React Query hooks for song, practice, resource, and folder operations with cache synchronization; api.delete() helper; localToday() utility.
Frontend reusable components
frontend/src/components/songs/*, frontend/src/components/folders/*
StatusBadge (learning state), SongRow (list item), ConfirmModal (confirmation), AddSongModal (create), ResourceList (attach links), FolderPicker (toggle membership), FolderSidebar (manage folders with dnd-kit), SortableSongList (reorder songs).
Frontend pages
frontend/src/pages/HomePage.tsx, frontend/src/pages/SongPage.tsx
HomePage refactored to full song library UI with folder sidebar, search/filter, add modal, and undo; SongPage for individual song management (edit metadata, log practice, manage resources/folders, delete).
Frontend routing and dependencies
frontend/src/App.tsx, frontend/package.json
Route /songs/:id for SongPage; add @dnd-kit packages for drag-and-drop.
Documentation
AGENTS.md, README.md, docs/superpowers/plans/2026-06-11-personal-songs.md
Document song identity/metadata modeling, practice uniqueness, folder semantics; clarify personal song tracking implementation; update schema docs for SQLite conditional unique indexes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • jwhumphries/BandWidth#1: Establishes the authenticated API routing infrastructure and base auth middleware that this PR builds upon with /api/songs and /api/folders route groups.

Poem

🐰 A song library, now complete with care,
Where practice logs and folders flourish there,
With drag-and-drop and notes so fine,
Each melody organized by design,
Rock on, dear songs—you're in good hands divine! 🎸

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Personal Songs (Plan 3)' accurately reflects the main objective of implementing a personal song library feature as specified in the PR objectives.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch personal-songs

@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: 10

🤖 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 `@frontend/src/components/folders/FolderSidebar.tsx`:
- Around line 123-130: The create handler can still trigger duplicate mutations
on very fast clicks; add the same double-submit guard pattern used by the rename
form: create a ref (e.g., createInFlightRef) in the FolderSidebar component,
check if createInFlightRef.current is true at the top of the create(e:
FormEvent) function and return early if so, set createInFlightRef.current = true
immediately before calling createFolder.mutate, and then clear it (set to false)
in the mutation callbacks (onSuccess and onError) so the guard resets after the
request completes; keep the existing createFolder.isPending disabled prop as
well for UX feedback.
- Around line 40-50: submitRename currently calls onRename synchronously and
hides the editor and sets submitted.current = true regardless of errors; change
it to handle async errors: make submitRename async, await onRename(name.trim()),
catch any error to display a short UI message/toast (or call a passed error
handler), and in the catch reset submitted.current = false and keep
setEditing(true) so the user can retry; reference submitRename, onRename,
submitted, setEditing, and name to locate and update the logic.

In `@frontend/src/components/songs/ResourceList.tsx`:
- Around line 41-46: The anchor in ResourceList (the <a> element rendering
r.url) uses rel="noreferrer" with target="_blank"; update the rel to include
"noopener" (e.g., "noreferrer noopener") to explicitly prevent the new page from
accessing window.opener. Locate the anchor in ResourceList.tsx (the element with
className "link min-w-0 flex-1 truncate" and href={r.url}) and add noopener to
the rel attribute.

In `@frontend/src/hooks/folders.ts`:
- Around line 57-71: The onSettled invalidation in useSetFolderEntries causes a
refetch even after successful optimistic updates; to align with
useReorderFolders, remove the onSettled handler from useSetFolderEntries so it
only invalidates onError (keep the onError invalidateQueries) and rely on the
optimistic update in onMutate; locate useSetFolderEntries and delete the
onSettled property (and its invalidateQueries call) so behavior matches
useReorderFolders.

In `@frontend/src/pages/HomePage.tsx`:
- Around line 53-59: The practiced flow and undoPractice mutation lack error
handling: update the logPractice.mutate call in practiced and the undoPractice
mutation to include onError handlers that surface a brief error toast to the
user (and optionally log the error) and clear any optimistic undo state via
setUndo as needed; specifically add onError to logPractice.mutate (alongside
existing onSuccess) to show a user-facing message like "Failed to log practice"
and to undoPractice's mutate to show "Failed to undo practice" and keep internal
state consistent so the UI doesn't silently fail.

In `@frontend/src/pages/SongPage.tsx`:
- Around line 95-106: The select's onChange passes e.target.value (a string)
into updateSong.mutate where the mutation expects a SongStatus; cast the value
to SongStatus to satisfy types and document the constraint—update the onChange
handler in SongPage (the select with id="status", value={song.status}, options
from statusOptions) to call updateSong.mutate({ status: e.target.value as
SongStatus }) so the type system knows the value matches the SongStatus union.
- Around line 47-53: The component currently only checks the `song` value and
shows a spinner, so update the `useSong(id)` usage in the SongPage component to
also destructure and handle `isLoading`, `isError`, `error`, and `refetch` (or
equivalent) from the hook; replace the current `if (!song)` branch with logic
that shows the loading spinner when `isLoading`, and when `isError` render a
concise error state (user-facing message using `error.message` if available and
a retry button that calls `refetch`) so network/404/authorization failures don’t
leave users stuck on an infinite spinner.

In `@internal/model/songs.go`:
- Around line 28-36: The Song model currently allows OwnerUserID and OwnerBandID
to be both null or both set; add enforcement so exactly one is set: preferred
fix is a DB-level CHECK constraint (ALTER TABLE / CREATE TABLE migration adding
CHECK ((OwnerUserID IS NULL) != (OwnerBandID IS NULL))) tied to the Song schema;
if DB engine doesn't support CHECK or for safety also add repository-layer
validation in repository.CreateSong (and any update method) to reject
creates/updates where both are null or both non-null, returning a validation
error; update any migrations and tests accordingly and reference the Song struct
and OwnerUserID/OwnerBandID fields and repository.CreateSong in the changes.

In `@internal/repository/folders.go`:
- Around line 47-72: FoldersForUser performs an N+1 by querying FolderEntry per
folder; modify it to preload entries in one query by using GORM Preload on
model.Folder (ensure model.Folder has Entries []FolderEntry with
gorm:"foreignKey:FolderID"), then iterate folders' Entries to build
FolderWithSongs (keep ID/Name/Position and collect SongID from each Entry);
update the function's use of r.db to use r.db.Where("owner_user_id = ?",
userID).Preload("Entries", func(db *gorm.DB) *gorm.DB { return
db.Order("position, id") }).Order("position, id").Find(&folders).Error and
remove the per-folder query loop.

In `@internal/repository/resources.go`:
- Around line 19-36: The CreateResource method currently reads MAX(position)
then inserts maxPos+1 separately, causing race conditions; fix by performing the
read-and-insert inside a DB transaction and lock the relevant rows when
computing MAX(position) (use a SELECT FOR UPDATE / row lock via r.db
transaction/locking clauses on model.Resource filtered by songID and userID) so
concurrent creators serialize and receive unique positions; alternatively add a
unique constraint on (song_id, user_id, position) and implement
retry-on-conflict logic when r.db.Create(res) fails due to constraint
violation—update Repo.CreateResource and model.Resource accordingly.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 77d0c875-5e03-49a2-a013-1d4fd9d1ca82

📥 Commits

Reviewing files that changed from the base of the PR and between 4f6f67b and ad84d58.

⛔ Files ignored due to path filters (1)
  • frontend/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (46)
  • AGENTS.md
  • README.md
  • cmd/bandwidth/server.go
  • cmd/bandwidth/server_test.go
  • docs/superpowers/plans/2026-06-11-personal-songs.md
  • frontend/package.json
  • frontend/src/App.tsx
  • frontend/src/components/folders/FolderPicker.test.tsx
  • frontend/src/components/folders/FolderPicker.tsx
  • frontend/src/components/folders/FolderSidebar.test.tsx
  • frontend/src/components/folders/FolderSidebar.tsx
  • frontend/src/components/folders/SortableSongList.tsx
  • frontend/src/components/songs/AddSongModal.tsx
  • frontend/src/components/songs/ConfirmModal.tsx
  • frontend/src/components/songs/ResourceList.tsx
  • frontend/src/components/songs/SongRow.tsx
  • frontend/src/components/songs/StatusBadge.tsx
  • frontend/src/hooks/folders.ts
  • frontend/src/hooks/songs.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/dates.test.ts
  • frontend/src/lib/dates.ts
  • frontend/src/lib/types.ts
  • frontend/src/pages/HomePage.test.tsx
  • frontend/src/pages/HomePage.tsx
  • frontend/src/pages/SongPage.test.tsx
  • frontend/src/pages/SongPage.tsx
  • internal/handlers/folders.go
  • internal/handlers/folders_test.go
  • internal/handlers/practice.go
  • internal/handlers/practice_test.go
  • internal/handlers/resources.go
  • internal/handlers/songs.go
  • internal/handlers/songs_test.go
  • internal/model/songs.go
  • internal/repository/folders.go
  • internal/repository/folders_test.go
  • internal/repository/passwordresets.go
  • internal/repository/practice.go
  • internal/repository/practice_test.go
  • internal/repository/repository.go
  • internal/repository/repository_test.go
  • internal/repository/resources.go
  • internal/repository/resources_test.go
  • internal/repository/songs.go
  • internal/repository/songs_test.go

Comment on lines +40 to +50
const submitRename = (e: FormEvent) => {
e.preventDefault();
if (submitted.current) {
return;
}
submitted.current = true;
if (name.trim()) {
onRename(name.trim());
}
setEditing(false);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Rename errors are silently ignored.

When submitRename calls onRename, any mutation error is not surfaced to the user. The form exits edit mode (line 49) regardless of success or failure, and the submitted flag remains true, preventing immediate retry. Users who experience a failed rename won't receive feedback and must click the rename button again to retry.

This is acceptable for a low-stakes operation like folder renaming, but consider showing a brief error toast or message if you want to improve the UX for network/server errors.

🤖 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 `@frontend/src/components/folders/FolderSidebar.tsx` around lines 40 - 50,
submitRename currently calls onRename synchronously and hides the editor and
sets submitted.current = true regardless of errors; change it to handle async
errors: make submitRename async, await onRename(name.trim()), catch any error to
display a short UI message/toast (or call a passed error handler), and in the
catch reset submitted.current = false and keep setEditing(true) so the user can
retry; reference submitRename, onRename, submitted, setEditing, and name to
locate and update the logic.

Comment on lines +123 to +130
const create = (e: FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
createFolder.mutate(
{name: newName.trim()},
{onSuccess: () => setNewName('')},
);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider adding double-submit guard to folder creation.

The rename form uses a useRef double-submit guard (line 38-45), but the create folder form relies only on disabled={createFolder.isPending} (line 180). While the button disable provides some protection, two rapid clicks before isPending becomes true could theoretically trigger duplicate mutations. For consistency with the rename pattern, consider adding a useRef guard to the create handler.

🤖 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 `@frontend/src/components/folders/FolderSidebar.tsx` around lines 123 - 130,
The create handler can still trigger duplicate mutations on very fast clicks;
add the same double-submit guard pattern used by the rename form: create a ref
(e.g., createInFlightRef) in the FolderSidebar component, check if
createInFlightRef.current is true at the top of the create(e: FormEvent)
function and return early if so, set createInFlightRef.current = true
immediately before calling createFolder.mutate, and then clear it (set to false)
in the mutation callbacks (onSuccess and onError) so the guard resets after the
request completes; keep the existing createFolder.isPending disabled prop as
well for UX feedback.

Comment thread frontend/src/components/songs/ResourceList.tsx
Comment on lines +57 to +71
export function useSetFolderEntries() {
const queryClient = useQueryClient();
return useMutation<void, ApiError, {id: number; songIds: number[]}>({
mutationFn: ({id, songIds}) =>
api.put<void>(`/api/folders/${id}/entries`, {songIds}),
onMutate: ({id, songIds}) => {
queryClient.setQueryData<Folder[] | undefined>(['folders'], folders =>
folders?.map(f => (f.id === id ? {...f, songIds} : f)),
);
},
onError: () => void queryClient.invalidateQueries({queryKey: ['folders']}),
onSettled: () =>
void queryClient.invalidateQueries({queryKey: ['folders']}),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider aligning invalidation strategy with useReorderFolders for consistency.

useSetFolderEntries invalidates on both onError and onSettled (line 67-69), causing a refetch even on successful mutations. In contrast, useReorderFolders only invalidates on error (line 53), trusting the optimistic update as the final state on success. Since both mutations return void and use optimistic updates, the approaches could be aligned for consistency.

The current approach is conservative (ensures consistency) but performs an extra fetch on success. If you prefer optimistic-only (like reorder), remove the onSettled handler. If you prefer always-refetch (safer), add onSettled to useReorderFolders as well.

🤖 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 `@frontend/src/hooks/folders.ts` around lines 57 - 71, The onSettled
invalidation in useSetFolderEntries causes a refetch even after successful
optimistic updates; to align with useReorderFolders, remove the onSettled
handler from useSetFolderEntries so it only invalidates onError (keep the
onError invalidateQueries) and rely on the optimistic update in onMutate; locate
useSetFolderEntries and delete the onSettled property (and its invalidateQueries
call) so behavior matches useReorderFolders.

Comment thread frontend/src/pages/HomePage.tsx
Comment thread frontend/src/pages/SongPage.tsx Outdated
Comment thread frontend/src/pages/SongPage.tsx
Comment thread internal/model/songs.go
Comment on lines +28 to +36
type Song struct {
ID uint `gorm:"primarykey"`
Title string `gorm:"not null"`
Artist string
OwnerUserID *uint `gorm:"index"`
OwnerBandID *uint `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Missing XOR constraint on Song ownership.

The Song model allows both OwnerUserID and OwnerBandID to be nil (orphaned song) or both to be non-null (ambiguous ownership). The schema does not enforce that exactly one ownership field must be set.

This can corrupt data integrity: orphaned songs could leak across user boundaries, and dual-ownership breaks the identity model described in the PR objectives ("a user XOR a band").

🛡️ Add a CHECK constraint or repository-layer enforcement

Option 1: SQLite CHECK constraint (preferred for DB-level guarantee):

 type Song struct {
 	ID          uint   `gorm:"primarykey"`
 	Title       string `gorm:"not null"`
 	Artist      string
-	OwnerUserID *uint `gorm:"index"`
-	OwnerBandID *uint `gorm:"index"`
+	OwnerUserID *uint `gorm:"index;check:(owner_user_id IS NOT NULL AND owner_band_id IS NULL) OR (owner_user_id IS NULL AND owner_band_id IS NOT NULL)"`
+	OwnerBandID *uint `gorm:"index"`
 	CreatedAt   time.Time
 	UpdatedAt   time.Time
 }

Option 2: Repository validation (if CHECK is not supported):
Add a validation hook in repository.CreateSong to reject songs that violate XOR before insertion.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type Song struct {
ID uint `gorm:"primarykey"`
Title string `gorm:"not null"`
Artist string
OwnerUserID *uint `gorm:"index"`
OwnerBandID *uint `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}
type Song struct {
ID uint `gorm:"primarykey"`
Title string `gorm:"not null"`
Artist string
OwnerUserID *uint `gorm:"index;check:(owner_user_id IS NOT NULL AND owner_band_id IS NULL) OR (owner_user_id IS NULL AND owner_band_id IS NOT NULL)"`
OwnerBandID *uint `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}
🤖 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 `@internal/model/songs.go` around lines 28 - 36, The Song model currently
allows OwnerUserID and OwnerBandID to be both null or both set; add enforcement
so exactly one is set: preferred fix is a DB-level CHECK constraint (ALTER TABLE
/ CREATE TABLE migration adding CHECK ((OwnerUserID IS NULL) != (OwnerBandID IS
NULL))) tied to the Song schema; if DB engine doesn't support CHECK or for
safety also add repository-layer validation in repository.CreateSong (and any
update method) to reject creates/updates where both are null or both non-null,
returning a validation error; update any migrations and tests accordingly and
reference the Song struct and OwnerUserID/OwnerBandID fields and
repository.CreateSong in the changes.

Comment on lines +47 to +72
// FoldersForUser returns the user's folders in position order, each with
// its ordered song IDs.
func (r *Repo) FoldersForUser(userID uint) ([]FolderWithSongs, error) {
var folders []model.Folder
err := r.db.Where("owner_user_id = ?", userID).
Order("position, id").Find(&folders).Error
if err != nil {
return nil, err
}
result := make([]FolderWithSongs, 0, len(folders))
for _, f := range folders {
var entries []model.FolderEntry
if err := r.db.Where("folder_id = ?", f.ID).
Order("position, id").Find(&entries).Error; err != nil {
return nil, err
}
songIDs := make([]uint, 0, len(entries))
for _, e := range entries {
songIDs = append(songIDs, e.SongID)
}
result = append(result, FolderWithSongs{
ID: f.ID, Name: f.Name, Position: f.Position, SongIDs: songIDs,
})
}
return result, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 🏗️ Heavy lift

N+1 query: load folder entries in a single query.

FoldersForUser loads entries for each folder inside a loop (lines 59-61), issuing one query per folder. For a user with many folders, this creates a performance bottleneck.

Use GORM's Preload to load all entries in a single query:

⚡ Proposed fix using Preload

Replace the current implementation with:

 func (r *Repo) FoldersForUser(userID uint) ([]FolderWithSongs, error) {
 	var folders []model.Folder
 	err := r.db.Where("owner_user_id = ?", userID).
+		Preload("Entries", func(db *gorm.DB) *gorm.DB {
+			return db.Order("position, id")
+		}).
 		Order("position, id").Find(&folders).Error
 	if err != nil {
 		return nil, err
 	}
 	result := make([]FolderWithSongs, 0, len(folders))
 	for _, f := range folders {
-		var entries []model.FolderEntry
-		if err := r.db.Where("folder_id = ?", f.ID).
-			Order("position, id").Find(&entries).Error; err != nil {
-			return nil, err
-		}
-		songIDs := make([]uint, 0, len(entries))
-		for _, e := range entries {
+		songIDs := make([]uint, 0, len(f.Entries))
+		for _, e := range f.Entries {
 			songIDs = append(songIDs, e.SongID)
 		}
 		result = append(result, FolderWithSongs{
 			ID: f.ID, Name: f.Name, Position: f.Position, SongIDs: songIDs,
 		})
 	}
 	return result, nil
 }

This requires model.Folder to have an Entries []FolderEntry field with the appropriate GORM association tag (e.g., gorm:"foreignKey:FolderID").

🤖 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 `@internal/repository/folders.go` around lines 47 - 72, FoldersForUser performs
an N+1 by querying FolderEntry per folder; modify it to preload entries in one
query by using GORM Preload on model.Folder (ensure model.Folder has Entries
[]FolderEntry with gorm:"foreignKey:FolderID"), then iterate folders' Entries to
build FolderWithSongs (keep ID/Name/Position and collect SongID from each
Entry); update the function's use of r.db to use r.db.Where("owner_user_id = ?",
userID).Preload("Entries", func(db *gorm.DB) *gorm.DB { return
db.Order("position, id") }).Order("position, id").Find(&folders).Error and
remove the per-folder query loop.

Comment on lines +19 to +36
func (r *Repo) CreateResource(songID, userID uint, url, label string) (*model.Resource, error) {
var maxPos int
err := r.db.Model(&model.Resource{}).
Select("COALESCE(MAX(position), 0)").
Where("song_id = ? AND user_id = ?", songID, userID).
Scan(&maxPos).Error
if err != nil {
return nil, err
}
res := &model.Resource{
SongID: songID, UserID: &userID,
URL: url, Label: label, Position: maxPos + 1,
}
if err := r.db.Create(res).Error; err != nil {
return nil, err
}
return res, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Race condition in position assignment.

CreateResource reads MAX(position) then writes maxPos + 1 in two separate operations. Concurrent creates for the same (songID, userID) can read the same maxPos, then both write the same new position, violating the expected unique ordering for drag-and-drop reordering.

The PR objectives describe "drag-reorder" behavior, which requires stable, unique positions per scope.

🔒 Proposed fix using a transaction or advisory lock

Option 1: Transaction with SELECT FOR UPDATE (SQLite supports this):

 func (r *Repo) CreateResource(songID, userID uint, url, label string) (*model.Resource, error) {
+	return r.createResourceTx(songID, userID, url, label)
+}
+
+func (r *Repo) createResourceTx(songID, userID uint, url, label string) (*model.Resource, error) {
+	tx := r.db.Begin()
+	defer func() {
+		if r := recover(); r != nil {
+			tx.Rollback()
+		}
+	}()
+
 	var maxPos int
-	err := r.db.Model(&model.Resource{}).
+	err := tx.Model(&model.Resource{}).
 		Select("COALESCE(MAX(position), 0)").
 		Where("song_id = ? AND user_id = ?", songID, userID).
+		Clauses(clause.Locking{Strength: "UPDATE"}).
 		Scan(&maxPos).Error
 	if err != nil {
+		tx.Rollback()
 		return nil, err
 	}
 	res := &model.Resource{
 		SongID: songID, UserID: &userID,
 		URL: url, Label: label, Position: maxPos + 1,
 	}
-	if err := r.db.Create(res).Error; err != nil {
+	if err := tx.Create(res).Error; err != nil {
+		tx.Rollback()
 		return nil, err
 	}
+	if err := tx.Commit().Error; err != nil {
+		return nil, err
+	}
 	return res, nil
 }

Option 2: Add a unique constraint on (song_id, user_id, position) and retry on conflict (more complex, requires conflict handling).

🤖 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 `@internal/repository/resources.go` around lines 19 - 36, The CreateResource
method currently reads MAX(position) then inserts maxPos+1 separately, causing
race conditions; fix by performing the read-and-insert inside a DB transaction
and lock the relevant rows when computing MAX(position) (use a SELECT FOR UPDATE
/ row lock via r.db transaction/locking clauses on model.Resource filtered by
songID and userID) so concurrent creators serialize and receive unique
positions; alternatively add a unique constraint on (song_id, user_id, position)
and implement retry-on-conflict logic when r.db.Create(res) fails due to
constraint violation—update Repo.CreateResource and model.Resource accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jwhumphries jwhumphries merged commit 111ed0e into main Jun 11, 2026
5 checks passed
@jwhumphries jwhumphries deleted the personal-songs branch June 11, 2026 23:55
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