Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
Phase 10: Ausschreibungs-Radar Foundation & DÖE Ingestion - Pattern Map
Mapped: 2026-07-21 Files analyzed: 12 (new) Analogs found: 12 / 12
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
apps/api/src/tenders/tenders.module.ts |
module | request-response | apps/api/src/dkv/dkv.module.ts |
exact |
apps/api/src/tenders/tenders.seed.ts |
config/registry-seed | event-driven (OnModuleInit) | apps/api/src/cert-manager/cert-manager.seed.ts |
exact |
apps/api/src/tenders/tenders.controller.ts |
controller | request-response | apps/api/src/dkv/dkv.controller.ts |
exact |
apps/api/src/tenders/tender-scheduler.service.ts |
service (cron) | event-driven | apps/api/src/dkv/dkv-scheduler.service.ts |
role-match (anti-pattern noted below) |
apps/api/src/tenders/tender-ingestion.service.ts |
service | CRUD + batch | apps/api/src/dkv/dkv.service.ts (processInbox/orchestration) |
role-match |
apps/api/src/tenders/tender-normalizer.service.ts |
service (transform) | transform | apps/api/src/dkv/dkv-parser.service.ts |
role-match |
apps/api/src/tenders/adapters/tender-source-adapter.interface.ts |
interface | — | apps/api/src/dkv/providers/imap.provider.ts + exchange-inbox.provider.ts (provider interface pattern) |
role-match |
apps/api/src/tenders/adapters/doe-opendata.adapter.ts |
service (HTTP client) | file-I/O (ZIP fetch/parse) | apps/api/src/favorites/icon-discovery.service.ts (native fetch + guard patterns) |
role-match |
apps/api/src/tenders/tender.types.ts |
types | — | apps/api/src/dkv/dkv.types.ts |
exact |
apps/api/src/tenders/dto/source-config.dto.ts |
dto/validation | request-response | apps/api/src/dkv/dto/dkv-config.dto.ts |
exact |
apps/api/src/tenders/dto/tender-query.dto.ts |
dto/validation | request-response | apps/api/src/dkv/dto/dkv-history.dto.ts |
exact |
apps/api/prisma/schema.prisma (add Tender, TenderSourcePollConfig models) |
model | CRUD | Module/DkvModuleConfig models in same file |
exact |
apps/api/prisma/migrations/<timestamp>_add_tender_radar/migration.sql |
migration | — | apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql |
exact (style: hand-written SQL, IF NOT EXISTS) |
apps/web/src/lib/module-loader.ts (add tender-radar entry) |
config (frontend registry) | request-response | existing dkv-fleet/cert-manager entries in same file |
exact |
Pattern Assignments
apps/api/src/tenders/tenders.module.ts (module, request-response)
Analog: apps/api/src/dkv/dkv.module.ts (full file read, 73 lines)
Core pattern — self-seeding module via OnModuleInit, imports ModuleRegistryModule:
@Module({
imports: [ModuleRegistryModule /* + SettingsModule if admin poll-interval config reuses SettingsService */],
controllers: [TendersController],
providers: [
TenderIngestionService,
TenderSchedulerService,
TenderNormalizerService,
DoeOpenDataAdapter,
],
exports: [TenderIngestionService],
})
export class TendersModule implements OnModuleInit {
private readonly logger = new Logger(TendersModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedTendersModule(this.moduleRegistryService);
this.logger.log('Ausschreibungs-Radar module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed Ausschreibungs-Radar module', error);
}
}
}
Note: PrismaModule is global — no explicit import needed (same as DKV). ScheduleModule.forRoot() is already registered in AppModule (do not re-register).
apps/api/src/tenders/tenders.seed.ts (config/registry-seed)
Analog: apps/api/src/cert-manager/cert-manager.seed.ts (full file, 27 lines) — preferred over dkv.seed.ts because isSystem: true + "admin must activate via Marketplace" phrasing matches this phase's CONFIG-01 exactly (DKV's comment says "available to all tenants" which is not the desired behavior here).
Core pattern:
import { ModuleRegistryService } from '../module-registry/module-registry.service';
export async function seedTendersModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'tender-radar',
name: 'Ausschreibungs-Radar',
version: '1.0.0',
category: 'procurement', // or an existing category value — confirm during planning
description: {
de: 'Deutsche Ausschreibungen automatisch erfassen und durchsuchen',
en: 'Automatically track and search German public tenders',
},
isSystem: true,
});
}
seedModule() is an upsert by slug (ModuleRegistryService.seedModule, apps/api/src/module-registry/module-registry.service.ts:158-187) — reuse unmodified, no new registry mechanism needed.
apps/api/src/tenders/tenders.controller.ts (controller, request-response)
Analog: apps/api/src/dkv/dkv.controller.ts (full file, 243 lines)
Imports pattern (lines 1-24):
import { Body, Controller, Get, NotFoundException, Param, Post, Put, Query, Req } from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
Auth/Guard pattern (per-handler, lines 59-60, 76-77, etc.) — every admin route decorated individually, no controller-level @Roles:
@Get('source-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getSourceConfig(@Req() req: any) { ... }
V4 note from RESEARCH.md: admin source-config routes must be @Roles(Role.ADMIN, Role.SUPER_ADMIN)-guarded like DkvController, but the list/detail GET /tenders routes must NOT be tenant-row-filtered (global table) — only gated by TenantModuleActivation (module licensing), which is the one place this controller structurally diverges from DKV. Use ModuleGuard (apps/api/src/module-registry/module.guard.ts) for that gate rather than req.tenantId row-scoping.
Tenant-extraction helper pattern (lines 234-241) — reuse verbatim for the admin config routes only (not for the global tender list):
private _requireTenant(req: any): string {
const tenantId = req.tenantId as string | undefined;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
return tenantId;
}
Scheduler-interaction pattern on config save (lines 76-90) — after admin updates pollIntervalMin/isActive, push the change into the live scheduler without restart:
@Put('source-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSourceConfig(@Body() dto: SourceConfigDto) {
const result = await this.tenderIngestionService.saveSourceConfig(dto);
if (dto.isActive && dto.pollIntervalMin) {
this.tenderScheduler.setInterval(dto.pollIntervalMin);
} else if (dto.isActive === false) {
this.tenderScheduler.stopJob();
}
return result;
}
Difference from DKV: no tenantId argument to setInterval() — the DÖE source config is a platform-wide singleton (per RESEARCH.md Pitfall D), so the scheduler signature drops the tenant parameter entirely.
apps/api/src/tenders/tender-scheduler.service.ts (service/cron, event-driven)
Analog: apps/api/src/dkv/dkv-scheduler.service.ts (full file, 146 lines) — reuse the mechanics, do not reuse the per-tenant framing.
Reuse verbatim — CronJob resolution + SchedulerRegistry dynamic job pattern (lines 1-14, 96-130):
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
require('cron').CronJob as new (cronTime: string, onTick: () => void) => { start(): void };
setInterval(intervalMin: number): void {
try {
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
} catch { /* not yet registered — expected on first call */ }
const cronExpr = intervalMin < 60
? `*/${intervalMin} * * * *`
: `0 */${Math.floor(intervalMin / 60)} * * *`;
const job = new CronJobClass(cronExpr, () => {
this.tenderIngestionService.pollDueSources().catch((err) =>
this.logger.error(`Tender poll tick failed: ${(err as Error).message}`),
);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
job.start();
}
ANTI-PATTERN — DO NOT COPY (lines 43, 59-69): DKV's onModuleInit() loads config via this.dkvService.loadConfig() which internally calls findFirst() scoped to "first active config, single-tenant assumption" and stores this.activeTenantId on the service instance. RESEARCH.md's Pitfall D explains why this specific instance is actually safe for Phase 10 (DÖE has exactly one platform-wide config row, so findUnique({ where: { sourceType: 'doe-opendata' } }) on a fixed known slug is correct — not the same anti-pattern as a hypothetical per-tenant findFirst()). Concretely:
- Do NOT store
activeTenantIdon the scheduler instance — there is no tenant dimension here. - DO use
findUnique({ where: { sourceType: 'doe-opendata' } })(or equivalent fixed-slug lookup), notfindFirst()on an unfiltered/ordered query, to make the "singleton config" intent explicit in code (self-documenting against future copy-paste into a per-tenant source). - The two-tenant acceptance test (ROADMAP.md Success Criteria 4/5) must assert zero additional HTTP calls / cron jobs /
Tenderrows when a 2nd tenant activates the module — i.e. prove absence of tenant-scaled behavior, not correct per-tenant iteration. - Day-cursor gating (RESEARCH.md Pattern 1) must live inside
TenderIngestionService.pollDueSources(), not the scheduler — the scheduler only controls cron-tick frequency (can stay hourly per D-04); the ingestion service decides whetherdayCursor < today(Europe/Berlin)before making any HTTP call (Pitfall A).
apps/api/src/tenders/tender-ingestion.service.ts (service, CRUD + batch)
Analog: apps/api/src/dkv/dkv.service.ts (orchestration methods processInbox/checkNow — read for the overall shape: fetch → parse → normalize → persist → log).
Core pattern (orchestration + day-cursor gate + upsert-by-dedupKey):
async pollDueSources(): Promise<void> {
const config = await this.prisma.tenderSourcePollConfig.findUnique({
where: { sourceType: 'doe-opendata' },
});
if (!config?.isActive) return;
const nextDay = nextDayToFetch(config.lastIngestedDay); // RESEARCH.md "Day-cursor gate" snippet
if (!nextDay) return; // no-op tick — expected, not a bug (Pitfall A)
const rawRecords = await this.doeAdapter.fetchTenders(nextDay);
const normalized = rawRecords.map((r) => this.normalizer.normalize(r));
for (const tender of normalized) {
await this.prisma.tender.upsert({
where: { dedupKey: tender.dedupKey },
update: { ...tender, contentHash: tender.contentHash }, // SCHEMA-02 change detection
create: tender,
});
}
await this.prisma.tenderSourcePollConfig.update({
where: { sourceType: 'doe-opendata' },
data: { lastIngestedDay: new Date(nextDay) },
});
}
Note (multi-tenancy): This service must NOT call forTenant() (apps/api/src/prisma/prisma-tenant.extension.ts) for Tender/TenderSourcePollConfig writes — these are global, RLS-exempt tables by design (RESEARCH.md Architectural Responsibility Map + CONTEXT.md Integration Points). Use the plain (non-tenant-extended) PrismaService client for this service's queries.
Error handling pattern — catch-and-log per tick, never throw out of the cron callback (mirrors dkv-scheduler.service.ts lines 113-118):
this.tenderIngestionService.pollDueSources().catch((err) =>
this.logger.error(`Tender poll tick failed: ${(err as Error).message}`),
);
apps/api/src/tenders/tender-normalizer.service.ts (service/transform, transform)
Analog: apps/api/src/dkv/dkv-parser.service.ts (transform-style service — parse raw input into a typed internal shape; read for method shape, not copied verbatim since parsing target differs entirely — XML/JSON vs. PDF text).
Core pattern (structure only): one public normalize(raw: RawTenderRecord): NormalizedTenderFields entry point, private helper methods per field group, pure functions wherever possible (no I/O inside the normalizer — I/O stays in the adapter). Apply RESEARCH.md's filter/hash logic directly:
function isOpenTenderNotice(release: { tag?: string[] }): boolean {
if (release.tag?.includes('tender')) return true;
return false; // conservative: missing/other tags excluded (Pitfall C)
}
apps/api/src/tenders/adapters/doe-opendata.adapter.ts (service/HTTP client, file-I/O)
Analog: apps/api/src/favorites/icon-discovery.service.ts (full file, 380 lines) — the native fetch + AbortController timeout convention is the established project pattern for outbound HTTP (no axios anywhere in the codebase).
Core fetch pattern to copy (lines 254-265, adapted — DÖE needs no SSRF guard since it is a single fixed trusted government host, but keep the timeout/AbortController convention):
async function fetchDoeDay(pubDay: string, format: 'eforms.zip' | 'ocds.zip'): Promise<Buffer | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const res = await fetch(
`https://oeffentlichevergabe.de/api/notice-exports?pubDay=${pubDay}&format=${format}`,
{ signal: controller.signal },
);
if (res.status === 400) return null; // pubDay is today/future — nothing to fetch yet
if (!res.ok) throw new Error(`DÖE fetch failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
} finally {
clearTimeout(timeout);
}
}
Error handling pattern: treat HTTP 400 as an expected no-op signal (not an error to throw), everything else !res.ok throws — matches icon-discovery.service.ts's if (!response.ok) return null; early-return-on-failure convention, adapted to distinguish "expected no-op" (400) from "real failure" (5xx/network).
apps/api/src/tenders/dto/source-config.dto.ts (dto/validation, request-response)
Analog: apps/api/src/dkv/dto/dkv-config.dto.ts (full file, 111 lines)
Validation pattern to copy directly:
import { IsBoolean, IsInt, IsOptional, Max, Min } from 'class-validator';
export class SourceConfigDto {
/** Poll interval in minutes. Cron-tick frequency (D-04 default 60), NOT DÖE fetch frequency (day-cursor gated separately). */
@IsOptional()
@IsInt()
@Min(5) // same DoS-mitigation floor as DkvConfigDto.pollIntervalMin
@Max(1440)
pollIntervalMin?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
apps/api/src/tenders/dto/tender-query.dto.ts (dto/validation, request-response)
Analog: apps/api/src/dkv/dto/dkv-history.dto.ts (full file, 37 lines) — copy the pagination pattern verbatim (page/limit + @Type(() => Number) coercion, same bounds):
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class TenderQueryDto {
@IsOptional() @IsInt() @Min(1) @Type(() => Number) page?: number;
@IsOptional() @IsInt() @Min(1) @Max(100) @Type(() => Number) limit?: number;
// + filter fields specific to tenders (e.g. status=open|expired) — new, no analog needed, plain @IsOptional @IsIn
}
apps/api/prisma/schema.prisma (model, CRUD)
Analog: Module/TenantModuleActivation/DkvModuleConfig models (same file, lines 92-197) — field-naming and structural conventions to follow:
model Tender {
id String @id @default(uuid())
sourcePortal String // e.g. 'doe-opendata'
sourceNoticeId String
ocid String? // stable dedup key across notice versions, when present
dedupKey String @unique // ocid, or fallback sourcePortal:sourceNoticeId
title String
buyerName String?
cpvCodes String[] @default([])
region String?
deadlineAt DateTime? // frequently null per RESEARCH.md Pattern 4 — do not assume present
estimatedValue Decimal? @db.Decimal(14, 2)
procedureType String?
status String @default("active") // 'active' | 'expired'
sourceUrl String?
contentHash String // SCHEMA-02 change detection
publishedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([dedupKey])
// Deliberately NO tenantId field and NO @@index([tenantId]) — global table, CONTEXT.md Integration Points
}
model TenderSourcePollConfig {
id String @id @default(uuid())
sourceType String @unique // fixed slug, e.g. 'doe-opendata' — singleton row (RESEARCH.md Pitfall D)
pollIntervalMin Int @default(60)
isActive Boolean @default(false)
lastIngestedDay DateTime? // day-cursor, not a timestamp (RESEARCH.md Pattern 1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Conventions confirmed from existing models: id String @id @default(uuid()), createdAt/updatedAt pair with @default(now())/@updatedAt, @@index on frequently-filtered columns, @unique for natural singleton/lookup keys (mirrors DkvModuleConfig.tenantId @unique — here sourceType @unique plays the equivalent singleton role).
apps/api/prisma/migrations/<timestamp>_add_tender_radar/migration.sql (migration)
Analog: apps/api/prisma/migrations/20260714090000_add_ldap_user_exclude_list/migration.sql — hand-written, timestamped folder under apps/api/prisma/migrations/, IF NOT EXISTS guards:
-- CreateTable
CREATE TABLE IF NOT EXISTS "Tender" ( ... );
CREATE TABLE IF NOT EXISTS "TenderSourcePollConfig" ( ... );
Confirms project convention: migrations are handwritten (not auto-generated via prisma migrate dev in this repo's workflow), timestamp-prefixed folder naming (YYYYMMDDHHMMSS_description).
apps/web/src/lib/module-loader.ts (config, frontend registry)
Analog: existing dkv-fleet/cert-manager entries in the same MODULE_REGISTRY object (lines 31-49):
'tender-radar': {
component: dynamic(
() => import('@/app/(portal)/modules/tender-radar/page'),
{ ssr: false },
),
},
Critical: RESEARCH.md flags this explicitly — a module can be seeded in the DB and activated per-tenant, but the page 404s unless this MODULE_REGISTRY entry also exists (whitelist-only dynamic import, security-motivated per the file's own header comment lines 5-8). This entry is mandatory for CONFIG-01, not optional/cosmetic.
Shared Patterns
Module self-registration (CONFIG-01)
Source: apps/api/src/module-registry/module-registry.service.ts (seedModule, lines 158-187) + apps/api/src/cert-manager/cert-manager.seed.ts
Apply to: tenders.module.ts, tenders.seed.ts
Unmodified reuse — no new registry mechanism. isSystem: true + admin-driven Marketplace activation (not auto-activated per tenant), matching cert-manager's model over DKV's ("available to all tenants" is wrong for this phase).
Native fetch as sole HTTP client convention
Source: apps/api/src/favorites/icon-discovery.service.ts (lines 254-287), apps/api/src/calendar/providers/ics.provider.ts
Apply to: doe-opendata.adapter.ts
No axios anywhere in the codebase — AbortController + setTimeout-based timeout is the established idiom for any outbound call.
Dynamic SchedulerRegistry cron job (not static @Cron())
Source: apps/api/src/dkv/dkv-scheduler.service.ts (lines 96-130)
Apply to: tender-scheduler.service.ts
Reuse mechanics (CronJob require()-resolution workaround, addCronJob/deleteCronJob replace-on-change pattern) but drop the per-tenant framing entirely (see anti-pattern note above).
Tenant-scoped Prisma extension — explicitly NOT applied to Tender tables
Source: apps/api/src/prisma/prisma-tenant.extension.ts (forTenant, full file, 25 lines)
Apply to: N/A for Tender/TenderSourcePollConfig (explicitly excluded); DO apply if any tenant-scoped admin-audit table is added in this phase (none currently planned — Suchprofile/Matches are Phase 11).
This is the one pattern to actively avoid misapplying: RLS via set_config('app.current_tenant', ...) must not wrap queries against the global Tender table, or a second tenant's session would silently filter out platform-wide data it should see.
class-validator DTO conventions
Source: apps/api/src/dkv/dto/dkv-config.dto.ts, dkv-history.dto.ts
Apply to: source-config.dto.ts, tender-query.dto.ts
@IsOptional() + explicit @Min/@Max bounds on every numeric field (DoS mitigation precedent, e.g. pollIntervalMin floor of 5); @Type(() => Number) required on any query-string-sourced numeric field before validation runs.
No Analog Found
None — every planned file has at least a role-match analog in the existing DKV/cert-manager/module-registry/favorites code. The adapters/tender-source-adapter.interface.ts is the weakest match (DKV's provider files are concrete IMAP/Exchange implementations without a formally separated interface file) — planner should treat ARCHITECTURE.md's TenderSourceAdapter interface sketch as primary and the DKV providers only as a loose structural reference (constructor-injected, single primary method).
Metadata
Analog search scope: apps/api/src/dkv/, apps/api/src/cert-manager/, apps/api/src/module-registry/, apps/api/src/favorites/, apps/api/src/calendar/providers/, apps/api/src/prisma/, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/, apps/web/src/lib/module-loader.ts
Files scanned: 13 read in full (dkv-scheduler.service.ts, dkv.seed.ts, dkv.module.ts, dkv.controller.ts, prisma-tenant.extension.ts, module-registry.service.ts, icon-discovery.service.ts, cert-manager.seed.ts, dkv-config.dto.ts, dkv-vehicle.dto.ts, dkv-history.dto.ts, schema.prisma excerpt, one migration.sql) + module-loader.ts grep excerpt
Pattern extraction date: 2026-07-21