diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md
index 96a5fec..41172a5 100644
--- a/documentation/backend/services/notifications.md
+++ b/documentation/backend/services/notifications.md
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
## Key Details
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
-- **Events:** request_pending_approval, request_approved, request_available, request_error
+- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
@@ -35,6 +35,7 @@ model NotificationBackend {
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_available | Plex/ABS scan completes | Audiobook available in library |
| request_error | Download/import fails | Request failed at any stage |
+| issue_reported | User reports issue | User reports problem with available audiobook |
## Notification Triggers
@@ -67,6 +68,10 @@ model NotificationBackend {
- After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload
+**Issue Reported (reported-issue.service.ts)**
+- After user reports issue with available audiobook → issue_reported
+- Payload: issue ID (as requestId), book title/author, reporter username, reason (as message)
+
## Configuration Encryption
**Encrypted Values:**
@@ -91,13 +96,14 @@ model NotificationBackend {
- Format: Event title + book details + user + error (if applicable)
**Discord (Rich Embeds):**
-- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
-- Fields: Title, Author, Requested By, Error (if applicable)
-- Footer: Request ID
+- Color-coded by event (yellow=pending, green=approved, blue=available, red=error, orange=issue)
+- Fields: Title, Author, Requested/Reported By, Error/Reason (if applicable)
+- Footer: Request/Issue ID
- Timestamp: Event time
-**ntfy (JSON with Tags):**
-- Tags: mailbox_with_mail, white_check_mark, tada, x (rendered as emojis by ntfy)
+**ntfy (JSON Publishing to Base URL):**
+- Endpoint: POST to base `serverUrl` (default: https://ntfy.sh), topic in JSON body
+- Tags: mailbox_with_mail, white_check_mark, tada, x, triangular_flag_on_post (rendered as emojis by ntfy)
- Priority: Default (3) for pending/approved, High (4) for available/error
- Format: Event title + book details + user + error (if applicable)
- Auth: Optional Bearer token via `accessToken` config field
@@ -142,7 +148,7 @@ model NotificationBackend {
**Modal Features:**
- Type-first selection (user clicks "Add Discord" or "Add Pushover")
- Password inputs for sensitive values
-- Event subscription checkboxes (4 events, default: available + error)
+- Event subscription checkboxes (5 events, default: available + error)
- Test button (sends synchronous test notification)
- Save button (validates and creates/updates backend)
diff --git a/documentation/phase3/qbittorrent.md b/documentation/phase3/qbittorrent.md
index 66f2563..76aa7c7 100644
--- a/documentation/phase3/qbittorrent.md
+++ b/documentation/phase3/qbittorrent.md
@@ -175,19 +175,19 @@ interface TorrentInfo {
}
type TorrentState =
- // Core states
+ // Core states (*DL = download phase, *UP = upload/post-download phase)
| 'downloading' | 'uploading'
- | 'stalledDL' | 'stalledUP'
- | 'pausedDL' | 'pausedUP'
- | 'queuedDL' | 'queuedUP'
- | 'checkingDL' | 'checkingUP'
+ | 'stalledDL' | 'stalledUP' // stalledUP → completed (download done)
+ | 'pausedDL' | 'pausedUP' // pausedUP → completed (download done, paused seeding)
+ | 'queuedDL' | 'queuedUP' // queuedUP → completed (download done)
+ | 'checkingDL' | 'checkingUP' // checkingUP → completed (download done, rechecking)
| 'error' | 'missingFiles' | 'allocating'
// Forced states (user clicked "Force Resume")
- | 'forcedDL' | 'forcedUP'
+ | 'forcedDL' | 'forcedUP' // forcedUP → completed (download done)
// Metadata fetching
| 'metaDL' | 'forcedMetaDL'
// qBittorrent v5.0+ (renamed paused → stopped)
- | 'stoppedDL' | 'stoppedUP'
+ | 'stoppedDL' | 'stoppedUP' // stoppedUP → completed (download done)
// Other
| 'checkingResumeData' | 'moving';
```
@@ -241,7 +241,13 @@ type TorrentState =
- Adding all 8 missing states to `TorrentState` type union
- Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface)
- `forcedUP` → `seeding`/`completed` enables monitor to trigger import
- - `stoppedDL`/`stoppedUP` → `paused` ensures qBittorrent v5.x compatibility
+ - `stoppedDL` → `paused` ensures qBittorrent v5.x compatibility
+
+**16. pausedUP/stoppedUP mapped as paused instead of completed** - RDT-Client (and qBittorrent after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by:
+ - `pausedUP` → `seeding` (unified) / `completed` (legacy) — triggers completion in monitor
+ - `stoppedUP` → `seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x
+ - `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused
+ - Key insight: any `*UP` state is post-download; any `*DL` state is pre-completion
## Tech Stack
diff --git a/prisma/migrations/20260211000000_add_goodreads_sync/migration.sql b/prisma/migrations/20260211000000_add_goodreads_sync/migration.sql
new file mode 100644
index 0000000..1c95487
--- /dev/null
+++ b/prisma/migrations/20260211000000_add_goodreads_sync/migration.sql
@@ -0,0 +1,46 @@
+-- CreateTable
+CREATE TABLE "goodreads_shelves" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "rss_url" TEXT NOT NULL,
+ "last_sync_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "goodreads_shelves_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "goodreads_book_mappings" (
+ "id" TEXT NOT NULL,
+ "goodreads_book_id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "author" TEXT NOT NULL,
+ "audible_asin" TEXT,
+ "cover_url" TEXT,
+ "no_match" BOOLEAN NOT NULL DEFAULT false,
+ "last_search_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "goodreads_book_mappings_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "goodreads_shelves_user_id_idx" ON "goodreads_shelves"("user_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "goodreads_shelves_user_id_rss_url_key" ON "goodreads_shelves"("user_id", "rss_url");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "goodreads_book_mappings_goodreads_book_id_key" ON "goodreads_book_mappings"("goodreads_book_id");
+
+-- CreateIndex
+CREATE INDEX "goodreads_book_mappings_goodreads_book_id_idx" ON "goodreads_book_mappings"("goodreads_book_id");
+
+-- CreateIndex
+CREATE INDEX "goodreads_book_mappings_audible_asin_idx" ON "goodreads_book_mappings"("audible_asin");
+
+-- AddForeignKey
+ALTER TABLE "goodreads_shelves" ADD CONSTRAINT "goodreads_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260211100000_add_goodreads_shelf_metadata/migration.sql b/prisma/migrations/20260211100000_add_goodreads_shelf_metadata/migration.sql
new file mode 100644
index 0000000..8f7b77d
--- /dev/null
+++ b/prisma/migrations/20260211100000_add_goodreads_shelf_metadata/migration.sql
@@ -0,0 +1,3 @@
+-- Add cached book count and cover URLs to goodreads_shelves for rich UI display
+ALTER TABLE "goodreads_shelves" ADD COLUMN "book_count" INTEGER;
+ALTER TABLE "goodreads_shelves" ADD COLUMN "cover_urls" TEXT;
diff --git a/prisma/migrations/20260211200000_add_reported_issues/migration.sql b/prisma/migrations/20260211200000_add_reported_issues/migration.sql
new file mode 100644
index 0000000..441bcba
--- /dev/null
+++ b/prisma/migrations/20260211200000_add_reported_issues/migration.sql
@@ -0,0 +1,32 @@
+-- CreateTable
+CREATE TABLE "reported_issues" (
+ "id" TEXT NOT NULL,
+ "audiobook_id" TEXT NOT NULL,
+ "reporter_id" TEXT NOT NULL,
+ "reason" VARCHAR(250) NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'open',
+ "resolved_at" TIMESTAMP(3),
+ "resolved_by_id" TEXT,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "reported_issues_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "reported_issues_audiobook_id_idx" ON "reported_issues"("audiobook_id");
+
+-- CreateIndex
+CREATE INDEX "reported_issues_reporter_id_idx" ON "reported_issues"("reporter_id");
+
+-- CreateIndex
+CREATE INDEX "reported_issues_status_idx" ON "reported_issues"("status");
+
+-- AddForeignKey
+ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_audiobook_id_fkey" FOREIGN KEY ("audiobook_id") REFERENCES "audiobooks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index bf35442..5780358 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -64,6 +64,9 @@ model User {
requests Request[]
bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[]
+ goodreadsShelves GoodreadsShelf[]
+ reportedIssues ReportedIssue[] @relation("Reporter")
+ resolvedIssues ReportedIssue[] @relation("Resolver")
@@index([plexId])
@@index([role])
@@ -197,7 +200,8 @@ model Audiobook {
completedAt DateTime? @map("completed_at")
// Relations
- requests Request[]
+ requests Request[]
+ reportedIssues ReportedIssue[]
@@index([audibleAsin])
@@index([plexGuid])
@@ -456,3 +460,71 @@ model NotificationBackend {
@@index([enabled])
@@map("notification_backends")
}
+
+// ============================================================================
+// REPORTED ISSUES TABLE
+// User-reported problems with available audiobooks (corrupted, wrong book, etc.)
+// ============================================================================
+
+model ReportedIssue {
+ id String @id @default(uuid())
+ audiobookId String @map("audiobook_id")
+ reporterId String @map("reporter_id")
+ reason String @db.VarChar(250)
+ status String @default("open") // open, dismissed, replaced
+ resolvedAt DateTime? @map("resolved_at")
+ resolvedById String? @map("resolved_by_id")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ // Relations
+ audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
+ reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade)
+ resolvedBy User? @relation("Resolver", fields: [resolvedById], references: [id], onDelete: SetNull)
+
+ @@index([audiobookId])
+ @@index([reporterId])
+ @@index([status])
+ @@map("reported_issues")
+}
+
+// ============================================================================
+// GOODREADS SYNC TABLES
+// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
+// ============================================================================
+
+model GoodreadsShelf {
+ id String @id @default(uuid())
+ userId String @map("user_id")
+ name String // Extracted from RSS
+ rssUrl String @map("rss_url") @db.Text
+ lastSyncAt DateTime? @map("last_sync_at")
+ bookCount Int? @map("book_count")
+ coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ // Relations
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, rssUrl])
+ @@index([userId])
+ @@map("goodreads_shelves")
+}
+
+model GoodreadsBookMapping {
+ id String @id @default(uuid())
+ goodreadsBookId String @unique @map("goodreads_book_id")
+ title String
+ author String
+ audibleAsin String? @map("audible_asin")
+ coverUrl String? @map("cover_url") @db.Text
+ noMatch Boolean @default(false) @map("no_match")
+ lastSearchAt DateTime? @map("last_search_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@index([goodreadsBookId])
+ @@index([audibleAsin])
+ @@map("goodreads_book_mappings")
+}
diff --git a/src/app/admin/components/ReportedIssuesSection.tsx b/src/app/admin/components/ReportedIssuesSection.tsx
new file mode 100644
index 0000000..975baa9
--- /dev/null
+++ b/src/app/admin/components/ReportedIssuesSection.tsx
@@ -0,0 +1,242 @@
+/**
+ * Component: Admin Reported Issues Section
+ * Documentation: documentation/backend/services/reported-issues.md
+ *
+ * Displays open reported issues on the admin dashboard.
+ * Allows dismiss or search-for-replacement actions.
+ */
+
+'use client';
+
+import React, { useState } from 'react';
+import { createPortal } from 'react-dom';
+import { useToast } from '@/components/ui/Toast';
+import { formatDistanceToNow } from 'date-fns';
+import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
+import { fetchJSON } from '@/lib/utils/api';
+import { mutate } from 'swr';
+
+interface ReportedIssue {
+ id: string;
+ reason: string;
+ status: string;
+ createdAt: string;
+ audiobook: {
+ id: string;
+ title: string;
+ author: string;
+ coverArtUrl: string | null;
+ audibleAsin: string | null;
+ };
+ reporter: {
+ id: string;
+ plexUsername: string;
+ avatarUrl: string | null;
+ };
+}
+
+interface ReportedIssuesSectionProps {
+ issues: ReportedIssue[];
+}
+
+export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
+ const toast = useToast();
+ const [loadingStates, setLoadingStates] = useState>({});
+ const [replaceIssue, setReplaceIssue] = useState(null);
+
+ const handleDismiss = async (issueId: string) => {
+ setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
+
+ try {
+ await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
+ method: 'POST',
+ body: JSON.stringify({ action: 'dismiss' }),
+ });
+
+ toast.success('Issue dismissed');
+ await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
+ } catch (error) {
+ toast.error(
+ `Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ } finally {
+ setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
+ }
+ };
+
+ const handleReplaceSuccess = async () => {
+ toast.success('Replacement download started');
+ setReplaceIssue(null);
+ await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
+ await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
+ };
+
+ return (
+ <>
+
+ {/* Section Header */}
+
+
+
+
+ Reported Issues
+
+
+
+ {issues.length}
+
+
+
+ {/* Issues Grid */}
+
+ {issues.map((issue) => {
+ const isLoading = loadingStates[issue.id] || false;
+
+ return (
+
+ {/* Card Content */}
+
+
+ {/* Cover Image */}
+
+ {issue.audiobook.coverArtUrl ? (
+

+ ) : (
+
+ )}
+
+
+ {/* Info */}
+
+
+ {issue.audiobook.title}
+
+
+ {issue.audiobook.author}
+
+
+ {/* Reporter */}
+
+ {issue.reporter.avatarUrl ? (
+

+ ) : (
+
+ )}
+
+ {issue.reporter.plexUsername}
+
+
+
+ {/* Timestamp */}
+
+ {formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
+
+
+
+
+ {/* Reason */}
+
+ {issue.reason}
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {/* Interactive Search Modal for Replacement */}
+ {replaceIssue && createPortal(
+
+ setReplaceIssue(null)}
+ onSuccess={handleReplaceSuccess}
+ audiobook={{
+ title: replaceIssue.audiobook.title,
+ author: replaceIssue.audiobook.author,
+ }}
+ asin={replaceIssue.audiobook.audibleAsin || undefined}
+ replaceIssueId={replaceIssue.id}
+ />
+
,
+ document.body
+ )}
+ >
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 2e342cf..9fcd9b7 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard';
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast';
+import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
@@ -328,6 +329,14 @@ function AdminDashboardContent() {
}
);
+ const { data: reportedIssuesData } = useSWR(
+ '/api/admin/reported-issues',
+ authenticatedFetcher,
+ {
+ refreshInterval: 10000,
+ }
+ );
+
const { data: settingsData } = useSWR(
'/api/admin/settings',
authenticatedFetcher,
@@ -578,6 +587,11 @@ function AdminDashboardContent() {
)}
+ {/* Reported Issues */}
+ {reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && (
+
+ )}
+
{/* Active Downloads */}
diff --git a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx
index 0c97111..2da0801 100644
--- a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx
+++ b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { RMABLogger } from '@/lib/utils/logger';
import { fetchWithAuth } from '@/lib/utils/api';
+import { EVENT_LABELS } from '@/lib/constants/notification-events';
const logger = RMABLogger.create('NotificationsTab');
@@ -43,12 +44,7 @@ interface ModalState {
backend?: NotificationBackend;
}
-const eventLabels: Record = {
- request_pending_approval: 'Request Pending Approval',
- request_approved: 'Request Approved',
- request_available: 'Audiobook Available',
- request_error: 'Request Error',
-};
+const eventLabels: Record = EVENT_LABELS;
export function NotificationsTab() {
const [backends, setBackends] = useState([]);
diff --git a/src/app/api/admin/notifications/[id]/route.ts b/src/app/api/admin/notifications/[id]/route.ts
index d2f854b..1501b88 100644
--- a/src/app/api/admin/notifications/[id]/route.ts
+++ b/src/app/api/admin/notifications/[id]/route.ts
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService } from '@/lib/services/notification';
+import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
@@ -15,7 +16,7 @@ const logger = RMABLogger.create('API.Admin.Notifications.Id');
const UpdateBackendSchema = z.object({
name: z.string().min(1).optional(),
config: z.record(z.any()).optional(),
- events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
+ events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1).optional(),
enabled: z.boolean().optional(),
});
diff --git a/src/app/api/admin/notifications/route.ts b/src/app/api/admin/notifications/route.ts
index 44dab48..2be7ad5 100644
--- a/src/app/api/admin/notifications/route.ts
+++ b/src/app/api/admin/notifications/route.ts
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
+import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
@@ -16,7 +17,7 @@ const CreateBackendSchema = z.object({
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
name: z.string().min(1),
config: z.record(z.any()),
- events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
+ events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1),
enabled: z.boolean().default(true),
});
diff --git a/src/app/api/admin/reported-issues/[id]/replace/route.ts b/src/app/api/admin/reported-issues/[id]/replace/route.ts
new file mode 100644
index 0000000..41f5ce7
--- /dev/null
+++ b/src/app/api/admin/reported-issues/[id]/replace/route.ts
@@ -0,0 +1,87 @@
+/**
+ * Component: Admin Replace Audiobook API
+ * Documentation: documentation/backend/services/reported-issues.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { replaceAudiobook, ReportedIssueError } from '@/lib/services/reported-issue.service';
+import { z } from 'zod';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Admin.ReportedIssues.Replace');
+
+const ReplaceSchema = z.object({
+ torrent: z.object({
+ guid: z.string(),
+ title: z.string(),
+ size: z.number(),
+ seeders: z.number().optional(),
+ leechers: z.number().optional(),
+ indexer: z.string(),
+ indexerId: z.number().optional(),
+ downloadUrl: z.string(),
+ infoUrl: z.string().optional(),
+ publishDate: z.string().transform((str) => new Date(str)),
+ infoHash: z.string().optional(),
+ format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
+ bitrate: z.string().optional(),
+ hasChapters: z.boolean().optional(),
+ protocol: z.enum(['torrent', 'usenet']).optional(),
+ }),
+});
+
+/**
+ * POST /api/admin/reported-issues/[id]/replace
+ * Atomically replace audiobook content: delete old → create new request → start download → resolve issue
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ if (!req.user) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'User not authenticated' },
+ { status: 401 }
+ );
+ }
+
+ const { id } = await params;
+ const body = await req.json();
+ const { torrent } = ReplaceSchema.parse(body);
+
+ const result = await replaceAudiobook(id, req.user.id, torrent);
+
+ return NextResponse.json({
+ success: true,
+ request: result.request,
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'ValidationError', details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ if (error instanceof ReportedIssueError) {
+ return NextResponse.json(
+ { error: 'ReplaceError', message: error.message },
+ { status: error.statusCode }
+ );
+ }
+
+ logger.error('Failed to replace audiobook', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: 'ServerError', message: 'Failed to replace audiobook' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/reported-issues/[id]/resolve/route.ts b/src/app/api/admin/reported-issues/[id]/resolve/route.ts
new file mode 100644
index 0000000..9ec8a53
--- /dev/null
+++ b/src/app/api/admin/reported-issues/[id]/resolve/route.ts
@@ -0,0 +1,74 @@
+/**
+ * Component: Admin Resolve Reported Issue API
+ * Documentation: documentation/backend/services/reported-issues.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { dismissIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
+import { z } from 'zod';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Admin.ReportedIssues.Resolve');
+
+const ResolveSchema = z.object({
+ action: z.enum(['dismiss']),
+});
+
+/**
+ * POST /api/admin/reported-issues/[id]/resolve
+ * Dismiss a reported issue
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ if (!req.user) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'User not authenticated' },
+ { status: 401 }
+ );
+ }
+
+ const { id } = await params;
+ const body = await req.json();
+ const { action } = ResolveSchema.parse(body);
+
+ if (action === 'dismiss') {
+ const issue = await dismissIssue(id, req.user.id);
+ return NextResponse.json({ success: true, issue });
+ }
+
+ return NextResponse.json(
+ { error: 'InvalidAction', message: 'Unknown action' },
+ { status: 400 }
+ );
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'ValidationError', details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ if (error instanceof ReportedIssueError) {
+ return NextResponse.json(
+ { error: 'ResolveError', message: error.message },
+ { status: error.statusCode }
+ );
+ }
+
+ logger.error('Failed to resolve issue', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: 'ServerError', message: 'Failed to resolve issue' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/reported-issues/route.ts b/src/app/api/admin/reported-issues/route.ts
new file mode 100644
index 0000000..9efc37c
--- /dev/null
+++ b/src/app/api/admin/reported-issues/route.ts
@@ -0,0 +1,39 @@
+/**
+ * Component: Admin Reported Issues List API
+ * Documentation: documentation/backend/services/reported-issues.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { getOpenIssues } from '@/lib/services/reported-issue.service';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.Admin.ReportedIssues');
+
+/**
+ * GET /api/admin/reported-issues
+ * Get all open reported issues with audiobook metadata and reporter info
+ */
+export async function GET(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ return requireAdmin(req, async () => {
+ try {
+ const issues = await getOpenIssues();
+
+ return NextResponse.json({
+ success: true,
+ issues,
+ count: issues.length,
+ });
+ } catch (error) {
+ logger.error('Failed to fetch reported issues', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: 'ServerError', message: 'Failed to fetch reported issues' },
+ { status: 500 }
+ );
+ }
+ });
+ });
+}
diff --git a/src/app/api/audiobooks/[asin]/report-issue/route.ts b/src/app/api/audiobooks/[asin]/report-issue/route.ts
new file mode 100644
index 0000000..bf951ea
--- /dev/null
+++ b/src/app/api/audiobooks/[asin]/report-issue/route.ts
@@ -0,0 +1,69 @@
+/**
+ * Component: Report Issue API
+ * Documentation: documentation/backend/services/reported-issues.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { reportIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
+import { z } from 'zod';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.ReportIssue');
+
+const ReportIssueSchema = z.object({
+ reason: z.string().min(1, 'Reason is required').max(250, 'Reason must be 250 characters or less'),
+ title: z.string().optional(),
+ author: z.string().optional(),
+ coverArtUrl: z.string().optional(),
+});
+
+/**
+ * POST /api/audiobooks/[asin]/report-issue
+ * Report an issue with an available audiobook
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ asin: string }> }
+) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ try {
+ if (!req.user) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'User not authenticated' },
+ { status: 401 }
+ );
+ }
+
+ const { asin } = await params;
+ const body = await req.json();
+ const { reason, title, author, coverArtUrl } = ReportIssueSchema.parse(body);
+
+ const issue = await reportIssue(asin, req.user.id, reason, { title, author, coverArtUrl });
+
+ return NextResponse.json({ success: true, issue }, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'ValidationError', details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ if (error instanceof ReportedIssueError) {
+ return NextResponse.json(
+ { error: 'ReportIssueError', message: error.message },
+ { status: error.statusCode }
+ );
+ }
+
+ logger.error('Failed to report issue', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: 'ServerError', message: 'Failed to report issue' },
+ { status: 500 }
+ );
+ }
+ });
+}
diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts
index ae0a73d..194302a 100644
--- a/src/app/api/requests/route.ts
+++ b/src/app/api/requests/route.ts
@@ -6,11 +6,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
-import { getJobQueueService } from '@/lib/services/job-queue.service';
-import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
-import { getAudibleService } from '@/lib/integrations/audible.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
+import { createRequestForUser } from '@/lib/services/request-creator.service';
const logger = RMABLogger.create('API.Requests');
@@ -45,274 +43,34 @@ export async function POST(request: NextRequest) {
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
- // First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
- // This catches the gap where files are organized but Plex hasn't scanned yet
- const existingActiveRequest = await prisma.request.findFirst({
- where: {
- audiobook: {
- audibleAsin: audiobook.asin,
- },
- type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
- status: { in: ['downloaded', 'available'] },
- deletedAt: null,
- },
- include: {
- user: { select: { plexUsername: true } },
- },
- });
+ const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
- if (existingActiveRequest) {
- const status = existingActiveRequest.status;
- const isOwnRequest = existingActiveRequest.userId === req.user.id;
-
- return NextResponse.json(
- {
- error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
- message: status === 'available'
- ? 'This audiobook is already available in your Plex library'
- : 'This audiobook is being processed and will be available soon',
- requestStatus: status,
- isOwnRequest,
- requestedBy: existingActiveRequest.user?.plexUsername,
- },
- { status: 409 }
- );
- }
-
- // Second check: Is audiobook already in Plex library? (fallback for non-requested books)
- const plexMatch = await findPlexMatch({
+ const result = await createRequestForUser(req.user.id, {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
- });
+ description: audiobook.description,
+ coverArtUrl: audiobook.coverArtUrl,
+ }, { skipAutoSearch });
- if (plexMatch) {
+ if (!result.success) {
+ const statusMap: Record = {
+ already_available: { error: 'AlreadyAvailable', status: 409 },
+ being_processed: { error: 'BeingProcessed', status: 409 },
+ duplicate: { error: 'DuplicateRequest', status: 409 },
+ user_not_found: { error: 'UserNotFound', status: 404 },
+ };
+ const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
return NextResponse.json(
- {
- error: 'AlreadyAvailable',
- message: 'This audiobook is already available in your Plex library',
- plexGuid: plexMatch.plexGuid,
- },
- { status: 409 }
+ { error: mapped.error, message: result.message },
+ { status: mapped.status }
);
}
- // Fetch full details from Audnexus to get releaseDate, year, and series
- let year: number | undefined;
- let series: string | undefined;
- let seriesPart: string | undefined;
- try {
- const audibleService = getAudibleService();
- const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
-
- if (audnexusData?.releaseDate) {
- try {
- const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
- if (!isNaN(releaseYear)) {
- year = releaseYear;
- logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
- }
- } catch (error) {
- logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
- }
-
- // Extract series data
- if (audnexusData?.series) {
- series = audnexusData.series;
- logger.debug(`Extracted series: ${series}`);
- }
- if (audnexusData?.seriesPart) {
- seriesPart = audnexusData.seriesPart;
- logger.debug(`Extracted seriesPart: ${seriesPart}`);
- }
- } catch (error) {
- logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
-
- // Try to find existing audiobook record by ASIN
- let audiobookRecord = await prisma.audiobook.findFirst({
- where: { audibleAsin: audiobook.asin },
- });
-
- // If not found, create new audiobook record
- if (!audiobookRecord) {
- audiobookRecord = await prisma.audiobook.create({
- data: {
- audibleAsin: audiobook.asin,
- title: audiobook.title,
- author: audiobook.author,
- narrator: audiobook.narrator,
- description: audiobook.description,
- coverArtUrl: audiobook.coverArtUrl,
- year,
- series,
- seriesPart,
- status: 'requested',
- },
- });
- logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
- } else if (year || series || seriesPart) {
- // Always update year/series if we have them from Audnexus (even if audiobook already has them)
- audiobookRecord = await prisma.audiobook.update({
- where: { id: audiobookRecord.id },
- data: {
- ...(year && { year }),
- ...(series && { series }),
- ...(seriesPart && { seriesPart }),
- },
- });
- logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
- }
-
- // Check if user already has an active (non-deleted) audiobook request for this audiobook
- const existingRequest = await prisma.request.findFirst({
- where: {
- userId: req.user.id,
- audiobookId: audiobookRecord.id,
- type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
- deletedAt: null, // Only check active requests
- },
- });
-
- if (existingRequest) {
- // Allow re-requesting if the status is failed, warn, or cancelled
- const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
-
- if (!canReRequest) {
- return NextResponse.json(
- {
- error: 'DuplicateRequest',
- message: 'You have already requested this audiobook',
- request: existingRequest,
- },
- { status: 409 }
- );
- }
-
- // Delete the existing failed/warn/cancelled request
- logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
- await prisma.request.delete({
- where: { id: existingRequest.id },
- });
- }
-
- // Check if we should skip auto-search (for interactive search)
- const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
-
- // Check if request needs approval
- let needsApproval = false;
- let shouldTriggerSearch = !skipAutoSearch;
-
- // Fetch user with autoApproveRequests setting
- const user = await prisma.user.findUnique({
- where: { id: req.user.id },
- select: {
- role: true,
- autoApproveRequests: true,
- },
- });
-
- if (!user) {
- return NextResponse.json(
- { error: 'UserNotFound', message: 'User not found' },
- { status: 404 }
- );
- }
-
- // Determine if approval is needed
- if (user.role === 'admin') {
- // Admins always auto-approve
- needsApproval = false;
- } else {
- // Check user's personal setting first
- if (user.autoApproveRequests === true) {
- needsApproval = false;
- } else if (user.autoApproveRequests === false) {
- needsApproval = true;
- } else {
- // User setting is null, check global setting
- const globalConfig = await prisma.configuration.findUnique({
- where: { key: 'auto_approve_requests' },
- });
- // Default to true if not configured (backward compatibility)
- const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
- needsApproval = !globalAutoApprove;
- }
- }
-
- // Determine initial status
- let initialStatus: string;
- if (needsApproval) {
- initialStatus = 'awaiting_approval';
- shouldTriggerSearch = false; // Don't trigger search if awaiting approval
- } else if (skipAutoSearch) {
- initialStatus = 'awaiting_search';
- } else {
- initialStatus = 'pending';
- }
-
- // Create request with appropriate status
- const newRequest = await prisma.request.create({
- data: {
- userId: req.user.id,
- audiobookId: audiobookRecord.id,
- status: initialStatus,
- type: 'audiobook', // Explicit type for user-created requests
- progress: 0,
- },
- include: {
- audiobook: true,
- user: {
- select: {
- id: true,
- plexUsername: true,
- },
- },
- },
- });
-
- const jobQueue = getJobQueueService();
-
- // Send notification based on approval status
- if (initialStatus === 'awaiting_approval') {
- // Request needs approval - send pending notification
- await jobQueue.addNotificationJob(
- 'request_pending_approval',
- newRequest.id,
- audiobookRecord.title,
- audiobookRecord.author,
- newRequest.user.plexUsername || 'Unknown User'
- ).catch((error) => {
- logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
- });
- } else {
- // Request was auto-approved (either automatic or interactive search) - send approved notification
- await jobQueue.addNotificationJob(
- 'request_approved',
- newRequest.id,
- audiobookRecord.title,
- audiobookRecord.author,
- newRequest.user.plexUsername || 'Unknown User'
- ).catch((error) => {
- logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
- });
- }
-
- // Trigger search job only if not skipped and not awaiting approval
- if (shouldTriggerSearch) {
- await jobQueue.addSearchJob(newRequest.id, {
- id: audiobookRecord.id,
- title: audiobookRecord.title,
- author: audiobookRecord.author,
- asin: audiobookRecord.audibleAsin || undefined,
- });
- }
-
return NextResponse.json({
success: true,
- request: newRequest,
+ request: result.request,
}, { status: 201 });
} catch (error) {
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
diff --git a/src/app/api/user/goodreads-shelves/[id]/route.ts b/src/app/api/user/goodreads-shelves/[id]/route.ts
new file mode 100644
index 0000000..ed072f1
--- /dev/null
+++ b/src/app/api/user/goodreads-shelves/[id]/route.ts
@@ -0,0 +1,50 @@
+/**
+ * Component: Goodreads Shelf Delete Route
+ * Documentation: documentation/backend/services/goodreads-sync.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { prisma } from '@/lib/db';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.GoodreadsShelves');
+
+/**
+ * DELETE /api/user/goodreads-shelves/[id]
+ * Remove a Goodreads shelf subscription (ownership check)
+ */
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ try {
+ if (!req.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { id } = await params;
+
+ const shelf = await prisma.goodreadsShelf.findUnique({
+ where: { id },
+ });
+
+ if (!shelf) {
+ return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
+ }
+
+ // Ownership check
+ if (shelf.userId !== req.user.id) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ await prisma.goodreadsShelf.delete({ where: { id } });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ logger.error('Failed to delete shelf', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json({ error: 'Failed to delete shelf' }, { status: 500 });
+ }
+ });
+}
diff --git a/src/app/api/user/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts
new file mode 100644
index 0000000..9736619
--- /dev/null
+++ b/src/app/api/user/goodreads-shelves/route.ts
@@ -0,0 +1,174 @@
+/**
+ * Component: Goodreads Shelves API Routes
+ * Documentation: documentation/backend/services/goodreads-sync.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
+import { prisma } from '@/lib/db';
+import { fetchAndValidateRss } from '@/lib/services/goodreads-sync.service';
+import { getJobQueueService } from '@/lib/services/job-queue.service';
+import { z } from 'zod';
+import { RMABLogger } from '@/lib/utils/logger';
+
+const logger = RMABLogger.create('API.GoodreadsShelves');
+
+const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
+
+const AddShelfSchema = z.object({
+ rssUrl: z.string().url().refine(
+ (url) => GOODREADS_RSS_PATTERN.test(url),
+ { message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
+ ),
+});
+
+/**
+ * GET /api/user/goodreads-shelves
+ * List the current user's Goodreads shelves with book counts and covers
+ */
+export async function GET(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ try {
+ if (!req.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const shelves = await prisma.goodreadsShelf.findMany({
+ where: { userId: req.user.id },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ const shelvesWithMeta = shelves.map((shelf) => {
+ // Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
+ let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
+ if (shelf.coverUrls) {
+ const parsed = JSON.parse(shelf.coverUrls);
+ if (Array.isArray(parsed)) {
+ books = parsed.map((item: unknown) => {
+ if (typeof item === 'string') {
+ return { coverUrl: item, asin: null, title: '', author: '' };
+ }
+ const obj = item as Record;
+ return {
+ coverUrl: (obj.coverUrl as string) || '',
+ asin: (obj.asin as string) || null,
+ title: (obj.title as string) || '',
+ author: (obj.author as string) || '',
+ };
+ });
+ }
+ }
+
+ return {
+ id: shelf.id,
+ name: shelf.name,
+ rssUrl: shelf.rssUrl,
+ lastSyncAt: shelf.lastSyncAt,
+ createdAt: shelf.createdAt,
+ bookCount: shelf.bookCount ?? null,
+ books,
+ };
+ });
+
+ return NextResponse.json({ success: true, shelves: shelvesWithMeta });
+ } catch (error) {
+ logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
+ return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
+ }
+ });
+}
+
+/**
+ * POST /api/user/goodreads-shelves
+ * Add a new Goodreads shelf subscription
+ */
+export async function POST(request: NextRequest) {
+ return requireAuth(request, async (req: AuthenticatedRequest) => {
+ try {
+ if (!req.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await req.json();
+ const { rssUrl } = AddShelfSchema.parse(body);
+
+ // Check for duplicate
+ const existing = await prisma.goodreadsShelf.findUnique({
+ where: { userId_rssUrl: { userId: req.user.id, rssUrl } },
+ });
+
+ if (existing) {
+ return NextResponse.json(
+ { error: 'DuplicateShelf', message: 'You have already added this shelf' },
+ { status: 409 }
+ );
+ }
+
+ // Validate by fetching the RSS feed
+ let shelfName: string;
+ let bookCount: number;
+ let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
+ try {
+ const rssData = await fetchAndValidateRss(rssUrl);
+ shelfName = rssData.shelfName;
+ bookCount = rssData.books.length;
+ initialBooks = rssData.books
+ .filter(b => b.coverUrl)
+ .slice(0, 8)
+ .map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error: 'InvalidRSS',
+ message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ },
+ { status: 400 }
+ );
+ }
+
+ const shelf = await prisma.goodreadsShelf.create({
+ data: {
+ userId: req.user.id,
+ name: shelfName,
+ rssUrl,
+ bookCount,
+ coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
+ },
+ });
+
+ // Trigger immediate sync for this shelf (unlimited lookups, process all books)
+ try {
+ const jobQueue = getJobQueueService();
+ await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
+ logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
+ } catch (error) {
+ logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
+ }
+
+ return NextResponse.json({
+ success: true,
+ shelf: {
+ id: shelf.id,
+ name: shelf.name,
+ rssUrl: shelf.rssUrl,
+ lastSyncAt: shelf.lastSyncAt,
+ createdAt: shelf.createdAt,
+ bookCount: shelf.bookCount,
+ books: initialBooks,
+ },
+ bookCount,
+ }, { status: 201 });
+ } catch (error) {
+ logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'ValidationError', details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
+ }
+ });
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 176c9f6..c56e79d 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -196,3 +196,12 @@ body {
.animate-toast-in {
animation: toast-slide-in 0.3s ease-out;
}
+
+/* Hide scrollbar while keeping scroll functional */
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index f82ca15..1d88640 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -11,80 +11,63 @@ import { RequestCard } from '@/components/requests/RequestCard';
import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
+import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
+
+const statConfig = [
+ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
+ { key: 'active', label: 'Active', color: 'text-blue-500' },
+ { key: 'waiting', label: 'Waiting', color: 'text-amber-500' },
+ { key: 'completed', label: 'Complete', color: 'text-emerald-500' },
+ { key: 'failed', label: 'Failed', color: 'text-red-500' },
+ { key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' },
+] as const;
+
+type StatKey = (typeof statConfig)[number]['key'];
export default function ProfilePage() {
const { user } = useAuth();
- // Always show only the current user's own requests (even for admins)
const { requests, isLoading } = useRequests(undefined, 50, true);
- // Calculate statistics
const stats = useMemo(() => {
if (!requests.length) {
- return {
- total: 0,
- completed: 0,
- active: 0,
- waiting: 0,
- failed: 0,
- cancelled: 0,
- };
+ return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 };
}
-
return {
total: requests.length,
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
- active: requests.filter((r: any) =>
- ['pending', 'searching', 'downloading', 'processing'].includes(r.status)
- ).length,
- waiting: requests.filter((r: any) =>
- ['awaiting_search', 'awaiting_import'].includes(r.status)
- ).length,
+ active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
+ waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
failed: requests.filter((r: any) => r.status === 'failed').length,
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
};
}, [requests]);
- // Get active downloads (downloading or processing)
const activeDownloads = useMemo(() => {
- return requests.filter((r: any) =>
- ['downloading', 'processing'].includes(r.status)
- );
+ return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
}, [requests]);
- // Get recent requests (last 5)
const recentRequests = useMemo(() => {
return [...requests]
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5);
}, [requests]);
- // Redirect to login if not authenticated
if (!user) {
return (
-
-
-
);
@@ -94,183 +77,83 @@ export default function ProfilePage() {
-
- {/* User Info Card */}
-
-
+
+ {/* Profile Card — gradient banner + avatar + info + stats */}
+
+ {/* Gradient Banner */}
+
+
+ {/* Profile Content — overlapping the banner */}
+
{/* Avatar */}
-
- {user.avatarUrl ? (
-

- ) : (
-
- {user.username.charAt(0).toUpperCase()}
-
- )}
-
+ {user.avatarUrl ? (
+

+ ) : (
+
+ {user.username.charAt(0).toUpperCase()}
+
+ )}
- {/* User Details */}
-
-
- {user.username}
-
- {user.email && (
-
- {user.email}
-
- )}
-
-
- {user.role === 'admin' ? 'Administrator' : 'User'}
-
-
- Plex ID: {user.plexId}
-
-
-
-
-
-
- {/* Statistics Grid */}
-
- {/* Total Requests */}
-
-
-
-
-
Total
-
- {isLoading ? '...' : stats.total}
-
-
+ {/* Name + Email + Badge */}
+
+ {user.username}
+
+ {user.email && (
+
+ {user.email}
+
+ )}
+
+
+ {user.role === 'admin' ? 'Administrator' : 'User'}
+
- {/* Active Requests */}
-
-
-
-
-
-
-
+ {/* Stats Strip */}
+
+ {statConfig.map((stat) => (
+
+
+ {isLoading ? '\u2013' : stats[stat.key as StatKey]}
+
+
+ {stat.label}
-
-
Active
-
- {isLoading ? '...' : stats.active}
-
-
-
+ ))}
+
- {/* Waiting Requests */}
-
-
-
-
-
Waiting
-
- {isLoading ? '...' : stats.waiting}
-
-
-
-
-
- {/* Completed Requests */}
-
-
-
-
-
Completed
-
- {isLoading ? '...' : stats.completed}
-
-
-
-
-
- {/* Failed Requests */}
-
-
-
-
-
Failed
-
- {isLoading ? '...' : stats.failed}
-
-
-
-
-
- {/* Cancelled Requests */}
-
-
-
-
-
Cancelled
-
- {isLoading ? '...' : stats.cancelled}
-
-
-
-
-
+ {/* Goodreads Shelves */}
+
{/* Active Downloads */}
{activeDownloads.length > 0 && (
-
-
-
+
+
@@ -278,21 +161,23 @@ export default function ProfilePage() {
))}
-
+
)}
{/* Recent Requests */}
-
-
-
+
+
{isLoading ? (
@@ -300,14 +185,14 @@ export default function ProfilePage() {
{[1, 2, 3].map((i) => (
@@ -320,47 +205,34 @@ export default function ProfilePage() {
))}
) : (
-
+
-
+
-
-
- No requests yet
-
-
- Start by searching for audiobooks and requesting them
-
-
-
+
+ No requests yet
+
+
+ Search for audiobooks to get started
+
+
+
+
+
+ Search Audiobooks
+
)}
-
+
);
diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx
index 4d7c0b6..9b9ecc3 100644
--- a/src/components/audiobooks/AudiobookCard.tsx
+++ b/src/components/audiobooks/AudiobookCard.tsx
@@ -244,6 +244,7 @@ export function AudiobookCard({
requestStatus={audiobook.requestStatus}
isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername}
+ hasReportedIssue={audiobook.hasReportedIssue}
/>
>
);
diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx
index f7c788e..3a759c8 100644
--- a/src/components/audiobooks/AudiobookDetailsModal.tsx
+++ b/src/components/audiobooks/AudiobookDetailsModal.tsx
@@ -16,6 +16,7 @@ import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hoo
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
+import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
interface AudiobookDetailsModalProps {
asin: string;
@@ -27,6 +28,7 @@ interface AudiobookDetailsModalProps {
isAvailable?: boolean;
requestedByUsername?: string | null;
hideRequestActions?: boolean;
+ hasReportedIssue?: boolean;
}
// Status helper
@@ -65,6 +67,7 @@ export function AudiobookDetailsModal({
isAvailable = false,
requestedByUsername = null,
hideRequestActions = false,
+ hasReportedIssue = false,
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { squareCovers } = usePreferences();
@@ -79,6 +82,7 @@ export function AudiobookDetailsModal({
const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
+ const [showReportIssue, setShowReportIssue] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
@@ -316,6 +320,33 @@ export function AudiobookDetailsModal({
)}
+ {/* Issue Reported Badge */}
+ {isAvailable && hasReportedIssue && (
+
+
+
+
+
+ Issue Reported
+
+
+ )}
+
+ {/* Report Issue Button - inline with metadata, not in action bar */}
+ {isAvailable && !hasReportedIssue && user && (
+
+
+
+ )}
+
{/* Quick Metadata */}
{audiobook.durationMinutes && (
@@ -526,6 +557,7 @@ export function AudiobookDetailsModal({
)}
>
)}
+
)}
@@ -594,6 +626,22 @@ export function AudiobookDetailsModal({
,
document.body
)}
+
+ {/* Report Issue Modal */}
+ {showReportIssue && audiobook && (
+
setShowReportIssue(false)}
+ onSuccess={() => {
+ setShowReportIssue(false);
+ showNotification('Issue reported!');
+ }}
+ asin={asin}
+ bookTitle={audiobook.title}
+ bookAuthor={audiobook.author}
+ coverArtUrl={audiobook.coverArtUrl}
+ />
+ )}
>
);
}
diff --git a/src/components/audiobooks/ReportIssueModal.tsx b/src/components/audiobooks/ReportIssueModal.tsx
new file mode 100644
index 0000000..0bb7283
--- /dev/null
+++ b/src/components/audiobooks/ReportIssueModal.tsx
@@ -0,0 +1,143 @@
+/**
+ * Component: Report Issue Modal
+ * Documentation: documentation/frontend/components.md
+ *
+ * Sub-modal for reporting problems with available audiobooks.
+ * Rendered via portal at z-[60] to layer above AudiobookDetailsModal.
+ */
+
+'use client';
+
+import React, { useState } from 'react';
+import { createPortal } from 'react-dom';
+import { useReportIssue } from '@/lib/hooks/useReportedIssues';
+
+interface ReportIssueModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ asin: string;
+ bookTitle: string;
+ bookAuthor: string;
+ coverArtUrl?: string;
+}
+
+export function ReportIssueModal({
+ isOpen,
+ onClose,
+ onSuccess,
+ asin,
+ bookTitle,
+ bookAuthor,
+ coverArtUrl,
+}: ReportIssueModalProps) {
+ const { reportIssue, isLoading } = useReportIssue();
+ const [reason, setReason] = useState('');
+ const [error, setError] = useState(null);
+
+ const maxChars = 250;
+ const canSubmit = reason.trim().length > 0 && reason.length <= maxChars && !isLoading;
+
+ const handleSubmit = async () => {
+ if (!canSubmit) return;
+
+ setError(null);
+ try {
+ await reportIssue(asin, reason.trim(), {
+ title: bookTitle,
+ author: bookAuthor,
+ coverArtUrl,
+ });
+ setReason('');
+ onSuccess();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to report issue');
+ }
+ };
+
+ if (!isOpen) return null;
+
+ const modalContent = (
+ !isLoading && onClose()}
+ >
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
+
+ Report Issue
+
+
+ {bookTitle}
+
+
+
+
+ {/* Reason Textarea */}
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+
+ return createPortal(modalContent, document.body);
+}
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index b3f172b..6fafe8b 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button';
import { VersionBadge } from '@/components/ui/VersionBadge';
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
+import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export function Header() {
@@ -20,6 +21,7 @@ export function Header() {
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
+ const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
// Check if user can change password (local users only)
@@ -90,6 +92,15 @@ export function Header() {
>
Profile
+
{canChangePassword && (