mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 05:10:11 +00:00
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:
@@ -3,24 +3,21 @@
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
// Re-export event types from central source of truth
|
||||
export type { NotificationEvent } from '@/lib/constants/notification-events';
|
||||
|
||||
// Backend type — string-based, registry is the runtime source of truth
|
||||
export type NotificationBackendType = string;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
event: import('@/lib/constants/notification-events').NotificationEvent;
|
||||
requestId?: string;
|
||||
issueId?: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
message?: string; // For error/issue events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ export type {
|
||||
ProviderMetadata,
|
||||
} from './INotificationProvider';
|
||||
|
||||
// Centralized event constants (re-exported for convenience)
|
||||
export {
|
||||
NOTIFICATION_EVENTS,
|
||||
NOTIFICATION_EVENT_KEYS,
|
||||
EVENT_LABELS,
|
||||
getEventMeta,
|
||||
getEventLabel,
|
||||
} from '@/lib/constants/notification-events';
|
||||
export type { NotificationSeverity, NotificationPriority, NotificationEventMeta } from '@/lib/constants/notification-events';
|
||||
|
||||
// Core service
|
||||
export {
|
||||
NotificationService,
|
||||
|
||||
@@ -130,7 +130,7 @@ export class NotificationService {
|
||||
|
||||
const encrypted = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (encrypted[field] && !this.isEncrypted(encrypted[field])) {
|
||||
if (encrypted[field] && !this.encryptionService.isEncryptedFormat(encrypted[field])) {
|
||||
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
|
||||
}
|
||||
}
|
||||
@@ -155,25 +155,66 @@ export class NotificationService {
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypt any sensitive fields that were stored as plaintext due to
|
||||
* the isEncrypted() false-positive bug (URLs with exactly 2 colons).
|
||||
* Safe to call multiple times — skips already-encrypted values.
|
||||
*/
|
||||
async reEncryptUnprotectedBackends(): Promise<number> {
|
||||
let fixed = 0;
|
||||
|
||||
try {
|
||||
const backends = await prisma.notificationBackend.findMany();
|
||||
|
||||
for (const backend of backends) {
|
||||
const provider = getProvider(backend.type);
|
||||
if (!provider) continue;
|
||||
|
||||
const config = backend.config as any;
|
||||
let needsUpdate = false;
|
||||
const updatedConfig = { ...config };
|
||||
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (updatedConfig[field] && !this.encryptionService.isEncryptedFormat(updatedConfig[field])) {
|
||||
updatedConfig[field] = this.encryptionService.encrypt(updatedConfig[field]);
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await prisma.notificationBackend.update({
|
||||
where: { id: backend.id },
|
||||
data: { config: updatedConfig },
|
||||
});
|
||||
fixed++;
|
||||
logger.info(`Re-encrypted plaintext sensitive fields for backend: ${backend.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed > 0) {
|
||||
logger.warn(`Re-encrypted ${fixed} backend(s) with unprotected sensitive fields`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to re-encrypt backends', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(sensitiveFields: string[], config: any): any {
|
||||
const decrypted = { ...config };
|
||||
for (const field of sensitiveFields) {
|
||||
if (decrypted[field] && this.isEncrypted(decrypted[field])) {
|
||||
if (decrypted[field] && this.encryptionService.isEncryptedFormat(decrypted[field])) {
|
||||
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface AppriseConfig {
|
||||
serverUrl: string;
|
||||
@@ -13,12 +14,12 @@ export interface AppriseConfig {
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
// Apprise notification types by event
|
||||
const APPRISE_TYPES: Record<string, string> = {
|
||||
request_pending_approval: 'info',
|
||||
request_approved: 'success',
|
||||
request_available: 'success',
|
||||
request_error: 'failure',
|
||||
// Apprise notification types by severity
|
||||
const SEVERITY_TYPES: Record<NotificationSeverity, string> = {
|
||||
info: 'info',
|
||||
success: 'success',
|
||||
error: 'failure',
|
||||
warning: 'warning',
|
||||
};
|
||||
|
||||
export class AppriseProvider implements INotificationProvider {
|
||||
@@ -41,10 +42,11 @@ export class AppriseProvider implements INotificationProvider {
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const appriseConfig = config as unknown as AppriseConfig;
|
||||
const meta = getEventMeta(payload.event);
|
||||
const { title, body } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
||||
const notificationType = APPRISE_TYPES[payload.event] || 'info';
|
||||
const notificationType = SEVERITY_TYPES[meta.severity];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -107,26 +109,21 @@ export class AppriseProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const eventTitles: Record<string, string> = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
`\u{1F4DA} ${title}`,
|
||||
`\u270D\uFE0F ${author}`,
|
||||
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
title: meta.title,
|
||||
body: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
@@ -11,20 +12,12 @@ export interface DiscordConfig {
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
// Discord embed colors by severity
|
||||
const SEVERITY_COLORS: Record<NotificationSeverity, number> = {
|
||||
info: 0xfbbf24, // yellow-400
|
||||
success: 0x22c55e, // green-500
|
||||
error: 0xef4444, // red-500
|
||||
warning: 0xf97316, // orange-500
|
||||
};
|
||||
|
||||
export class DiscordProvider implements INotificationProvider {
|
||||
@@ -67,23 +60,25 @@ export class DiscordProvider implements INotificationProvider {
|
||||
|
||||
private formatEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
{ name: isIssue ? 'Reported By' : 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
title: `${meta.emoji} ${meta.title}`,
|
||||
color: SEVERITY_COLORS[meta.severity],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
text: isIssue ? `Issue ID: ${payload.issueId}` : `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface NtfyConfig {
|
||||
serverUrl?: string;
|
||||
@@ -14,20 +15,18 @@ export interface NtfyConfig {
|
||||
|
||||
const DEFAULT_SERVER_URL = 'https://ntfy.sh';
|
||||
|
||||
// ntfy priorities by event type (1=min, 2=low, 3=default, 4=high, 5=urgent)
|
||||
const NTFY_PRIORITIES = {
|
||||
request_pending_approval: 3, // Default
|
||||
request_approved: 3, // Default
|
||||
request_available: 4, // High
|
||||
request_error: 4, // High
|
||||
// ntfy priorities by notification priority (1=min, 2=low, 3=default, 4=high, 5=urgent)
|
||||
const PRIORITY_MAP: Record<NotificationPriority, number> = {
|
||||
normal: 3,
|
||||
high: 4,
|
||||
};
|
||||
|
||||
// ntfy tags (emojis) by event type
|
||||
const NTFY_TAGS = {
|
||||
request_pending_approval: ['mailbox_with_mail'],
|
||||
request_approved: ['white_check_mark'],
|
||||
request_available: ['tada'],
|
||||
request_error: ['x'],
|
||||
// ntfy tags (emojis) by severity
|
||||
const SEVERITY_TAGS: Record<NotificationSeverity, string[]> = {
|
||||
info: ['mailbox_with_mail'],
|
||||
success: ['white_check_mark'],
|
||||
error: ['x'],
|
||||
warning: ['triangular_flag_on_post'],
|
||||
};
|
||||
|
||||
export class NtfyProvider implements INotificationProvider {
|
||||
@@ -48,10 +47,12 @@ export class NtfyProvider implements INotificationProvider {
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const ntfyConfig = config as unknown as NtfyConfig;
|
||||
const meta = getEventMeta(payload.event);
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
|
||||
const url = `${serverUrl}/${ntfyConfig.topic}`;
|
||||
// ntfy JSON publishing requires POSTing to the base server URL (not the topic URL).
|
||||
// The topic is included in the JSON body. See: https://docs.ntfy.sh/publish/#publish-as-json
|
||||
const url = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -65,8 +66,8 @@ export class NtfyProvider implements INotificationProvider {
|
||||
topic: ntfyConfig.topic,
|
||||
title,
|
||||
message,
|
||||
priority: ntfyConfig.priority ?? NTFY_PRIORITIES[payload.event],
|
||||
tags: NTFY_TAGS[payload.event],
|
||||
priority: ntfyConfig.priority ?? PRIORITY_MAP[meta.priority],
|
||||
tags: SEVERITY_TAGS[meta.severity],
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -83,26 +84,21 @@ export class NtfyProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const eventTitles = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
`\u{1F4DA} ${title}`,
|
||||
`\u270D\uFE0F ${author}`,
|
||||
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
title: meta.title,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events';
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
@@ -12,12 +13,10 @@ export interface PushoverConfig {
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// Pushover priorities by event type
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
// Pushover priorities by notification priority (Normal=0, High=1)
|
||||
const PRIORITY_MAP: Record<NotificationPriority, number> = {
|
||||
normal: 0,
|
||||
high: 1,
|
||||
};
|
||||
|
||||
export class PushoverProvider implements INotificationProvider {
|
||||
@@ -48,6 +47,7 @@ export class PushoverProvider implements INotificationProvider {
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const pushoverConfig = config as unknown as PushoverConfig;
|
||||
const meta = getEventMeta(payload.event);
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
@@ -55,7 +55,7 @@ export class PushoverProvider implements INotificationProvider {
|
||||
user: pushoverConfig.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(pushoverConfig.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
priority: String(pushoverConfig.priority ?? PRIORITY_MAP[meta.priority]),
|
||||
...(pushoverConfig.device && { device: pushoverConfig.device }),
|
||||
});
|
||||
|
||||
@@ -78,43 +78,23 @@ export class PushoverProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
`${meta.emoji} ${meta.title}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
`\u{1F4DA} ${title}`,
|
||||
`\u270D\uFE0F ${author}`,
|
||||
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
title: meta.title,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user