| 10-ausschreibungs-radar-foundation-d-e-ingestion |
04 |
api |
| scheduler |
| ingestion |
| multi-tenant-safety |
| cron |
| scheduler-registry |
| tdd |
| poll-once-fan-out-many |
|
| phase |
provides |
| 10-01 (foundation & dependencies) |
Tender / TenderSourcePollConfig Prisma models (dedupKey @unique, sourceType @unique) |
|
| phase |
provides |
| 10-02 (marketplace registration) |
TendersModule skeleton, singleton doe-opendata poll config seeded on boot |
|
| phase |
provides |
| 10-03 (adapter & normalizer) |
DoeOpenDataAdapter.fetchTenders(dayCursor), TenderNormalizerService.normalize() |
|
|
| TenderIngestionService — pollDueSources() (day-cursor gate, SCHEMA-02 upsert change-detection, catch-up loop) + pruneExpiredTenders() (D-05 retention) |
| TenderSchedulerService — single global cron job 'tender-doe-poll' (poll-once-fan-out-many, INGEST-06) |
| Automated proof of the phase's headline acceptance criterion (Success Criteria 4 & 5) |
| 2nd-tenant activation triggers zero additional DÖE calls/cron jobs/Tender rows |
|
|
| 10-05 controller/DTOs (consumes setInterval()/stopJob() on admin config save) |
| phase 11 saved searches/filter UI (consumes the ingested Tender delta) |
|
| added |
patterns |
|
|
| Poll-once-fan-out-many scheduler: single named cron job, no tenant parameter anywhere in the class — the DKV findFirst()/activeTenantId single-tenant framing is explicitly NOT reused (Pitfall D) |
| Day-cursor gate lives in the ingestion service, not the scheduler — cron-tick frequency (D-04, admin-configurable) is decoupled from actual-fetch frequency (day-granularity, Pitfall A) |
| Singleton platform-wide config loaded via findUnique on a fixed slug (sourceType: 'doe-opendata'), never findFirst — self-documenting against future copy-paste into a per-tenant source |
| prisma.tender.upsert({ where: { dedupKey } }) as the SCHEMA-02 change-detection seam — identical notice never duplicates, changed contentHash updates in place |
| D-05 retention as two-phase updateMany/deleteMany with deadlineAt:{lt:...} filters — null-deadline rows are structurally excluded (SQL NULL comparisons are always false), not just by convention |
| In-memory prisma-shaped fake (Maps) for service-level tests — matches this repo's established convention (ldap.service.spec.ts) rather than a live DB connection |
|
|
| created |
modified |
| apps/api/src/tenders/tender-ingestion.service.ts |
| apps/api/src/tenders/tender-ingestion.service.spec.ts |
| apps/api/src/tenders/tender-scheduler.service.ts |
| apps/api/src/tenders/tender-scheduler.service.spec.ts |
|
| apps/api/src/tenders/tenders.module.ts |
|
|
| Comment wording avoiding literal 'forTenant'/'activeTenantId'/'findFirst' tokens in the two new service files — both grep-gate assertions (task-level and spec-level) check for the literal string, and an explanatory comment mentioning the anti-pattern by name would itself trip the assertion (same class of issue documented as a Rule 1 fix in Plan 10-01) |
| TenderIngestionService.politeDelayMs is a protected, test-overridable field (not a hardcoded sleep) so the catch-up-loop unit test doesn't sleep for real while production still gets the ~1.5s politeness delay between successive day-fetches |
| Task 3's two-tenant test drives the REAL (unmocked) ModuleRegistryService against a fake prisma, not a stand-in activation function — this exercises the genuine activateForTenant() call path, making the 'zero additional calls/jobs/rows' assertion a true integration proof rather than a tautology |
| pruneExpiredTenders() is called once per successful pollDueSources() tick (i.e., only when at least one day was actually fetched) — not on the day-cursor no-op branch, so a no-op tick stays a true no-op with zero DB writes beyond the read |
|
|
| id |
description |
requirement |
verification |
human_judgment |
| D1 |
A changed DÖE notice (same dedupKey, new contentHash) updates the existing Tender row instead of creating a duplicate (SCHEMA-02) |
SCHEMA-02 |
| kind |
ref |
status |
| unit |
tender-ingestion.service.spec.ts — 'updates the existing row in place when the same dedupKey reappears with a changed contentHash' — asserts store size stays 1, contentHash/deadlineAt reflect the new version |
pass |
|
| kind |
ref |
status |
| unit |
tender-ingestion.service.spec.ts — 'inserts a fresh notice, and does NOT duplicate when the identical notice ... reappears' — asserts store size stays 1 across 2 catch-up-day fetches of the identical record |
pass |
|
|
false |
|
| id |
description |
requirement |
verification |
human_judgment |
| D2 |
DÖE is polled once on a shared global schedule regardless of tenant count — poll-once-fan-out-many, never per-tenant (INGEST-06) |
INGEST-06 |
| kind |
ref |
status |
| integration |
tender-scheduler.service.spec.ts — 2nd-tenant activation asserts addCronJob still called exactly once, pollDueSources never called by activation, tender.count() stays 0 |
pass |
|
| kind |
ref |
status |
| automated_ui |
grep -c activeTenantId src/tenders/tender-scheduler.service.ts == 0; grep -c findFirst == 0; grep -c findUnique >= 1 |
pass |
|
|
false |
|
| id |
description |
verification |
human_judgment |
| D3 |
The poll tick is day-cursor gated: no upstream fetch when dayCursor >= today Europe/Berlin (D-01 from-now, no historical backfill) |
| kind |
ref |
status |
| unit |
tender-ingestion.service.spec.ts — 'makes NO adapter call and returns when the day-cursor is not strictly before Berlin-today' + 'does nothing when ... config is not active' |
pass |
|
|
false |
|
| id |
description |
verification |
human_judgment |
| D4 |
Tenders past their deadline are marked expired and pruned after 90 days; deadline-less rows are never auto-expired (D-05) |
| kind |
ref |
status |
| unit |
tender-ingestion.service.spec.ts — pruneExpiredTenders test: active+past-deadline -> expired, expired+91d-old -> deleted, expired+30d-old -> retained, null-deadline -> untouched, active+future-deadline -> untouched |
pass |
|
|
false |
|
| id |
description |
verification |
human_judgment |
| D5 |
Service uses the plain PrismaService — no tenant RLS extension on Tender/TenderSourcePollConfig (D-03, T-10-09) |
| kind |
ref |
status |
| automated_ui |
tender-ingestion.service.spec.ts source-inspection test: grep-equivalent expect(source).not.toMatch(/forTenant/) on tender-ingestion.service.ts |
pass |
|
|
false |
|
|
~25min |
2026-07-21 |
complete |