feat(17-03): "Meine Quellen" wird vollstaendig — Postfach, eigene Feeds, Benachrichtigung

- RssFeedSource bekommt isPlatformWide (server-derived), createRssFeed nimmt
  einen scope-Parameter (personal/platform, Vorgabe personal)
- RssFeedListForm bekommt scope-Prop: personal zeigt eigene Feeds editierbar +
  plattformweite als schlichte Aufzaehlung ohne Knoepfe darunter; platform
  zeigt nur plattformweite Feeds editierbar
- DigestIntervalForm aus settings/page.tsx unveraendert herausgeloest (keine
  neuen Beschriftungen, gleiche settings.*-Schluessel)
- my-sources/page.tsx um "Meine Feeds" und "Benachrichtigung" erweitert,
  Verweis auf die Administrationsseite nur fuer ADMIN/SUPER_ADMIN
- settings/page.tsx vorgezogen auf RssFeedListForm scope="platform" (Rule 3,
  eigener Type-Check-Verify sonst rot) — volle Rollenpruesung folgt Task 2

Rule 1: createRssFeed's Antwort traegt kein isPlatformWide (nur GET mappt es
serverseitig) — RssFeedListForm setzt es nach dem Anlegen lokal aus dem
verwendeten scope, sonst wuerde ein frisch angelegter plattformweiter Feed
bis zum naechsten Neuladen aus seiner eigenen Liste verschwinden.
This commit is contained in:
2026-08-12 11:56:42 +02:00
parent e45d7f2068
commit 4100bb575e
7 changed files with 261 additions and 49 deletions
+24 -12
View File
@@ -401,10 +401,11 @@ export async function saveNotificationPref(
}
/**
* 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.
* A single RSS feed source — either platform-wide (admin-managed, D-08/
* D-14) or personal (owned by exactly one user, Phase 17 Plan 02, D-02).
* `isPlatformWide` is a SERVER-DERIVED display flag (T-17-12) — the raw
* ownership `userId` never reaches the client; the UI only needs to know
* whether a row is editable by the current caller, not who owns it.
*/
export interface RssFeedSource {
id: string;
@@ -413,9 +414,11 @@ export interface RssFeedSource {
isActive: boolean;
createdAt: string;
updatedAt: string;
/** true = maintained by the administration, applies to everyone; false = the caller's own personal feed. */
isPlatformWide: boolean;
}
/** Payload accepted by POST /modules/tender-radar/rss-feeds. */
/** Payload accepted by POST /modules/tender-radar/rss-feeds (scope goes as a separate function argument, see `createRssFeed`). */
export interface CreateRssFeedPayload {
url: string;
label: string;
@@ -442,7 +445,8 @@ async function extractErrorMessage(res: Response, fallback: string): Promise<str
}
/**
* List every admin-managed RSS feed (global, D-08).
* List every platform-wide RSS feed plus the caller's own personal feeds
* (Phase 17 Plan 02, D-02). Each entry carries `isPlatformWide`.
* GET /modules/tender-radar/rss-feeds
*/
export async function listRssFeeds(): Promise<RssFeedSource[]> {
@@ -458,20 +462,26 @@ export async function listRssFeeds(): Promise<RssFeedSource[]> {
}
/**
* 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.
* Add a new RSS feed URL. `scope` is a WISH the server re-checks, never
* trusted by itself (Phase 17 Plan 02, D-02, T-17-08): `'platform'`
* requires the caller to hold ADMIN/SUPER_ADMIN and is rejected with 403
* otherwise; `'personal'` (the default) creates a feed owned by the
* caller. Rejected with the backend's specific hostname/SSRF-guard message
* (D-14) when the URL is denylisted/private/loopback, or with the
* per-user cap message once 20 personal feeds are reached (T-17-10) — the
* rejection message is relayed as-is via `extractErrorMessage` so the
* caller sees WHY, not just that the save failed.
* POST /modules/tender-radar/rss-feeds
*/
export async function createRssFeed(
payload: CreateRssFeedPayload,
scope: 'personal' | 'platform' = 'personal',
): 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),
body: JSON.stringify({ ...payload, scope }),
});
if (!res.ok) {
throw new Error(await extractErrorMessage(res, 'Failed to create RSS feed'));
@@ -480,7 +490,9 @@ export async function createRssFeed(
}
/**
* Remove a global RSS feed.
* Remove an RSS feed. Ownership/authorization is enforced entirely on the
* server (T-17-07) — the caller may remove their own personal feed, or, if
* ADMIN/SUPER_ADMIN, a platform-wide feed.
* DELETE /modules/tender-radar/rss-feeds/:feedId
*/
export async function deleteRssFeed(id: string): Promise<void> {