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>
- 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>
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 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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()
- 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
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).
- 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
- 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