Files
ReadMeABook/src/lib/services/reported-issue.service.ts
T
kikootwo 20c8fb0898 Add reported-issues, Goodreads sync & notifs
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.
2026-02-11 16:49:55 -05:00

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';
}
}