GREEN phase (TDD) for Task 1: extractCandidateLinks (cheerio a[href] +
footer-noise filter + MAX_LINKS_PER_EMAIL cap, plaintext regex fallback),
titleFromEmail (subject -> first body line -> fallback), and
sourceNoticeIdFor (sha256 link hash) implement D-04's generic, no-portal-
specific-parser evaluation of alert emails.
SourceType gains 'email-alert'; TenderNormalizerService routes it through
the existing normalizeBag() path (same as ai-netserver/cosinex-dtvp/rss).
EmailAlertAdapter.fetchTenders() is a Task-1 placeholder — Task 2 wires the
real per-tenant fan-out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RED phase (TDD) for Task 1: pure-function tests for extractCandidateLinks,
titleFromEmail, sourceNoticeIdFor — covers HTML + plaintext bodies,
footer-noise removal, link cap, and D-04 no-portal-specific-parser restraint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds GET/POST/DELETE /modules/tender-radar/rss-feeds (Roles-guarded
ADMIN/SUPER_ADMIN), declared before the existing @Get(':id') handler to
avoid NestJS route-order shadowing. Delegates to
TenderRssFeedSourceService; the denylist/SSRF rejection (D-14) surfaces
as a 400 unchanged.
Web: tender-radar-api.ts gains listRssFeeds/createRssFeed/deleteRssFeed
(relaying the backend's specific rejection message via
extractErrorMessage), and a new RssFeedListForm client component renders
an "RSS-Feeds" section on the tender-radar settings page (D-09) — list,
add (with inline denylist error), and remove global feed URLs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Global admin-managed RSS feed list (TenderRssFeedSource, D-08/D-14) with
a save-time hostname/SSRF guard (TenderRssFeedSourceService) — RSS feed
URLs are runtime admin input, so the code-level SourceRegistry denylist
gate does not cover them; a separate check rejects DENYLISTED_PORTALS
hostnames, non-http(s) schemes, and private/loopback hosts.
Adds TenderSourcePollConfig.pollGranularity ('day' | 'tick', D-15):
pollDueSources() branches per source — 'day' sources keep the existing
lastIngestedDay gate byte-unchanged, 'tick' sources (rss) fetch on every
active scheduler tick regardless of lastIngestedDay, since the day-cursor
gate was built for a genuine daily batch-export API and would otherwise
silently cap RSS to one fetch per calendar day.
Wires RssAdapter.fetchTenders() to fan out over active feed rows (native
fetch + AbortController 15s + response-size ceiling, catch-per-feed),
registers it in tenders.module.ts, and seeds the 'rss' poll config
active with pollGranularity='tick' plus a default-active service.bund.de
feed row (subreport-elvis has no single canonical URL — zero rows seeded,
admin adds relevant municipality feeds).
Migration applied locally per project convention (host -> container IP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixture-first RSS parsing (INGEST-04): parses live-captured
service.bund.de (pubDate present, numeric-HTML-entity titles) and
subreport-elvis (pubDate absent, CDATA titles) feed shapes into
RawTenderRecord[] via fast-xml-parser, mirroring the DoeOpenDataAdapter
config. SourceType extended with 'rss'; normalize() dispatches 'rss'
through the existing normalizeBag() path unchanged (D-04/D-05).
Rule 1 fix: fast-xml-parser only decodes the 5 predefined XML entities,
not numeric character references — added an explicit decode step so
service.bund.de titles ("Übermittlung...") render correctly
instead of leaking raw entity syntax.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add InboxMessage type (subject + html/text body) and fetchMessages() to
the InboxProvider interface, ImapProvider, and ExchangeInboxProvider
- IMAP: findBodyParts() walks the MIME tree for first text/html + text/plain
parts, reusing the connect/lock/search/fetchAll skeleton; marks \Seen
- EWS: new getItemBodySoap() requests item:Body, extracts BodyType via the
existing extractAttr/extractAll helpers, marks IsRead via markReadSoap
- Net-new spec coverage (imap.provider.spec.ts, exchange-inbox.provider.spec.ts)
mocking ImapFlow and httpntlm.post (via require.cache stub, since httpntlm
is loaded with a raw require() that vi.mock cannot intercept)
- fetchPdfAttachments untouched in both providers (D-02); full API suite
(301 tests) + tsc --noEmit stay green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Move ImapProvider, ExchangeInboxProvider, InboxProvider into apps/api/src/inbox/
- Move InboxConfig/InboxAttachment/InboxEmail into new inbox.types.ts
- dkv.types.ts re-exports the moved types so existing DKV imports keep compiling
- DKV switches import paths to ../inbox/... and imports InboxModule
- Pure move + import-path swap: fetchPdfAttachments and all DKV logic unchanged (D-01/D-02)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- bagRecord() helper builds inline RawTenderRecords for the flat ocdsPayload
bag shape (no fixtures exist for NetServer/cosinex-DTVP)
- ai-netserver and cosinex-dtvp full-bag mapping, null/empty-field fallback,
and contentHash-format assertions
- Existing DOE fixture-based assertions untouched, confirming the refactor
didn't change DOE-path behavior
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Extract shared assemble() tail (status/dedupKey/contentHash/publishedAt)
so it is computed identically across all sources, not duplicated
- Move existing DOE eForms/OCDS extraction into normalizeDoe() (byte-identical
behavior, regression guard)
- Add normalizeBag() for the flat ocdsPayload bag shared by the NetServer and
cosinex/DTVP scraper adapters ({title, buyerName, procedureType,
legalFramework, deadlineAt}); legalFramework deliberately not mapped
- normalize() dispatches on raw.sourceType, defaulting to the DOE path so the
additive SourceType union never throws
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds CosinexAdapter to TendersModule's providers and registers it with
SourceRegistry at DI boot, alongside DoeOpenDataAdapter/NetServerAdapter
(cosinex-dtvp is not AGB-denylisted, so registration succeeds). Seeds a
cosinex-dtvp TenderSourcePollConfig row with isActive: false, matching
the ai-netserver "framework ready, activation deferred" stance (D-02).
tsc --noEmit clean; src/tenders slice: 21 files, 221/221 tests pass
(205 pre-existing + 16 new cosinex.adapter.spec.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate HTML adapter for the cosinex Vergabemarktplatz (DTVP) satellite
(sourceType='cosinex-dtvp'), distinct from the NetServer adapter since
cosinex markup differs structurally. Live inspection (2026-07-23) found
the "Aktuelle Bekanntmachungen" results table is fully server-rendered
(not JS-dependent as D-01 anticipated), so selectors are fully populated
rather than falling back to a needs-JS stub — parses publish date,
deadline (or "nv"), title, legal framework/procedure type, buyer name,
and a real per-notice deep link (pid) into RawTenderRecord[].
Rule 1 fix: cosinex serves charset=ISO-8859-1 with raw Latin-1 bytes for
umlauts (not HTML entities); Response.text() always UTF-8-decodes per
the Fetch spec, so the adapter reads arrayBuffer() and decodes explicitly
via TextDecoder('iso-8859-1') to avoid mojibake.
16 spec tests pass against a live-captured fixture (20 rows, transcoded
to UTF-8 on disk): full-fixture parse, deadline/publish date parsing,
nested-<abbr> procedure-type extraction, umlaut decoding, empty/broken
HTML and missing-pid row fallback, fetch-throw/non-2xx fallback, no-axios
and no-input-interpolated-URL guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tender.sources[] added to the API client type (sourcePortal, sourceUrl,
sourceNoticeId). TenderDetail now renders one link per TenderSource
with a German portal label (DÖE/tender24/DTVP/...), falling back to
the existing single sourceUrl block when sources is missing or empty
(older responses, single-source tenders).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /modules/tender-radar/:id now includes the TenderSource relation
(sourcePortal, sourceUrl, sourceNoticeId) so a cross-source-deduped
tender's detail response carries links to all its source portals, not
just the single primary sourceUrl column. Route order unchanged (:id
stays after all static routes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register SourceRegistry and TenderDedupService as providers.
onModuleInit registers DoeOpenDataAdapter with the registry before the
scheduler's first tick — the DI-boot-time enforcement point for the
INGEST-07 denylist gate (D-06). This is Wave 2's sole writer of
tenders.module.ts; 13-04 (NetServer) and 13-05 (cosinex) add their
own registry.register(...) calls additively in later waves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the DÖE-only findUnique with findMany({isActive:true}) fan-out
(poll-once-fan-out-many, D-01). Each active TenderSourcePollConfig is
resolved through SourceRegistry.get(sourceType) and processed inside
its own try/catch (catch-per-source, D-01) — one broken/blocking source
no longer aborts the tick for the others. dedupActive =
activePortalCount >= 2 (D-05) is computed once per tick and passed to
TenderDedupService.resolve(), which now replaces the direct
tender.upsert call. Delta-only matchDelta boundary (D-07) preserved:
only genuinely-created tender IDs across all sources are collected.
Extended tender-ingestion.service.spec.ts: multi-config fan-out,
catch-per-source isolation, dedupActive gate assertion, adapter-missing
skip, plus the existing SCHEMA-02/D-07/retention/day-cursor suites
updated to the new registry+dedup constructor shape (all green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve(n, {dedupActive}) matches OCID -> source:noticeId -> fingerprint
(fingerprint tier hard-gated by dedupActive, D-05). On any match the
existing Tender gets an additional TenderSource attached (D-03 merge)
instead of a new Tender row; SCHEMA-02 change-detection is preserved
inline (matched Tender's mutable fields refresh when contentHash
differs, exactly as the old direct tender.upsert UPDATE branch did).
No match -> tender.create (with computed fingerprint) + tenderSource.create.
Plain PrismaService, no forTenant()/RLS (T-10-09).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GREEN — SourceRegistry.register() throws DeniedPortalError when any
of an adapter's declared portals is in DENYLISTED_PORTALS
(vergabe24, aumass), enforced at DI-registration time (INGEST-07/
D-06), not just documented. get()/activeAdapters() support the
Plan 13-03 poll-once-fan-out-many scheduler. 6/6 tests pass, no
Prisma/scraping import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RED — proves Erfolgskriterium 4 (INGEST-07): registering an adapter
whose portals include vergabe24 or aumass must throw DeniedPortalError,
including a mixed portals array with one denylisted entry. Also covers
legitimate register/get/activeAdapters happy paths. Fake adapter stub,
no real scraping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TenderSourceAdapter gains a readonly portals: readonly string[] field
so one adapter can serve multiple portals (NetServer: 3, Plan 13-04)
and so SourceRegistry can gate registration per-portal (INGEST-07).
DoeOpenDataAdapter declares portals = ['doe-opendata'] additively,
no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SourceType now covers 'doe-opendata' | 'ai-netserver' | 'cosinex-dtvp'
(13-RESEARCH Pattern 1) so the Plan 13-04/05 adapters can register
without further type-contract changes. NormalizedTenderFields gains an
optional fingerprint field for the SCHEMA-03 dedup resolver (Plan
13-03) to populate later. tsc --noEmit clean; full API suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Additive schema change (SCHEMA-03/D-03/D-04): new model TenderSource
(1:n Tender, @@unique[sourcePortal, sourceNoticeId], onDelete Cascade)
and a nullable Tender.fingerprint column + index. dedupKey stays
unchanged as the SCHEMA-02 upsert target.
Migration 20260723120000_add_tender_source applies in strict order
(Pitfall 5): table+column create, then one TenderSource row per
pre-existing Tender via SQL INSERT/SELECT, then the unique constraint.
Applied locally against the tessera dev DB (container IP, no host
port) — verified via psql: TenderSource count == Tender count == 2851.
backfill-tender-source.ts is a one-time script that computes
Tender.fingerprint via the Task-1 tenderFingerprint() function
(Decimal->number conversion for estimatedValue, T-13-01-03) — run via
the compiled dist/ output (source uses standard extensionless TS
imports for tsc compatibility). Confirmed: 2851/2851 rows backfilled,
idempotent re-run verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GREEN: title+buyer dominant, CPV division (order-independent, dedup'd),
value bucketed by order-of-magnitude, deadline truncated to day-grain.
sha256 hex, deterministic, no I/O — foundation for the Task-2 backfill
and the Plan 13-03 dedup resolver's fingerprint tier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LDAP-imported users have no local passwordHash, and validateUser only checked
the local password, so they could never log in. Now a passwordless user with
an ldapDn is authenticated by binding as their OWN DN with the entered
password against the tenant's active LDAP config (reusing the ldaps TLS-skip
option). Empty passwords are rejected before binding to avoid AD's
unauthenticated-bind bypass. Local-password users are unchanged.
LdapService.verifyUserCredentials added; LdapModule now exports
LdapConfigService; AuthModule imports LdapModule (no circular dep). 8 new
specs (bind success/fail, empty-password guard, login via bind, wrong pw, no
config, no ldapDn, inactive). API 226 green, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a per-tenant "Skip TLS certificate verification" toggle to the LDAP
admin page so admins can connect to an AD whose ldaps:// certificate is
signed by an internal/self-signed CA (Node error: "unable to verify the
first certificate"). When enabled, ldapts is given
tlsOptions.rejectUnauthorized=false; the flag is ignored for plain ldap://
(no TLS). Defaults to full verification.
New Boolean column LdapConfig.tlsRejectUnauthorized (@default(true)) +
migration; wired through DTOs, config service, all Client creations
(test/groups/user-search/import/sync) and the test-connection endpoint. UI
checkbox with an insecure-network warning (de/en). 3 new service specs;
API 218 green, web 131 green, both apps tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an AD single-user search (by cn/sAMAccountName/displayName/mail) and a
selective import to the LDAP admin page, alongside the existing group/OU
filter. Imported users are deduped against existing ones by (ldapDn, then
username): a manually-imported user carries its ldapDn, so a later
department/group sync matches and updates it in place instead of creating a
duplicate. Search results flag alreadyImported; import skips existing users
and links a missing ldapDn. Extracted shared mapEntry/upsertMappedUser
helpers so sync and manual import resolve identity identically.
Backend: GET /ldap/users/search, POST /ldap/users/import (RFC-4515 escaped
query, ADMIN-guarded). 6 new service specs (search flags, create, skip,
ldapDn-link, denylist). Full API suite 215 green, both apps tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fetchNotificationPref/saveNotificationPref for GET/PUT
/modules/tender-radar/notification-pref (NOTIFY-01)
- SavedSearch/Create/UpdateSavedSearchPayload now carry instantAlert
(NOTIFY-02, D-04)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TenderNotificationPrefService: per-user digestInterval CRUD (default
'daily', upsert on @@unique userId, D-01/D-03)
- UpdateNotificationPrefDto: @IsIn(['daily','weekly','off']) validation (V5)
- GET/PUT /modules/tender-radar/notification-pref, declared before
@Get(':id') (route-order pitfall)
- instantAlert passthrough in Create/UpdateSavedSearchDto and
TenderSavedSearchService.create/update (NOTIFY-02, D-04)
- All pref/profile routes scoped strictly via extractTriageContext(req),
never from body/query (T-12-14, IDOR)
- Updated tenders.controller.spec.ts fakes for the new constructor param
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>