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
@@ -1,31 +1,37 @@
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useAuthStore } from '@/lib/stores/auth-store';
import { EmailAlertConfigForm } from '../settings/components/EmailAlertConfigForm';
import { RssFeedListForm } from '../settings/components/RssFeedListForm';
import { DigestIntervalForm } from '../settings/components/DigestIntervalForm';
/**
* "Meine Quellen" — the per-user Ausschreibungs-Radar module page (Phase
* 17, Plan 01, D-01/D-05).
* 17, D-01/D-02/D-04/D-05).
*
* Until this plan, the alert mailbox lived on the admin-only
* `/modules/tender-radar/settings` page, one config per TENANT — a second
* colleague with their own portal account could not connect their own
* inbox. Phase 17 moves ownership to the USER (TenderEmailConfig.userId).
* This page is where every user with module access manages their own
* mailbox, reusing the existing `EmailAlertConfigForm` UNCHANGED — it
* already talks to the same `GET`/`PUT /modules/tender-radar/email-config`
* endpoints, which now resolve ownership by userId instead of tenantId
* (TendersController.getEmailConfig/saveEmailConfig).
*
* Deliberately a SEPARATE page from `/settings` (D-01 open point 4), not a
* new section on `/settings/general`: module-specific settings stay with
* the module rather than accumulating on a generic account page as more
* modules adopt the same per-user pattern (DKV-Fleet, future modules).
* Plan 01 built this page with a single section (the mailbox). Plan 03
* (D-04, the original backlog trigger) completes it with two more: the
* caller's own RSS feeds (`RssFeedListForm scope="personal"` — reused
* unchanged from the admin settings page, Plan 02's `isPlatformWide` split
* decides what's editable here) and the digest interval
* (`DigestIntervalForm`, extracted from the settings page — it was already
* per-user, D-01/D-03 of Phase 12, only misplaced). Together: mailbox, own
* feeds, own notification cadence — everything a normal module user is
* allowed to touch, on one page, in one scroll.
*
* D-05 (harte Grenze): `Tender` stays platform-global — connecting a
* mailbox here only changes WHO feeds sources in, not WHO sees hits. The
* intro text below says so explicitly, so nobody is surprised that a
* colleague's inbox produces results everyone at the tenant can see.
* mailbox or feed here only changes WHO feeds sources in, not WHO sees
* hits. The intro text says so explicitly (unchanged from Plan 01), and
* the RSS section repeats the same honesty for feeds (`mySources.
* platformFeedsNote` in `RssFeedListForm`).
*
* Administrators additionally see a link to the admin-only settings page
* (poll interval, platform-wide feeds) — everyone else does not, because
* that page rejects them anyway (Plan 03, D-03). The role comes from the
* existing auth store; while it's not loaded yet (`user === null`), the
* link is omitted rather than flashing briefly (T-17-16).
*
* No module-loader whitelist change needed — nested route under the
* already-whitelisted `tender-radar` module page (same reasoning as
@@ -33,6 +39,8 @@ import { EmailAlertConfigForm } from '../settings/components/EmailAlertConfigFor
*/
export default function TenderRadarMySourcesPage() {
const t = useTranslations('tenderRadar');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
return (
<div className="mx-auto max-w-2xl p-6">
@@ -49,6 +57,31 @@ export default function TenderRadarMySourcesPage() {
</h2>
<EmailAlertConfigForm />
</div>
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('mySources.feedsSectionTitle')}
</h2>
<RssFeedListForm scope="personal" />
</div>
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('mySources.notificationSectionTitle')}
</h2>
<DigestIntervalForm />
</div>
{isAdmin && (
<div className="mt-8 border-t border-border pt-6">
<Link
href="/modules/tender-radar/settings"
className="text-sm text-primary hover:underline"
>
{t('page.settingsTitle')} &rarr;
</Link>
</div>
)}
</div>
);
}
@@ -0,0 +1,100 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
fetchNotificationPref,
saveNotificationPref,
type NotificationPref,
} from '@/lib/tender-radar-api';
/**
* DigestIntervalForm — this user's digest interval preference (Plan 12-04,
* NOTIFY-01, D-01/D-03: `TenderNotificationPref.userId` — already per-user
* before this plan). Extracted verbatim from the admin settings page's
* inline block in Phase 17 Plan 03 (D-04) — the preference only lived on
* the wrong page, the admin-only settings page, below three sections a
* normal user could not use. No behavior change from the extraction and no
* new translation keys: both "Meine Quellen" and the admin settings page
* render this component and reuse the existing `settings.*` i18n keys
* unchanged.
*/
export function DigestIntervalForm() {
const t = useTranslations('tenderRadar');
const [digestInterval, setDigestInterval] = useState<
NotificationPref['digestInterval']
>('daily');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
fetchNotificationPref()
.then((pref) => setDigestInterval(pref.digestInterval))
.catch((err) => {
setError(
err instanceof Error ? err.message : t('settings.errorLoad'),
);
})
.finally(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = async (value: string) => {
const next = value as NotificationPref['digestInterval'];
setDigestInterval(next);
setError(null);
setSaveSuccess(false);
setIsSaving(true);
try {
const result = await saveNotificationPref(next);
setDigestInterval(result.digestInterval);
setSaveSuccess(true);
} catch (err) {
setError(
err instanceof Error ? err.message : t('settings.errorSave'),
);
} finally {
setIsSaving(false);
}
};
return (
<div>
{isLoading ? (
<div className="h-9 max-w-xs rounded bg-muted animate-pulse" />
) : (
<div>
<label
htmlFor="tr-digest-interval"
className="mb-1 block text-sm text-foreground"
>
{t('settings.digestIntervalLabel')}
</label>
<select
id="tr-digest-interval"
value={digestInterval}
onChange={(e) => handleChange(e.target.value)}
disabled={isSaving}
className="h-9 max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
>
<option value="daily">{t('settings.digestDaily')}</option>
<option value="weekly">{t('settings.digestWeekly')}</option>
<option value="off">{t('settings.digestOff')}</option>
</select>
<p className="mt-1 text-xs text-muted-foreground">
{t('settings.digestHelp')}
</p>
</div>
)}
{saveSuccess && (
<p className="mt-2 text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
{t('settings.saveSuccess')}
</p>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
);
}
@@ -9,24 +9,40 @@ import {
listRssFeeds,
} from '@/lib/tender-radar-api';
interface RssFeedListFormProps {
/**
* 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.
* `'personal'`: the editable list is the CALLER'S OWN feeds (create,
* remove); any platform-wide feeds appear below as a short,
* non-interactive list with a note that the administration maintains
* them — used on the per-user "Meine Quellen" page (Phase 17 Plan 03,
* D-02).
* `'platform'`: the editable list is the platform-wide feeds; the
* caller's own personal feeds — and everyone else's — never appear
* here at all, because the server only returns them to their owner —
* used on the admin-only settings page.
*/
scope: 'personal' | 'platform';
}
/**
* CRUD UI for the RSS feed list (Plan 14-02 origin, reshaped in Phase 17
* Plan 03 for D-02's two-part ownership model: platform-wide feeds,
* admin-managed, apply to everyone; personal feeds, owned by exactly one
* user). The SAME component serves both `/my-sources` (`scope="personal"`)
* and the admin `/settings` page (`scope="platform"`) — the server decides
* what's actually allowed (T-17-08), this prop only decides what to show
* and which `scope` wish to attach to a create request.
*
* 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.
* The save-time hostname/SSRF guard (T-14-02-01) and the 20-feed personal
* cap (T-17-10) live entirely on the backend — this form does NOT
* duplicate that validation client-side; a rejected URL surfaces the
* backend's specific error message inline via `createRssFeed`'s relayed
* error message, unchanged from before Phase 17.
*
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace.
*/
export function RssFeedListForm() {
export function RssFeedListForm({ scope }: RssFeedListFormProps) {
const t = useTranslations('tenderRadar');
const [feeds, setFeeds] = useState<RssFeedSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -70,8 +86,21 @@ export function RssFeedListForm() {
setIsAdding(true);
try {
const created = await createRssFeed({ url: url.trim(), label: label.trim() });
setFeeds((prev) => [...prev, created]);
const created = await createRssFeed(
{ url: url.trim(), label: label.trim() },
scope,
);
// The POST endpoints return the raw created row, NOT the
// isPlatformWide-derived shape GET /rss-feeds maps server-side
// (T-17-12 only touches the list handler) — derive it locally from
// the scope we just requested with instead of trusting an absent
// field. Without this a freshly created platform feed would vanish
// from its own editable list until the next reload (Rule 1, found
// while wiring `scope` support in this task).
setFeeds((prev) => [
...prev,
{ ...created, isPlatformWide: scope === 'platform' },
]);
setUrl('');
setLabel('');
} catch (err) {
@@ -98,6 +127,12 @@ export function RssFeedListForm() {
}
};
const editableFeeds = feeds.filter((feed) =>
scope === 'platform' ? feed.isPlatformWide : !feed.isPlatformWide,
);
const readonlyPlatformFeeds =
scope === 'personal' ? feeds.filter((feed) => feed.isPlatformWide) : [];
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';
@@ -112,7 +147,7 @@ export function RssFeedListForm() {
</div>
) : (
<ul className="space-y-2">
{feeds.map((feed) => (
{editableFeeds.map((feed) => (
<li
key={feed.id}
className="flex items-center justify-between gap-3 rounded border border-border px-3 py-2"
@@ -139,7 +174,7 @@ export function RssFeedListForm() {
</button>
</li>
))}
{feeds.length === 0 && (
{editableFeeds.length === 0 && (
<li className="text-sm text-muted-foreground">
{t('rssFeeds.empty')}
</li>
@@ -195,6 +230,32 @@ export function RssFeedListForm() {
</button>
{addError && <p className="text-sm text-destructive">{addError}</p>}
</div>
{scope === 'personal' && readonlyPlatformFeeds.length > 0 && (
<div className="space-y-2 border-t border-border pt-4">
<p className="text-xs text-muted-foreground">
{t('mySources.platformFeedsNote')}
</p>
<ul className="space-y-2">
{readonlyPlatformFeeds.map((feed) => (
<li
key={feed.id}
className="rounded border border-border px-3 py-2"
>
<p className="truncate text-sm font-medium text-foreground">
{feed.label}{' '}
<span className="text-xs text-muted-foreground">
({feed.isActive ? t('rssFeeds.activeLabel') : t('rssFeeds.inactiveLabel')})
</span>
</p>
<p className="truncate text-xs text-muted-foreground">
{feed.url}
</p>
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -99,7 +99,7 @@ export default function TenderRadarSettingsPage() {
<p className="mb-4 text-xs text-muted-foreground">
{t('settings.rssSectionBody')}
</p>
<RssFeedListForm />
<RssFeedListForm scope="platform" />
</div>
<div className="mt-8 border-t border-border pt-6">
+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> {
+4 -1
View File
@@ -937,7 +937,10 @@
"mySources": {
"title": "Meine Quellen",
"intro": "Ausschreibungen, die aus Ihrem Postfach hereinkommen, erscheinen danach in der Trefferliste aller Kolleginnen und Kollegen Ihres Mandanten — es gibt keine getrennte Sichtbarkeit je Postfach.",
"mailboxSectionTitle": "Mein Postfach"
"mailboxSectionTitle": "Mein Postfach",
"feedsSectionTitle": "Meine Feeds",
"notificationSectionTitle": "Benachrichtigung",
"platformFeedsNote": "Diese Feeds werden von der Administration gepflegt und gelten für alle."
}
}
}
+4 -1
View File
@@ -937,7 +937,10 @@
"mySources": {
"title": "My sources",
"intro": "Tenders that arrive through your mailbox will then appear in the results list for every colleague at your organization — there is no separate visibility per mailbox.",
"mailboxSectionTitle": "My mailbox"
"mailboxSectionTitle": "My mailbox",
"feedsSectionTitle": "My feeds",
"notificationSectionTitle": "Notification",
"platformFeedsNote": "These feeds are maintained by the administration and apply to everyone."
}
}
}