docs(planning): add v1.1 milestone audit and 260723-lvg quick plan
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 45s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 14:55:27 +02:00
parent ae85b91468
commit 7e5a6a6e01
2 changed files with 278 additions and 0 deletions
@@ -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()"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add admin-gated POST /poll-now endpoint + controller spec</name>
<files>apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts</files>
<behavior>
- `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.
</behavior>
<action>
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 &lt; `getTender` index. Leave all existing tests untouched.
</action>
<verify>
<automated>cd apps/api &amp;&amp; pnpm vitest run src/tenders/tenders.controller.spec.ts</automated>
</verify>
<done>Controller spec passes: pollNow delegates to pollDueSources, returns {ok:true}, is roles-gated, declared before getTender; all pre-existing tenders.controller tests still green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Frontend "Jetzt abrufen" button, pollNow client, refetch wiring + i18n</name>
<files>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</files>
<behavior>
- 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).
</behavior>
<action>
`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&lt;{ ok: boolean }&gt;`. 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 `&lt;div className="flex flex-col items-end gap-1"&gt;`
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`: `&lt;SpinnerIcon /&gt;{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 `&lt;Link&gt;` and the new `&lt;PollNowButton onPolled={() =&gt; setRefreshKey((k) =&gt; k + 1)} /&gt;`
together in a right-side `&lt;div className="flex items-center gap-2"&gt;` inside the existing
header `flex items-start justify-between` row (button sits NEXT TO the gear). Change the list
render to `&lt;ResultsList refreshKey={refreshKey} /&gt;`.
`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.
</action>
<verify>
<automated>cd apps/web &amp;&amp; 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</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
- 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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/quick/260723-lvg-jetzt-abrufen-button-fuer-ausschreibungs/260723-lvg-SUMMARY.md` when done.
</output>
+41
View File
@@ -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.