645 Commits

Author SHA1 Message Date
schalli 5395ce6b6d test(12-03): prove instant+digest single-mail guarantee across both channels
Integration spec runs TenderMatchingService.matchDelta and
TenderDigestScheduler.runDigest against one shared mocked-Prisma store (no
live DB, mocked TenderMailService, no live SMTP) to prove the NOTIFY-03
core invariant end-to-end:

- instantAlert=true: matchDelta sends exactly one instant mail and stamps
  notifiedAt='instant'; the subsequent digest run then sees zero eligible
  matches for that user and sends zero digest mails
  (sendInstant=1, sendDigest=0).
- instantAlert=false: matchDelta never dispatches instant; the digest run
  is the only channel and sends exactly one mail
  (sendInstant=0, sendDigest=1).

Both scenarios assert total mail count across channels === 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:22:04 +02:00
schalli ec032ad89d feat(12-03): instant-alert dispatch at end of matchDelta
GREEN: after all match upserts of a poll tick are written, profiles with
instantAlert=true are checked for fresh (notifiedAt=NULL, tenderId IN
newTenderIds) matches. If any exist they are bundled into one
TenderMailService.sendInstant call per profile per tick (D-05). notifiedAt
is stamped 'instant' only on a successful send (D-06) -- the same
eligibility gate the digest reads, so a tender x profile pair can never be
notified twice across instant and digest. Instant dispatch runs
synchronously in the tick, before any later digest run.

Each profile's dispatch is wrapped in its own try/catch so a send
failure/thrown error never aborts the tick or the remaining profiles'
dispatch; notifiedAt stays NULL on failure and is retried next tick/digest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:20:49 +02:00
schalli 86c184f8d5 test(12-03): add failing tests for instant-alert dispatch in matchDelta
RED: covers D-04 (instantAlert=true only), D-05 (bundling per profile/tick),
D-06 (stamp notifiedAt/channel=instant only after success), retry-safety on
send failure (per-profile catch, other profiles unaffected), and no-op when
a profile has no fresh notifiedAt=NULL matches this tick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:20:43 +02:00
schalli 32441678c1 feat(12-02): register TenderMailService/TenderDigestScheduler, import SettingsModule
TendersModule imports SettingsModule so TenderMailService can inject
SettingsService (getDecryptedSmtpConfig), and registers
TenderMailService + TenderDigestScheduler as providers alongside the
existing TenderMatchingService (12-01). ScheduleModule.forRoot() is
already global in AppModule — not re-imported. DI graph verified
resolvable via npx tsc --noEmit; full apps/api suite green (192/192).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:14:40 +02:00
schalli c0a8906d6a feat(12-02): TenderDigestScheduler — ein globaler Cron, findMany über fällige Nutzer
A single platform-wide @nestjs/schedule cron (daily 07:00), registered
via SchedulerRegistry exactly like TenderSchedulerService — NOT the
DkvSchedulerService single-tenant pattern (Pitfall 1). Selects
candidate users as distinct userId with an open TenderMatch
(notifiedAt IS NULL) via findMany across all tenants, resolves each
user's TenderNotificationPref.digestInterval (missing row -> daily
default, D-01: daily always due, weekly only on Monday Europe/Berlin,
off never), groups their un-notified matches by saved-search profile
name into one TenderMailService.sendDigest call per user (D-02), and
stamps notifiedAt+channel='digest' ONLY after a successful send — the
shared notifiedAt-IS-NULL eligibility gate that guarantees no
double-send with instant alerts (D-06).

