Files
tessera-ctl/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-01-PLAN.md
T

13 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
10-ausschreibungs-radar-foundation-d-e-ingestion 01 execute 1
apps/api/package.json
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260721120000_add_tender_radar/migration.sql
false
SCHEMA-01
truths artifacts key_links
Global (tenant-agnostic) Tender table exists in Postgres with OCDS-oriented nullable deadline/value fields (SCHEMA-01)
Singleton TenderSourcePollConfig row model exists to drive the shared poll (INGEST-06 foundation)
fast-xml-parser, adm-zip and csv-parse are installed in @tessera/api
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260721120000_add_tender_radar/migration.sql
Tender.dedupKey @unique is the upsert target for SCHEMA-02 change detection
Tender has NO tenantId column — RLS/forTenant deliberately does not apply (global data, per D-03)
Lay the data + dependency foundation for the Ausschreibungs-Radar module: install the three ingestion libraries (with a supply-chain checkpoint for the [SUS] `adm-zip` package), add the platform-global `Tender` and singleton `TenderSourcePollConfig` Prisma models, and apply the migration to the live database.

Phase Goal (user story)

As a Tessera-Administrator, I want to das Ausschreibungs-Radar-Modul im Marketplace aktivieren und automatisch normalisierte deutsche DÖE-Ausschreibungen auf einem gemeinsamen, mandantensicheren Zeitplan erfassen lassen, so that alle Mandanten aktuelle, bietbare Vergaben durchsuchen koennen, ohne dass pro Mandant redundant gepollt wird.

Purpose: Every later slice (module registration, adapter, normalizer, ingestion, scheduler) depends on these tables and packages existing. The global-not-tenant-scoped shape of Tender (D-03) is the single highest-leverage decision and is locked in here. Output: Migrated Tender + TenderSourcePollConfig tables, generated Prisma client types, three installed packages.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-RESEARCH.md @.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-PATTERNS.md @.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-CONTEXT.md @apps/api/prisma/schema.prisma @apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql

Artifacts This Phase Produces (whole-phase reference)

