docs(07): create phase 7 execution plans for DKV fleet module
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>
This commit is contained in:
@@ -0,0 +1,829 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user