Each candidate user is processed in its own try/catch: a missing SMTP
config, a send failure, or an unexpected thrown error for one
user/tenant leaves that user's matches notifiedAt=NULL (retried next
run) and never aborts the run for the rest (Pitfall 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:13:56 +02:00
schalli 2f305840a2 test(12-02): add failing TenderDigestScheduler spec (RED)
Covers NOTIFY-01/03: due-date selection (daily always, weekly only on
Monday Europe/Berlin, off never, missing pref row defaults to daily —
D-01), multi-tenant safety via findMany over ALL due users across ALL
tenants (never findFirst — the documented DkvSchedulerService v1-gap,
Pitfall 1), one sectioned mail per user grouping matches by saved
search (D-02), the no-double-send notifiedAt eligibility gate (only
notifiedAt=NULL selected, stamped notifiedAt+channel=digest only after
a successful send — D-06), and per-user robustness so one failing/
skipped/throwing user never aborts the run for the rest (Pitfall 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:12:54 +02:00
schalli 53e72dfa05 feat(12-02): TenderMailService — mandanten-SMTP-Versand (DkvMailService-Klon)
Structural clone of DkvMailService (RESEARCH.md Pattern F / D-08): a
fresh nodemailer transport is built from
settingsService.getDecryptedSmtpConfig(tenantId) on every send, never
a cached/global mailer, and transport.close() always runs in finally
(WR-01 socket-leak guard).

Unlike DkvMailService, sendDigest/sendInstant never throw — a missing
SmtpConfig or a send failure both resolve to false so the digest
scheduler (Task 2) can decide whether to stamp TenderMatch.notifiedAt
without a per-caller try/catch, and a cron run never crashes because
one tenant lacks SMTP config (Pitfall 6).

sendDigest builds ONE mail sectioned by saved-search profile name
(D-02); sendInstant builds ONE collective mail per profile (D-05).
estimatedValue is formatted via String() only, never Number()-coerced
(mostly-null Decimal field). Tender titles/profile names are
HTML-escaped before interpolation (T-12-08).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:11:57 +02:00
schalli fbcc108341 test(12-02): add failing TenderMailService spec (RED)
Covers NOTIFY-04: per-send getDecryptedSmtpConfig(tenantId) SMTP
resolution, fresh nodemailer transport + close() in finally (WR-01),
no-throw skip on missing SmtpConfig, sectioned digest body without
blind Number() coercion of estimatedValue, and HTML-escaping of
tender titles/profile names (T-12-08 email-injection guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:11:50 +02:00
schalli b45047f0b3 feat(12-01): wire matchDelta into pollDueSources; register TenderMatchingService
pollDueSources now collects genuinely-new tender IDs via an indexed
dedupKey pre-check (existing upsert doesn't report create-vs-update),
and calls TenderMatchingService.matchDelta(newTenderIds) once at the
end of the tick — the delta-only matching boundary (D-07). Changed/
re-seen rows are excluded, only genuinely new rows trigger matching.

TenderMatchingService registered as a provider in TendersModule and
injected into TenderIngestionService. Ingestion spec extended to
assert matchDelta receives only the new IDs, and is not called when
no new tenders were ingested this tick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:05:41 +02:00
schalli 92d4c969de feat(12-01): implement TenderMatchingService.matchDelta (GREEN)
matchDelta(newTenderIds) loads all active TenderSavedSearch profiles,
reuses buildTenderWhere(profile.filters) AND-ed with id IN newTenderIds
(delta-only boundary, D-07), and upserts TenderMatch on
@@unique([tenderId, savedSearchId]) with update:{} — idempotent, so a
re-match never resets an already-set notifiedAt (D-06). Per-profile
catch-and-log so one broken filters JSON never aborts the whole delta
(matches pollDueSources' existing catch-and-log convention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:03:36 +02:00
schalli 57d22a034f test(12-01): add failing test for TenderMatchingService.matchDelta
RED — covers delta-only matching (D-07), match creation, idempotent
upsert preserving notifiedAt (D-06), and empty-delta no-op. Service
does not exist yet (Cannot find module).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:02:59 +02:00
schalli 6c3e110949 feat(12-01): add TenderMatch/TenderNotificationPref schema + apply migration
- TenderMatch: one row per (tenderId, savedSearchId) pair, single nullable
  notifiedAt as the matched-vs-notified eligibility gate (D-06)
- TenderNotificationPref: per-user digest interval (daily/weekly/off, D-01/D-03)
- TenderSavedSearch.instantAlert: per-profile instant alert flag, default off (D-04)
- Migration 20260722100000_add_tender_notifications applied to local dev DB
  (docker exec psql), recorded in _prisma_migrations, prisma generate run

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:02:18 +02:00
schalli dd3ff9ea30 feat(11-06): SavedSearchBar UI — save/load/rename/delete profiles (FILTER-06)
GREEN phase — extends tender-radar-api.ts with listSavedSearches/
createSavedSearch/updateSavedSearch/deleteSavedSearch (plain fetch,
credentials: include), adds SavedSearchBar with the
serializeFiltersFromSearchParams/filtersToSearchParams round-trip helpers
(URL searchParams <-> filters JSON, deliberately excluding page/tender —
navigation state, not filter state), and mounts it above FilterPanel in
page.tsx. Hardcoded German UI per phase convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:50:55 +02:00
schalli ca843ab134 test(11-06): add failing spec for SavedSearchBar (FILTER-06)
RED phase — serialization round-trip contract (URL searchParams <-> filters
JSON, page/tender excluded), save/load/rename/delete flows. Component does
not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:50:48 +02:00
schalli 38fbf35c75 feat(11-06): saved-searches CRUD routes on TendersController (FILTER-06)
Adds GET/POST /saved-searches and PATCH/DELETE /saved-searches/:searchId,
registers TenderSavedSearchService as a module provider, and wires it into
the controller via extractTriageContext (userId/tenantId from the auth
context, never the body/query — T-11-14/V4 IDOR). Static saved-searches
routes are declared before @Get(':id') (Pitfall 5/T-11-16); mutation routes
use :searchId to avoid ambiguity with the Tender :id param.

Also fixes a Prisma InputJsonValue type mismatch in
TenderSavedSearchService (Rule 1 — caught by tsc --noEmit, same cast
convention as dashboard.service.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:48:00 +02:00
schalli df94d921ef feat(11-06): TenderSavedSearch model + CRUD service (FILTER-06)
GREEN phase — adds TenderSavedSearch (userId+tenantId scoped, filters
Json, @@unique([userId,name])), the migration (applied to local dev DB),
and TenderSavedSearchService following the FavoritesService/
TenderTriageService pattern: manual where:{userId} scoping (no
forTenant()/RLS), ownership check before update/remove, P2002 unique
conflicts translated to ConflictException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:45:04 +02:00
schalli 2b0b4f897b test(11-06): add failing spec for TenderSavedSearchService (FILTER-06)
RED phase — CRUD scoped by userId (FavoritesService/TenderTriageService
pattern), @@unique([userId,name]) conflict handling, ownership checks on
update/remove (IDOR). Service module does not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:44:59 +02:00
schalli 1eb9e4f567 feat(11-05): read/favorite triage toggles + Merklisten-Filter in the UI
Adds fetchTriage()/setTriage() to tender-radar-api.ts (plain fetch,
consistent with the existing client). ResultsList batch-fetches the
current user's triage state for the visible ids and merges it into a
local per-tenderId map; a failed triage fetch never blocks rendering the
list itself. Each row gets a Gelesen/Ungelesen and a Favorit toggle
(optimistic update with revert-on-failure, event.stopPropagation() so the
row's own click-to-open-detail doesn't fire); read rows render dimmed.
FilterPanel gains a "Nur Favoriten/Merkliste" checkbox writing favOnly
into the URL, which ResultsList already forwards generically to the
backend. ResultsList.test.tsx extended (Rule 3 — required to keep the
component test green with the new triage batch call) with coverage for
batch-merge, both toggles, optimistic revert, and graceful degradation
when the triage fetch fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:37:30 +02:00
schalli 5f97eca804 feat(11-05): GET/PUT /modules/tender-radar/triage routes + favOnly filter
Adds the batch-triage read/write routes (declared before @Get(':id') per
the route-order pitfall, T-11-13) and wires them through
TenderTriageService with userId/tenantId always derived from the request
context, never the body (T-11-10 / V4 IDOR). Extends TenderQueryDto/
buildTenderWhere with favOnly (UI-04): the controller resolves the
current user's favorited tenderIds server-side before building the
where-clause, and an empty favorites list yields zero matches instead of
the unfiltered catalog. Both batch-ids and favIds in-lists are bounded
(T-11-11 DoS). tenders.controller.spec.ts constructor calls updated for
the new TenderTriageService dependency (Rule 3 — required to keep the
existing suite compiling/passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:33:40 +02:00
schalli 58b0f3da50 feat(11-05): implement TenderTriageService (GREEN) + apply migration
TenderTriageService upserts per-user read/favourite state on the
@@unique([userId,tenderId]) target (idempotent), scopes every query by
userId (V4/IDOR, FavoritesService pattern), and exposes favoriteIds() for
the upcoming favOnly filter. Migration 20260721160000_add_tender_triage
applied to the local dev DB (FK ON DELETE CASCADE verified via \d),
Prisma client regenerated. All 8 spec cases green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:30:09 +02:00
schalli 7bb695d37a test(11-05): add failing TenderTriageService spec + TenderTriage schema/migration
RED phase (TDD) for UI-03/04 per-user triage (gelesen/ungelesen, Favorit).
Adds the TenderTriage Prisma model (userId-scoped, onDelete: Cascade to
Tender per Pitfall 6) and its migration, plus a failing spec proving
upsert idempotency, strict userId scoping (V4/IDOR, T-11-10), and cascade
consistency — service implementation follows in the GREEN commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:28:38 +02:00
schalli 653c63b072 feat(11-04): TenderDetail component with ?tender=<id> wiring
Adds getTender() to the api client and a self-fetching TenderDetail
overlay (pattern: SourceConfigForm) rendering all Tender fields plus a
safe (rel=noopener noreferrer, target=_blank) sourceUrl link. No local
mirroring of Vergabeunterlagen — rawPayload is 100% NULL in the live DB
(research finding), so only the source link exists; NULL value/deadline
render graceful German placeholders (D-05 applies to the detail view too).

page.tsx reads ?tender=<id> via useSearchParams and renders TenderDetail
as an overlay; closing removes the param. ResultsList row clicks set the
param (deviation: ResultsList.tsx was not listed in the plan's
files_modified but is required by the plan's own done-criteria/key_link
"ResultsList-Zeile setzt ?tender=<id>" — Rule 3 auto-fix, blocking).

3/3 TenderDetail tests pass; full web suite (117 tests) and tsc --noEmit
both clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:21:54 +02:00
schalli 3cc7194d9e test(11-04): add failing test for TenderDetail component
Covers the three must-have truths: source link with safe target/rel,
graceful NULL placeholders for value/deadline, and the mandatory
no-local-mirroring notice for Vergabeunterlagen (rawPayload is 100%
NULL in the live DB per research - no document URLs exist to mirror).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:20:28 +02:00
schalli d60bfd1966 feat(11-03): CPV-Filter + Wert-min/max-UI in Builder, DTO, FilterPanel
TenderQueryDto gains a validated cpv[] field (single-or-repeated query
param, normalized via @Transform); buildTenderWhere adds a cpvDivisions
hasSome branch (FILTER-03, Pitfall 2 — never an exact match against raw
cpvCodes). FilterPanel gets a CPV-Division autocomplete (search-by-label,
multi-select chips, repeated ?cpv= params) plus the previously
backend-only value filter's UI: valueMin/valueMax number inputs and an
"ohne Wertangabe einschließen" toggle (default on, matches the builder's
includeNullValue default from Plan 11-01) so the 91.6% NULL-value rows
stay visible by default. Verified against the live DB: cpv=45 matches all
three raw formats ("45", "45000000", "45000000-7") via the backfilled
cpvDivisions column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:14:51 +02:00
schalli f9591dc491 feat(11-03): cpvDivisions column — normalizer + backfill migration
Adds Tender.cpvDivisions String[] (@@index Gin) derived at ingestion time
via cpv-catalog.ts's divisionOf() — replaces exact-match cpvCodes
comparison with a typesafe, GIN-indexable hasSome target (FILTER-03,
Pitfall 2). Backfill migration 20260721150000_tender_cpv_divisions_backfill
applied locally: 1612/1671 rows populated across all observed divisions
(741 rows carry division '45' — Bauarbeiten); idempotent (second run:
UPDATE 0, ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS both skip
cleanly). Applied via docker exec psql + `prisma migrate resolve
--applied` + `prisma generate` against the local dev DB only — no
Docker deploy on the test server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:11:40 +02:00
schalli 7651ab6813 feat(11-03): implement CPV-Divisions-Katalog + normalizeCpv (GREEN)
Static 45-entry EU-CPV division catalog with German labels plus
normalizeCpv/divisionOf/cpvMatchesDivisions helpers. Two-sided
normalization to the leading 2-digit division handles all three live-DB
formats ("45", "45000000", "45000000-7") without an exact-match
Pitfall-2 bug. No full EU CPV vocabulary shipped (D-03, Open Question 2
RESOLVED) — no new npm dependency, static data file only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:08:47 +02:00
schalli a64f98463c test(11-03): add failing spec for CPV-Divisions-Katalog + normalizeCpv
RED-first (TDD): normalizeCpv/divisionOf/cpvMatchesDivisions across the
three inconsistent live-DB CPV formats ("45", "45000000", "45000000-7")
plus a CPV_DIVISIONS catalog shape check — module does not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:08:07 +02:00
schalli 69e25298c9 feat(11-02): wire Region/PLZ/Bundesland filter into query builder + FilterPanel
TenderQueryDto gains validated plz (@MaxLength(5)), region, and
bundesland fields. buildTenderWhere adds three conditional AND-branches:
plz startsWith, bundesland exact-match against the now-backfilled indexed
column (Pitfall 1), and region startsWith (usable independent of the
bundesland column). FilterPanel gets a PLZ input and a 16-Land Bundesland
dropdown (mirrors NUTS1_BUNDESLAND — web/api are separate packages) that
write plz/bundesland into the URL searchParams; ResultsList already
forwards the full URLSearchParams to listTenders(), so no additional
fetch wiring was needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:03:21 +02:00
schalli e4db602597 feat(11-02): normalizer derives bundesland + backfill migration for existing rows
Replace the Phase-10-deferred `bundesland = null` assignment with
bundeslandFromRegion(region) so new ingests are Bundesland-filterable
immediately. Add @@index([bundesland]) for filter performance. New
handwritten migration 20260721140000_tender_bundesland_backfill backfills
the ~1671 pre-existing rows (idempotent UPDATE, only where bundesland IS
NULL AND region IS NOT NULL) — applied locally via
`docker exec tessera-ctl-db-1 psql`, resolved as applied in
_prisma_migrations, and prisma generate re-run.

Verified on the local dev DB: 933/1671 rows now have bundesland set
across all 16 Länder (738 remain NULL where region itself is NULL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:01:32 +02:00
schalli d12e1c9aa9 feat(11-02): implement NUTS-1 to Bundesland derivation
bundeslandFromRegion() derives one of the 16 Bundesland names from a
region's NUTS-1 (3-char) prefix; nutsPrefixFor() reverses a Bundesland
name back to its prefix for the query builder. Null-safe throughout —
7/7 tests green, validated against real region samples from the live DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:59:35 +02:00
schalli 3dec35f498 test(11-02): add failing spec for NUTS-1 to Bundesland derivation
RED: bundeslandFromRegion/nutsPrefixFor do not exist yet. Locks the
derivation (16 NUTS-1 prefixes + null-safety + real DB region samples)
that makes the Bundesland filter return real hits (Pitfall 1, FILTER-02).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:59:14 +02:00
schalli ee2c40863e feat(11-01): replace tender-radar placeholder with real results UI (GREEN)
Replaces the Phase 10 module stub with a Master-Container page.tsx
(Suspense-wrapped for useSearchParams, Next 16 App Router) rendering
CoverageBanner + FilterPanel + ResultsList.

- ResultsList: URL-param-driven fetch via listTenders(), sortable
  Frist/Wert/Veröffentlicht column headers, "keine Wertangabe" for
  estimatedValue=null rows (D-05), pagination.
- FilterPanel: Freitext (q), Sortierung, "nur noch offene" toggle
  (openOnly, default on), Abgabefrist von/bis date inputs — param names
  match the TenderQueryDto field names 1:1 for the Plan 11-06
  Saved-Search serialization contract.
- CoverageBanner: German coverage hint shown while GET /coverage
  reports only the doe-opendata source (D-12, UI-05).

All strings hardcoded German (i18n = Phase 14). Plain fetch throughout,
no TanStack Query (not installed, per RESEARCH Open Question 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:54:51 +02:00
schalli b55bb55c0e test(11-01): add failing ResultsList spec + extend tender-radar-api client (RED)
Extends tender-radar-api.ts with the Tender type, listTenders(params)
and fetchCoverage() (plain fetch, credentials:'include', matching the
established fetchSourceConfig pattern — TanStack Query is not installed).

Adds the RED-first ResultsList.test.tsx: render items with
title/buyerName/deadline/value, "keine Wertangabe" for null estimatedValue
(D-05), and an empty-state message. ResultsList.tsx does not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:53:04 +02:00
schalli 49622ba022 feat(11-01): wire listTenders through the query builder + add GET /coverage
listTenders now delegates where/orderBy composition to
tender-query.builder.ts (buildTenderWhere/buildOrderBy) instead of the
fixed status/publishedAt clause; pagination bounds unchanged (T-10-15).

New GET /modules/tender-radar/coverage handler returns active-tender
counts grouped by sourcePortal (D-12, UI-05 coverage banner data
source). Declared before @Get(':id') — same static-route-before-:id
convention as source-config (Pitfall 5, Phase-10 regression guard).

Extends tenders.controller.spec.ts: sort whitelist pass-through,
unknown-sort fallback, pagination skip/take, coverage response shape,
and a declaration-order regression test for getCoverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:50:23 +02:00
schalli fed5ecbc43 feat(11-01): implement tender-query.builder (GREEN)
buildTenderWhere: conditional Prisma where-builder covering keyword
(D-01), openOnly deadline default + explicit deadline range (D-04),
and NULL-graceful value filter (D-05, Kern-Test: value filter never
eliminates estimatedValue=null rows). buildOrderBy: sort whitelist
(deadline/value/published) defaulting to publishedAt desc (UI-01,
T-11-01 — no dynamic orderBy keys from user input).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:48:56 +02:00
schalli 426a1ea3b8 test(11-01): add failing tender-query.builder spec (RED)
Extends TenderQueryDto with validated filter/sort params (q, openOnly,
deadlineFrom/To, valueMin/Max, includeNullValue, sort) and adds the
RED-first spec for the not-yet-implemented tender-query.builder.ts:
NULL-graceful value filter (D-05), openOnly deadline default (D-04),
explicit deadline range, and sort whitelist (UI-01).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:47:40 +02:00
schalli 48d1246043 fix(10): resolve GET /source-config 404 shadowed by :id route
The admin source-config settings form failed to load with "Failed to
fetch tender-radar source config". Network trace showed
GET /modules/tender-radar/source-config returning 404.

Root cause: NestJS RouterExplorer maps routes in method-declaration
order. `@Get(':id')` was declared before `@Get('source-config')`, so
the param route captured "source-config" as an id and shadowed the
static handler (401 unauthenticated, 404 past the guard — no Tender
with id "source-config").

Fix: declare `@Get('source-config')` before `@Get(':id')`. Add a
declaration-order regression test — unit tests call controller methods
directly, bypass routing, and could never catch route shadowing.

Verified live: settings form now loads real config, interval save
persists and live-re-registers the scheduler (INGEST-06). Phase 10
verification raised human_needed -> passed after full browser UAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:52:17 +02:00
schalli 8e3dfda64d test(10-06): SourceConfigForm component test — fetch, save, bounds
- fetch-on-mount populates pollIntervalMin input
- Speichern calls saveSourceConfig with edited interval + isActive
- interval below 5 or above 1440 rejected client-side, no save call
- automated proof for the INGEST-06 admin-UI slice
2026-07-21 11:25:43 +02:00
schalli 4360bc0c6b feat(10-06): tender-radar-api client + admin source-config settings form
- tender-radar-api.ts: fetchSourceConfig/saveSourceConfig hitting GET/PUT
  /modules/tender-radar/source-config (Plan 05 endpoint)
- SourceConfigForm: load-on-mount, numeric interval (5-1440 min, mirrors
  backend SourceConfigDto bounds) + isActive toggle, read-only sourceType/
  lastIngestedDay display
- settings/page.tsx: standard App Router route rendering the form,
  no module-loader whitelist change needed
2026-07-21 11:24:32 +02:00
schalli eaff1d86bb test(10-05): controller spec — global read not tenant-scoped, admin config applies to scheduler
- GET / findMany where clause asserted to have no tenantId key
- GET /:id returns tender / throws NotFoundException for missing id
- PUT /source-config isActive=true+pollIntervalMin=30 -> setInterval(30) single-arg
- PUT /source-config isActive=false -> stopJob()
2026-07-21 11:18:35 +02:00
schalli 7b9b6b8c1c feat(10-05): wire TendersController — global read + admin source-config
- GET / and GET /🆔 paginated global Tender catalog, @UseModule('tender-radar')-gated, never row-scoped by tenant id
- GET/PUT /source-config: @Roles(ADMIN, SUPER_ADMIN)-guarded singleton doe-opendata config
- PUT /source-config live-applies pollIntervalMin/isActive to TenderSchedulerService (setInterval/stopJob, no tenant arg) — INGEST-06
- Registered TendersController in TendersModule.controllers
2026-07-21 11:17:13 +02:00
schalli f6e636c37d feat(10-05): add SourceConfigDto and TenderQueryDto
- SourceConfigDto: pollIntervalMin (@Min(5) @Max(1440)), isActive
- TenderQueryDto: page/limit pagination (copied from DkvHistoryQueryDto) + status filter
2026-07-21 11:15:36 +02:00
schalli 444c68b8ec test(10-04): add two-tenant safety integration test (Success Criteria 4 & 5)
Proves the phase's headline acceptance criterion: activating tender-radar
for a 2nd tenant triggers zero additional DÖE calls, zero additional cron
jobs (still exactly one 'tender-doe-poll'), and zero additional Tender rows.
Drives the real (unmocked) ModuleRegistryService against a fake prisma to
exercise the genuine activateForTenant() call path.

Passes immediately because Task 2's TenderSchedulerService already
implements the poll-once-fan-out-many invariant correctly — this test
locks in and regression-proofs that already-correct architecture rather
than driving new production code (documented in SUMMARY under TDD Gate
Compliance).
2026-07-21 11:11:09 +02:00
schalli 0ba74108db feat(10-04): implement TenderSchedulerService — single global cron (poll-once-fan-out-many)
- One named cron job 'tender-doe-poll' for the whole platform; setInterval()
  takes no tenant argument (INGEST-06) — reuses DkvSchedulerService's
  CronJob require()-resolution + SchedulerRegistry mechanics, drops the
  per-tenant activeTenantId framing entirely
- onModuleInit() loads the singleton doe-opendata config via findUnique on
  the fixed sourceType slug, never findFirst (Pitfall D)
- Day-cursor gate stays inside TenderIngestionService.pollDueSources() —
  this scheduler only controls cron-tick frequency (Pitfall A separation)
- Registered in TendersModule.providers; ScheduleModule already global via
  AppModule, no re-registration needed
2026-07-21 11:10:21 +02:00
schalli 6fb734bf3c feat(10-04): implement TenderIngestionService — day-cursor gate, upsert change-detect, D-05 retention
- pollDueSources(): singleton doe-opendata config via findUnique (fixed slug,
  not findFirst); day-cursor gate (nextDayToFetch) no-ops when nothing new
  (Pitfall A); catch-up loop from lastIngestedDay+1 to today-1 with a polite
  1.5s delay between successive day-fetches
- prisma.tender.upsert({ where: { dedupKey } }) — SCHEMA-02 change-detection
  seam: identical notice does not duplicate, changed contentHash updates in
  place
- pruneExpiredTenders(): marks active+past-deadline rows 'expired', deletes
  expired rows older than 90 days, never touches deadlineAt=null rows (D-05)
- Plain PrismaService throughout — no tenant RLS extension on the global
  Tender/TenderSourcePollConfig tables (D-03, T-10-09)
- Registered in TendersModule.providers
2026-07-21 11:08:59 +02:00
schalli f9e52ab95f test(10-04): add failing spec for TenderIngestionService (day-cursor gate, SCHEMA-02, D-05)
- Day-cursor no-op gate (Pitfall A)
- SCHEMA-02 change detection: fresh insert, identical no-dup, changed-content update
- Catch-up cursor advance across missed days
- D-05 retention: expire past-deadline, prune >90d expired, never touch null-deadline
- Multi-tenant safety: asserts no forTenant() call in the implementation
2026-07-21 11:07:49 +02:00
schalli 31607df48c feat(10-03): implement TenderNormalizerService — fields, dedupKey, hash
- normalize(raw): eForms-DE XML primary for deadlineAt/estimatedValue/
  procedureType (RESEARCH Pattern 3); OCDS primary for ocid/buyerName/
  title/cpvCodes/region/plz
- deadlineAt/estimatedValue nullable by mandate (RESEARCH Pattern 4) —
  missing data normalizes to null, never thrown or zero
- dedupKey priority: ocid -> sourcePortal:sourceNoticeId fallback
- contentHash = sha256(title+deadlineAt+estimatedValue+status), stable
  across repeat calls, changes when the deadline changes (SCHEMA-02 hook)
- Register TenderNormalizerService in TendersModule.providers
- All tender-normalizer.service.spec.ts tests green (6/6); full API
  suite green (59/59); tsc --noEmit clean
2026-07-21 10:58:33 +02:00
schalli 3764feb50b feat(10-03): implement DoeOpenDataAdapter — fetch, extract, D-02 filter
- Native fetch + AbortController 15s timeout (icon-discovery idiom, no
  axios); URL host hardcoded, only the internally-computed dayCursor is
  interpolated (T-10-06)
- HTTP 400 treated as an expected no-op (pubDay today/future) -> []
- adm-zip extraction with a pre-extraction decompression-bomb ceiling
  check (sum entry.header.size vs ~50MB) before any entry buffer is read
  (T-10-07); entries are never written to disk
- D-02 open-tender filter: positive tag.includes('tender') match only —
  award/planning/untagged-with-awards excluded (Pitfall C)
- Register DoeOpenDataAdapter in TendersModule.providers
- All doe-opendata.adapter.spec.ts tests green (6/6)
2026-07-21 10:57:29 +02:00
schalli f88e2a8141 test(10-03): add failing specs + real DÖE fixtures for adapter/normalizer
- Capture real, trimmed DÖE day-export fixtures (pubDay=2026-07-19, 8
  notices spanning tender/award/planning/untagged-with-awards tag classes)
  under tenders/__fixtures__/, live-downloaded from oeffentlichevergabe.de
- Define RawTenderRecord/NormalizedTenderFields/SourceType (tender.types.ts)
  and TenderSourceAdapter (day-cursor fetchTenders signature per RESEARCH
  Pattern 1)
- RED: doe-opendata.adapter.spec.ts asserts D-02 exact-count filtering,
  HTTP-400 no-op, and a zip-bomb ceiling guard (T-10-07)
- RED: tender-normalizer.service.spec.ts asserts eForms-primary deadline
  recovery where OCDS is null (RESEARCH Pattern 3), nullable deadline/value,
  dedupKey priority (ocid -> sourcePortal:sourceNoticeId), and a stable
  sha256 contentHash
2026-07-21 10:56:13 +02:00
schalli 9a7ed71d7a test(10-02): add seed registration coverage for tender-radar (CONFIG-01)
- asserts seedModule called once with slug 'tender-radar', isSystem: true,
  and a bilingual (de/en) description object
2026-07-21 10:43:43 +02:00