Prisma models: Tender, TenderSourcePollConfig (Plan 01). NestJS classes: TendersModule, seedTendersModule() (Plan 02); DoeOpenDataAdapter, TenderSourceAdapter interface, TenderNormalizerService (Plan 03); TenderIngestionService, TenderSchedulerService (Plan 04); TendersController, SourceConfigDto, TenderQueryDto (Plan 05). Types: RawTenderRecord, NormalizedTenderFields, SourceType in tenders/tender.types.ts (Plan 03). Scheduler methods: TenderSchedulerService.setInterval(intervalMin), stopJob(); TenderIngestionService.pollDueSources(), pruneExpiredTenders() (Plan 04). Config keys / slugs: module slug tender-radar; TenderSourcePollConfig.sourceType = 'doe-opendata' (singleton). New file paths: apps/api/src/tenders/**, apps/web/src/app/(portal)/modules/tender-radar/page.tsx, apps/web/src/lib/module-loader.ts (+tender-radar entry), fixture ZIPs under apps/api/src/tenders/__fixtures__/.

Task 1: adm-zip package legitimacy verification Nothing yet — this gate precedes the `pnpm add adm-zip` step. `adm-zip` was tagged [SUS]/[ASSUMED] in 10-RESEARCH.md Package Legitimacy Audit (flagged `too-new`, manually assessed a false positive, but package-name provenance is training-knowledge so it requires a human spot-check per the package-legitimacy protocol). Pause before installing `adm-zip`. Present the legitimacy evidence below to the human and wait for explicit approval (or a named alternative) before Task 2 runs `pnpm add`. Do not auto-approve — this is a blocking supply-chain gate (T-10-SC). 1. Open https://www.npmjs.com/package/adm-zip — confirm weekly downloads in the millions, last publish is a patch release (not a brand-new package), and repository points to github.com/cthackers/adm-zip. 2. Open https://github.com/cthackers/adm-zip — confirm it is an established, maintained repo (stars, issue history, MIT license). 3. Optionally check the Socket.dev / Snyk score for adm-zip@0.6.0. 4. Confirm `fast-xml-parser` and `csv-parse` are already approved in 10-RESEARCH.md (carried over from STACK.md — no re-flag needed). - Human types "approved" after confirming adm-zip is a legitimate, established package, OR names an alternative (e.g. `unzipper`) if not. - This checkpoint is NOT auto-approvable regardless of workflow.auto_advance (supply-chain gate). Type "approved" to proceed with the install, or name a replacement ZIP library. Task 2: Install ingestion packages + add Tender/TenderSourcePollConfig models - apps/api/prisma/schema.prisma (DkvModuleConfig model at ~line 178 for field/convention reference; datasource + generator at top) - apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql (handwritten migration style: IF NOT EXISTS guards, timestamped folder) - .planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-PATTERNS.md (schema.prisma section — exact model field list) apps/api/package.json, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260721120000_add_tender_radar/migration.sql Run `pnpm --filter @tessera/api add fast-xml-parser adm-zip csv-parse` (only after Task 1 approval).
Add two models to `apps/api/prisma/schema.prisma` following the 10-PATTERNS.md schema section exactly:

`Tender` — global reference table, deliberately NO `tenantId` and NO `@@index([tenantId])` (D-03: platform-global data). Fields: `id String @id @default(uuid())`, `sourcePortal String`, `sourceNoticeId String`, `ocid String?`, `dedupKey String @unique`, `title String`, `buyerName String?`, `cpvCodes String[] @default([])`, `region String?`, `plz String?`, `bundesland String?`, `deadlineAt DateTime?` (frequently null per RESEARCH Pattern 4 — nullable is mandatory, not optional hardening), `estimatedValue Decimal? @db.Decimal(14,2)`, `procedureType String?`, `status String @default("active")` (values `active` | `expired`; supports D-05 retention marking), `sourceUrl String?`, `contentHash String` (SCHEMA-02 change detection), `rawPayload Json?` (debugging/re-normalization), `publishedAt DateTime`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`. Indexes: `@@index([status])`, `@@index([deadlineAt])`, `@@index([publishedAt])`.

`TenderSourcePollConfig` — singleton-per-source admin config (INGEST-06 foundation). Fields: `id String @id @default(uuid())`, `sourceType String @unique` (fixed slug, `doe-opendata` — the `@unique` makes the singleton intent explicit, mirroring DkvModuleConfig.tenantId @unique), `pollIntervalMin Int @default(60)` (D-04 default hourly), `isActive Boolean @default(false)`, `lastIngestedDay DateTime?` (day-cursor, NOT a timestamp — RESEARCH Pattern 1), `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`.

Write the handwritten migration at `apps/api/prisma/migrations/20260721120000_add_tender_radar/migration.sql` mirroring the ldap-user-exclude-list migration style: `CREATE TABLE IF NOT EXISTS "Tender" (...)` and `CREATE TABLE IF NOT EXISTS "TenderSourcePollConfig" (...)` with matching column types, plus `CREATE UNIQUE INDEX IF NOT EXISTS` on `Tender.dedupKey` and `TenderSourcePollConfig.sourceType`, and the three `Tender` indexes.

Run `pnpm --filter @tessera/api exec prisma generate` so the Prisma client TS types for `Tender`/`TenderSourcePollConfig` are available to downstream service tasks.
cd apps/api && grep -c "model Tender" prisma/schema.prisma && grep -c "model TenderSourcePollConfig" prisma/schema.prisma && test -f prisma/migrations/20260721120000_add_tender_radar/migration.sql && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | tail -5 - `grep -c "model Tender"` and `grep -c "model TenderSourcePollConfig"` in schema.prisma each return >= 1. - Migration SQL file exists at the timestamped path. - `Tender` has no `tenantId` column (assert: `grep -c "tenantId" ` within the Tender model block returns 0). - `prisma generate` succeeds and `import { Tender } from '@prisma/client'` type-checks. Both models present with correct fields, migration SQL written, Prisma client regenerated, packages installed. Task 3: [BLOCKING] Apply the tender-radar migration to the database - apps/api/prisma/migrations/migration_lock.toml (confirms postgresql provider + migrate workflow) - apps/api/prisma/migrations/20260721120000_add_tender_radar/migration.sql (the migration written in Task 2) apps/api/prisma/migrations/20260721120000_add_tender_radar/migration.sql [BLOCKING] Apply the handwritten migration to the live dev database so the Prisma client's compile-time types are backed by real tables (build/type checks pass WITHOUT this step because types come from the generated client, not the DB — this creates a false-positive verification state, so this task is mandatory before any ingestion/persistence verification).
Run `pnpm --filter @tessera/api exec prisma migrate deploy` (non-interactive; applies the committed handwritten migration). If `migrate deploy` reports drift or a non-empty schema conflict that requires interactive resolution, fall back to `pnpm --filter @tessera/api exec prisma db push` (schema is additive — two new tables, no destructive change expected). Then confirm the tables exist.
cd apps/api && pnpm exec prisma migrate status 2>&1 | tail -8 - `prisma migrate status` reports the database schema is up to date (no pending migrations), OR a follow-up `prisma db push` reports "already in sync". - Tables `Tender` and `TenderSourcePollConfig` exist in the database (verifiable via `prisma migrate status` clean state or a `prisma.tender.count()` call returning 0 without error). Migration applied; both tables live in the database; no pending migrations.

<threat_model>

Trust Boundaries

Boundary Description
npm registry → build Third-party package code (adm-zip) enters the trusted build via pnpm add
ORM → Postgres New global tables added; tenant-isolation design decision is encoded in schema shape

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10-SC Tampering pnpm add adm-zip (supply chain) high mitigate Task 1 blocking-human checkpoint verifies adm-zip on npmjs.com + github before install; fast-xml-parser/csv-parse pre-approved in RESEARCH
T-10-01 Information Disclosure Tender table schema high mitigate Tender carries NO tenantId by design (D-03) — it is platform-global reference data; per-tenant scoping lives one layer up (Phase 11 saved searches). Encoding this now prevents a later blanket where: { tenantId } habit from silently hiding global data from a second tenant
T-10-02 Denial of Service Decimal/String[] columns low accept Standard Postgres column types; ingestion-side size guards handled in Plan 03 (zip-bomb) — no DB-level risk introduced by the schema itself
</threat_model>
- `prisma migrate status` clean; both tables present. - `Tender` model has no `tenantId` field (design invariant for D-03 / multi-tenant global data). - Three packages resolvable: `pnpm --filter @tessera/api exec node -e "require('adm-zip');require('fast-xml-parser');require('csv-parse/sync')"`.

<success_criteria>

  • SCHEMA-01 data foundation: normalized OCDS-oriented Tender table exists with nullable deadline/value.
  • Packages installed; migration applied; client regenerated. </success_criteria>
Create `.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-01-SUMMARY.md` when done.