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

11 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 02 execute 2
10-01
apps/api/src/tenders/tenders.module.ts
apps/api/src/tenders/tenders.seed.ts
apps/api/src/tenders/tenders.seed.spec.ts
apps/api/src/app.module.ts
apps/web/src/lib/module-loader.ts
apps/web/src/app/(portal)/modules/tender-radar/page.tsx
true
CONFIG-01
truths artifacts key_links
Admin can find 'Ausschreibungs-Radar' in the marketplace and activate it for a tenant, same as DKV/Cert-Manager (CONFIG-01)
Activating the module for a tenant opens the module page (no 404) because the module-loader whitelist entry exists
The singleton 'doe-opendata' TenderSourcePollConfig row is seeded so the shared poll is admin-drivable
apps/api/src/tenders/tenders.module.ts
apps/api/src/tenders/tenders.seed.ts
apps/web/src/lib/module-loader.ts
apps/web/src/app/(portal)/modules/tender-radar/page.tsx
TendersModule.onModuleInit() calls seedTendersModule() → ModuleRegistryService.seedModule({ slug: 'tender-radar', isSystem: true })
module-loader.ts MODULE_REGISTRY['tender-radar'] entry is MANDATORY — page 404s without it even when activated
Deliver the first user-facing vertical slice: register the Ausschreibungs-Radar module in the marketplace (`isSystem: true`, admin-activatable per tenant) and make its portal page load when activated. Seed the singleton `doe-opendata` poll-config row so later plans have a config to drive.

Phase Goal (user story)

As a Tessera-Administrator, I want to das Ausschreibungs-Radar-Modul im Marketplace aktivieren, so that ich Zugang zum Ausschreibungs-Katalog fuer einen Mandanten freischalten kann — genau wie beim DKV-Fleet- und Cert-Manager-Modul (CONFIG-01).

Purpose: After this plan a real admin can DO something new: find the module in the marketplace, activate it for a tenant, and open its page. This is the CONFIG-01 slice end-to-end (registry seed → marketplace → activation → lazy-loaded page). Output: Self-seeding TendersModule, module-loader whitelist entry, minimal placeholder page, seeded singleton poll config.

<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-PATTERNS.md @apps/api/src/dkv/dkv.module.ts @apps/api/src/cert-manager/cert-manager.seed.ts @apps/api/src/module-registry/module-registry.service.ts @apps/web/src/lib/module-loader.ts Task 1: TendersModule skeleton + registry self-seed + singleton poll config - apps/api/src/dkv/dkv.module.ts (exact OnModuleInit self-seed pattern to copy) - apps/api/src/cert-manager/cert-manager.seed.ts (preferred seed analog — isSystem:true + "admin must activate via Marketplace" phrasing matches CONFIG-01) - apps/api/src/module-registry/module-registry.service.ts (seedModule upsert-by-slug signature, lines ~158-187) - apps/api/src/app.module.ts (imports array — add TendersModule alongside DkvModule/CertManagerModule) apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tenders.seed.ts, apps/api/src/app.module.ts Create `tenders.seed.ts` exporting `seedTendersModule(moduleRegistryService)` that calls `moduleRegistryService.seedModule({ slug: 'tender-radar', name: 'Ausschreibungs-Radar', version: '1.0.0', category: 'procurement', description: { de: 'Deutsche Ausschreibungen automatisch erfassen und durchsuchen', en: 'Automatically track and search German public tenders' }, isSystem: true })` — mirror `cert-manager.seed.ts` verbatim in structure (CONFIG-01). Confirm `procurement` is acceptable as a new category value (seedModule does not constrain category); if the marketplace UI needs a known category, reuse an existing one and note it in the summary.
Create `tenders.module.ts` implementing `OnModuleInit` exactly like `DkvModule`: `imports: [ModuleRegistryModule]`, `controllers: []` (added Plan 05), `providers: []` (services added Plans 03-04), and an `onModuleInit()` that calls `seedTendersModule(this.moduleRegistryService)` inside try/catch with a Logger. Also, in `onModuleInit()`, upsert the singleton poll config: `prisma.tenderSourcePollConfig.upsert({ where: { sourceType: 'doe-opendata' }, update: {}, create: { sourceType: 'doe-opendata', pollIntervalMin: 60, isActive: true } })` — inject `PrismaService` (PrismaModule is global). isActive defaults true so the platform-global DÖE poll (D-04 hourly) runs regardless of tenant activation; the day-cursor gate (Plan 04) makes this idempotent. Do NOT use `forTenant()` — this config and the Tender table are global (D-03).

