Add authors pages and requestType notifications

Introduce full authors browsing/detail feature and enhance notifications to support type-specific titles.

- Add server APIs: authors search, author detail, and author books routes (audnexus integration) that require auth and enrich results with library matches.
- Add frontend pages/components: /authors listing and /authors/[asin] detail pages; AuthorCard, AuthorGrid, AuthorDetailCard, SimilarAuthorsRow, and related skeletons.
- Add hook and integration stubs: new useAuthors hook and audnexus-authors integration; update audible service to expose audibleBaseUrl.
- Update AudiobookDetailsModal to use audibleBaseUrl and link author names to author detail pages.
- Add header navigation link to Authors.
- Notifications: extend docs and code to include requestType (audiobook|ebook), add getEventTitle/getEventMeta helpers, update queue signature and providers/processors/tests to pass/handle requestType so titles can be resolved per request type.
- Misc: job queue, processors, provider tests and notification tests updated to reflect new behavior.

This change enables browsing authors and provides type-aware notification titles without per-provider changes.
This commit is contained in:
kikootwo
2026-02-12 15:21:42 -05:00
parent e40e77c8fe
commit 89422fc77a
33 changed files with 1629 additions and 40 deletions
@@ -116,6 +116,7 @@ describe('Admin notifications test route', () => {
title: expect.any(String),
author: expect.any(String),
userName: 'Test User',
requestType: 'audiobook',
timestamp: expect.any(Date),
})
);
@@ -71,6 +71,33 @@ describe('processSendNotification', () => {
});
});
it('forwards requestType to notification service', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const payload = {
event: 'request_available' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
requestType: 'ebook',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
await processSendNotification(payload);
expect(notificationServiceMock.sendNotification).toHaveBeenCalledWith({
event: 'request_available',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
requestType: 'ebook',
timestamp: expect.any(Date),
});
});
it('does not throw if notification service fails', async () => {
notificationServiceMock.sendNotification.mockRejectedValue(new Error('Service error'));
+1
View File
@@ -172,6 +172,7 @@ describe('AppriseProvider', () => {
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
requestType: 'audiobook',
timestamp: new Date(),
}
);
@@ -31,6 +31,32 @@ vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
describe('getEventTitle', () => {
it('returns type-specific title when requestType matches titleByRequestType', async () => {
const { getEventTitle } = await import('@/lib/constants/notification-events');
expect(getEventTitle('request_available', 'audiobook')).toBe('Audiobook Available');
expect(getEventTitle('request_available', 'ebook')).toBe('Ebook Available');
});
it('returns default title when requestType is not provided', async () => {
const { getEventTitle } = await import('@/lib/constants/notification-events');
expect(getEventTitle('request_available')).toBe('Request Available');
expect(getEventTitle('request_available', undefined)).toBe('Request Available');
});
it('returns default title when requestType does not match any entry', async () => {
const { getEventTitle } = await import('@/lib/constants/notification-events');
expect(getEventTitle('request_available', 'podcast')).toBe('Request Available');
});
it('returns default title for events without titleByRequestType', async () => {
const { getEventTitle } = await import('@/lib/constants/notification-events');
expect(getEventTitle('request_approved', 'audiobook')).toBe('Request Approved');
expect(getEventTitle('request_error')).toBe('Request Error');
expect(getEventTitle('request_pending_approval')).toBe('New Request Pending Approval');
});
});
describe('NotificationService', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -275,6 +301,68 @@ describe('NotificationService', () => {
expect(body.embeds[0].color).toBe(2278750); // Green for approved (0x22C55E)
});
it('uses type-specific title for request_available with requestType', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { DiscordProvider } = await import('@/lib/services/notification');
const provider = new DiscordProvider();
// Test audiobook
await provider.send(
{ webhookUrl: 'https://discord.com/webhook' },
{
event: 'request_available',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
requestType: 'audiobook',
timestamp: new Date('2024-01-01T00:00:00Z'),
}
);
let body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.embeds[0].title).toBe('\u{1F389} Audiobook Available');
// Test ebook
fetchMock.mockClear();
await provider.send(
{ webhookUrl: 'https://discord.com/webhook' },
{
event: 'request_available',
requestId: 'req-2',
title: 'Test Book 2',
author: 'Test Author 2',
userName: 'Test User',
requestType: 'ebook',
timestamp: new Date('2024-01-01T00:00:00Z'),
}
);
body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.embeds[0].title).toBe('\u{1F389} Ebook Available');
// Test fallback (no requestType)
fetchMock.mockClear();
await provider.send(
{ webhookUrl: 'https://discord.com/webhook' },
{
event: 'request_available',
requestId: 'req-3',
title: 'Test Book 3',
author: 'Test Author 3',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
}
);
body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.embeds[0].title).toBe('\u{1F389} Request Available');
});
it('uses default username if not provided', async () => {
fetchMock.mockResolvedValue({
ok: true,