Commit Graph

434 Commits

Author SHA1 Message Date
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
schalli 3292afb913 feat(10-02): add tender-radar module-loader whitelist entry + placeholder page
- MODULE_REGISTRY['tender-radar'] entry (mirrors cert-manager/dkv-fleet shape)
- minimal client-component placeholder page proving activation -> page-load path
- hardcoded German string is an intentional MVP stub; full i18n is CONFIG-03 (Phase 14)
2026-07-21 10:43:28 +02:00
schalli e7b40946c8 feat(10-02): register TendersModule with marketplace self-seed + singleton poll config
- tenders.seed.ts seeds slug 'tender-radar' (isSystem: true, category 'procurement')
- tenders.module.ts self-seeds registry on boot (mirrors DkvModule pattern)
- upserts singleton doe-opendata TenderSourcePollConfig row (global, no forTenant())
- TendersModule added to app.module.ts imports
2026-07-21 10:42:44 +02:00
schalli 713b1eb748 feat(10-01): add Tender/TenderSourcePollConfig models + ingestion packages
- Install fast-xml-parser, adm-zip, csv-parse in @tessera/api
- Add global Tender model (no tenantId — D-03 platform-global data)
- Add singleton TenderSourcePollConfig (sourceType @unique)
- Handwritten additive migration for both tables
- Regenerate Prisma client
2026-07-21 10:33:24 +02:00
schalli 9b65ac63c3 fix(favorites): normalize scheme-less URLs so favicons resolve
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Successful in 40s
Tessera CI/CD / Build & Publish Images (push) Successful in 22s
A favorite entered as a bare host ("ctl.de") passed @IsUrl() but had no
scheme, so `new URL()` threw inside icon discovery and it silently fell
back to a relative "/favicon.ico" — which 502'd through the icon proxy
and left the widget showing the first-letter placeholder ("C").

