Files
tessera-ctl/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-PATTERNS.md
T
2026-07-21 10:10:41 +02:00

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 activeTenantId on the scheduler instance — there is no tenant dimension here.
  • DO use findUnique({ where: { sourceType: 'doe-opendata' } }) (or equivalent fixed-slug lookup), not findFirst() 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 / Tender rows 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 whether dayCursor < 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