Files
tessera-ctl/apps/api/src/dkv/dkv.controller.ts
T
schalli c2ebc8daa0 feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
- Proxmox-Schreibwege und Handelsware-Einstellungen auf @ModuleManage umgestellt
- DKV-Fleet: ganze Klasse Verwalten-Stufe, Benutzen allein bleibt ohne Zugriff
- Metadaten-Test belegt umgestellte und bewusst Administratoren vorbehaltene Handler
- Webseiten (Proxmox, Handelsware, Widget) folgen canManage, DKV-Zugriffsseite erklärt die Stufe

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:31:08 +02:00

243 lines
9.4 KiB
TypeScript

import {
BadRequestException,
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ModuleManage } from '../module-registry/module.guard';
import type {
AuthenticatedRequest,
UploadedFileLike,
} from '../auth/types/auth-user';
import type { Response } from 'express';
import { DkvSchedulerService } from './dkv-scheduler.service';
import { DkvService } from './dkv.service';
import { DkvConfigDto } from './dto/dkv-config.dto';
import { DkvHistoryQueryDto } from './dto/dkv-history.dto';
import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
/**
* DkvController — all /dkv/* routes, manager level (V4, 261002-icv).
*
* The whole module is Verwalten-level: `@ModuleManage('dkv-fleet')` on the
* class replaces the former per-handler @Roles(ADMIN, SUPER_ADMIN). Access is
* therefore limited to administrators and to users with the grant level
* "Verwalten" (MANAGE) on the dkv-fleet module. Users with only "Benutzen"
* (USE) keep getting 403 exactly as before — nothing was widened. The class
* guard additionally requires the dkv-fleet module to be active for the
* tenant (the web page already required that).
* Global JwtAuthGuard enforces JWT authentication; ModuleGuard enforces the
* grant level. No route is publicly accessible.
*
* Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards).
* All operations are scoped to the authenticated tenant's data.
*
* Routes:
* GET /dkv/config — get module config (no encrypted creds)
* PUT /dkv/config — save module config; updates scheduler
* POST /dkv/check-now — manual inbox poll trigger
* POST /dkv/test-connection — test inbox connection with form values
* GET /dkv/history — paginated processing history
* GET /dkv/exports/:filename — download a saved xlsx file
* GET /dkv/vehicles — list vehicle master records
* POST /dkv/vehicles — create a vehicle master record
* PUT /dkv/vehicles/:id — update a vehicle master record
* DELETE /dkv/vehicles/:id — delete a vehicle master record
* POST /dkv/vehicles/import — bulk-import from CSV upload
*/
@Controller('dkv')
@ModuleManage('dkv-fleet')
export class DkvController {
constructor(
private readonly dkvService: DkvService,
private readonly dkvScheduler: DkvSchedulerService,
) {}
// ─── Config ────────────────────────────────────────────────────────────────
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
@Get('config')
async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
const config = await this.dkvService.getConfigForApi(tenantId);
if (!config) {
throw new NotFoundException('DKV module not yet configured');
}
return config;
}
/**
* PUT /dkv/config — upsert module config.
*
* After saving, re-applies the cron job if isActive is true,
* or stops the cron job if isActive is false.
*/
@Put('config')
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
const result = await this.dkvService.saveConfig(tenantId, dto);
// Update scheduler to reflect the new interval / active state
if (dto.isActive && dto.pollIntervalMin) {
this.dkvScheduler.setInterval(dto.pollIntervalMin, tenantId);
} else if (dto.isActive === false) {
this.dkvScheduler.stopJob(tenantId);
}
return result;
}
// ─── Manual trigger + connection test ──────────────────────────────────────
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */
@Post('check-now')
async checkNow(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.checkNow(tenantId);
}
/**
* POST /dkv/test-connection — test inbox connection with current form values.
* Used by the InboxConfigForm "Verbindung testen" button before saving.
*/
@Post('test-connection')
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.testConnection(tenantId, dto);
}
// ─── History ───────────────────────────────────────────────────────────────
/**
* GET /dkv/history?page=1&limit=20 — paginated processing history.
* T-07-06: pagination parameters validated by DkvHistoryQueryDto.
*/
@Get('history')
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
const tenantId = this._requireTenant(req);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
return this.dkvService.getHistory(tenantId, page, limit);
}
// ─── Export file download ──────────────────────────────────────────────────
/**
* GET /dkv/exports/:filename — stream a DKV xlsx export file as an attachment.
*
* T-07-09: DkvService.getExportFile() validates the filename against the
* `DKV_*.xlsx` whitelist pattern before reading from user-files/. Any filename
* containing path separators or non-whitelisted characters is rejected.
*/
@Get('exports/:filename')
async downloadExport(
@Req() req: AuthenticatedRequest,
@Param('filename') filename: string,
@Res() res: Response,
) {
const tenantId = this._requireTenant(req);
try {
const buffer = await this.dkvService.getExportFile(tenantId, filename);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.send(buffer);
} catch (error) {
if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error;
}
throw error;
}
}
// ─── Vehicle Master CRUD ───────────────────────────────────────────────────
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
@Get('vehicles')
async listVehicles(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.listVehicles(tenantId);
}
/** POST /dkv/vehicles — create a new vehicle master record. */
@Post('vehicles')
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.createVehicle(tenantId, dto);
}
/** PUT /dkv/vehicles/:id — update an existing vehicle master record. */
@Put('vehicles/:id')
async updateVehicle(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateVehicleDto,
) {
const tenantId = this._requireTenant(req);
return this.dkvService.updateVehicle(tenantId, id, dto);
}
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
@Delete('vehicles/:id')
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this._requireTenant(req);
return this.dkvService.deleteVehicle(tenantId, id);
}
/**
* POST /dkv/vehicles/import — bulk import from a CSV file upload.
*
* Accepts a multipart form with:
* - `file`: the CSV file (field name must be "file")
* - `mode`: 'merge' (default) or 'replace'
*
* FileInterceptor buffers the upload in memory (no disk write).
* The controller reads `file.buffer.toString('utf-8')` and passes to DkvService.
*/
@Post('vehicles/import')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
}))
async importVehicles(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
@Body('mode') mode: string,
) {
const tenantId = this._requireTenant(req);
if (!file?.buffer) {
throw new BadRequestException('No CSV file uploaded (field name must be "file")');
}
const csvText = file.buffer.toString('utf-8');
const importMode = mode === 'replace' ? 'replace' : 'merge';
return this.dkvService.importVehiclesCsv(tenantId, csvText, importMode);
}
// ─── Private helpers ───────────────────────────────────────────────────────
/** Extract and validate tenantId from request; throw BadRequestException when absent. */
private _requireTenant(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new BadRequestException('No tenant context');
}
return tenantId;
}
}