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.
This commit is contained in:
kikootwo
2026-02-11 16:49:55 -05:00
parent b013538b63
commit 20c8fb0898
69 changed files with 4167 additions and 766 deletions
+267
View File
@@ -0,0 +1,267 @@
/**
* Component: Request Creator Service
* Documentation: documentation/backend/services/requests.md
*
* Shared request-creation logic used by both the API route and Goodreads sync.
* Encapsulates: duplicate detection, library check, Audnexus enrichment,
* audiobook record creation, approval flow, notification queuing, and search job triggering.
*/
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 { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('RequestCreator');
export interface CreateRequestInput {
asin: string;
title: string;
author: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
}
export interface CreateRequestOptions {
skipAutoSearch?: boolean;
}
export type CreateRequestResult =
| { success: true; request: any }
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
/**
* Create a request for a user, with full duplicate detection, library checks,
* Audnexus enrichment, approval flow, notifications, and search job triggering.
*/
export async function createRequestForUser(
userId: string,
audiobook: CreateRequestInput,
options: CreateRequestOptions = {}
): Promise<CreateRequestResult> {
const { skipAutoSearch = false } = options;
// Check for existing active request (downloaded/available) for this ASIN
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: { audibleAsin: audiobook.asin },
type: 'audiobook',
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
});
if (existingActiveRequest) {
const status = existingActiveRequest.status;
return {
success: false,
reason: status === 'available' ? 'already_available' : 'being_processed',
message: status === 'available'
? 'This audiobook is already available in your library'
: 'This audiobook is being processed and will be available soon',
};
}
// Check if audiobook is already in Plex/ABS library
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
if (plexMatch) {
return {
success: false,
reason: 'already_available',
message: 'This audiobook is already available in your library',
};
}
// Fetch full details from Audnexus for year/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;
}
} catch {
// Ignore parse errors
}
}
if (audnexusData?.series) series = audnexusData.series;
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Find or create audiobook record
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
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} for ASIN ${audiobook.asin}`);
} else {
// Update existing record with clean metadata (e.g. Audnexus title replacing Goodreads title)
const updates: Record<string, any> = {};
if (audiobook.title && audiobook.title !== audiobookRecord.title) updates.title = audiobook.title;
if (audiobook.author && audiobook.author !== audiobookRecord.author) updates.author = audiobook.author;
if (audiobook.coverArtUrl && !audiobookRecord.coverArtUrl) updates.coverArtUrl = audiobook.coverArtUrl;
if (year) updates.year = year;
if (series) updates.series = series;
if (seriesPart) updates.seriesPart = seriesPart;
if (Object.keys(updates).length > 0) {
audiobookRecord = await prisma.audiobook.update({
where: { id: audiobookRecord.id },
data: updates,
});
}
}
// Check if user already has an active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId,
audiobookId: audiobookRecord.id,
type: 'audiobook',
deletedAt: null,
},
});
if (existingRequest) {
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return {
success: false,
reason: 'duplicate',
message: 'You have already requested this audiobook',
};
}
// Delete 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 ANY user's active request for same audiobook (avoid duplicate processing)
const anyActiveRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobookRecord.id,
type: 'audiobook',
status: { notIn: ['failed', 'warn', 'cancelled', 'available', 'downloaded'] },
deletedAt: null,
},
});
if (anyActiveRequest && anyActiveRequest.userId !== userId) {
return {
success: false,
reason: 'being_processed',
message: 'This audiobook is already being requested by another user',
};
}
// Determine if approval is needed
let needsApproval = false;
let shouldTriggerSearch = !skipAutoSearch;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, autoApproveRequests: true, plexUsername: true },
});
if (!user) {
return { success: false, reason: 'user_not_found', message: 'User not found' };
}
if (user.role === 'admin') {
needsApproval = false;
} else {
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false;
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else {
initialStatus = 'pending';
}
// Create request
const newRequest = await prisma.request.create({
data: {
userId,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook',
progress: 0,
},
include: {
audiobook: true,
user: { select: { id: true, plexUsername: true } },
},
});
const jobQueue = getJobQueueService();
// Send notification
const notificationType = initialStatus === 'awaiting_approval' ? 'request_pending_approval' : 'request_approved';
await jobQueue.addNotificationJob(
notificationType,
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger search job
if (shouldTriggerSearch) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
});
}
return { success: true, request: newRequest };
}