238 lines
15 KiB
Markdown
238 lines
15 KiB
Markdown
---
|
||
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 < `getTender` index. Leave all existing tests untouched.
|
||
</action>
|
||
<verify>
|
||
<automated>cd apps/api && 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<{ 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.
|
||
</action>
|
||
<verify>
|
||
<automated>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</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>
|