mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 05:10:11 +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.
229 lines
6.9 KiB
TypeScript
229 lines
6.9 KiB
TypeScript
/**
|
|
* Component: Notification Service
|
|
* Documentation: documentation/backend/services/notifications.md
|
|
*/
|
|
|
|
import { getEncryptionService } from '../encryption.service';
|
|
import { RMABLogger } from '../../utils/logger';
|
|
import { prisma } from '../../db';
|
|
import { INotificationProvider, NotificationPayload, ProviderMetadata } from './INotificationProvider';
|
|
import { AppriseProvider } from './providers/apprise.provider';
|
|
import { DiscordProvider } from './providers/discord.provider';
|
|
import { NtfyProvider } from './providers/ntfy.provider';
|
|
import { PushoverProvider } from './providers/pushover.provider';
|
|
|
|
const logger = RMABLogger.create('NotificationService');
|
|
|
|
// Provider registry
|
|
const providers = new Map<string, INotificationProvider>();
|
|
|
|
export function registerProvider(provider: INotificationProvider): void {
|
|
providers.set(provider.type, provider);
|
|
}
|
|
|
|
export function getProvider(type: string): INotificationProvider | undefined {
|
|
return providers.get(type);
|
|
}
|
|
|
|
// Register built-in providers
|
|
registerProvider(new AppriseProvider());
|
|
registerProvider(new DiscordProvider());
|
|
registerProvider(new NtfyProvider());
|
|
registerProvider(new PushoverProvider());
|
|
|
|
export function getRegisteredProviderTypes(): string[] {
|
|
return Array.from(providers.keys());
|
|
}
|
|
|
|
export function getAllProviderMetadata(): ProviderMetadata[] {
|
|
return Array.from(providers.values()).map((p) => p.metadata);
|
|
}
|
|
|
|
export class NotificationService {
|
|
private encryptionService = getEncryptionService();
|
|
|
|
/**
|
|
* Send notification to all enabled backends subscribed to the event
|
|
*/
|
|
async sendNotification(payload: NotificationPayload): Promise<void> {
|
|
try {
|
|
// Get all enabled backends subscribed to this event
|
|
const backends = await prisma.notificationBackend.findMany({
|
|
where: {
|
|
enabled: true,
|
|
events: {
|
|
array_contains: payload.event,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (backends.length === 0) {
|
|
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
|
return;
|
|
}
|
|
|
|
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
|
event: payload.event,
|
|
requestId: payload.requestId,
|
|
});
|
|
|
|
// Send to all backends in parallel (atomic per-backend)
|
|
const results = await Promise.allSettled(
|
|
backends.map((backend) =>
|
|
this.sendToBackend(backend.type, backend.config, payload)
|
|
)
|
|
);
|
|
|
|
// Log results
|
|
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
|
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
|
|
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
|
event: payload.event,
|
|
requestId: payload.requestId,
|
|
});
|
|
|
|
// Log individual failures
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'rejected') {
|
|
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
|
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
backend: backends[index].type,
|
|
});
|
|
}
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to send notifications', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
event: payload.event,
|
|
requestId: payload.requestId,
|
|
});
|
|
// Don't throw - non-blocking
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Route notification to type-specific provider
|
|
*/
|
|
async sendToBackend(
|
|
type: string,
|
|
config: any,
|
|
payload: NotificationPayload
|
|
): Promise<void> {
|
|
const provider = getProvider(type);
|
|
if (!provider) {
|
|
throw new Error(`Unsupported backend type: ${type}`);
|
|
}
|
|
|
|
const decryptedConfig = this.decryptConfig(provider.sensitiveFields, config);
|
|
return provider.send(decryptedConfig, payload);
|
|
}
|
|
|
|
/**
|
|
* Encrypt sensitive config values before saving
|
|
*/
|
|
encryptConfig(type: string, config: any): any {
|
|
const provider = getProvider(type);
|
|
if (!provider) {
|
|
return { ...config };
|
|
}
|
|
|
|
const encrypted = { ...config };
|
|
for (const field of provider.sensitiveFields) {
|
|
if (encrypted[field] && !this.encryptionService.isEncryptedFormat(encrypted[field])) {
|
|
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
|
|
}
|
|
}
|
|
return encrypted;
|
|
}
|
|
|
|
/**
|
|
* Mask sensitive config values for API responses
|
|
*/
|
|
maskConfig(type: string, config: any): any {
|
|
const provider = getProvider(type);
|
|
if (!provider) {
|
|
return { ...config };
|
|
}
|
|
|
|
const masked = { ...config };
|
|
for (const field of provider.sensitiveFields) {
|
|
if (masked[field]) {
|
|
masked[field] = '••••••••';
|
|
}
|
|
}
|
|
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.encryptionService.isEncryptedFormat(decrypted[field])) {
|
|
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
|
|
}
|
|
}
|
|
return decrypted;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let notificationService: NotificationService | null = null;
|
|
|
|
export function getNotificationService(): NotificationService {
|
|
if (!notificationService) {
|
|
notificationService = new NotificationService();
|
|
}
|
|
return notificationService;
|
|
}
|