feat(14-02): add RSS feed admin routes, API client, and settings UI
Adds GET/POST/DELETE /modules/tender-radar/rss-feeds (Roles-guarded
ADMIN/SUPER_ADMIN), declared before the existing @Get(':id') handler to
avoid NestJS route-order shadowing. Delegates to
TenderRssFeedSourceService; the denylist/SSRF rejection (D-14) surfaces
as a 400 unchanged.
Web: tender-radar-api.ts gains listRssFeeds/createRssFeed/deleteRssFeed
(relaying the backend's specific rejection message via
extractErrorMessage), and a new RssFeedListForm client component renders
an "RSS-Feeds" section on the tender-radar settings page (D-09) — list,
add (with inline denylist error), and remove global feed URLs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { TendersController } from './tenders.controller';
|
import { TendersController } from './tenders.controller';
|
||||||
|
|
||||||
@@ -85,6 +85,21 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
|
|||||||
return { user: { id: userId, tenantId }, tenantId } as any;
|
return { user: { id: userId, tenantId }, tenantId } as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake TenderRssFeedSourceService for controller-level wiring tests (Plan
|
||||||
|
* 14-02, Task 3). Default stubs echo/list-nothing; individual tests
|
||||||
|
* override via `.mockResolvedValueOnce`/reassigning the mock — including
|
||||||
|
* `create` rejecting with BadRequestException to prove the denylist error
|
||||||
|
* surfaces through the controller unchanged.
|
||||||
|
*/
|
||||||
|
function makeFakeRssFeedService() {
|
||||||
|
return {
|
||||||
|
list: vi.fn(async () => [] as any[]),
|
||||||
|
create: vi.fn(async (dto: any) => ({ id: 'feed-1', isActive: true, ...dto })),
|
||||||
|
remove: vi.fn(async (_id: string) => ({ success: true })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fake TenderSavedSearchService for controller-level wiring tests (Plan
|
* Fake TenderSavedSearchService for controller-level wiring tests (Plan
|
||||||
* 11-06, Task 2). Default stubs return empty/echo results — individual
|
* 11-06, Task 2). Default stubs return empty/echo results — individual
|
||||||
@@ -131,6 +146,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({});
|
await controller.listTenders({});
|
||||||
@@ -150,6 +166,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getTender('t1');
|
const result = await controller.getTender('t1');
|
||||||
@@ -169,6 +186,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getTender('t1');
|
const result = await controller.getTender('t1');
|
||||||
@@ -203,6 +221,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
@@ -220,6 +239,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
||||||
@@ -239,6 +259,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.saveSourceConfig({ isActive: false });
|
await controller.saveSourceConfig({ isActive: false });
|
||||||
@@ -276,6 +297,22 @@ describe('TendersController — route declaration order (static route before :id
|
|||||||
expect(coverageIdx).toBeLessThan(idIdx);
|
expect(coverageIdx).toBeLessThan(idIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('declares listRssFeeds/createRssFeed/removeRssFeed before getTender so GET /:id cannot shadow "rss-feeds" (Plan 14-02, Pitfall 5)', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||||
|
const listIdx = methods.indexOf('listRssFeeds');
|
||||||
|
const createIdx = methods.indexOf('createRssFeed');
|
||||||
|
const removeIdx = methods.indexOf('removeRssFeed');
|
||||||
|
const idIdx = methods.indexOf('getTender');
|
||||||
|
|
||||||
|
expect(listIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(createIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(removeIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(listIdx).toBeLessThan(idIdx);
|
||||||
|
expect(createIdx).toBeLessThan(idIdx);
|
||||||
|
expect(removeIdx).toBeLessThan(idIdx);
|
||||||
|
});
|
||||||
|
|
||||||
it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => {
|
it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => {
|
||||||
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||||
const listTriageIdx = methods.indexOf('listTriage');
|
const listTriageIdx = methods.indexOf('listTriage');
|
||||||
@@ -325,6 +362,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
prefService as any,
|
prefService as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.getNotificationPref(makeFakeRequest('u-real'));
|
await controller.getNotificationPref(makeFakeRequest('u-real'));
|
||||||
@@ -343,6 +381,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
prefService as any,
|
prefService as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.setNotificationPref(
|
await controller.setNotificationPref(
|
||||||
@@ -366,6 +405,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
||||||
@@ -384,6 +424,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.createSavedSearch(
|
await controller.createSavedSearch(
|
||||||
@@ -408,6 +449,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.updateSavedSearch(
|
await controller.updateSavedSearch(
|
||||||
@@ -432,6 +474,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
savedSearchService as any,
|
savedSearchService as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
||||||
@@ -452,6 +495,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
||||||
@@ -480,6 +524,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
||||||
@@ -498,6 +543,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ page: 3, limit: 10 } as any);
|
await controller.listTenders({ page: 3, limit: 10 } as any);
|
||||||
@@ -519,6 +565,7 @@ describe('TendersController — GET /coverage', () => {
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await controller.getCoverage();
|
const result = await controller.getCoverage();
|
||||||
@@ -547,6 +594,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
||||||
@@ -564,6 +612,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
||||||
@@ -581,6 +630,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
||||||
@@ -602,6 +652,7 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () =>
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.setTriage(
|
await controller.setTriage(
|
||||||
@@ -628,6 +679,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||||
@@ -650,6 +702,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||||
@@ -670,6 +723,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
triageService as any,
|
triageService as any,
|
||||||
makeFakeSavedSearchService() as any,
|
makeFakeSavedSearchService() as any,
|
||||||
makeFakeNotificationPrefService() as any,
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
||||||
@@ -677,3 +731,91 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
expect(triageService.favoriteIds).not.toHaveBeenCalled();
|
expect(triageService.favoriteIds).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', () => {
|
||||||
|
it('GET /rss-feeds delegates to tenderRssFeedSource.list()', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
|
rssFeedService.list.mockResolvedValueOnce([
|
||||||
|
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
||||||
|
]);
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
rssFeedService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.listRssFeeds();
|
||||||
|
|
||||||
|
expect(rssFeedService.list).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /rss-feeds delegates to tenderRssFeedSource.create(dto)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
rssFeedService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any;
|
||||||
|
await controller.createRssFeed(dto);
|
||||||
|
|
||||||
|
expect(rssFeedService.create).toHaveBeenCalledWith(dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
|
rssFeedService.create.mockRejectedValueOnce(
|
||||||
|
new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"),
|
||||||
|
);
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
rssFeedService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.createRssFeed({
|
||||||
|
url: 'https://www.vergabe24.de/rss.xml',
|
||||||
|
label: 'vergabe24',
|
||||||
|
} as any),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /rss-feeds/:feedId delegates to tenderRssFeedSource.remove(feedId)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
rssFeedService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.removeRssFeed('feed-1');
|
||||||
|
|
||||||
|
expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1');
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import { UpdateNotificationPrefDto } from './dto/notification-pref.dto';
|
|||||||
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
||||||
import { SourceConfigDto } from './dto/source-config.dto';
|
import { SourceConfigDto } from './dto/source-config.dto';
|
||||||
import { TenderQueryDto } from './dto/tender-query.dto';
|
import { TenderQueryDto } from './dto/tender-query.dto';
|
||||||
|
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
||||||
import { TenderTriageDto } from './dto/tender-triage.dto';
|
import { TenderTriageDto } from './dto/tender-triage.dto';
|
||||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||||
|
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||||
import { TenderTriageService } from './tender-triage.service';
|
import { TenderTriageService } from './tender-triage.service';
|
||||||
@@ -61,6 +63,7 @@ export class TendersController {
|
|||||||
private readonly tenderTriage: TenderTriageService,
|
private readonly tenderTriage: TenderTriageService,
|
||||||
private readonly tenderSavedSearch: TenderSavedSearchService,
|
private readonly tenderSavedSearch: TenderSavedSearchService,
|
||||||
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
||||||
|
private readonly tenderRssFeedSource: TenderRssFeedSourceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,6 +159,49 @@ export class TendersController {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── RSS-Feeds admin CRUD (Roles-guarded, GLOBAL, D-08/D-14) ───────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /modules/tender-radar/rss-feeds — list every admin-managed RSS feed
|
||||||
|
* (global, no tenantId — D-08). `@Roles`-guarded: this is a
|
||||||
|
* platform-admin action, not a per-tenant module feature, same stance as
|
||||||
|
* `source-config` above.
|
||||||
|
*
|
||||||
|
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
||||||
|
* as `source-config`/`coverage`/`triage`/... above (Pitfall 5): NestJS
|
||||||
|
* matches routes in declaration order, so a `@Get(':id')` placed first
|
||||||
|
* would capture "rss-feeds" as an id and shadow this handler.
|
||||||
|
*/
|
||||||
|
@Get('rss-feeds')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async listRssFeeds() {
|
||||||
|
return this.tenderRssFeedSource.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/tender-radar/rss-feeds — add a new global RSS feed URL.
|
||||||
|
* The save-time hostname/SSRF guard (D-14, T-14-02-01) lives in
|
||||||
|
* `TenderRssFeedSourceService.create()` — a denylisted/private-host URL
|
||||||
|
* surfaces as a 400 (BadRequestException) here, unchanged.
|
||||||
|
*/
|
||||||
|
@Post('rss-feeds')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async createRssFeed(@Body() dto: TenderRssFeedDto) {
|
||||||
|
return this.tenderRssFeedSource.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /modules/tender-radar/rss-feeds/:feedId — remove a global RSS
|
||||||
|
* feed. Uses `:feedId` (not `:id`) so this route can never be confused
|
||||||
|
* with the Tender `:id` route below (Pitfall 5, same convention as
|
||||||
|
* `saved-searches/:searchId`).
|
||||||
|
*/
|
||||||
|
@Delete('rss-feeds/:feedId')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async removeRssFeed(@Param('feedId') feedId: string) {
|
||||||
|
return this.tenderRssFeedSource.remove(feedId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /modules/tender-radar/coverage — distribution of active tenders
|
* GET /modules/tender-radar/coverage — distribution of active tenders
|
||||||
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock @/lib/tender-radar-api
|
||||||
|
const mockListRssFeeds = vi.fn();
|
||||||
|
const mockCreateRssFeed = vi.fn();
|
||||||
|
const mockDeleteRssFeed = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/tender-radar-api', () => ({
|
||||||
|
listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args),
|
||||||
|
createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args),
|
||||||
|
deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockListRssFeeds.mockReset();
|
||||||
|
mockCreateRssFeed.mockReset();
|
||||||
|
mockDeleteRssFeed.mockReset();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const FEED_SERVICE_BUND = {
|
||||||
|
id: 'feed-1',
|
||||||
|
url: 'https://www.service.bund.de/rss.xml',
|
||||||
|
label: 'service-bund',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-07-23T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RssFeedListForm', () => {
|
||||||
|
it('loads feeds on mount and renders each one with its label and URL', async () => {
|
||||||
|
mockListRssFeeds.mockResolvedValue([FEED_SERVICE_BUND]);
|
||||||
|
|
||||||
|
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||||
|
render(<RssFeedListForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(FEED_SERVICE_BUND.url)).toBeInTheDocument();
|
||||||
|
expect(mockListRssFeeds).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty-state message when there are no feeds', async () => {
|
||||||
|
mockListRssFeeds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||||
|
render(<RssFeedListForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Noch keine RSS-Feeds hinterlegt/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filling the add form and clicking "Feed hinzufügen" calls createRssFeed and appends the new feed to the list', async () => {
|
||||||
|
mockListRssFeeds.mockResolvedValue([]);
|
||||||
|
mockCreateRssFeed.mockResolvedValue({
|
||||||
|
id: 'feed-2',
|
||||||
|
url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615',
|
||||||
|
label: 'subreport-neuss',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-07-23T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||||
|
render(<RssFeedListForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Feed-URL/i), {
|
||||||
|
target: { value: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Bezeichnung/i), {
|
||||||
|
target: { value: 'subreport-neuss' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateRssFeed).toHaveBeenCalledWith({
|
||||||
|
url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615',
|
||||||
|
label: 'subreport-neuss',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('subreport-neuss', { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the backend denylist rejection message inline without adding a row', async () => {
|
||||||
|
mockListRssFeeds.mockResolvedValue([]);
|
||||||
|
mockCreateRssFeed.mockRejectedValue(
|
||||||
|
new Error(
|
||||||
|
"Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt (Denylist) und darf nicht als RSS-Feed hinterlegt werden.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||||
|
render(<RssFeedListForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Feed-URL/i), {
|
||||||
|
target: { value: 'https://www.vergabe24.de/rss.xml' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Bezeichnung/i), {
|
||||||
|
target: { value: 'vergabe24' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/AGB-seitig für automatisierten Zugriff gesperrt/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty URL/label client-side without calling createRssFeed', async () => {
|
||||||
|
mockListRssFeeds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||||
|
render(<RssFeedListForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/URL und Bezeichnung sind erforderlich/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockCreateRssFeed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking "Entfernen" calls deleteRssFeed and removes the row from the list', async () => {
|
||||||
|
mockListRssFeeds.mockResolvedValue([FEED_SERVICE_BUND]);
|
||||||
|
mockDeleteRssFeed.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||||
|
render(<RssFeedListForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /entfernen/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeleteRssFeed).toHaveBeenCalledWith('feed-1');
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
type RssFeedSource,
|
||||||
|
createRssFeed,
|
||||||
|
deleteRssFeed,
|
||||||
|
listRssFeeds,
|
||||||
|
} from '@/lib/tender-radar-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin CRUD UI for the GLOBAL RSS feed list (Plan 14-02, INGEST-04,
|
||||||
|
* D-08/D-14). Unlike SourceConfigForm (a single platform-wide singleton),
|
||||||
|
* this is a list: an admin adds one row per RSS feed URL (service.bund.de,
|
||||||
|
* a subreport-elvis municipality feed, ...), each independently
|
||||||
|
* activatable/deletable.
|
||||||
|
*
|
||||||
|
* The save-time hostname/SSRF guard (T-14-02-01) lives entirely on the
|
||||||
|
* backend (TenderRssFeedSourceService) — this form does NOT duplicate that
|
||||||
|
* validation client-side; a rejected URL surfaces the backend's specific
|
||||||
|
* error message inline (e.g. "Der Host 'www.vergabe24.de' ist AGB-seitig
|
||||||
|
* für automatisierten Zugriff gesperrt...") via `createRssFeed`'s relayed
|
||||||
|
* error message.
|
||||||
|
*
|
||||||
|
* Hardcoded German strings for now — full i18n is CONFIG-03 (Plan 14-05),
|
||||||
|
* matching the same intentional MVP-stub convention as SourceConfigForm.
|
||||||
|
*/
|
||||||
|
export function RssFeedListForm() {
|
||||||
|
const [feeds, setFeeds] = useState<RssFeedSource[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadFeeds = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
listRssFeeds()
|
||||||
|
.then((result) => {
|
||||||
|
setFeeds(result);
|
||||||
|
setLoadError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setLoadError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'RSS-Feeds konnten nicht geladen werden',
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFeeds();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
setAddError(null);
|
||||||
|
|
||||||
|
if (!url.trim() || !label.trim()) {
|
||||||
|
setAddError('URL und Bezeichnung sind erforderlich.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
const created = await createRssFeed({ url: url.trim(), label: label.trim() });
|
||||||
|
setFeeds((prev) => [...prev, created]);
|
||||||
|
setUrl('');
|
||||||
|
setLabel('');
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'RSS-Feed konnte nicht gespeichert werden',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (id: string) => {
|
||||||
|
setRemoveError(null);
|
||||||
|
setRemovingId(id);
|
||||||
|
try {
|
||||||
|
await deleteRssFeed(id);
|
||||||
|
setFeeds((prev) => prev.filter((feed) => feed.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setRemoveError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'RSS-Feed konnte nicht entfernt werden',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRemovingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||||
|
const labelCls = 'mb-1 block text-sm text-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-9 rounded bg-muted animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{feeds.map((feed) => (
|
||||||
|
<li
|
||||||
|
key={feed.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded border border-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{feed.label}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({feed.isActive ? 'aktiv' : 'inaktiv'})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{feed.url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(feed.id)}
|
||||||
|
disabled={removingId === feed.id}
|
||||||
|
aria-label={`RSS-Feed "${feed.label}" entfernen`}
|
||||||
|
className="shrink-0 rounded px-2 py-1 text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{removingId === feed.id ? 'Entfernt...' : 'Entfernen'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{feeds.length === 0 && (
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
Noch keine RSS-Feeds hinterlegt.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadError && <p className="text-sm text-destructive">{loadError}</p>}
|
||||||
|
{removeError && <p className="text-sm text-destructive">{removeError}</p>}
|
||||||
|
|
||||||
|
<div className="space-y-3 border-t border-border pt-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rss-feed-url" className={labelCls}>
|
||||||
|
Feed-URL *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rss-feed-url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml"
|
||||||
|
className={inputCls}
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value);
|
||||||
|
setAddError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rss-feed-label" className={labelCls}>
|
||||||
|
Bezeichnung *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rss-feed-label"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="z. B. service-bund"
|
||||||
|
className={inputCls}
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLabel(e.target.value);
|
||||||
|
setAddError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={isAdding}
|
||||||
|
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isAdding ? 'Wird hinzugefügt...' : 'Feed hinzufügen'}
|
||||||
|
</button>
|
||||||
|
{addError && <p className="text-sm text-destructive">{addError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
saveNotificationPref,
|
saveNotificationPref,
|
||||||
type NotificationPref,
|
type NotificationPref,
|
||||||
} from '@/lib/tender-radar-api';
|
} from '@/lib/tender-radar-api';
|
||||||
|
import { RssFeedListForm } from './components/RssFeedListForm';
|
||||||
import { SourceConfigForm } from './components/SourceConfigForm';
|
import { SourceConfigForm } from './components/SourceConfigForm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,11 @@ import { SourceConfigForm } from './components/SourceConfigForm';
|
|||||||
* than split into a separate component — this is a single select, no
|
* than split into a separate component — this is a single select, no
|
||||||
* standalone unit-test coverage was called for in this plan.
|
* standalone unit-test coverage was called for in this plan.
|
||||||
*
|
*
|
||||||
|
* Plan 14-02 (INGEST-04, D-08/D-09/D-14) adds an "RSS-Feeds" section below
|
||||||
|
* SourceConfigForm: RssFeedListForm, the admin CRUD list for the GLOBAL
|
||||||
|
* (not per-tenant) RSS feed sources. Extends this existing settings page
|
||||||
|
* rather than building a new one (D-09).
|
||||||
|
*
|
||||||
* No module-loader whitelist change is needed here: this is a standard
|
* No module-loader whitelist change is needed here: this is a standard
|
||||||
* Next.js App Router route nested under the already-whitelisted
|
* Next.js App Router route nested under the already-whitelisted
|
||||||
* `tender-radar` module page (Plan 10-02).
|
* `tender-radar` module page (Plan 10-02).
|
||||||
@@ -82,6 +88,18 @@ export default function TenderRadarSettingsPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<SourceConfigForm />
|
<SourceConfigForm />
|
||||||
|
|
||||||
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||||
|
RSS-Feeds
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-xs text-muted-foreground">
|
||||||
|
Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder
|
||||||
|
eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten
|
||||||
|
gleich, nicht pro Mandant konfigurierbar.
|
||||||
|
</p>
|
||||||
|
<RssFeedListForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 border-t border-border pt-6">
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||||
Benachrichtigungen
|
Benachrichtigungen
|
||||||
|
|||||||
@@ -353,3 +353,96 @@ export async function saveNotificationPref(
|
|||||||
if (!res.ok) throw new Error('Failed to save notification preference');
|
if (!res.ok) throw new Error('Failed to save notification preference');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single admin-managed, GLOBAL RSS feed source (Plan 14-02, D-08/D-14).
|
||||||
|
* Unlike every other resource in this file, this is NOT per-tenant/per-user
|
||||||
|
* data — the list is shared platform-wide, mirroring `SourceConfig`'s
|
||||||
|
* global stance.
|
||||||
|
*/
|
||||||
|
export interface RssFeedSource {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload accepted by POST /modules/tender-radar/rss-feeds. */
|
||||||
|
export interface CreateRssFeedPayload {
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the backend's error message from a non-2xx JSON error body
|
||||||
|
* (Nest's default exception filter shape: `{ statusCode, message, error }`)
|
||||||
|
* so the save-time denylist/SSRF rejection (D-14, T-14-02-01) surfaces its
|
||||||
|
* specific reason inline instead of a generic "failed to save" string.
|
||||||
|
*/
|
||||||
|
async function extractErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { message?: unknown };
|
||||||
|
if (typeof body.message === 'string' && body.message) return body.message;
|
||||||
|
if (Array.isArray(body.message) && body.message.length) {
|
||||||
|
return body.message.join(', ');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* body wasn't JSON — fall through to the generic message */
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List every admin-managed RSS feed (global, D-08).
|
||||||
|
* GET /modules/tender-radar/rss-feeds
|
||||||
|
*/
|
||||||
|
export async function listRssFeeds(): Promise<RssFeedSource[]> {
|
||||||
|
const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await extractErrorMessage(res, 'Failed to fetch RSS feeds'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new global RSS feed URL. Rejected with the backend's specific
|
||||||
|
* hostname/SSRF-guard message (D-14) when the URL is denylisted/private/
|
||||||
|
* loopback — the rejection message is relayed as-is via `extractErrorMessage`
|
||||||
|
* so the admin sees WHY, not just that the save failed.
|
||||||
|
* POST /modules/tender-radar/rss-feeds
|
||||||
|
*/
|
||||||
|
export async function createRssFeed(
|
||||||
|
payload: CreateRssFeedPayload,
|
||||||
|
): Promise<RssFeedSource> {
|
||||||
|
const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(res, 'Failed to create RSS feed'));
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a global RSS feed.
|
||||||
|
* DELETE /modules/tender-radar/rss-feeds/:feedId
|
||||||
|
*/
|
||||||
|
export async function deleteRssFeed(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(res, 'Failed to delete RSS feed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user