Files
schalli 7e5a6a6e01
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
docs(planning): add v1.1 milestone audit and 260723-lvg quick plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 14:55:27 +02:00

238 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>