From 7e5a6a6e01a49cb07e431e3030782dca7d17ecd5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 28 Jul 2026 14:55:27 +0200 Subject: [PATCH] docs(planning): add v1.1 milestone audit and 260723-lvg quick plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../260723-lvg-PLAN.md | 237 ++++++++++++++++++ .planning/v1.1-MILESTONE-AUDIT.md | 41 +++ 2 files changed, 278 insertions(+) create mode 100644 .planning/quick/260723-lvg-jetzt-abrufen-button-fuer-ausschreibungs/260723-lvg-PLAN.md create mode 100644 .planning/v1.1-MILESTONE-AUDIT.md diff --git a/.planning/quick/260723-lvg-jetzt-abrufen-button-fuer-ausschreibungs/260723-lvg-PLAN.md b/.planning/quick/260723-lvg-jetzt-abrufen-button-fuer-ausschreibungs/260723-lvg-PLAN.md new file mode 100644 index 0000000..197d038 --- /dev/null +++ b/.planning/quick/260723-lvg-jetzt-abrufen-button-fuer-ausschreibungs/260723-lvg-PLAN.md @@ -0,0 +1,237 @@ +--- +phase: quick-260723-lvg +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/api/src/tenders/tenders.controller.ts + - apps/api/src/tenders/tenders.controller.spec.ts + - apps/web/src/lib/tender-radar-api.ts + - apps/web/src/app/(portal)/modules/tender-radar/page.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.test.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json +autonomous: true +requirements: [quick-260723-lvg] + +must_haves: + truths: + - "An admin clicking 'Jetzt abrufen' triggers an immediate TenderIngestionService.pollDueSources() run on the server" + - "The button shows a spinner and is disabled while the poll is in flight (DKV check-now pattern)" + - "After a successful poll the tender result list refetches without the user changing any filter" + - "A failed poll (e.g. 403 for a non-admin) surfaces a clear i18n error message, list stays intact" + - "New tenderRadar.page.* keys exist in BOTH de.json and en.json (parity spec green)" + artifacts: + - "POST /modules/tender-radar/poll-now route on TendersController, @Roles(ADMIN, SUPER_ADMIN), declared before @Get(':id')" + - "pollNow() client in tender-radar-api.ts" + - "PollNowButton.tsx self-contained button component + PollNowButton.test.tsx" + - "refreshKey wiring: page.tsx passes it, ResultsList.tsx refetches on it" + key_links: + - "PollNowButton.onPolled -> page.tsx setRefreshKey -> ResultsList refetch" + - "pollNow() client -> POST /modules/tender-radar/poll-now -> tenderIngestionService.pollDueSources()" +--- + + +Add a manual "Jetzt abrufen" poll trigger to the Ausschreibungs-Radar, analogous +to DKV's "Jetzt prüfen"/check-now. Backend: one admin-gated endpoint that runs +`TenderIngestionService.pollDueSources()` immediately. Frontend: a button in the +tender-radar page header next to the existing gear, DKV spinner/disabled pattern, +result-list refetch on success, DE/EN i18n. + +Purpose: Give admins an on-demand way to pull due sources without waiting for the +scheduler tick — the standard operator affordance already present in DKV. + +Output: One POST route (+ controller spec), one API client function, one +PollNowButton component (+ test), a refreshKey wiring in page.tsx/ResultsList, +and paired de/en i18n keys. + +## Semantic honesty (READ FIRST — do NOT introduce a force flag) + +`pollDueSources()` does NOT force a re-download. It honors the existing cursor: +- `'day'`-granularity sources (DÖE `doe-opendata`) fetch only not-yet-ingested + days via `nextDayToFetch` — on a fresh DB that is "now", but if today was + already ingested this tick is a No-Op for that source. +- `'tick'`-granularity sources (`rss`, `email-alert`) fetch on every active tick. + +The button is therefore "fällige Quellen jetzt abrufen" (fetch DUE sources now), +NOT "alles neu herunterladen". Label copy and the button `title` must reflect +this. Adding a `force` parameter to `pollDueSources()` is explicitly OUT OF SCOPE. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/STATE.md +@CLAUDE.md + +# Backend reference — DKV check-now analog + tenders controller conventions +@apps/api/src/dkv/dkv.controller.ts +@apps/api/src/tenders/tenders.controller.ts +@apps/api/src/tenders/tenders.controller.spec.ts +@apps/api/src/auth/decorators/roles.decorator.ts + +# Frontend reference — DKV button/spinner pattern + tender-radar page/list/api/i18n +@apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx +@apps/web/src/lib/dkv-api.ts +@apps/web/src/app/(portal)/modules/tender-radar/page.tsx +@apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx +@apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx +@apps/web/src/lib/tender-radar-api.ts +@apps/web/src/messages/tenderRadar-parity.spec.ts + + + + + + Task 1: Add admin-gated POST /poll-now endpoint + controller spec + apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts + + - `pollNow()` calls `this.tenderIngestionService.pollDueSources()` exactly once and resolves to `{ ok: true }`. + - `pollNow` is declared BEFORE `getTender` in the class body (Object.getOwnPropertyNames order), mirroring the Pitfall-5 route-order convention used for every other static route. + - `pollNow` carries `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` metadata — assert via `Reflect.getMetadata(ROLES_KEY, TendersController.prototype.pollNow)` (import `ROLES_KEY` from `../auth/decorators/roles.decorator`), expect it to contain both roles. + - All existing controller instantiations in the spec (7 positional constructor args) keep passing unchanged. + + + In `tenders.controller.ts`: inject `TenderIngestionService` by APPENDING it as the + LAST constructor parameter (`private readonly tenderIngestionService: TenderIngestionService`) + — appending preserves every existing `new TendersController(...7 args...)` call site in the + spec (they pass undefined for the new slot and never touch pollNow). Import + `TenderIngestionService` from `./tender-ingestion.service`. `TenderIngestionService` is + already a provider in `tenders.module.ts`, so no module change is needed. + + Add a new handler `pollNow()` in a clearly-commented "Admin manual poll trigger" section + placed immediately AFTER `getSourceConfig` (line ~190) and well before `@Get(':id')` + (`getTender`). Decorate with `@Post('poll-now')` and `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. + Because the platform-wide upstream fetch is a DoS/rate lever, gate to admins only (T-lvg-01). + Body: `await this.tenderIngestionService.pollDueSources(); return { ok: true };`. Do NOT + add a `force` argument — `pollDueSources()` takes none and honors the day-cursor by design. + In the handler doc comment, state that this fetches DUE sources now (not a forced + re-download) and note it is declared before `@Get(':id')` per the route-order pitfall. + `pollDueSources()` never throws (catch-and-log per tick + per source), so no try/catch here. + + In `tenders.controller.spec.ts`: add a `makeFakeIngestionService()` helper returning + `{ pollDueSources: vi.fn(async () => undefined) }`. Add a new describe block + "TendersController — POST /poll-now (admin manual trigger)" with tests: + (1) `pollNow()` calls the fake `pollDueSources` once and returns `{ ok: true }` — construct + the controller with the 7 existing fakes PLUS the ingestion fake as the 8th arg; + (2) roles metadata via `Reflect.getMetadata(ROLES_KEY, TendersController.prototype.pollNow)` + contains `Role.ADMIN` and `Role.SUPER_ADMIN` (import `Role` from `@prisma/client`, `ROLES_KEY` + from the roles decorator). Add one test to the existing "route declaration order" describe + asserting `pollNow` index < `getTender` index. Leave all existing tests untouched. + + + cd apps/api && pnpm vitest run src/tenders/tenders.controller.spec.ts + + Controller spec passes: pollNow delegates to pollDueSources, returns {ok:true}, is roles-gated, declared before getTender; all pre-existing tenders.controller tests still green. + + + + Task 2: Frontend "Jetzt abrufen" button, pollNow client, refetch wiring + i18n + apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/PollNowButton.test.tsx, apps/web/src/app/(portal)/modules/tender-radar/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json + + - PollNowButton renders a button labelled `t('page.pollNow')`; clicking it calls the mocked `pollNow` client once. + - While the promise is pending the button is disabled and shows the spinner + `t('page.polling')` copy. + - On success it calls the `onPolled` prop callback (drives the list refetch) and shows no error. + - On rejection it renders an error message `t('page.pollError')` and does NOT call `onPolled`. + - ResultsList refetches when its `refreshKey` prop changes (incremented on poll success). + - de.json and en.json define the identical tenderRadar key set (parity spec green). + + + `tender-radar-api.ts`: add `pollNow()` — `POST ${API_URL}/modules/tender-radar/poll-now`, + `credentials: 'include'`, no body; `if (!res.ok) throw new Error('Failed to trigger tender poll');` + `return res.json();` Type the return as `Promise<{ ok: boolean }>`. Mirror the DKV + `checkNow` client shape. + + `components/PollNowButton.tsx` (NEW, `'use client'`): self-contained unit so it can be tested + in isolation (same convention as CoverageBanner/ResultsList/SavedSearchBar tests). Copy the + `SpinnerIcon` SVG from the DKV page (20×20, `animate-spin`, `stroke="currentColor"`). Props: + `{ onPolled?: () => void }`. Local state: `isPolling` (bool), `pollError` (string|null). Uses + `useTranslations('tenderRadar')`. Root is `<div className="flex flex-col items-end gap-1">` + so it drops into the header's right cluster and grows an error line downward. Render a primary + button mirroring the DKV "Jetzt prüfen" button styles (`rounded bg-primary px-4 py-2 text-sm + font-medium text-primary-foreground ... flex items-center`, `disabled={isPolling}`, opacity/cursor + when polling). Button `title={t('page.pollNowTitle')}` (honest "fällige Quellen jetzt abrufen"). + While `isPolling`: `<SpinnerIcon />{t('page.polling')}`; else `t('page.pollNow')`. + `handlePoll`: set `isPolling` true + clear error, `await pollNow()`, on success call + `onPolled?.()`, catch → `setPollError(t('page.pollError'))`, finally `setIsPolling(false)`. + When `pollError` is set, render a small right-aligned `text-xs text-destructive` line with the + message and a dismiss "×" button (`aria-label={t('page.pollErrorDismiss')}`) that clears it. + + `page.tsx`: import `useState` from react and `PollNowButton`. Add + `const [refreshKey, setRefreshKey] = useState(0);` in `TenderRadarContent`. Wrap the existing + gear `<Link>` and the new `<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />` + together in a right-side `<div className="flex items-center gap-2">` inside the existing + header `flex items-start justify-between` row (button sits NEXT TO the gear). Change the list + render to `<ResultsList refreshKey={refreshKey} />`. + + `ResultsList.tsx`: accept an optional prop — change the signature to + `export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {})`. Add + `refreshKey` to the `load` `useCallback` dependency array (alongside `paramsKey`, keeping the + existing eslint-disable line) so an incremented `refreshKey` re-runs `load()` and refetches the + list without any URL/filter change. This is the same parent-increments-refreshKey pattern DKV + uses for InvoiceHistoryTable (STATE decision 07-05). + + `de.json` + `en.json`: add under `tenderRadar.page` (both locales — parity is enforced by + tenderRadar-parity.spec.ts, missing-in-either fails): `pollNow`, `pollNowTitle`, `polling`, + `pollError`, `pollErrorDismiss`. Suggested DE: "Jetzt abrufen" / "Fällige Quellen jetzt + abrufen" / "Wird abgerufen…" / "Der Abruf konnte nicht ausgelöst werden. Möglicherweise fehlen + Ihnen die nötigen Rechte." / "Schließen". EN mirrors: "Fetch now" / "Fetch due sources now" / + "Fetching…" / "The fetch could not be triggered. You may not have the required permissions." / + "Dismiss". + + `PollNowButton.test.tsx` (NEW): mirror ResultsList.test.tsx setup — `vi.mock('@/lib/tender-radar-api', ...)` + exposing a `mockPollNow`; `vi.mock('next-intl', ...)` with a flat key→copy lookup for the + `page.*` keys used. Tests: (1) renders the pollNow label; (2) click calls `pollNow` once and, on + resolve, calls the `onPolled` prop (use `waitFor`); (3) while pending the button is disabled and + shows the polling copy (resolve a deferred promise to observe the transition); (4) on reject it + shows the pollError copy and never calls `onPolled`. Use `@testing-library/react` + render/fireEvent/waitFor/cleanup, `afterEach` reset, matching the existing test file style. + + + cd apps/web && pnpm vitest run src/app/\(portal\)/modules/tender-radar/components/PollNowButton.test.tsx src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx src/messages/tenderRadar-parity.spec.ts + + PollNowButton test green (render, click→pollNow, spinner/disabled, error→no onPolled); ResultsList test still green with the new optional prop; tenderRadar de/en parity spec green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser → API (POST /poll-now) | authenticated client triggers a platform-wide upstream fetch | +| API → external tender sources | pollDueSources fans out to DÖE/RSS/etc. upstreams | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-lvg-01 | Denial of Service | POST /modules/tender-radar/poll-now | medium | mitigate | `@Roles(ADMIN, SUPER_ADMIN)` gates the trigger — non-admins get 403; upstream fetch is not user-open. pollDueSources honors the day-cursor so repeated clicks are largely No-Op for `day` sources (idempotent). | +| T-lvg-02 | Elevation of Privilege | poll-now handler | low | mitigate | Global JwtAuthGuard + RolesGuard enforce the `@Roles` decorator; no per-body privilege input. Spec asserts roles metadata is present. | +| T-lvg-03 | Information Disclosure | 403 error surfaced in UI | low | accept | Generic i18n error copy; no backend internals leaked. Button visible to all, action denied server-side. | + + + +- API: `cd apps/api && pnpm vitest run src/tenders/tenders.controller.spec.ts` green. +- Web: `cd apps/web && pnpm vitest run` for the three targeted specs green. +- No `force` argument added to `pollDueSources()` anywhere (semantic honesty). +- New i18n keys present in BOTH de.json and en.json. + + + +- POST /modules/tender-radar/poll-now exists, admin-gated, declared before @Get(':id'), delegates to pollDueSources(), returns {ok:true}. +- Header button next to the gear triggers the poll with DKV spinner/disabled UX; success refetches the list; failure shows an i18n error. +- All targeted API + web specs green; parity spec green. +- Two atomic commits (Task 1 backend, Task 2 frontend). No push, no docker restart. + + + +Create `.planning/quick/260723-lvg-jetzt-abrufen-button-fuer-ausschreibungs/260723-lvg-SUMMARY.md` when done. + diff --git a/.planning/v1.1-MILESTONE-AUDIT.md b/.planning/v1.1-MILESTONE-AUDIT.md new file mode 100644 index 0000000..02403f9 --- /dev/null +++ b/.planning/v1.1-MILESTONE-AUDIT.md @@ -0,0 +1,41 @@ +# v1.1 Ausschreibungs-Radar — Milestone Audit + +**Milestone:** v1.1 (Phasen 10–14) +**Audited:** 2026-07-24 +**Verdict:** GAPS — 1 neuer Blocker, 1 Warnung, 2 akzeptierte Deferrals + +## Phasen-Verifikationen (aggregiert) + +| Phase | Status | Score | +|-------|--------|-------| +| 10 Foundation & DÖE | passed | 5/5 | +| 11 Filter/UI/Saved Searches | passed | 5/5 | +| 12 Notifications | passed | 4/4 | +| 13 Scraping-Adapter + Dedup | gaps_found | 3/4 (Truth 3 Dedup dormant, Option C) | +| 14 RSS/E-Mail/Rollout | human_needed | 4/5 (EWS-Live-Test offen) | + +Alle 24 Requirements sind in REQUIREMENTS.md als `[x]` markiert. Der Integrations-Checker bestätigt: die Ingestion-Pipeline aller 5 Quellen ist verdrahtet (Registry → pollDueSources → Normalizer → Tender), Filter/UI/Notifications/Denylist/i18n sind end-to-end verbunden. ABER: + +## BLOCKER — NetServer/cosinex-Adapter haben keinen Aktivierungspfad + +**Betroffene REQ-IDs: INGEST-02, INGEST-03, CONFIG-02 (teilweise)** + +- `ai-netserver` und `cosinex-dtvp` `TenderSourcePollConfig`-Zeilen werden mit `isActive: false` geseedet, Kommentar „activation deferred to Phase 14 UI". +- Aber Phase 14 hat diese UI nie geliefert: `SourceConfigDto` (`dto/source-config.dto.ts`) hat KEIN `sourceType`-Feld; `GET/PUT /modules/tender-radar/source-config` (`tenders.controller.ts:43,190-215`) ist auf `DOE_SOURCE_TYPE = 'doe-opendata'` hartverdrahtet. `SourceConfigForm.tsx` spricht denselben Single-Source-Endpoint an. +- Es existiert KEINE Route/Service-Methode/UI, die `isActive` für `ai-netserver`/`cosinex-dtvp` umschalten kann. +- Netto: Die in Phase 13 gebauten, getesteten, registrierten Adapter (INGEST-02/03) sind produktiv unerreichbar — sie bleiben für immer `isActive:false` (außer manuellem DB-Write). Widerspricht Phase-13-Kriterien 1/2 und CONFIG-02 („Admin verwaltet Quellen-Poll-Konfiguration"). + +**Fix (klein):** `SourceConfigDto`/Endpoint auf beliebigen `sourceType` generalisieren (nicht nur DÖE) + `SourceConfigForm` alle pollbaren Quellen auflisten und togglebar machen. Denylist-Gate bleibt (vergabe24/aumass werden ohnehin nicht registriert). + +## WARNUNG — Scheduler-Cron hängt allein am DÖE-Config + +`TenderSchedulerService.onApplicationBootstrap()` (`tender-scheduler.service.ts:73-89`) registriert den einen globalen Cron NUR, wenn `doe-opendata` aktiv ist; die admin `source-config` PUT-Route ruft `stopJob()`, wenn DÖE deaktiviert wird. Da dieser eine Cron via `pollDueSources()` ALLE aktiven Quellen fächert, würde ein Deaktivieren von DÖE still auch RSS + E-Mail-Ingestion abschalten. Architektur-Schuld (Phase-13/14-Fan-out auf Phase-10-Single-Source-Scheduler gesetzt, ohne die Bootstrap/Stop-Bedingung auf „irgendeine aktive Quelle" umzustellen). Nicht user-facing im Normalfall (DÖE ist active-by-default, kein Toggle für die anderen exponiert) → WARNUNG, nicht Blocker. + +## Akzeptierte Deferrals (keine neuen Findings) + +1. **SCHEMA-03 Cross-Source-Fingerprint-Dedup dormant** — `dedupActive = activePortalCount >= 2`; Fingerprint-CPV-Asymmetrie (Option C, 13-VERIFICATION.md). Hinweis: durch den Blocker oben verschärft — mit inaktiven NetServer/cosinex sind praktisch nur DÖE+RSS aktiv, echte Cross-Source-Overlaps bleiben unwahrscheinlich. +2. **14-03 EWS/Exchange-Live-Test** — braucht echtes Postfach, vom User bewusst vertagt. + +## Empfehlung + +Den Blocker (Quellen-Aktivierungs-UI) vor dem formalen v1.1-Abschluss schließen — er ist klein und macht INGEST-02/03 + CONFIG-02 erst wirklich wahr. Die Scheduler-Warnung optional gleich mitnehmen. EWS-Test + Dedup-dormant bleiben legitime „braucht-Live-Daten"-Deferrals nach v1.2.