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 {
/**
* `'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';
}
/**
* 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.
* 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">