15 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260723-lvg | 01 | execute | 1 |
|
true |
|
|
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ÖEdoe-opendata) fetch only not-yet-ingested days vianextDayToFetch— 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.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/STATE.md @CLAUDE.mdBackend 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.
<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> |
<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>