From 4100bb575e8cd6154591544fbaa53ec983787ed5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 12 Aug 2026 11:56:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(17-03):=20"Meine=20Quellen"=20wird=20volls?= =?UTF-8?q?taendig=20=E2=80=94=20Postfach,=20eigene=20Feeds,=20Benachricht?= =?UTF-8?q?igung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../modules/tender-radar/my-sources/page.tsx | 69 ++++++++---- .../components/DigestIntervalForm.tsx | 100 ++++++++++++++++++ .../settings/components/RssFeedListForm.tsx | 93 +++++++++++++--- .../modules/tender-radar/settings/page.tsx | 2 +- apps/web/src/lib/tender-radar-api.ts | 36 ++++--- apps/web/src/messages/de.json | 5 +- apps/web/src/messages/en.json | 5 +- 7 files changed, 261 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx diff --git a/apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx index 5ba8258..43b9965 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx @@ -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 (
@@ -49,6 +57,31 @@ export default function TenderRadarMySourcesPage() {
+ +
+

+ {t('mySources.feedsSectionTitle')} +

+ +
+ +
+

+ {t('mySources.notificationSectionTitle')} +

+ +
+ + {isAdmin && ( +
+ + {t('page.settingsTitle')} → + +
+ )} ); } diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx new file mode 100644 index 0000000..dceb10f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx @@ -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(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 ( +
+ {isLoading ? ( +
+ ) : ( +
+ + +

+ {t('settings.digestHelp')} +

+
+ )} + + {saveSuccess && ( +

+ {t('settings.saveSuccess')} +

+ )} + {error &&

{error}

} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx index 4e68bbf..51821db 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx @@ -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([]); 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() {
) : (