Add `TendersModule` to the `imports` array in `app.module.ts`.
cd apps/api && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | tail -5 && grep -c "TendersModule" src/app.module.ts - `tenders.seed.ts` seeds slug `tender-radar` with `isSystem: true`. - `TendersModule` is imported in `app.module.ts` (`grep -c "TendersModule" src/app.module.ts` >= 2 — import line + array entry). - Singleton `doe-opendata` config upserted on init (idempotent — re-boot does not create a second row; guaranteed by `sourceType @unique`). - `tsc --noEmit` passes. Module self-seeds into the registry on boot and seeds the singleton poll config; app compiles. Task 2: Web module-loader whitelist entry + minimal portal page - apps/web/src/lib/module-loader.ts (MODULE_REGISTRY object — copy the 'cert-manager' entry shape) - apps/web/src/app/(portal)/modules/cert-manager/page.tsx (page.tsx conventions: 'use client', useTranslations, default export) apps/web/src/lib/module-loader.ts, apps/web/src/app/(portal)/modules/tender-radar/page.tsx Add a `'tender-radar'` entry to `MODULE_REGISTRY` in `module-loader.ts` pointing at `dynamic(() => import('@/app/(portal)/modules/tender-radar/page'), { ssr: false })` — identical shape to the existing `'cert-manager'`/`'dkv-fleet'` entries. This whitelist entry is MANDATORY for CONFIG-01: the page 404s even when the module is activated if the slug is not whitelisted (security-motivated whitelist, module-loader header comment).
Create a minimal `tender-radar/page.tsx` client component (default export, `'use client'`) that renders the module title and a short "Ausschreibungen werden erfasst" placeholder. Real results UI is Phase 11 — this is the thinnest page that proves the activation → page-load path. Use a hardcoded German string here (full i18n is CONFIG-03, Phase 14); note this as an intentional MVP stub in the summary.
cd apps/web && grep -c "tender-radar" src/lib/module-loader.ts && test -f "src/app/(portal)/modules/tender-radar/page.tsx" && pnpm exec tsc --noEmit 2>&1 | tail -5 - `MODULE_REGISTRY['tender-radar']` entry present (`grep -c "tender-radar" module-loader.ts` >= 1). - `tender-radar/page.tsx` exists and default-exports a component. - Web `tsc --noEmit` passes. Activated module opens its page instead of 404; whitelist entry in place. Task 3: Seed registration test (Wave 0 coverage for CONFIG-01) - apps/api/src/cert-manager/cert-manager.service.spec.ts (existing vitest spec style in this repo) - apps/api/src/tenders/tenders.seed.ts (unit under test, from Task 1) apps/api/src/tenders/tenders.seed.spec.ts Create `tenders.seed.spec.ts` (Vitest). Provide a mock `ModuleRegistryService` with a spied `seedModule`. Assert that `seedTendersModule(mock)` calls `seedModule` once with `slug: 'tender-radar'`, `isSystem: true`, and a description object containing both `de` and `en` keys. This is the Wave 0 automated proof for CONFIG-01 registration. cd apps/api && pnpm test -- tenders.seed 2>&1 | tail -15 - Test asserts `seedModule` called with `slug: 'tender-radar'` and `isSystem: true`. - `pnpm --filter @tessera/api test -- tenders.seed` passes. Registration behaviour has automated coverage; green.

<threat_model>

Trust Boundaries

Boundary Description
tenant → module activation A tenant's activation must gate only visibility, not create a second data copy or leak another tenant's config
URL slug → dynamic import Only whitelisted slugs may resolve to a component (module-loader security whitelist)

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10-03 Elevation of Privilege module-loader.ts dynamic import medium mitigate Only the fixed 'tender-radar' slug is whitelisted; arbitrary URL slugs return null (existing whitelist mechanism, unchanged)
T-10-04 Information Disclosure per-tenant module activation high mitigate Activation uses the existing ModuleRegistryService / TenantModuleActivation mechanism unchanged; the singleton doe-opendata config is global (one row, sourceType @unique) so a 2nd tenant activation cannot create or read a per-tenant duplicate. No forTenant() applied to the global config
T-10-05 Tampering registry self-seed on boot low accept seedModule is an idempotent upsert-by-slug; repeated boots do not duplicate the module row
</threat_model>
- Module appears in the registry with slug `tender-radar` after boot (seed test green). - Activating the module for a tenant opens `/modules/tender-radar` (whitelist entry present). - Singleton poll config seeded exactly once (idempotent upsert). - `pnpm --filter @tessera/api test` green; both apps `tsc --noEmit` clean.

<success_criteria>

  • CONFIG-01: admin can find and activate Ausschreibungs-Radar in the marketplace, same as DKV/Cert-Manager, and open its page. </success_criteria>
Create `.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-02-SUMMARY.md` when done.