de06794e67
6 plans covering full pipeline: PDF parsing foundation (Wave 0), inbox providers + export/SMTP services (Wave 1), pipeline orchestration + frontend pages + settings UI (Wave 2). Includes D-06 MailModule DB-config migration and Nyquist validation strategy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
830 lines
26 KiB
Markdown
830 lines
26 KiB
Markdown
# Phase 7: DKV Fleet Module - Pattern Map
|
||
|
||
**Mapped:** 2026-06-26
|
||
**Files analyzed:** 21 new/modified files
|
||
**Analogs found:** 19 / 21
|
||
|
||
---
|
||
|
||
## File Classification
|
||
|
||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||
|-------------------|------|-----------|----------------|---------------|
|
||
| `apps/api/src/dkv/dkv.module.ts` | module | — | `apps/api/src/calendar/calendar.module.ts` | exact |
|
||
| `apps/api/src/dkv/dkv.controller.ts` | controller | request-response | `apps/api/src/ldap/ldap.controller.ts` | exact |
|
||
| `apps/api/src/dkv/dkv.service.ts` | service | orchestration | `apps/api/src/calendar/calendar.service.ts` | role-match |
|
||
| `apps/api/src/dkv/dkv-scheduler.service.ts` | service | event-driven | `apps/api/src/ldap/ldap-sync.scheduler.ts` | exact |
|
||
| `apps/api/src/dkv/dkv-parser.service.ts` | service | transform | `apps/api/src/calendar/calendar.service.ts` | partial |
|
||
| `apps/api/src/dkv/dkv-export.service.ts` | service | file-I/O | — | no analog |
|
||
| `apps/api/src/dkv/dkv-mail.service.ts` | service | request-response | `apps/api/src/mail/mail.service.ts` | role-match |
|
||
| `apps/api/src/dkv/providers/inbox-provider.interface.ts` | interface | — | `apps/api/src/calendar/calendar.service.ts` (CalendarProvider) | exact |
|
||
| `apps/api/src/dkv/providers/imap.provider.ts` | provider | file-I/O | `apps/api/src/calendar/providers/caldav.provider.ts` | role-match |
|
||
| `apps/api/src/dkv/providers/exchange-inbox.provider.ts` | provider | request-response | `apps/api/src/calendar/providers/exchange.provider.ts` | exact |
|
||
| `apps/api/src/dkv/dto/dkv-config.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | exact |
|
||
| `apps/api/src/dkv/dto/dkv-vehicle.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | exact |
|
||
| `apps/api/src/dkv/dto/dkv-history.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | role-match |
|
||
| `apps/api/src/settings/settings.module.ts` | module | — | `apps/api/src/calendar/calendar.module.ts` | role-match |
|
||
| `apps/api/src/settings/settings.controller.ts` | controller | request-response | `apps/api/src/ldap/ldap.controller.ts` | exact |
|
||
| `apps/api/src/settings/settings.service.ts` | service | CRUD | `apps/api/src/calendar/calendar.service.ts` | role-match |
|
||
| `apps/api/src/settings/dto/smtp-config.dto.ts` | dto | — | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | role-match |
|
||
| `apps/api/src/app.module.ts` (modify) | module | — | self | — |
|
||
| `apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx` | component | request-response | `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` | exact |
|
||
| `apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx` | component | CRUD | `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx` | role-match |
|
||
| `apps/web/src/app/(portal)/settings/general/smtp/page.tsx` | component | CRUD | `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx` | role-match |
|
||
|
||
---
|
||
|
||
## Pattern Assignments
|
||
|
||
### `apps/api/src/dkv/dkv.module.ts` (module)
|
||
|
||
**Analog:** `apps/api/src/calendar/calendar.module.ts`
|
||
|
||
**Module structure pattern** (lines 1–33):
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { DkvController } from './dkv.controller';
|
||
import { DkvService } from './dkv.service';
|
||
import { DkvSchedulerService } from './dkv-scheduler.service';
|
||
import { DkvParserService } from './dkv-parser.service';
|
||
import { DkvExportService } from './dkv-export.service';
|
||
import { DkvMailService } from './dkv-mail.service';
|
||
import { ImapProvider } from './providers/imap.provider';
|
||
import { ExchangeInboxProvider } from './providers/exchange-inbox.provider';
|
||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||
|
||
@Module({
|
||
controllers: [DkvController],
|
||
providers: [
|
||
DkvService,
|
||
DkvSchedulerService,
|
||
DkvParserService,
|
||
DkvExportService,
|
||
DkvMailService,
|
||
ImapProvider,
|
||
ExchangeInboxProvider,
|
||
CalendarCryptoService, // imported from CalendarModule — inject directly, not re-declared
|
||
],
|
||
exports: [DkvService],
|
||
})
|
||
export class DkvModule {}
|
||
```
|
||
|
||
**Note:** `CalendarCryptoService` must also be exported from `CalendarModule` (add `exports: [CalendarCryptoService]` there) so DkvModule can import it.
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/dkv.controller.ts` (controller, request-response)
|
||
|
||
**Analog:** `apps/api/src/ldap/ldap.controller.ts`
|
||
|
||
**Imports pattern** (lines 1–19):
|
||
```typescript
|
||
import {
|
||
BadRequestException,
|
||
Body,
|
||
Controller,
|
||
Delete,
|
||
Get,
|
||
NotFoundException,
|
||
Param,
|
||
Patch,
|
||
Post,
|
||
Req,
|
||
UploadedFile,
|
||
UseInterceptors,
|
||
} from '@nestjs/common';
|
||
import { FileInterceptor } from '@nestjs/platform-express';
|
||
import { Role } from '@prisma/client';
|
||
import { Roles } from '../auth/decorators/roles.decorator';
|
||
import { DkvService } from './dkv.service';
|
||
import { DkvConfigDto } from './dto/dkv-config.dto';
|
||
import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
|
||
```
|
||
|
||
**Auth/Guard pattern** — ADMIN-only, same as `ldap.controller.ts` lines 38–40:
|
||
```typescript
|
||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||
```
|
||
Apply to every handler. Global `JwtAuthGuard` already enforces JWT; `@Roles` adds role check.
|
||
|
||
**Tenant extraction pattern** — copy from `ldap.controller.ts` lines 40–47:
|
||
```typescript
|
||
const tenantId = req.tenantId;
|
||
if (!tenantId) {
|
||
throw new BadRequestException('No tenant context');
|
||
}
|
||
```
|
||
|
||
**Core routes pattern:**
|
||
```typescript
|
||
@Controller('dkv')
|
||
export class DkvController {
|
||
constructor(private readonly dkvService: DkvService) {}
|
||
|
||
// GET /dkv/config — get module config (no decrypted passwords)
|
||
// PUT /dkv/config — save module config
|
||
// POST /dkv/check-now — manual inbox poll trigger
|
||
// GET /dkv/history — processing history (paginated)
|
||
// GET /dkv/exports/:filename — file download
|
||
// GET /dkv/vehicles — list vehicle master
|
||
// POST /dkv/vehicles — create vehicle
|
||
// PUT /dkv/vehicles/:id — update vehicle
|
||
// DELETE /dkv/vehicles/:id — delete vehicle
|
||
// POST /dkv/vehicles/import — CSV bulk import
|
||
}
|
||
```
|
||
|
||
**Error handling pattern** — follow `ldap.controller.ts`:
|
||
```typescript
|
||
try {
|
||
const result = await this.dkvService.someMethod(tenantId, dto);
|
||
return result;
|
||
} catch (error) {
|
||
if (error instanceof NotFoundException) throw error;
|
||
if (error instanceof BadRequestException) throw error;
|
||
throw error; // let global exception filter handle it
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/dkv.service.ts` (service, orchestration)
|
||
|
||
**Analog:** `apps/api/src/calendar/calendar.service.ts`
|
||
|
||
**Imports pattern** (lines 1–14 of calendar.service.ts):
|
||
```typescript
|
||
import {
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||
// + DkvParserService, DkvExportService, DkvMailService, providers
|
||
```
|
||
|
||
**Safe DB select pattern** — never return encrypted credentials (calendar.service.ts lines 50–67):
|
||
```typescript
|
||
const CONFIG_SAFE_SELECT = {
|
||
id: true,
|
||
tenantId: true,
|
||
protocol: true,
|
||
host: true,
|
||
port: true,
|
||
encryption: true,
|
||
folder: true,
|
||
senderFilter: true,
|
||
pollIntervalMin: true,
|
||
isActive: true,
|
||
exportRecipient: true,
|
||
vehicleFormatString: true,
|
||
// encryptedInboxCreds: NEVER included
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
} as const;
|
||
```
|
||
|
||
**Credential encryption pattern** (from RESEARCH.md Code Examples):
|
||
```typescript
|
||
// Encrypt at save time
|
||
const encryptedInboxCreds = this.crypto.encrypt(
|
||
JSON.stringify({ username: dto.username, password: dto.password })
|
||
);
|
||
|
||
// Decrypt at use time (never log result — T-05-13)
|
||
const { username, password } = JSON.parse(
|
||
this.crypto.decrypt(config.encryptedInboxCreds!)
|
||
);
|
||
```
|
||
|
||
**Error handling — generic messages** (exchange.provider.ts lines 42–48):
|
||
```typescript
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`DKV inbox poll failed for tenant ${tenantId}: ${(error as Error).message}`,
|
||
);
|
||
// T-05-13: no credential details in error
|
||
return [];
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/dkv-scheduler.service.ts` (service, event-driven)
|
||
|
||
**Analog:** `apps/api/src/ldap/ldap-sync.scheduler.ts`
|
||
|
||
**Full structure pattern** (ldap-sync.scheduler.ts lines 1–89):
|
||
```typescript
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
// DkvSchedulerService DIFFERS: uses SchedulerRegistry + dynamic CronJob
|
||
// instead of static @Cron — because interval is configurable from DB
|
||
|
||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||
import { CronJob } from 'cron';
|
||
|
||
const JOB_NAME = 'dkv-inbox-poll';
|
||
|
||
@Injectable()
|
||
export class DkvSchedulerService implements OnModuleInit {
|
||
private readonly logger = new Logger(DkvSchedulerService.name);
|
||
|
||
constructor(
|
||
private readonly schedulerRegistry: SchedulerRegistry,
|
||
private readonly dkvService: DkvService,
|
||
) {}
|
||
|
||
onModuleInit() {
|
||
this.dkvService.loadConfig().then(config => {
|
||
if (config?.isActive) this.setInterval(config.pollIntervalMin);
|
||
}).catch(err => this.logger.error('Failed to init DKV scheduler', err));
|
||
}
|
||
|
||
setInterval(intervalMin: number): void {
|
||
try {
|
||
this.schedulerRegistry.getCronJob(JOB_NAME).stop();
|
||
this.schedulerRegistry.deleteCronJob(JOB_NAME);
|
||
} catch { /* not yet registered */ }
|
||
|
||
const job = new CronJob(`*/${intervalMin} * * * *`, () => {
|
||
this.dkvService.processInbox().catch(err =>
|
||
this.logger.error('DKV inbox poll failed', err)
|
||
);
|
||
});
|
||
this.schedulerRegistry.addCronJob(JOB_NAME, job);
|
||
job.start();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Key difference from LdapSyncScheduler:** Static `@Cron()` decorator cannot change at runtime. Use `SchedulerRegistry.addCronJob()` pattern instead.
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/dkv-parser.service.ts` (service, transform)
|
||
|
||
**No direct analog** — unique PDF parsing logic. Use NestJS `@Injectable()` service wrapper.
|
||
|
||
**Service shell pattern** (follows all other services):
|
||
```typescript
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
|
||
@Injectable()
|
||
export class DkvParserService {
|
||
private readonly logger = new Logger(DkvParserService.name);
|
||
|
||
async parsePdf(buffer: Buffer): Promise<DkvVehicleBlock[]> {
|
||
// Wave 0: validate pdf-parse v2 API against user-files/invoice.pdf first
|
||
// then implement production regex
|
||
}
|
||
}
|
||
```
|
||
|
||
**Critical: pdf-parse v2 API** — old `pdfParse(buffer)` function call does NOT exist in v2:
|
||
```typescript
|
||
import { PDFParse } from 'pdf-parse';
|
||
|
||
async function extractPdfText(buffer: Buffer): Promise<string> {
|
||
const parser = new PDFParse({ data: buffer });
|
||
const result = await parser.getText();
|
||
await parser.destroy();
|
||
return result.text;
|
||
}
|
||
```
|
||
|
||
**DKV vehicle block regex** (ASSUMED — validate against `user-files/invoice.pdf` in Wave 0):
|
||
```typescript
|
||
const vehicleBlockPattern =
|
||
/VEHICLE:\s+(\S+)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
|
||
```
|
||
|
||
**German number parsing** (mandatory for Kilometerstand/Menge):
|
||
```typescript
|
||
parseFloat(raw.replace(/\./g, '').replace(',', '.'))
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/dkv-export.service.ts` (service, file-I/O)
|
||
|
||
**No analog** — first file-I/O service in codebase. Use NestJS `@Injectable()` pattern.
|
||
|
||
**SheetJS buffer pattern** (RESEARCH.md Pattern 5):
|
||
```typescript
|
||
import * as XLSX from 'xlsx';
|
||
|
||
function buildExcelBuffer(rows: ExportRow[]): Buffer {
|
||
const headers = ['Lieferdatum', 'Fahrzeug', 'Fahrer', 'Ort', 'Kilometerstand'];
|
||
const data = rows.map(r => [r.lieferdatum, r.fahrzeug, r.fahrer, r.ort, r.kilometerstand]);
|
||
const ws = XLSX.utils.aoa_to_sheet([headers, ...data]);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, 'DKV Export');
|
||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
||
}
|
||
```
|
||
|
||
**Fahrzeug format string resolver** (RESEARCH.md Code Examples):
|
||
```typescript
|
||
function resolveFahrzeug(vehicle: DkvVehicleMaster, formatString: string): string {
|
||
return formatString
|
||
.replace('{Marke}', vehicle.marke)
|
||
.replace('{Modell}', vehicle.modell)
|
||
.replace('{Kennzeichen}', vehicle.kennzeichen)
|
||
.replace('{Fahrer}', vehicle.fahrer);
|
||
}
|
||
```
|
||
|
||
**10-file prune guard** — add processing lock to prevent race:
|
||
```typescript
|
||
private processing = false;
|
||
|
||
async processInbox(): Promise<void> {
|
||
if (this.processing) return; // Pitfall 7: race condition guard
|
||
this.processing = true;
|
||
try { /* ... */ } finally { this.processing = false; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/dkv-mail.service.ts` (service, request-response)
|
||
|
||
**Analog:** `apps/api/src/mail/mail.service.ts`
|
||
|
||
**Key difference:** Do NOT use `MailerService` from `@nestjs-modules/mailer`. Use `nodemailer.createTransport()` directly — transport created at send time from DB config (Pitfall 3).
|
||
|
||
**Imports pattern** (mail.service.ts lines 1–4, adapted):
|
||
```typescript
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import * as nodemailer from 'nodemailer';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||
```
|
||
|
||
**Error handling pattern** (mail.service.ts lines 69–78):
|
||
```typescript
|
||
try {
|
||
await transport.sendMail({ ... });
|
||
this.logger.log(`DKV export sent to ${recipient}`);
|
||
} catch (error) {
|
||
// Log but surface to caller for retry logic (unlike MailService which swallows)
|
||
this.logger.error(
|
||
`Failed to send DKV export to ${recipient}`,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw error; // DkvService handles retries with exponential backoff
|
||
}
|
||
```
|
||
|
||
**Dynamic SMTP transport pattern** (RESEARCH.md Pattern 6):
|
||
```typescript
|
||
const transport = nodemailer.createTransport({
|
||
host: smtpConfig.host,
|
||
port: smtpConfig.port,
|
||
secure: smtpConfig.encryption === 'ssl-tls',
|
||
requireTLS: smtpConfig.encryption === 'starttls',
|
||
auth: smtpConfig.username
|
||
? { user: smtpConfig.username, pass: decryptedPassword }
|
||
: undefined,
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/providers/inbox-provider.interface.ts` (interface)
|
||
|
||
**Analog:** `apps/api/src/calendar/calendar.service.ts` — `CalendarProvider` interface (lines 33–44)
|
||
|
||
```typescript
|
||
// Mirror of CalendarProvider pattern — same structure, different method names
|
||
export interface InboxEmail {
|
||
uid: number | string;
|
||
messageId: string;
|
||
subject: string;
|
||
from: string;
|
||
date: Date;
|
||
attachments: InboxAttachment[];
|
||
}
|
||
|
||
export interface InboxAttachment {
|
||
filename: string;
|
||
contentType: string;
|
||
buffer: Buffer;
|
||
}
|
||
|
||
export interface InboxProvider {
|
||
fetchPdfAttachments(config: InboxConfig): Promise<InboxEmail[]>;
|
||
testConnection(config: InboxConfig): Promise<boolean>;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/providers/exchange-inbox.provider.ts` (provider, request-response)
|
||
|
||
**Analog:** `apps/api/src/calendar/providers/exchange.provider.ts` (full file, 228 lines)
|
||
|
||
**Class shell + Logger pattern** (exchange.provider.ts lines 1–48):
|
||
```typescript
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { InboxEmail, InboxProvider } from './inbox-provider.interface';
|
||
|
||
@Injectable()
|
||
export class ExchangeInboxProvider implements InboxProvider {
|
||
private readonly logger = new Logger(ExchangeInboxProvider.name);
|
||
|
||
async fetchPdfAttachments(config: InboxConfig): Promise<InboxEmail[]> {
|
||
try {
|
||
return await this.fetchViaEws(config);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`EWS inbox fetch failed: ${(error as Error).message}`,
|
||
// T-05-13: never include credentials in error
|
||
);
|
||
return [];
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**EWS dynamic import pattern** (exchange.provider.ts lines 154–155):
|
||
```typescript
|
||
const ews: any = await import('ews-javascript-api');
|
||
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
|
||
service.Url = new ews.Uri(config.host);
|
||
service.Credentials = new ews.WebCredentials(config.username, config.password);
|
||
```
|
||
|
||
**Critical difference from ExchangeProvider:** Use `ews.WellKnownFolderName.Inbox` with `service.FindItems()` and `ews.EmailMessage` — NOT `FindAppointments` / `CalendarView` (Pitfall 6).
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/providers/imap.provider.ts` (provider, file-I/O)
|
||
|
||
**Analog:** `apps/api/src/calendar/providers/caldav.provider.ts` (role-match — async provider shell)
|
||
|
||
**Class shell:**
|
||
```typescript
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { ImapFlow } from 'imapflow';
|
||
import { InboxEmail, InboxProvider } from './inbox-provider.interface';
|
||
|
||
@Injectable()
|
||
export class ImapProvider implements InboxProvider {
|
||
private readonly logger = new Logger(ImapProvider.name);
|
||
// ...
|
||
}
|
||
```
|
||
|
||
**Critical imapflow pattern** (RESEARCH.md Pattern 2 + Pitfall 1):
|
||
```typescript
|
||
// NEVER call download() inside fetch() async iterator — causes IMAP deadlock
|
||
// Always: fetchAll() first → loop → download()
|
||
const messages = await client.fetchAll(
|
||
uids.join(','),
|
||
{ envelope: true, bodyStructure: true },
|
||
{ uid: true },
|
||
);
|
||
for (const msg of messages) {
|
||
const { content } = await client.download(String(msg.uid), partId, { uid: true });
|
||
}
|
||
```
|
||
|
||
**Logger suppression** (security — never log IMAP credentials):
|
||
```typescript
|
||
const client = new ImapFlow({
|
||
// ...
|
||
logger: false, // suppress verbose imapflow logs (contain credentials)
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/dkv/dto/dkv-config.dto.ts` (dto)
|
||
|
||
**Analog:** `apps/api/src/calendar/dto/create-calendar-source.dto.ts`
|
||
|
||
**Validation decorator pattern** (lines 1–47):
|
||
```typescript
|
||
import {
|
||
IsBoolean,
|
||
IsEmail,
|
||
IsIn,
|
||
IsInt,
|
||
IsNotEmpty,
|
||
IsOptional,
|
||
IsString,
|
||
Max,
|
||
Min,
|
||
} from 'class-validator';
|
||
|
||
export class DkvConfigDto {
|
||
@IsIn(['imap', 'exchange'])
|
||
protocol!: string;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
host?: string;
|
||
|
||
@IsOptional()
|
||
@IsInt()
|
||
@Min(1)
|
||
@Max(65535)
|
||
port?: number;
|
||
|
||
@IsIn(['none', 'starttls', 'ssl-tls'])
|
||
encryption!: string;
|
||
|
||
@IsOptional()
|
||
@IsString()
|
||
folder?: string;
|
||
|
||
@IsOptional()
|
||
@IsEmail()
|
||
senderFilter?: string; // IsEmail() validator — Pitfall: credential injection
|
||
|
||
@IsOptional()
|
||
@IsEmail()
|
||
exportRecipient?: string;
|
||
|
||
@IsOptional()
|
||
@IsInt()
|
||
@Min(1)
|
||
pollIntervalMin?: number;
|
||
|
||
@IsOptional()
|
||
@IsBoolean()
|
||
isActive?: boolean;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/settings/settings.controller.ts` (controller, request-response)
|
||
|
||
**Analog:** `apps/api/src/ldap/ldap.controller.ts`
|
||
|
||
**Pattern:** Same admin-only guard, same tenant extraction. Routes: `GET /settings/smtp` and `PUT /settings/smtp`.
|
||
|
||
```typescript
|
||
@Controller('settings')
|
||
export class SettingsController {
|
||
constructor(private readonly settingsService: SettingsService) {}
|
||
|
||
@Get('smtp')
|
||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||
async getSmtpConfig(@Req() req: any) {
|
||
const tenantId = req.tenantId;
|
||
if (!tenantId) throw new BadRequestException('No tenant context');
|
||
const config = await this.settingsService.getSmtpConfig(tenantId);
|
||
// Never return decryptedPassword — return hasPassword boolean (T-05-09 pattern)
|
||
return config ? { ...config, encryptedPassword: undefined, hasPassword: !!config.encryptedPassword } : null;
|
||
}
|
||
|
||
@Put('smtp')
|
||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||
async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
|
||
const tenantId = req.tenantId;
|
||
if (!tenantId) throw new BadRequestException('No tenant context');
|
||
return this.settingsService.saveSmtpConfig(tenantId, dto);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx` (component, request-response)
|
||
|
||
**Analog:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx`
|
||
|
||
**Full component pattern** (domaincheck/page.tsx lines 1–64):
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useTranslations } from 'next-intl';
|
||
import { useState } from 'react';
|
||
|
||
export default function DkvFleetPage() {
|
||
const t = useTranslations('dkvFleet');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
return (
|
||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||
<p className="text-sm text-muted-foreground mt-1">{t('description')}</p>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
|
||
{/* InvoiceHistoryTable + manual trigger button */}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Layout:** `mx-auto max-w-5xl space-y-6 p-6` — wider than domaincheck (27 vehicles × 5 columns needs more space).
|
||
|
||
---
|
||
|
||
### `apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx` (component, CRUD)
|
||
|
||
**Analog:** `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx`
|
||
|
||
**Pattern** (calendar/page.tsx lines 1–21):
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useTranslations } from 'next-intl';
|
||
import { InboxConfigForm } from './components/InboxConfigForm';
|
||
import { VehicleTable } from './components/VehicleTable';
|
||
import { CsvImportButton } from './components/CsvImportButton';
|
||
|
||
export default function DkvFleetSettingsPage() {
|
||
const t = useTranslations('dkvFleet');
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||
{t('settingsTitle')}
|
||
</h1>
|
||
<InboxConfigForm />
|
||
<VehicleTable />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/web/src/app/(portal)/settings/general/smtp/page.tsx` (component, CRUD)
|
||
|
||
**Analog:** `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx`
|
||
|
||
This page lives in the settings section — uses `SettingsLayout` (already wraps the route).
|
||
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useTranslations } from 'next-intl';
|
||
|
||
export default function SmtpSettingsPage() {
|
||
const t = useTranslations('settings');
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||
{t('categorySmtp')}
|
||
</h1>
|
||
{/* SmtpConfigForm component */}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Settings sidebar extension** — add to `apps/web/src/components/settings/settings-sidebar.tsx`:
|
||
```typescript
|
||
// Extend items array — add new "Allgemein" category above "Dashboard"
|
||
const generalItems = [
|
||
{ label: t('categorySmtp'), href: '/settings/general/smtp' },
|
||
];
|
||
```
|
||
|
||
---
|
||
|
||
## Shared Patterns
|
||
|
||
### Authentication / Authorization
|
||
**Source:** `apps/api/src/ldap/ldap.controller.ts` lines 38–40
|
||
**Apply to:** All DKV and Settings controller handlers
|
||
```typescript
|
||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||
```
|
||
Global `JwtAuthGuard` is already applied in `main.ts`. `@Roles` decorator adds role check on top.
|
||
|
||
---
|
||
|
||
### Credential Encryption (AES-256-GCM)
|
||
**Source:** `apps/api/src/calendar/crypto.service.ts` (full file, 75 lines)
|
||
**Apply to:** `dkv.service.ts` (inbox creds), `settings.service.ts` (SMTP password)
|
||
```typescript
|
||
// Inject CalendarCryptoService — it handles the key lifecycle
|
||
constructor(private readonly crypto: CalendarCryptoService) {}
|
||
|
||
// Encrypt at save time
|
||
const encrypted = this.crypto.encrypt(JSON.stringify({ username, password }));
|
||
|
||
// Decrypt at use time — NEVER log result (T-05-13)
|
||
const creds = JSON.parse(this.crypto.decrypt(encrypted));
|
||
```
|
||
|
||
---
|
||
|
||
### Tenant Context Extraction
|
||
**Source:** `apps/api/src/ldap/ldap.controller.ts` lines 40–47
|
||
**Apply to:** All DKV and Settings controller handlers
|
||
```typescript
|
||
const tenantId = req.tenantId;
|
||
if (!tenantId) {
|
||
throw new BadRequestException('No tenant context');
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Generic Error Logging (no credential exposure)
|
||
**Source:** `apps/api/src/calendar/providers/exchange.provider.ts` lines 42–48
|
||
**Apply to:** `dkv-mail.service.ts`, `imap.provider.ts`, `exchange-inbox.provider.ts`, `dkv-scheduler.service.ts`
|
||
```typescript
|
||
this.logger.error(
|
||
`Operation failed for tenant ${tenantId}: ${(error as Error).message}`,
|
||
// T-05-13: message only — never include stack with potential credential details
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
### NestJS Logger
|
||
**Source:** `apps/api/src/calendar/providers/exchange.provider.ts` line 15
|
||
**Apply to:** All new `@Injectable()` services and providers
|
||
```typescript
|
||
private readonly logger = new Logger(ClassName.name);
|
||
```
|
||
|
||
---
|
||
|
||
### Safe API Response (no encrypted fields)
|
||
**Source:** `apps/api/src/calendar/calendar.service.ts` lines 50–67
|
||
**Apply to:** `dkv.service.ts` (config), `settings.service.ts` (SMTP config)
|
||
|
||
Define a `const X_SAFE_SELECT` Prisma select object that explicitly excludes `encryptedInboxCreds` / `encryptedPassword`. Never return these fields to the frontend.
|
||
|
||
---
|
||
|
||
### Frontend `'use client'` + `useTranslations` shell
|
||
**Source:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` lines 1–7
|
||
**Apply to:** All new Next.js page and component files
|
||
```typescript
|
||
'use client';
|
||
|
||
import { useTranslations } from 'next-intl';
|
||
import { useState } from 'react';
|
||
```
|
||
|
||
---
|
||
|
||
### Frontend Card Layout
|
||
**Source:** `apps/web/src/app/(portal)/modules/domaincheck/page.tsx` lines 40–64
|
||
**Apply to:** `dkv-fleet/page.tsx`, settings form components
|
||
```typescript
|
||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
|
||
{/* content */}
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
## No Analog Found
|
||
|
||
| File | Role | Data Flow | Reason |
|
||
|------|------|-----------|--------|
|
||
| `apps/api/src/dkv/dkv-export.service.ts` | service | file-I/O | No file-writing service exists in codebase (first file-I/O service) |
|
||
| `apps/api/src/dkv/dkv-parser.service.ts` | service | transform | No binary-parsing / regex transform service exists (novel pattern) |
|
||
|
||
For these files, use RESEARCH.md Patterns 3–5 (pdf-parse v2, DKV regex, SheetJS) as primary reference. Wrap in standard `@Injectable()` NestJS service shell from the shared pattern above.
|
||
|
||
---
|
||
|
||
## AppModule Modification
|
||
|
||
**File:** `apps/api/src/app.module.ts`
|
||
**Required change:** Add `ScheduleModule.forRoot()` (NOT imported yet — confirmed by codebase grep) and `DkvModule`:
|
||
|
||
```typescript
|
||
import { ScheduleModule } from '@nestjs/schedule';
|
||
import { DkvModule } from './dkv/dkv.module';
|
||
import { SettingsModule } from './settings/settings.module';
|
||
|
||
@Module({
|
||
imports: [
|
||
// ... existing imports ...
|
||
ScheduleModule.forRoot(), // prerequisite for DkvSchedulerService (Pitfall 4)
|
||
DkvModule,
|
||
SettingsModule,
|
||
],
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Analog search scope:** `apps/api/src/`, `apps/web/src/app/(portal)/`
|
||
**Files scanned:** 21 existing source files
|
||
**Pattern extraction date:** 2026-06-26
|