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:
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user