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 + }