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:
2026-07-23 13:29:07 +02:00
parent e812738c3a
commit 48a2dc1026
6 changed files with 668 additions and 1 deletions
+93
View File
@@ -353,3 +353,96 @@ export async function saveNotificationPref(
if (!res.ok) throw new Error('Failed to save notification preference');
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'));
}
}