- add normalizeUrl() (prepend https:// when no scheme present)
- apply it in discoverFavoriteIconUrl and when storing the favorite url,
  so both the link and discovery use the normalized value
- on update, re-run discovery when the icon field is cleared, so editing
  a previously-broken favorite repairs its icon
- tests: normalizeUrl cases + end-to-end discovery (apple-touch extraction,
  scheme-less fallback stays absolute)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:58:44 +02:00
schalli 9d1323fe97 feat(ldap): per-user exclude/denylist filter for sync
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 43s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m46s
Add a per-username denylist so individual accounts (service accounts like
administrator, krbtgt, guest, ldap$) can be excluded from LDAP sync,
independent of the group/OU include-filter which only scopes the search.

- schema: LdapConfig.userExcludeList String[] (+ migration)
- sync: skip excluded usernames (case-insensitive) before recording the DN,
  so an already-imported user added to the list gets deactivated next sync
- DTO / config service / controller / scheduler: thread userExcludeList through
- web: exclude-list admin UI section (add/remove/save) + de/en translations
- tests: 3 specs covering empty list, case-insensitive skip, deactivation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:34:01 +02:00
schalli aaa29226c9 feat(ldap): search box for the discovered groups/OUs list
Tessera CI/CD / Lint & Type Check (push) Successful in 39s
Tessera CI/CD / Tests (push) Successful in 40s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m25s
Real directories can return many groups/OUs, making the checkbox list
tedious to scroll through. Adds a client-side search input above the
list that filters by name or DN substring (case-insensitive) as you
type, so picking the right groups for the import filter is faster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:06:10 +02:00
schalli 246dc89a98 fix(ldap): treat ldapts empty-array attributes as absent, not "undefined"
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 25s
ldapts represents a missing/absent LDAP attribute as an empty array
([]), not undefined -- entry['mail'] is [] when an account has no mail
set. The field-mapping loop did Array.isArray(value) ? String(value[0])
: ..., and String(undefined) is the literal string "undefined". Every
synced entry without that attribute got mappedData['email'] = "undefined"
(a truthy string, so the `|| fallback` never kicked in), and the second
such entry onward crashed with a unique constraint violation on email
since they all shared the exact same literal string.

Found live: syncing against a real Zentyal/Samba AD directory failed
on every entry after the first (Kevin Schaller, krbtgt, Guest, the DC
computer object, etc.) with "Unique constraint failed on the fields:
(email)".

Fix: resolve array values to their first element (or use the raw
value for non-arrays) and only keep it when actually present and
non-empty, so a genuinely missing attribute falls through to the
`${username}@ldap.local` fallback instead of the string "undefined".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:28:48 +02:00
schalli baff7ce4db fix(auth): make usernames case-insensitive
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 24s
Username lookups (login, admin seed, LDAP sync) compared case-sensitively
against a stored value with whatever casing it was created with, so
"Admin" and "admin" were treated as different accounts.

Normalizes at every write and read path: UserService.create/update
lowercase the username before persisting, findByUsername lowercases
the lookup input, AuthService.validateUser lowercases before the login
query, AdminSeedService lowercases the configured admin username, and
the LDAP sync loop lowercases the mapped sAMAccountName before using it
for lookup/create/update -- so AD casing differences don't create
duplicate accounts either.

Added a data migration to lowercase any existing mixed-case usernames.
It relies on the User.username unique constraint to fail loudly if two
existing accounts would collide after normalizing, rather than silently
merging them.

Verified locally: logged in with "ADMIN" (uppercase) against the
existing lowercase "admin" account after rebuilding the API image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:20:49 +02:00
schalli 010aceb1ac feat(ldap): support anonymous bind (no bind DN/password required)
Tessera CI/CD / Lint & Type Check (push) Successful in 41s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m40s
bindDn and bindPassword are now optional on LdapConfig (nullable
migration) and throughout the DTOs/service/client -- an admin can
leave both blank to connect to directories that permit anonymous
read access. LdapService.bind() falls back to an RFC 4513 anonymous
bind (empty DN + empty password) whenever either field is missing,
shared across testConnection, listGroups, and syncUsersForTenant.

Frontend: removed the required attribute from Bind-DN/Bind-Passwort,
added a placeholder hint ("leer = anonymous bind"), and the
"Verbindung testen" button now only needs a Server-URL to enable
(not bindDn+bindPassword). Config responses now return bindPassword
as null (not a misleading "********") when no password is set.

Verified locally: submitted only a Server-URL with both bind fields
empty and confirmed the request reached the anonymous-bind code path
(DNS failure for the unreachable test host, not a validation error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 12:57:26 +02:00
schalli 39aa4bff2a feat(ldap): allow testing connection before saving a config
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m35s
"Verbindung testen" only appeared after a config was already saved,
so a fresh/never-configured tenant had no way to validate connection
details at all. Now the button is always visible; POST /ldap/test-connection
accepts optional ad-hoc serverUrl/bindDn/bindPassword and falls back
field-by-field to the saved config for anything omitted (bindPassword
in particular, since the form never re-sends the masked saved password).

Verified locally: tested with typed-but-unsaved values (base DN left
blank, nothing saved) and got a real connection attempt/error back,
not a 404 "no config" response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 10:55:46 +02:00
schalli 8e8305ce18 revert(ldap): remove CTL-specific AD connection prefill
Tessera CI/CD / Lint & Type Check (push) Successful in 42s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m27s
The prior quick task (260707-csw) pre-filled the LDAP connection form
with one customer's specific Active Directory values (balios.ctl.local,
dc=ctl,dc=local) and a matching bind-DN hint. User clarified this was
never wanted -- Tessera is a generic multi-tenant product, and baking
one customer's infrastructure into the shared admin UI is wrong,
especially since a `dcdown -v` reinstall surfaced it unexpectedly as
seemingly-baked-in defaults on a fresh system.

Reverted to blank fields with generic example placeholders
(ldap.example.com / dc=example,dc=com / cn=admin,dc=example,dc=com),
matching the form's state before that change. Removed the now-unused
bindDnHint i18n key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 09:33:34 +02:00