import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Post, Put, Req, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { decodeUploadFilename } from '../accounting/decode-upload-filename'; import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user'; import { ModuleManage, UseModule } from '../module-registry/module.guard'; import { HandelswareAccountDto } from './dto/handelsware-account.dto'; import { HandelswareSettingsDto } from './dto/handelsware-settings.dto'; import { HandelswareDatevService } from './handelsware-datev.service'; const MAX_NEW_ACCOUNTS = 10_000; /** * Das Formularfeld `newAccounts` ist ein JSON-Text (Liste der von der Vorschau * gemeldeten neuen Konten). Defensiv gelesen: gueltiges JSON, ein Feld, hoechstens * 10 000 Eintraege, jeder mit Text-`name` und ganzzahligem `gegenkonto`. */ export function parseNewAccountsField(raw: unknown): { name: string; gegenkonto: number }[] { const bad = () => new BadRequestException({ code: 'newAccountsInvalid', message: 'Die Angaben zu den neuen Konten sind ungültig.', }); if (raw === undefined || raw === null || raw === '') return []; if (typeof raw !== 'string') throw bad(); let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw bad(); } if (!Array.isArray(parsed) || parsed.length > MAX_NEW_ACCOUNTS) throw bad(); return parsed.map((entry) => { if ( typeof entry !== 'object' || entry === null || typeof (entry as { name?: unknown }).name !== 'string' || !Number.isInteger((entry as { gegenkonto?: unknown }).gegenkonto) ) { throw bad(); } const { name, gegenkonto } = entry as { name: string; gegenkonto: number }; return { name, gegenkonto }; }); } /** * `@UseModule('handelsware-datev')` auf Klassenebene — Aktivierung UND Freigabe. * `tenantId` kommt ausschliesslich aus `req.tenantId`. Die Einstellungen aendern * Administratoren und Benutzer mit der Freigabestufe Verwalten * (`@ModuleManage`, 261002-icv; T-FM5-02); die Kontenliste pflegen alle Benutzer mit * Modulzugriff. * * REIHENFOLGE: alle statischen Routen (`accounts`, `accounts/export-csv`, * `accounts/import-csv`) stehen VOR `accounts/:id` — sonst faengt `:id` sie ab * (Unit-Tests sehen das nicht, `handelsware-datev.controller.spec.ts` prueft die * Deklarationsreihenfolge). */ @Controller('modules/handelsware-datev') @UseModule('handelsware-datev') export class HandelswareDatevController { constructor(private readonly service: HandelswareDatevService) {} private requireTenantId(req: AuthenticatedRequest): string { const tenantId = req.tenantId; if (!tenantId) { throw new ForbiddenException('Kein Mandantenkontext'); } return tenantId; } @Get('settings') async getSettings(@Req() req: AuthenticatedRequest) { return this.service.getSettings(this.requireTenantId(req)); } @Put('settings') @ModuleManage('handelsware-datev') async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareSettingsDto) { return this.service.saveSettings(this.requireTenantId(req), dto); } @Post('preview') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } })) async preview( @Req() req: AuthenticatedRequest, @UploadedFile() file: UploadedFileLike | undefined, ) { const tenantId = this.requireTenantId(req); if (!file) { throw new BadRequestException('Keine Datei hochgeladen'); } return this.service.preview(tenantId, { buffer: file.buffer, originalname: decodeUploadFilename(file.originalname), }); } @Post('export') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } })) async export( @Req() req: AuthenticatedRequest, @UploadedFile() file: UploadedFileLike | undefined, @Body('buchungsdatum') buchungsdatum?: string, @Body('newAccounts') newAccounts?: string, ) { const tenantId = this.requireTenantId(req); if (!file) { throw new BadRequestException('Keine Datei hochgeladen'); } return this.service.export( tenantId, { buffer: file.buffer, originalname: decodeUploadFilename(file.originalname) }, typeof buchungsdatum === 'string' ? buchungsdatum.trim() : '', parseNewAccountsField(newAccounts), ); } @Get('accounts') async listAccounts(@Req() req: AuthenticatedRequest) { return this.service.listAccounts(this.requireTenantId(req)); } @Post('accounts') async createAccount(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareAccountDto) { return this.service.createAccount(this.requireTenantId(req), dto); } @Get('accounts/export-csv') async exportAccountsCsv(@Req() req: AuthenticatedRequest) { return this.service.exportAccountsCsv(this.requireTenantId(req)); } @Post('accounts/import-csv') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 1024 * 1024 } })) async importAccountsCsv( @Req() req: AuthenticatedRequest, @UploadedFile() file: UploadedFileLike | undefined, ) { const tenantId = this.requireTenantId(req); if (!file) { throw new BadRequestException('Keine Datei hochgeladen'); } return this.service.importAccountsCsv(tenantId, file.buffer); } @Put('accounts/:id') async updateAccount( @Req() req: AuthenticatedRequest, @Param('id') id: string, @Body() dto: HandelswareAccountDto, ) { return this.service.updateAccount(this.requireTenantId(req), id, dto); } @Delete('accounts/:id') async deleteAccount(@Req() req: AuthenticatedRequest, @Param('id') id: string) { return this.service.deleteAccount(this.requireTenantId(req), id); } }