--- 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.