mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-19 04:30:10 +00:00
20c8fb0898
Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
/**
|
|
* Component: Reported Issue Service
|
|
* Documentation: documentation/backend/services/reported-issues.md
|
|
*
|
|
* Handles user-reported problems with available audiobooks.
|
|
* Supports dismiss (admin closes) and replace (admin picks new torrent) workflows.
|
|
*/
|
|
|
|
import { prisma } from '@/lib/db';
|
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
|
|
const logger = RMABLogger.create('ReportedIssue');
|
|
|
|
/**
|
|
* Report an issue with an available audiobook
|
|
*/
|
|
export async function reportIssue(
|
|
asin: string,
|
|
reporterId: string,
|
|
reason: string,
|
|
metadata?: { title?: string; author?: string; coverArtUrl?: string }
|
|
) {
|
|
// Validate the book is in the library
|
|
const plexMatch = await findPlexMatch({
|
|
asin,
|
|
title: metadata?.title || '',
|
|
author: metadata?.author || '',
|
|
});
|
|
|
|
if (!plexMatch) {
|
|
throw new ReportedIssueError('This audiobook is not currently in your library', 404);
|
|
}
|
|
|
|
// Find or create audiobook record for this ASIN
|
|
let audiobook = await prisma.audiobook.findFirst({
|
|
where: { audibleAsin: asin },
|
|
});
|
|
|
|
if (!audiobook) {
|
|
audiobook = await prisma.audiobook.create({
|
|
data: {
|
|
audibleAsin: asin,
|
|
title: metadata?.title || 'Unknown Title',
|
|
author: metadata?.author || 'Unknown Author',
|
|
coverArtUrl: metadata?.coverArtUrl,
|
|
status: 'requested',
|
|
},
|
|
});
|
|
logger.info(`Created audiobook record for ASIN ${asin} to link reported issue`);
|
|
}
|
|
|
|
// Check for existing open issue
|
|
const existingIssue = await prisma.reportedIssue.findFirst({
|
|
where: {
|
|
audiobookId: audiobook.id,
|
|
status: 'open',
|
|
},
|
|
});
|
|
|
|
if (existingIssue) {
|
|
throw new ReportedIssueError('An issue has already been reported for this audiobook', 409);
|
|
}
|
|
|
|
const issue = await prisma.reportedIssue.create({
|
|
data: {
|
|
audiobookId: audiobook.id,
|
|
reporterId,
|
|
reason,
|
|
},
|
|
include: {
|
|
audiobook: { select: { title: true, author: true, audibleAsin: true } },
|
|
reporter: { select: { plexUsername: true } },
|
|
},
|
|
});
|
|
|
|
logger.info(`Issue reported for "${audiobook.title}" by user ${reporterId}`);
|
|
|
|
// Queue notification (non-blocking)
|
|
try {
|
|
const { getJobQueueService } = await import('./job-queue.service');
|
|
const jobQueue = getJobQueueService();
|
|
await jobQueue.addNotificationJob(
|
|
'issue_reported',
|
|
issue.id,
|
|
audiobook.title,
|
|
audiobook.author,
|
|
issue.reporter.plexUsername,
|
|
reason
|
|
);
|
|
} catch (error) {
|
|
logger.error('Failed to queue issue_reported notification', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
return issue;
|
|
}
|
|
|
|
/**
|
|
* Dismiss a reported issue (admin action)
|
|
*/
|
|
export async function dismissIssue(issueId: string, adminUserId: string) {
|
|
const issue = await prisma.reportedIssue.findUnique({
|
|
where: { id: issueId },
|
|
});
|
|
|
|
if (!issue) {
|
|
throw new ReportedIssueError('Issue not found', 404);
|
|
}
|
|
|
|
if (issue.status !== 'open') {
|
|
throw new ReportedIssueError('Issue is already resolved', 409);
|
|
}
|
|
|
|
const updated = await prisma.reportedIssue.update({
|
|
where: { id: issueId },
|
|
data: {
|
|
status: 'dismissed',
|
|
resolvedAt: new Date(),
|
|
resolvedById: adminUserId,
|
|
},
|
|
});
|
|
|
|
logger.info(`Issue ${issueId} dismissed by admin ${adminUserId}`);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Replace audiobook content for a reported issue (atomic admin action):
|
|
* 1. Validate issue is open
|
|
* 2. Delete old content (via request delete or direct library deletion)
|
|
* 3. Create new request + start download with selected torrent
|
|
* 4. Resolve issue as "replaced"
|
|
*/
|
|
export async function replaceAudiobook(
|
|
issueId: string,
|
|
adminUserId: string,
|
|
torrent: any
|
|
) {
|
|
const issue = await prisma.reportedIssue.findUnique({
|
|
where: { id: issueId },
|
|
include: {
|
|
audiobook: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
author: true,
|
|
audibleAsin: true,
|
|
coverArtUrl: true,
|
|
narrator: true,
|
|
plexGuid: true,
|
|
absItemId: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!issue) {
|
|
throw new ReportedIssueError('Issue not found', 404);
|
|
}
|
|
|
|
if (issue.status !== 'open') {
|
|
throw new ReportedIssueError('Issue is already resolved', 409);
|
|
}
|
|
|
|
const audiobook = issue.audiobook;
|
|
|
|
// Step 1: Find existing active request for this audiobook
|
|
const existingRequest = await prisma.request.findFirst({
|
|
where: {
|
|
audiobookId: audiobook.id,
|
|
type: 'audiobook',
|
|
deletedAt: null,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
// Step 2: Delete old content
|
|
if (existingRequest) {
|
|
// Has an RMAB request — use deleteRequest which handles torrent cleanup, files, library backend
|
|
const { deleteRequest } = await import('./request-delete.service');
|
|
const deleteResult = await deleteRequest(existingRequest.id, adminUserId);
|
|
if (!deleteResult.success) {
|
|
logger.warn(`deleteRequest partial failure for ${existingRequest.id}: ${deleteResult.error}`);
|
|
// Continue anyway - we want replacement to proceed
|
|
}
|
|
logger.info(`Deleted existing request ${existingRequest.id} for replacement`);
|
|
} else {
|
|
// No RMAB request — book was added to library outside RMAB
|
|
await deleteFromLibrary(audiobook);
|
|
logger.info(`Deleted library content directly for "${audiobook.title}" (no RMAB request)`);
|
|
}
|
|
|
|
// Step 3: Reset audiobook record for new request
|
|
await prisma.audiobook.update({
|
|
where: { id: audiobook.id },
|
|
data: {
|
|
status: 'requested',
|
|
plexGuid: null,
|
|
absItemId: null,
|
|
filePath: null,
|
|
fileFormat: null,
|
|
fileSizeBytes: null,
|
|
filesHash: null,
|
|
},
|
|
});
|
|
|
|
// Step 4: Create new request + start download (admin-initiated, no approval needed)
|
|
const newRequest = await prisma.request.create({
|
|
data: {
|
|
userId: adminUserId,
|
|
audiobookId: audiobook.id,
|
|
status: 'downloading',
|
|
type: 'audiobook',
|
|
progress: 0,
|
|
},
|
|
include: {
|
|
audiobook: true,
|
|
user: { select: { id: true, plexUsername: true } },
|
|
},
|
|
});
|
|
|
|
// Queue download job with selected torrent
|
|
const { getJobQueueService } = await import('./job-queue.service');
|
|
const jobQueue = getJobQueueService();
|
|
await jobQueue.addDownloadJob(
|
|
newRequest.id,
|
|
{
|
|
id: audiobook.id,
|
|
title: audiobook.title,
|
|
author: audiobook.author,
|
|
},
|
|
torrent
|
|
);
|
|
|
|
// Step 5: Resolve issue
|
|
await prisma.reportedIssue.update({
|
|
where: { id: issueId },
|
|
data: {
|
|
status: 'replaced',
|
|
resolvedAt: new Date(),
|
|
resolvedById: adminUserId,
|
|
},
|
|
});
|
|
|
|
logger.info(`Issue ${issueId} resolved via replacement. New request: ${newRequest.id}`);
|
|
return { issue, request: newRequest };
|
|
}
|
|
|
|
/**
|
|
* Get all open issues with audiobook metadata and reporter info (admin list)
|
|
*/
|
|
export async function getOpenIssues() {
|
|
return prisma.reportedIssue.findMany({
|
|
where: { status: 'open' },
|
|
include: {
|
|
audiobook: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
author: true,
|
|
coverArtUrl: true,
|
|
audibleAsin: true,
|
|
},
|
|
},
|
|
reporter: {
|
|
select: {
|
|
id: true,
|
|
plexUsername: true,
|
|
avatarUrl: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Batch query for open issues by ASINs (used for enrichment in audiobook-matcher)
|
|
*/
|
|
export async function getOpenIssuesByAsins(asins: string[]): Promise<Set<string>> {
|
|
if (asins.length === 0) return new Set();
|
|
|
|
const issues = await prisma.reportedIssue.findMany({
|
|
where: {
|
|
status: 'open',
|
|
audiobook: {
|
|
audibleAsin: { in: asins },
|
|
},
|
|
},
|
|
select: {
|
|
audiobook: {
|
|
select: { audibleAsin: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return new Set(
|
|
issues
|
|
.map((i) => i.audiobook.audibleAsin)
|
|
.filter((asin): asin is string => asin !== null)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete audiobook content from library backend directly (no RMAB request).
|
|
* Used when a book was added to Plex/ABS outside of RMAB.
|
|
* Mirrors the library deletion logic from request-delete.service.ts lines 280-440.
|
|
*/
|
|
async function deleteFromLibrary(audiobook: {
|
|
id: string;
|
|
title: string;
|
|
author: string;
|
|
audibleAsin: string | null;
|
|
plexGuid: string | null;
|
|
absItemId: string | null;
|
|
}) {
|
|
const { getConfigService } = await import('./config.service');
|
|
const configService = getConfigService();
|
|
const backendMode = await configService.getBackendMode();
|
|
|
|
// Delete from library backend API
|
|
if (backendMode === 'audiobookshelf') {
|
|
// absItemId may be null if the book was added outside RMAB.
|
|
// Fall back to looking up the ABS item ID from plex_library by ASIN
|
|
// (plexGuid stores the ABS item ID when using ABS backend).
|
|
let itemId = audiobook.absItemId;
|
|
if (!itemId && audiobook.audibleAsin) {
|
|
const libraryRecord = await prisma.plexLibrary.findFirst({
|
|
where: {
|
|
OR: [
|
|
{ asin: audiobook.audibleAsin },
|
|
{ plexGuid: { contains: audiobook.audibleAsin } },
|
|
],
|
|
},
|
|
select: { plexGuid: true },
|
|
});
|
|
itemId = libraryRecord?.plexGuid ?? null;
|
|
}
|
|
|
|
if (itemId) {
|
|
try {
|
|
const { deleteABSItem } = await import('./audiobookshelf/api');
|
|
await deleteABSItem(itemId);
|
|
logger.info(`Deleted ABS item ${itemId} for "${audiobook.title}"`);
|
|
} catch (error) {
|
|
logger.error(`Failed to delete ABS item ${itemId}`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
} else {
|
|
logger.warn(`No ABS item ID found for "${audiobook.title}" (ASIN: ${audiobook.audibleAsin}) — skipping ABS deletion`);
|
|
}
|
|
} else if (backendMode === 'plex' && audiobook.plexGuid) {
|
|
try {
|
|
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
|
|
where: { plexGuid: audiobook.plexGuid },
|
|
select: { plexRatingKey: true },
|
|
});
|
|
|
|
if (plexLibraryRecord?.plexRatingKey) {
|
|
const plexServerUrl = (await configService.get('plex_url')) || '';
|
|
const plexToken = (await configService.get('plex_token')) || '';
|
|
|
|
if (plexServerUrl && plexToken) {
|
|
const { getPlexService } = await import('../integrations/plex.service');
|
|
const plexService = getPlexService();
|
|
await plexService.deleteItem(plexServerUrl, plexToken, plexLibraryRecord.plexRatingKey);
|
|
logger.info(`Deleted Plex item ${plexLibraryRecord.plexRatingKey} for "${audiobook.title}"`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to delete Plex item for "${audiobook.title}"`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Delete plex_library records by ASIN
|
|
if (audiobook.audibleAsin) {
|
|
try {
|
|
const result = await prisma.plexLibrary.deleteMany({
|
|
where: {
|
|
OR: [
|
|
{ asin: audiobook.audibleAsin },
|
|
{ plexGuid: { contains: audiobook.audibleAsin } },
|
|
],
|
|
},
|
|
});
|
|
if (result.count > 0) {
|
|
logger.info(`Deleted ${result.count} plex_library record(s) by ASIN "${audiobook.audibleAsin}"`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to delete plex_library records for ASIN "${audiobook.audibleAsin}"`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom error class for reported issues
|
|
*/
|
|
export class ReportedIssueError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public statusCode: number
|
|
) {
|
|
super(message);
|
|
this.name = 'ReportedIssueError';
|
|
}
|
|
}
|