diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts index cff7ae9..25f49d5 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts @@ -6,11 +6,12 @@ import { CashboxExportSubscriptionService } from './cashbox-export-subscription. describe('CashboxExportSubscriptionService', () => { const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) }; const access = { assertAtLeast: jest.fn() }; + const logger = { info: jest.fn() }; let service: CashboxExportSubscriptionService; beforeEach(() => { jest.clearAllMocks(); - service = new CashboxExportSubscriptionService(repository as any, access as any); + service = new CashboxExportSubscriptionService(repository as any, access as any, logger as any); }); it('returns a paused default when no subscription exists yet', async () => { @@ -78,6 +79,11 @@ describe('CashboxExportSubscriptionService', () => { nextRunDate: '2026-09-01T00:00:00.000Z', }), ); + expect(logger.info).toHaveBeenCalledWith({ + event: 'cashbox_export_subscription_update', + details: 'teamId=5 active=true interval=monthly recipients=1', + userId: 42, + }); jest.useRealTimers(); }); diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts index 875a4a1..c3320c3 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { LoggingService } from 'src/database/logging/logging.service'; import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; import { TeamAccessService } from 'src/teams/team-access.service'; import { Repository } from 'typeorm'; @@ -20,6 +21,7 @@ export class CashboxExportSubscriptionService { @InjectRepository(CashboxExportSubscription) private readonly repository: Repository, private readonly access: TeamAccessService, + private readonly logger: LoggingService, ) {} async getSubscription( @@ -67,6 +69,11 @@ export class CashboxExportSubscriptionService { } const saved = await this.repository.save(entity); + await this.logger.info({ + event: 'cashbox_export_subscription_update', + details: `teamId=${teamId} active=${dto.active} interval=${dto.interval} recipients=${dto.recipients.length}`, + userId, + }); return this.toResponse(saved); } diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts index 7ca0d20..a51d89d 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts @@ -67,6 +67,24 @@ describe('cashbox export HTTP boundary', () => { expect(service.exportForUser).not.toHaveBeenCalled(); }); + it('rejects a full ISO datetime instead of a plain YYYY-MM-DD date for from', async () => { + await request(app.getHttpServer()) + .get( + '/api/v1/cashbox-export/5?from=2026-08-01T12:00:00Z&to=2026-08-31&format=csv', + ) + .set('Authorization', 'Bearer user') + .expect(422); + expect(service.exportForUser).not.toHaveBeenCalled(); + }); + + it('rejects a malformed date string for to', async () => { + await request(app.getHttpServer()) + .get('/api/v1/cashbox-export/5?from=2026-08-01&to=not-a-date&format=csv') + .set('Authorization', 'Bearer user') + .expect(422); + expect(service.exportForUser).not.toHaveBeenCalled(); + }); + it('returns a CSV file with correct headers and content', async () => { const csvBuffer = Buffer.from('Datum;Typ;Wer;Notiz;Betrag;Periodensaldo', 'utf-8'); service.exportForUser.mockResolvedValue({ diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts index 9d433f3..8fb1dbd 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts @@ -1,10 +1,11 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { CashboxExportService } from './cashbox-export.service'; describe('CashboxExportService', () => { const teamRepository = { findOne: jest.fn() }; const access = { assertAtLeast: jest.fn() }; + const logger = { info: jest.fn() }; let service: CashboxExportService; let callOrder: string[]; @@ -31,7 +32,7 @@ describe('CashboxExportService', () => { return team; }); - service = new CashboxExportService(teamRepository as any, access as any); + service = new CashboxExportService(teamRepository as any, access as any, logger as any); }); it('checks permission before loading data', async () => { @@ -69,6 +70,15 @@ describe('CashboxExportService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); + it('rejects a range where from is after to without querying the team', async () => { + await expect( + service.exportForUser(5, 42, '2026-08-31', '2026-08-01', 'csv'), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(access.assertAtLeast).not.toHaveBeenCalled(); + expect(teamRepository.findOne).not.toHaveBeenCalled(); + }); + it('builds a CSV buffer with the correct content type and filename', async () => { const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv'); @@ -84,4 +94,14 @@ describe('CashboxExportService', () => { expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf'); expect(result.buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-'); }); + + it('logs a cashbox_export_download event after a successful export', async () => { + await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv'); + + expect(logger.info).toHaveBeenCalledWith({ + event: 'cashbox_export_download', + details: 'teamId=5 format=csv from=2026-08-01 to=2026-08-31', + userId: 42, + }); + }); }); diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts index d377acb..7a1c75d 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts @@ -1,5 +1,6 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { LoggingService } from 'src/database/logging/logging.service'; import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; import { Team } from 'src/teams/entities/team.entity'; import { TeamAccessService } from 'src/teams/team-access.service'; @@ -12,6 +13,7 @@ export class CashboxExportService { @InjectRepository(Team) private readonly teamRepository: Repository, private readonly access: TeamAccessService, + private readonly logger: LoggingService, ) {} async exportForUser( @@ -21,6 +23,11 @@ export class CashboxExportService { to: string, format: 'csv' | 'pdf', ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { + if (from > to) { + throw new BadRequestException( + 'Der Startzeitraum darf nicht nach dem Endzeitraum liegen.', + ); + } await this.access.assertAtLeast( userId, teamId, @@ -36,16 +43,28 @@ export class CashboxExportService { const rows = buildRows(team, from, to); if (format === 'csv') { - return { + const result = { buffer: Buffer.from(buildCsv(rows), 'utf-8'), contentType: 'text/csv; charset=utf-8', filename: `kassenbuch_${team.alias}_${from}_${to}.csv`, }; + await this.logger.info({ + event: 'cashbox_export_download', + details: `teamId=${teamId} format=${format} from=${from} to=${to}`, + userId, + }); + return result; } - return { + const result = { buffer: await buildPdf(team, rows, from, to), contentType: 'application/pdf', filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`, }; + await this.logger.info({ + event: 'cashbox_export_download', + details: `teamId=${teamId} format=${format} from=${from} to=${to}`, + userId, + }); + return result; } } diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts index 874b299..3ce5886 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts @@ -1,6 +1,5 @@ import { Team } from 'src/teams/entities/team.entity'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const PDFDocument = require('pdfkit'); +import PDFDocument = require('pdfkit'); export interface CashboxExportRow { date: string; diff --git a/myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts b/myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts index 4fadbd6..78d2dae 100644 --- a/myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts +++ b/myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts @@ -1,10 +1,10 @@ -import { IsDateString, IsIn } from 'class-validator'; +import { IsIn, Matches } from 'class-validator'; export class CashboxExportQueryDto { - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) from: string; - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) to: string; @IsIn(['csv', 'pdf']) diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts index 0788ec5..f9058b3 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts @@ -30,7 +30,7 @@ const rangeValid: ValidatorFn = (group): ValidationErrors | null => {
@if (downloadError()) { -

{{ downloadError() }}

+ } Von @@ -54,6 +54,14 @@ const rangeValid: ValidatorFn = (group): ValidationErrors | null => { `, + styles: ` + .error-message { + padding: 12px 16px; + border-radius: 12px; + color: var(--mat-sys-error); + background: var(--mat-sys-error-container); + } + `, }) export class CashboxExportDialog { protected readonly dialogRef = inject(MatDialogRef); diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.html b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.html index 9e076b5..09197c8 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.html +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.html @@ -1,10 +1,10 @@

Automatischen Versand einrichten

@if (loadError()) { -

{{ loadError() }}

+ } @if (saveError()) { -

{{ saveError() }}

+ } E-Mail-Adresse hinzufügen diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.scss b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.scss new file mode 100644 index 0000000..6c06def --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.scss @@ -0,0 +1,6 @@ +.error-message { + padding: 12px 16px; + border-radius: 12px; + color: var(--mat-sys-error); + background: var(--mat-sys-error-container); +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts index 904a062..a02d030 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts @@ -27,6 +27,7 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; MatSlideToggleModule, ], templateUrl: './cashbox-export-subscription-dialog.html', + styleUrl: './cashbox-export-subscription-dialog.scss', }) export class CashboxExportSubscriptionDialog { protected readonly dialogRef = inject(MatDialogRef); diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts index d2e7063..003c5a5 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts @@ -8,6 +8,7 @@ import { PenaltyApi } from '../../../core/team/penalty-api'; import { TeamStore } from '../../../core/team/team-store'; import { TransactionsApi } from '../../../core/team/transactions-api'; import { Cashbox } from './cashbox'; +import { CashboxExportDialog } from './cashbox-export-dialog/cashbox-export-dialog'; describe('Cashbox', () => { const team = { @@ -292,7 +293,7 @@ describe('Cashbox', () => { exportButton?.click(); expect(dialog.open).toHaveBeenCalledWith( - expect.anything(), + CashboxExportDialog, expect.objectContaining({ data: { teamId: expect.any(Number) } }), ); });