From f1b4f7e5b4fb7938706b136af7d83c4ea0ca4f3e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 4 Aug 2026 16:21:39 +0200 Subject: [PATCH] feat: add admin log viewer, log retention cleanup, and manual job triggers Global admins couldn't see the app's event log (no read endpoint or UI existed for it) and had no way to clean up old entries or re-run a scheduled job without touching the database or server directly. Backend: - LoggingService.findLogs() + admin-only LogsController (GET admin/logs) with level/event/date-range/search filtering and pagination, mirroring AdminUsersService.findPlayers(). - LogRetentionScheduler deletes log entries older than LOG_RETENTION_DAYS (default 365, via app.config.ts), following the existing @Cron scheduler pattern. - Admin-only POST admin/run endpoints on CashboxExportController and RecurringTransactionsController that invoke the existing schedulers' public run methods on demand - both are safe to re-run since their "due" queries advance nextRunDate only after a successful run. Frontend: - New /logs page (global-admin gated, same pattern as /users): AG-Grid infinite-scroll table with level/event/date-range/search filters, plus buttons to trigger the two jobs now and see the result land in the grid immediately. - LogsApi, and triggerRunNow() added to the existing CashboxExportApi and RecurringTransactionApi. - Discoverability link from /users to /logs. Co-Authored-By: Claude Sonnet 5 --- env-example | 1 + .../cashbox-export.controller.spec.ts | 32 +++ .../cashbox-export.controller.ts | 16 ++ .../cashbox-export.http.spec.ts | 3 + myteamwallet_backend/src/config/app.config.ts | 1 + .../logging/dto/admin-log-query.dto.ts | 38 +++ .../logging/log-retention.scheduler.spec.ts | 52 ++++ .../logging/log-retention.scheduler.ts | 32 +++ .../src/database/logging/logging.module.ts | 5 +- .../database/logging/logging.service.spec.ts | 93 +++++++ .../src/database/logging/logging.service.ts | 41 ++- .../database/logging/logs.controller.spec.ts | 66 +++++ .../src/database/logging/logs.controller.ts | 21 ++ .../logging/model/logging-event.type.ts | 46 +++- .../recurring-transactions.controller.spec.ts | 38 +++ .../recurring-transactions.controller.ts | 17 +- .../recurring-transactions.http.spec.ts | 3 + .../src/app/app.routes.ts | 5 + .../src/app/core/logs/logs-api.spec.ts | 46 ++++ .../src/app/core/logs/logs-api.ts | 26 ++ .../app/core/team/cashbox-export-api.spec.ts | 7 + .../src/app/core/team/cashbox-export-api.ts | 4 + .../team/recurring-transaction-api.spec.ts | 7 + .../core/team/recurring-transaction-api.ts | 4 + .../src/app/features/logs/logs.html | 93 +++++++ .../src/app/features/logs/logs.scss | 90 +++++++ .../src/app/features/logs/logs.spec.ts | 151 +++++++++++ .../src/app/features/logs/logs.ts | 241 ++++++++++++++++++ .../src/app/features/users/users.html | 3 + .../src/app/models/log.model.ts | 29 +++ 30 files changed, 1207 insertions(+), 4 deletions(-) create mode 100644 myteamwallet_backend/src/cashbox-export/cashbox-export.controller.spec.ts create mode 100644 myteamwallet_backend/src/database/logging/dto/admin-log-query.dto.ts create mode 100644 myteamwallet_backend/src/database/logging/log-retention.scheduler.spec.ts create mode 100644 myteamwallet_backend/src/database/logging/log-retention.scheduler.ts create mode 100644 myteamwallet_backend/src/database/logging/logs.controller.spec.ts create mode 100644 myteamwallet_backend/src/database/logging/logs.controller.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/logs/logs-api.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts create mode 100644 myteamwallet_frontend_modern/src/app/features/logs/logs.html create mode 100644 myteamwallet_frontend_modern/src/app/features/logs/logs.scss create mode 100644 myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/features/logs/logs.ts create mode 100644 myteamwallet_frontend_modern/src/app/models/log.model.ts diff --git a/env-example b/env-example index 33c1c72..721f214 100644 --- a/env-example +++ b/env-example @@ -4,6 +4,7 @@ APP_NAME="NestJS API" API_PREFIX=api FRONTEND_DOMAIN=http://localhost:3000 BACKEND_DOMAIN=http://localhost:3000 +LOG_RETENTION_DAYS=365 DATABASE_TYPE=postgres DATABASE_HOST=postgres diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.spec.ts new file mode 100644 index 0000000..5d66486 --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.spec.ts @@ -0,0 +1,32 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { RoleEnum } from '../roles/roles.enum'; +import { RolesGuard } from '../roles/roles.guard'; +import { CashboxExportController } from './cashbox-export.controller'; + +describe('CashboxExportController.runDueSubscriptionsNow', () => { + const service = { exportForUser: jest.fn() }; + const subscriptionService = { getSubscription: jest.fn(), upsertSubscription: jest.fn() }; + const scheduler = { runDueSubscriptions: jest.fn() }; + const controller = new CashboxExportController( + service as any, + subscriptionService as any, + scheduler as any, + ); + + beforeEach(() => jest.clearAllMocks()); + + it('is guarded by the global admin role', () => { + expect( + Reflect.getMetadata('roles', CashboxExportController.prototype.runDueSubscriptionsNow), + ).toEqual([RoleEnum.admin]); + expect( + Reflect.getMetadata(GUARDS_METADATA, CashboxExportController.prototype.runDueSubscriptionsNow), + ).toContain(RolesGuard); + }); + + it('delegates to the scheduler', async () => { + await controller.runDueSubscriptionsNow(); + + expect(scheduler.runDueSubscriptions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts index 72587f1..90d901b 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts @@ -2,8 +2,11 @@ import { Body, Controller, Get, + HttpCode, + HttpStatus, Param, ParseIntPipe, + Post, Put, Query, Request, @@ -13,8 +16,12 @@ import { import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; import type { Response } from 'express'; +import { Roles } from '../roles/roles.decorator'; +import { RoleEnum } from '../roles/roles.enum'; +import { RolesGuard } from '../roles/roles.guard'; import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto'; import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto'; +import { CashboxExportScheduler } from './cashbox-export.scheduler'; import { CashboxExportService } from './cashbox-export.service'; import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service'; @@ -27,6 +34,7 @@ export class CashboxExportController { constructor( private readonly service: CashboxExportService, private readonly subscriptionService: CashboxExportSubscriptionService, + private readonly scheduler: CashboxExportScheduler, ) {} @Get(':teamId') @@ -66,4 +74,12 @@ export class CashboxExportController { ) { return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto); } + + @Post('admin/run') + @HttpCode(HttpStatus.OK) + @UseGuards(RolesGuard) + @Roles([RoleEnum.admin]) + runDueSubscriptionsNow(): Promise { + return this.scheduler.runDueSubscriptions(); + } } 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 a51d89d..041c03d 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts @@ -10,6 +10,7 @@ import * as request from 'supertest'; import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum'; import validationOptions from '../utils/validation-options'; import { CashboxExportController } from './cashbox-export.controller'; +import { CashboxExportScheduler } from './cashbox-export.scheduler'; import { CashboxExportService } from './cashbox-export.service'; import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service'; @@ -22,6 +23,7 @@ describe('cashbox export HTTP boundary', () => { getSubscription: jest.fn(), upsertSubscription: jest.fn(), }; + const scheduler = { runDueSubscriptions: jest.fn() }; beforeAll(async () => { const module = await Test.createTestingModule({ @@ -29,6 +31,7 @@ describe('cashbox export HTTP boundary', () => { providers: [ { provide: CashboxExportService, useValue: service }, { provide: CashboxExportSubscriptionService, useValue: subscriptionService }, + { provide: CashboxExportScheduler, useValue: scheduler }, ], }) .overrideGuard(AuthGuard('jwt')) diff --git a/myteamwallet_backend/src/config/app.config.ts b/myteamwallet_backend/src/config/app.config.ts index b02abf3..e5c7f43 100644 --- a/myteamwallet_backend/src/config/app.config.ts +++ b/myteamwallet_backend/src/config/app.config.ts @@ -8,4 +8,5 @@ export default registerAs('app', () => ({ backendDomain: process.env.BACKEND_DOMAIN, port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000, apiPrefix: process.env.API_PREFIX || 'api', + logRetentionDays: parseInt(process.env.LOG_RETENTION_DAYS, 10) || 365, })); diff --git a/myteamwallet_backend/src/database/logging/dto/admin-log-query.dto.ts b/myteamwallet_backend/src/database/logging/dto/admin-log-query.dto.ts new file mode 100644 index 0000000..1abc1f7 --- /dev/null +++ b/myteamwallet_backend/src/database/logging/dto/admin-log-query.dto.ts @@ -0,0 +1,38 @@ +import { Type } from 'class-transformer'; +import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { LOGEVENT, LOGEVENT_VALUES, LOGLEVEL, LOGLEVEL_VALUES } from '../model/logging-event.type'; + +export class AdminLogQueryDto { + @IsOptional() + @IsIn(LOGLEVEL_VALUES) + level?: LOGLEVEL; + + @IsOptional() + @IsIn(LOGEVENT_VALUES) + event?: LOGEVENT; + + @IsOptional() + @IsDateString() + from?: string; + + @IsOptional() + @IsDateString() + to?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit = 50; +} diff --git a/myteamwallet_backend/src/database/logging/log-retention.scheduler.spec.ts b/myteamwallet_backend/src/database/logging/log-retention.scheduler.spec.ts new file mode 100644 index 0000000..b3382ca --- /dev/null +++ b/myteamwallet_backend/src/database/logging/log-retention.scheduler.spec.ts @@ -0,0 +1,52 @@ +import { LessThan } from 'typeorm'; +import { LogRetentionScheduler } from './log-retention.scheduler'; + +describe('LogRetentionScheduler', () => { + const repository = { delete: jest.fn() }; + const configService = { get: jest.fn() }; + const logger = { info: jest.fn() }; + let scheduler: LogRetentionScheduler; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2026-08-04T12:00:00.000Z')); + configService.get.mockReturnValue(365); + repository.delete.mockResolvedValue({ affected: 3 }); + scheduler = new LogRetentionScheduler(repository as any, configService as any, logger as any); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('deletes log entries older than the configured retention window', async () => { + await scheduler.cleanupOldLogs(); + + expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays'); + expect(repository.delete).toHaveBeenCalledWith({ + createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')), + }); + }); + + it('uses whatever retention window the config reports', async () => { + configService.get.mockReturnValue(30); + + await scheduler.cleanupOldLogs(); + + expect(repository.delete).toHaveBeenCalledWith({ + createdAt: LessThan(new Date('2026-07-05T12:00:00.000Z')), + }); + }); + + it('logs the number of deleted entries', async () => { + repository.delete.mockResolvedValue({ affected: 7 }); + + await scheduler.cleanupOldLogs(); + + expect(logger.info).toHaveBeenCalledWith({ + event: 'log_retention_cleanup_run', + details: 'deletedCount=7 retentionDays=365', + userId: -1, + }); + }); +}); diff --git a/myteamwallet_backend/src/database/logging/log-retention.scheduler.ts b/myteamwallet_backend/src/database/logging/log-retention.scheduler.ts new file mode 100644 index 0000000..e5eb333 --- /dev/null +++ b/myteamwallet_backend/src/database/logging/log-retention.scheduler.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThan, Repository } from 'typeorm'; +import { LogEntry } from './entities/log-entry.entity'; +import { LoggingService } from './logging.service'; + +@Injectable() +export class LogRetentionScheduler { + constructor( + @InjectRepository(LogEntry) + private readonly repository: Repository, + private readonly configService: ConfigService, + private readonly logger: LoggingService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_4AM) + async cleanupOldLogs(): Promise { + const retentionDays = this.configService.get('app.logRetentionDays'); + const cutoff = new Date(); + cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays); + + const result = await this.repository.delete({ createdAt: LessThan(cutoff) }); + + await this.logger.info({ + event: 'log_retention_cleanup_run', + details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`, + userId: -1, + }); + } +} diff --git a/myteamwallet_backend/src/database/logging/logging.module.ts b/myteamwallet_backend/src/database/logging/logging.module.ts index fe3f589..7e0b429 100644 --- a/myteamwallet_backend/src/database/logging/logging.module.ts +++ b/myteamwallet_backend/src/database/logging/logging.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { LogEntry } from './entities/log-entry.entity'; +import { LogRetentionScheduler } from './log-retention.scheduler'; import { LoggingService } from './logging.service'; +import { LogsController } from './logs.controller'; @Module({ imports: [TypeOrmModule.forFeature([LogEntry])], - providers: [LoggingService], + controllers: [LogsController], + providers: [LoggingService, LogRetentionScheduler], exports: [LoggingService], }) export class LoggingModule {} diff --git a/myteamwallet_backend/src/database/logging/logging.service.spec.ts b/myteamwallet_backend/src/database/logging/logging.service.spec.ts index 54ef725..515e238 100644 --- a/myteamwallet_backend/src/database/logging/logging.service.spec.ts +++ b/myteamwallet_backend/src/database/logging/logging.service.spec.ts @@ -23,3 +23,96 @@ describe('LoggingService', () => { expect(defaultRepository.save).not.toHaveBeenCalled(); }); }); + +describe('LoggingService.findLogs', () => { + let rows: any[]; + let total: number; + let query: any; + let repository: any; + let service: LoggingService; + + beforeEach(() => { + rows = []; + total = 0; + query = chain({ + getMany: jest.fn(() => rows), + getCount: jest.fn(() => total), + }); + repository = { + createQueryBuilder: jest.fn(() => query), + }; + service = new LoggingService(repository); + }); + + it('returns a paginated page with data, total and hasNextPage', async () => { + rows = [ + { id: 1, level: 'INFO', event: 'team_create', details: 'teamId=5', userId: 3, createdAt: new Date('2026-08-01') }, + ]; + total = 21; + + const result = await service.findLogs({ page: 1, limit: 20 }); + + expect(result).toEqual({ data: rows, page: 1, limit: 20, total: 21, hasNextPage: true }); + expect(query.orderBy).toHaveBeenCalledWith('log.createdAt', 'DESC'); + expect(query.offset).toHaveBeenCalledWith(0); + expect(query.limit).toHaveBeenCalledWith(20); + }); + + it('reports hasNextPage=false on the last page', async () => { + total = 20; + + const result = await service.findLogs({ page: 1, limit: 20 }); + + expect(result.hasNextPage).toBe(false); + }); + + it('offsets by (page - 1) * limit', async () => { + await service.findLogs({ page: 3, limit: 10 }); + + expect(query.offset).toHaveBeenCalledWith(20); + }); + + it('filters by level and event when provided', async () => { + await service.findLogs({ page: 1, limit: 20, level: 'ERROR', event: 'cashbox_export_subscription_run_fail' }); + + expect(query.andWhere).toHaveBeenCalledWith('log.level = :level', { level: 'ERROR' }); + expect(query.andWhere).toHaveBeenCalledWith('log.event = :event', { + event: 'cashbox_export_subscription_run_fail', + }); + }); + + it('filters by an inclusive date range when from/to are provided', async () => { + await service.findLogs({ page: 1, limit: 20, from: '2026-01-01', to: '2026-01-31' }); + + expect(query.andWhere).toHaveBeenCalledWith('log.createdAt >= :from', { from: '2026-01-01' }); + expect(query.andWhere).toHaveBeenCalledWith('log.createdAt <= :to', { to: '2026-01-31' }); + }); + + it('does not add level/event/date filters when omitted', async () => { + await service.findLogs({ page: 1, limit: 20 }); + + expect(query.andWhere).not.toHaveBeenCalled(); + }); + + it('filters details by a case-insensitive search term', async () => { + await service.findLogs({ page: 1, limit: 20, search: ' TeamId=5 ' }); + + expect(query.andWhere).toHaveBeenCalledWith('LOWER(log.details) LIKE :search', { + search: '%teamid=5%', + }); + }); + + it('ignores a blank search term', async () => { + await service.findLogs({ page: 1, limit: 20, search: ' ' }); + + expect(query.andWhere).not.toHaveBeenCalled(); + }); + + function chain(overrides: Record) { + const builder: Record = {}; + ['andWhere', 'orderBy', 'offset', 'limit'].forEach((method) => { + builder[method] = jest.fn(() => builder); + }); + return Object.assign(builder, overrides); + } +}); diff --git a/myteamwallet_backend/src/database/logging/logging.service.ts b/myteamwallet_backend/src/database/logging/logging.service.ts index 99ae584..787b85b 100644 --- a/myteamwallet_backend/src/database/logging/logging.service.ts +++ b/myteamwallet_backend/src/database/logging/logging.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { EntityManager, Repository } from 'typeorm'; import { CreateLogDTO } from './dto/create-log.dto'; import { LogEntry } from './entities/log-entry.entity'; -import { LOGEVENT } from './model/logging-event.type'; +import { LOGEVENT, LOGLEVEL } from './model/logging-event.type'; @Injectable() export class LoggingService { @@ -95,4 +95,43 @@ export class LoggingService { }; await this.repository.save(e); } + + async findLogs(query: { + page: number; + limit: number; + level?: LOGLEVEL; + event?: LOGEVENT; + from?: string; + to?: string; + search?: string; + }): Promise<{ + data: LogEntry[]; + page: number; + limit: number; + total: number; + hasNextPage: boolean; + }> { + const builder = this.repository.createQueryBuilder('log'); + if (query.level) builder.andWhere('log.level = :level', { level: query.level }); + if (query.event) builder.andWhere('log.event = :event', { event: query.event }); + if (query.from) builder.andWhere('log.createdAt >= :from', { from: query.from }); + if (query.to) builder.andWhere('log.createdAt <= :to', { to: query.to }); + const term = query.search?.trim().toLocaleLowerCase(); + if (term) { + builder.andWhere('LOWER(log.details) LIKE :search', { search: `%${term}%` }); + } + const total = await builder.getCount(); + const data = await builder + .orderBy('log.createdAt', 'DESC') + .offset((query.page - 1) * query.limit) + .limit(query.limit) + .getMany(); + return { + data, + page: query.page, + limit: query.limit, + total, + hasNextPage: query.page * query.limit < total, + }; + } } diff --git a/myteamwallet_backend/src/database/logging/logs.controller.spec.ts b/myteamwallet_backend/src/database/logging/logs.controller.spec.ts new file mode 100644 index 0000000..cdb0fdc --- /dev/null +++ b/myteamwallet_backend/src/database/logging/logs.controller.spec.ts @@ -0,0 +1,66 @@ +import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { RoleEnum } from '../../roles/roles.enum'; +import { RolesGuard } from '../../roles/roles.guard'; +import { AdminLogQueryDto } from './dto/admin-log-query.dto'; +import { LogsController } from './logs.controller'; + +describe('LogsController', () => { + const service = { findLogs: jest.fn() }; + const controller = new LogsController(service as any); + + beforeEach(() => jest.clearAllMocks()); + + it('uses a separate versioned admin/logs controller guarded by the global admin role', () => { + expect(Reflect.getMetadata(PATH_METADATA, LogsController)).toBe('admin/logs'); + expect(Reflect.getMetadata('roles', LogsController)).toEqual([RoleEnum.admin]); + expect(Reflect.getMetadata(GUARDS_METADATA, LogsController)).toContain(RolesGuard); + }); + + it('passes the query straight through to the service', async () => { + const query = { page: 2, limit: 50, level: 'ERROR' as const }; + + await controller.findLogs(query as any); + + expect(service.findLogs).toHaveBeenCalledWith(query); + }); +}); + +describe('AdminLogQueryDto', () => { + it('defaults page and limit when omitted', async () => { + const dto = plainToInstance(AdminLogQueryDto, {}); + + expect(await validate(dto)).toEqual([]); + expect(dto).toMatchObject({ page: 1, limit: 50 }); + }); + + it('accepts valid level, event, and date-range filters', async () => { + const dto = plainToInstance(AdminLogQueryDto, { + level: 'ERROR', + event: 'cashbox_export_subscription_run_fail', + from: '2026-01-01', + to: '2026-01-31', + search: 'teamId=5', + page: '2', + limit: '100', + }); + + expect(await validate(dto)).toEqual([]); + expect(dto).toMatchObject({ page: 2, limit: 100 }); + }); + + it('rejects an unknown level or event value', async () => { + const level = plainToInstance(AdminLogQueryDto, { level: 'NOPE' }); + const event = plainToInstance(AdminLogQueryDto, { event: 'not_a_real_event' }); + + expect(await validate(level)).not.toEqual([]); + expect(await validate(event)).not.toEqual([]); + }); + + it('rejects a limit above the maximum', async () => { + const dto = plainToInstance(AdminLogQueryDto, { limit: 500 }); + + expect(await validate(dto)).not.toEqual([]); + }); +}); diff --git a/myteamwallet_backend/src/database/logging/logs.controller.ts b/myteamwallet_backend/src/database/logging/logs.controller.ts new file mode 100644 index 0000000..5414fa3 --- /dev/null +++ b/myteamwallet_backend/src/database/logging/logs.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { Roles } from '../../roles/roles.decorator'; +import { RoleEnum } from '../../roles/roles.enum'; +import { RolesGuard } from '../../roles/roles.guard'; +import { AdminLogQueryDto } from './dto/admin-log-query.dto'; +import { LoggingService } from './logging.service'; + +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt'), RolesGuard) +@Roles([RoleEnum.admin]) +@Controller({ path: 'admin/logs', version: '1' }) +export class LogsController { + constructor(private readonly loggingService: LoggingService) {} + + @Get() + findLogs(@Query() query: AdminLogQueryDto) { + return this.loggingService.findLogs(query); + } +} diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index 4126421..8738b53 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -36,6 +36,50 @@ export type LOGEVENT = | 'cashbox_export_download' | 'cashbox_export_subscription_update' | 'cashbox_export_subscription_run' - | 'cashbox_export_subscription_run_fail'; + | 'cashbox_export_subscription_run_fail' + | 'log_retention_cleanup_run'; + +export const LOGEVENT_VALUES: LOGEVENT[] = [ + 'user_create', + 'application_start', + 'transaction_create', + 'team_transaction_create', + 'team_transaction_get', + 'user_login_success', + 'user_login_fail', + 'user_token_verification_success', + 'user_token_verification_fail', + 'user_invite_link_create', + 'user_invite_link_validate', + 'user_invite_link_validate_fail', + 'transaction_create_fail', + 'transaction_reverse', + 'player_creation', + 'admin_user_profile_update', + 'admin_user_role_update', + 'admin_user_status_update', + 'admin_player_assign', + 'admin_player_unlink', + 'player_active_update', + 'player_team_role_update', + 'penalty_catalog_create', + 'penalty_catalog_update', + 'penalty_catalog_delete', + 'team_create', + 'team_permissions_update', + 'scheduled_recurring_transaction_check_start', + 'scheduled_recurring_transaction_check_finished', + 'recurring_transaction_create', + 'recurring_transaction_update', + 'recurring_transaction_delete', + 'recurring_transaction_run', + 'cashbox_export_download', + 'cashbox_export_subscription_update', + 'cashbox_export_subscription_run', + 'cashbox_export_subscription_run_fail', + 'log_retention_cleanup_run', +]; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; + +export const LOGLEVEL_VALUES: LOGLEVEL[] = ['FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE']; diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.spec.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.spec.ts new file mode 100644 index 0000000..750e330 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.spec.ts @@ -0,0 +1,38 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { RoleEnum } from '../roles/roles.enum'; +import { RolesGuard } from '../roles/roles.guard'; +import { RecurringTransactionsController } from './recurring-transactions.controller'; + +describe('RecurringTransactionsController.runDueRecurringTransactionsNow', () => { + const service = { + getTeamRecurringTransactions: jest.fn(), + createRecurringTransaction: jest.fn(), + updateRecurringTransaction: jest.fn(), + deleteRecurringTransaction: jest.fn(), + }; + const scheduler = { runDueRecurringTransactions: jest.fn() }; + const controller = new RecurringTransactionsController(service as any, scheduler as any); + + beforeEach(() => jest.clearAllMocks()); + + it('is guarded by the global admin role', () => { + expect( + Reflect.getMetadata( + 'roles', + RecurringTransactionsController.prototype.runDueRecurringTransactionsNow, + ), + ).toEqual([RoleEnum.admin]); + expect( + Reflect.getMetadata( + GUARDS_METADATA, + RecurringTransactionsController.prototype.runDueRecurringTransactionsNow, + ), + ).toContain(RolesGuard); + }); + + it('delegates to the scheduler', async () => { + await controller.runDueRecurringTransactionsNow(); + + expect(scheduler.runDueRecurringTransactions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts index acf54e1..aded12b 100644 --- a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts @@ -14,8 +14,12 @@ import { } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; +import { Roles } from '../roles/roles.decorator'; +import { RoleEnum } from '../roles/roles.enum'; +import { RolesGuard } from '../roles/roles.guard'; import { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto'; import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto'; +import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler'; import { RecurringTransactionsService } from './recurring-transactions.service'; type AuthenticatedRequest = { user: { id: number } }; @@ -24,7 +28,10 @@ type AuthenticatedRequest = { user: { id: number } }; @UseGuards(AuthGuard('jwt')) @Controller({ path: 'recurring-transactions', version: '1' }) export class RecurringTransactionsController { - constructor(private readonly service: RecurringTransactionsService) {} + constructor( + private readonly service: RecurringTransactionsService, + private readonly scheduler: RecurringTransactionsScheduler, + ) {} @Get(':teamId') getTeamRecurringTransactions( @@ -59,4 +66,12 @@ export class RecurringTransactionsController { ): Promise { await this.service.deleteRecurringTransaction(id, request.user.id); } + + @Post('admin/run') + @HttpCode(HttpStatus.OK) + @UseGuards(RolesGuard) + @Roles([RoleEnum.admin]) + runDueRecurringTransactionsNow(): Promise { + return this.scheduler.runDueRecurringTransactions(); + } } diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts index 4b812a5..a596b2b 100644 --- a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts @@ -11,6 +11,7 @@ import validationOptions from '../utils/validation-options'; import { TransactionTypeEnum } from '../transactions/transaction-type.enum'; import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum'; import { RecurringTransactionsController } from './recurring-transactions.controller'; +import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler'; import { RecurringTransactionsService } from './recurring-transactions.service'; describe('recurring transactions HTTP boundary', () => { @@ -31,12 +32,14 @@ describe('recurring transactions HTTP boundary', () => { updateRecurringTransaction: jest.fn(() => entry), deleteRecurringTransaction: jest.fn(), }; + const scheduler = { runDueRecurringTransactions: jest.fn() }; beforeAll(async () => { const module = await Test.createTestingModule({ controllers: [RecurringTransactionsController], providers: [ { provide: RecurringTransactionsService, useValue: service }, + { provide: RecurringTransactionsScheduler, useValue: scheduler }, ], }) .overrideGuard(AuthGuard('jwt')) diff --git a/myteamwallet_frontend_modern/src/app/app.routes.ts b/myteamwallet_frontend_modern/src/app/app.routes.ts index 6fe99d6..fdf4306 100644 --- a/myteamwallet_frontend_modern/src/app/app.routes.ts +++ b/myteamwallet_frontend_modern/src/app/app.routes.ts @@ -48,6 +48,11 @@ export const routes: Routes = [ canActivate: [authGuard], loadComponent: () => import('./features/users/users').then((m) => m.Users), }, + { + path: 'logs', + canActivate: [authGuard], + loadComponent: () => import('./features/logs/logs').then((m) => m.Logs), + }, { path: 't/:token/:playerId', loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer), diff --git a/myteamwallet_frontend_modern/src/app/core/logs/logs-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/logs/logs-api.spec.ts new file mode 100644 index 0000000..a65f1b1 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/logs/logs-api.spec.ts @@ -0,0 +1,46 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { environment } from '../../../environments/environment'; +import { LogsApi } from './logs-api'; + +describe('LogsApi', () => { + let api: LogsApi; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + api = TestBed.inject(LogsApi); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('loads logs with page and limit only when no filters are set', () => { + api.loadLogs({ page: 2, limit: 50 }).subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}admin/logs?page=2&limit=50`); + expect(request.request.method).toBe('GET'); + request.flush({ data: [], page: 2, limit: 50, total: 0, hasNextPage: false }); + }); + + it('includes level, event, date-range and search filters when set', () => { + api + .loadLogs({ + page: 1, + limit: 50, + level: 'ERROR', + event: 'cashbox_export_subscription_run_fail', + from: '2026-01-01', + to: '2026-01-31', + search: 'teamId=5', + }) + .subscribe(); + const request = httpMock.expectOne( + `${environment.apiUrl}admin/logs?level=ERROR&event=cashbox_export_subscription_run_fail&from=2026-01-01&to=2026-01-31&search=teamId=5&page=1&limit=50`, + ); + expect(request.request.method).toBe('GET'); + request.flush({ data: [], page: 1, limit: 50, total: 0, hasNextPage: false }); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts b/myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts new file mode 100644 index 0000000..ca06748 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts @@ -0,0 +1,26 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { LogPage, LogQuery } from '../../models/log.model'; + +@Injectable({ providedIn: 'root' }) +export class LogsApi { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}admin/logs`; + + loadLogs(query: LogQuery): Observable { + return this.http.get(this.baseUrl, { params: this.toParams(query) }); + } + + private toParams(query: LogQuery): HttpParams { + let params = new HttpParams(); + if (query.level) params = params.set('level', query.level); + if (query.event) params = params.set('event', query.event); + if (query.from) params = params.set('from', query.from); + if (query.to) params = params.set('to', query.to); + if (query.search) params = params.set('search', query.search); + params = params.set('page', query.page).set('limit', query.limit); + return params; + } +} diff --git a/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts index 40730aa..381ad29 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts @@ -43,4 +43,11 @@ describe('CashboxExportApi', () => { expect(request.request.body).toEqual(update); request.flush({ ...update, nextRunDate: '2026-09-01T00:00:00.000Z' }); }); + + it('triggers the due-subscriptions run now', () => { + api.triggerRunNow().subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}cashbox-export/admin/run`); + expect(request.request.method).toBe('POST'); + request.flush(null); + }); }); diff --git a/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts b/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts index 21cf9ff..1bfa458 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts @@ -33,4 +33,8 @@ export class CashboxExportApi { ): Observable { return this.http.put(`${this.baseUrl}/${teamId}/subscription`, dto); } + + triggerRunNow(): Observable { + return this.http.post(`${this.baseUrl}/admin/run`, null); + } } diff --git a/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts index 8305f5e..f1f63f2 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts @@ -62,4 +62,11 @@ describe('RecurringTransactionApi', () => { expect(request.request.method).toBe('DELETE'); request.flush(null); }); + + it('triggers the due-recurring-transactions run now', () => { + api.triggerRunNow().subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/admin/run`); + expect(request.request.method).toBe('POST'); + request.flush(null); + }); }); diff --git a/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts index 2d7f1aa..f81925b 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts @@ -33,4 +33,8 @@ export class RecurringTransactionApi { deleteRecurringTransaction(id: number): Observable { return this.http.delete(`${this.baseUrl}/${id}`); } + + triggerRunNow(): Observable { + return this.http.post(`${this.baseUrl}/admin/run`, null); + } } diff --git a/myteamwallet_frontend_modern/src/app/features/logs/logs.html b/myteamwallet_frontend_modern/src/app/features/logs/logs.html new file mode 100644 index 0000000..3ef3dfe --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/logs/logs.html @@ -0,0 +1,93 @@ +
+ arrow_backZurück + + + @if (!isAdmin()) { +
+ lock + Kein Zugriff + Diese Seite ist nur für Administratoren sichtbar. +
+ } @else { +
+ + +
+ +
+ + Level + + @for (option of levelOptions; track option.value) { + {{ option.label }} + } + + + + + Event + + @for (option of eventOptions; track option.value) { + {{ option.label }} + } + + + + + Von + + + + + Bis + + + + + Suche in Details + search + + +
+ + + } +
diff --git a/myteamwallet_frontend_modern/src/app/features/logs/logs.scss b/myteamwallet_frontend_modern/src/app/features/logs/logs.scss new file mode 100644 index 0000000..385b713 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/logs/logs.scss @@ -0,0 +1,90 @@ +:host { + display: block; + min-height: 100dvh; + background: var(--mat-sys-surface); +} + +.logs-page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 28px 40px; +} + +.back-link { + margin-left: -12px; +} + +.page-header { + margin: 20px 0 26px; +} + +h1, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 4vw, 3rem); +} + +.page-header > p:last-child { + color: var(--mat-sys-on-surface-variant); +} + +.eyebrow { + margin-bottom: 6px; + color: var(--mat-sys-primary); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.page-state { + min-height: 240px; + display: grid; + place-content: center; + justify-items: center; + gap: 10px; + padding: 24px; + color: var(--mat-sys-on-surface-variant); + text-align: center; +} + +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; +} + +.filters mat-form-field { + min-width: 160px; +} + +.logs-grid { + height: 640px; + width: 100%; +} + +@media (max-width: 700px) { + .logs-page { + padding: 20px 16px 32px; + } + + .admin-actions { + flex-direction: column; + } + + .admin-actions button { + width: 100%; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts b/myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts new file mode 100644 index 0000000..a6976fb --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts @@ -0,0 +1,151 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AuthStore } from '../../core/auth/auth-store'; +import { CashboxExportApi } from '../../core/team/cashbox-export-api'; +import { RecurringTransactionApi } from '../../core/team/recurring-transaction-api'; +import { LogsApi } from '../../core/logs/logs-api'; +import { Logs } from './logs'; + +describe('Logs', () => { + const entries = [ + { + id: 1, + level: 'ERROR' as const, + event: 'cashbox_export_subscription_run_fail', + details: 'subscriptionId=1 teamId=5: smtp down', + userId: -1, + createdAt: '2026-08-04T04:00:00.000Z', + }, + ]; + + async function setup(isAdmin = true) { + const loadLogs = vi.fn(() => of({ data: entries, page: 1, limit: 50, total: 1, hasNextPage: false })); + const triggerCashboxRun = vi.fn(() => of(undefined)); + const triggerRecurringRun = vi.fn(() => of(undefined)); + const snackBarOpen = vi.fn(); + + await TestBed.configureTestingModule({ + imports: [Logs], + providers: [ + provideRouter([]), + { provide: AuthStore, useValue: { isGlobalAdmin: signal(isAdmin) } }, + { provide: LogsApi, useValue: { loadLogs } }, + { provide: CashboxExportApi, useValue: { triggerRunNow: triggerCashboxRun } }, + { provide: RecurringTransactionApi, useValue: { triggerRunNow: triggerRecurringRun } }, + { provide: MatSnackBar, useValue: { open: snackBarOpen } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(Logs); + fixture.detectChanges(); + return { + fixture, + component: fixture.componentInstance, + loadLogs, + triggerCashboxRun, + triggerRecurringRun, + snackBarOpen, + }; + } + + // AG Grid's real component initialization (layout/ResizeObserver setup) can + // run slower under the full suite's parallel load than in isolation, so this + // gets a longer timeout rather than the vitest default 5s. + it('shows the log grid and trigger buttons to a global admin', async () => { + const { fixture } = await setup(true); + + expect(fixture.nativeElement.querySelector('ag-grid-angular')).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Cashbox-Export jetzt ausführen'); + expect(fixture.nativeElement.textContent).toContain('Wiederkehrende Buchungen jetzt prüfen'); + }, 15000); + + it('hides the grid and shows no access for a non-admin', async () => { + const { fixture } = await setup(false); + + expect(fixture.nativeElement.querySelector('ag-grid-angular')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Kein Zugriff'); + }); + + it('builds a logs datasource sorted newest first with page/limit only when no filters are set', async () => { + const { component, loadLogs } = await setup(); + const successCallback = vi.fn(); + + const datasource = component['buildLogsDatasource'](); + datasource.getRows({ + startRow: 0, + endRow: 50, + sortModel: [], + filterModel: {}, + successCallback, + failCallback: vi.fn(), + } as unknown as Parameters[0]); + + expect(loadLogs).toHaveBeenCalledWith({ page: 1, limit: 50 }); + expect(successCallback).toHaveBeenCalledWith(entries, 1); + }); + + it('applies level, event, date-range and search filters to the datasource query', async () => { + const { component, loadLogs } = await setup(); + component['levelFilter'].set('ERROR'); + component['eventFilter'].set('cashbox_export_subscription_run_fail'); + component['fromFilter'].set('2026-01-01'); + component['toFilter'].set('2026-01-31'); + component['search'].set('teamId=5'); + + const datasource = component['buildLogsDatasource'](); + datasource.getRows({ + startRow: 50, + endRow: 100, + sortModel: [], + filterModel: {}, + successCallback: vi.fn(), + failCallback: vi.fn(), + } as unknown as Parameters[0]); + + expect(loadLogs).toHaveBeenCalledWith({ + page: 2, + limit: 50, + level: 'ERROR', + event: 'cashbox_export_subscription_run_fail', + from: '2026-01-01', + to: '2026-01-31', + search: 'teamId=5', + }); + }); + + it('triggers the cashbox export run and reloads the grid on success', async () => { + const { component, triggerCashboxRun, snackBarOpen } = await setup(); + const reloadSpy = vi.spyOn(component as any, 'reloadLogs'); + + component['triggerCashboxExportRun'](); + + expect(triggerCashboxRun).toHaveBeenCalledTimes(1); + expect(snackBarOpen).toHaveBeenCalled(); + expect(reloadSpy).toHaveBeenCalled(); + }); + + it('shows an error message when the cashbox export trigger fails', async () => { + const { component, snackBarOpen } = await setup(); + (component as any).cashboxExportApi.triggerRunNow = vi.fn(() => + throwError(() => new Error('boom')), + ); + + component['triggerCashboxExportRun'](); + + expect(snackBarOpen).toHaveBeenCalled(); + }); + + it('triggers the recurring-transactions run and reloads the grid on success', async () => { + const { component, triggerRecurringRun, snackBarOpen } = await setup(); + const reloadSpy = vi.spyOn(component as any, 'reloadLogs'); + + component['triggerRecurringTransactionsRun'](); + + expect(triggerRecurringRun).toHaveBeenCalledTimes(1); + expect(snackBarOpen).toHaveBeenCalled(); + expect(reloadSpy).toHaveBeenCalled(); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/features/logs/logs.ts b/myteamwallet_frontend_modern/src/app/features/logs/logs.ts new file mode 100644 index 0000000..fdb9311 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/logs/logs.ts @@ -0,0 +1,241 @@ +import { Component, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AgGridAngular } from 'ag-grid-angular'; +import type { + ColDef, + GetRowIdParams, + GridApi, + GridReadyEvent, + IDatasource, + IGetRowsParams, +} from 'ag-grid-community'; +import { Subject } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { AuthStore } from '../../core/auth/auth-store'; +import { CashboxExportApi } from '../../core/team/cashbox-export-api'; +import { RecurringTransactionApi } from '../../core/team/recurring-transaction-api'; +import { LogsApi } from '../../core/logs/logs-api'; +import { LogEntry, LogLevel, LogQuery } from '../../models/log.model'; +import '../../shared/ag-grid/ag-grid-modules'; +import { teamwalletGridTheme } from '../../shared/ag-grid/ag-grid-theme'; + +const LOG_LEVEL_OPTIONS: { value: string; label: string }[] = [ + { value: '', label: 'Alle Level' }, + { value: 'FATAL', label: 'FATAL' }, + { value: 'ERROR', label: 'ERROR' }, + { value: 'WARN', label: 'WARN' }, + { value: 'INFO', label: 'INFO' }, + { value: 'DEBUG', label: 'DEBUG' }, + { value: 'TRACE', label: 'TRACE' }, +]; + +// Kept in sync manually with LOGEVENT_VALUES (myteamwallet_backend/src/database/logging/model/logging-event.type.ts), +// the same way transaction type labels are already duplicated on the frontend elsewhere in this app. +const LOG_EVENT_OPTIONS: { value: string; label: string }[] = [ + { value: '', label: 'Alle Events' }, + { value: 'user_create', label: 'user_create' }, + { value: 'application_start', label: 'application_start' }, + { value: 'transaction_create', label: 'transaction_create' }, + { value: 'team_transaction_create', label: 'team_transaction_create' }, + { value: 'team_transaction_get', label: 'team_transaction_get' }, + { value: 'user_login_success', label: 'user_login_success' }, + { value: 'user_login_fail', label: 'user_login_fail' }, + { value: 'user_token_verification_success', label: 'user_token_verification_success' }, + { value: 'user_token_verification_fail', label: 'user_token_verification_fail' }, + { value: 'user_invite_link_create', label: 'user_invite_link_create' }, + { value: 'user_invite_link_validate', label: 'user_invite_link_validate' }, + { value: 'user_invite_link_validate_fail', label: 'user_invite_link_validate_fail' }, + { value: 'transaction_create_fail', label: 'transaction_create_fail' }, + { value: 'transaction_reverse', label: 'transaction_reverse' }, + { value: 'player_creation', label: 'player_creation' }, + { value: 'admin_user_profile_update', label: 'admin_user_profile_update' }, + { value: 'admin_user_role_update', label: 'admin_user_role_update' }, + { value: 'admin_user_status_update', label: 'admin_user_status_update' }, + { value: 'admin_player_assign', label: 'admin_player_assign' }, + { value: 'admin_player_unlink', label: 'admin_player_unlink' }, + { value: 'player_active_update', label: 'player_active_update' }, + { value: 'player_team_role_update', label: 'player_team_role_update' }, + { value: 'penalty_catalog_create', label: 'penalty_catalog_create' }, + { value: 'penalty_catalog_update', label: 'penalty_catalog_update' }, + { value: 'penalty_catalog_delete', label: 'penalty_catalog_delete' }, + { value: 'team_create', label: 'team_create' }, + { value: 'team_permissions_update', label: 'team_permissions_update' }, + { value: 'scheduled_recurring_transaction_check_start', label: 'scheduled_recurring_transaction_check_start' }, + { + value: 'scheduled_recurring_transaction_check_finished', + label: 'scheduled_recurring_transaction_check_finished', + }, + { value: 'recurring_transaction_create', label: 'recurring_transaction_create' }, + { value: 'recurring_transaction_update', label: 'recurring_transaction_update' }, + { value: 'recurring_transaction_delete', label: 'recurring_transaction_delete' }, + { value: 'recurring_transaction_run', label: 'recurring_transaction_run' }, + { value: 'cashbox_export_download', label: 'cashbox_export_download' }, + { value: 'cashbox_export_subscription_update', label: 'cashbox_export_subscription_update' }, + { value: 'cashbox_export_subscription_run', label: 'cashbox_export_subscription_run' }, + { value: 'cashbox_export_subscription_run_fail', label: 'cashbox_export_subscription_run_fail' }, + { value: 'log_retention_cleanup_run', label: 'log_retention_cleanup_run' }, +]; + +@Component({ + selector: 'app-logs', + imports: [ + RouterLink, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule, + AgGridAngular, + ], + templateUrl: './logs.html', + styleUrl: './logs.scss', +}) +export class Logs { + private readonly authStore = inject(AuthStore); + private readonly logsApi = inject(LogsApi); + private readonly cashboxExportApi = inject(CashboxExportApi); + private readonly recurringTransactionApi = inject(RecurringTransactionApi); + private readonly snackBar = inject(MatSnackBar); + + protected readonly isAdmin = this.authStore.isGlobalAdmin; + protected readonly gridTheme = teamwalletGridTheme; + protected readonly levelOptions = LOG_LEVEL_OPTIONS; + protected readonly eventOptions = LOG_EVENT_OPTIONS; + + protected readonly levelFilter = signal(''); + protected readonly eventFilter = signal(''); + protected readonly fromFilter = signal(''); + protected readonly toFilter = signal(''); + protected readonly search = signal(''); + protected readonly cashboxRunning = signal(false); + protected readonly recurringRunning = signal(false); + + private readonly searchInput$ = new Subject(); + private gridApi?: GridApi; + + protected readonly columnDefs: ColDef[] = [ + { + headerName: 'Zeitpunkt', + field: 'createdAt', + width: 170, + valueFormatter: (params) => + params.value + ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'medium' }).format( + new Date(params.value), + ) + : '', + }, + { + headerName: 'Level', + field: 'level', + width: 100, + cellClass: (params) => `log-level log-level--${(params.value ?? '').toLowerCase()}`, + }, + { headerName: 'Event', field: 'event', minWidth: 220, flex: 1 }, + { + headerName: 'Wer', + field: 'userId', + width: 90, + valueFormatter: (params) => (params.value === -1 ? 'System' : `#${params.value}`), + }, + { headerName: 'Details', field: 'details', minWidth: 260, flex: 2 }, + { + headerName: 'Dauer', + field: 'duration', + width: 90, + valueFormatter: (params) => (params.value != null ? `${params.value} ms` : ''), + }, + ]; + + protected readonly getRowId = (params: GetRowIdParams) => String(params.data.id); + + constructor() { + this.searchInput$.pipe(debounceTime(300), takeUntilDestroyed()).subscribe((value) => { + this.search.set(value); + this.reloadLogs(); + }); + } + + protected onGridReady(event: GridReadyEvent): void { + this.gridApi = event.api; + this.reloadLogs(); + } + + protected onFilterChange(): void { + this.reloadLogs(); + } + + protected onSearchInput(value: string): void { + this.searchInput$.next(value); + } + + private reloadLogs(): void { + this.gridApi?.setGridOption('datasource', this.buildLogsDatasource()); + } + + private buildLogsDatasource(): IDatasource { + return { + getRows: (params: IGetRowsParams) => { + const limit = Math.max(1, params.endRow - params.startRow); + const page = Math.floor(params.startRow / limit) + 1; + const query: LogQuery = { + page, + limit, + ...(this.levelFilter() ? { level: this.levelFilter() as LogLevel } : {}), + ...(this.eventFilter() ? { event: this.eventFilter() } : {}), + ...(this.fromFilter() ? { from: this.fromFilter() } : {}), + ...(this.toFilter() ? { to: this.toFilter() } : {}), + ...(this.search().trim() ? { search: this.search().trim() } : {}), + }; + + this.logsApi.loadLogs(query).subscribe({ + next: (result) => params.successCallback(result.data, result.total), + error: () => params.failCallback(), + }); + }, + }; + } + + protected triggerCashboxExportRun(): void { + if (this.cashboxRunning()) return; + this.cashboxRunning.set(true); + this.cashboxExportApi.triggerRunNow().subscribe({ + next: () => { + this.cashboxRunning.set(false); + this.snackBar.open('Cashbox-Export wurde ausgeführt.', undefined, { duration: 4000 }); + this.reloadLogs(); + }, + error: () => { + this.cashboxRunning.set(false); + this.snackBar.open('Cashbox-Export konnte nicht ausgeführt werden.', undefined, { duration: 5000 }); + }, + }); + } + + protected triggerRecurringTransactionsRun(): void { + if (this.recurringRunning()) return; + this.recurringRunning.set(true); + this.recurringTransactionApi.triggerRunNow().subscribe({ + next: () => { + this.recurringRunning.set(false); + this.snackBar.open('Wiederkehrende Buchungen wurden geprüft.', undefined, { duration: 4000 }); + this.reloadLogs(); + }, + error: () => { + this.recurringRunning.set(false); + this.snackBar.open( + 'Wiederkehrende Buchungen konnten nicht geprüft werden.', + undefined, + { duration: 5000 }, + ); + }, + }); + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.html b/myteamwallet_frontend_modern/src/app/features/users/users.html index ef1e9a2..bba3f19 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/users.html +++ b/myteamwallet_frontend_modern/src/app/features/users/users.html @@ -4,6 +4,9 @@

Organisation

Benutzer

Konten und sichtbare Teamzuordnungen im Überblick.

+ @if (isAdmin()) { + receipt_longLogs + }