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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ APP_NAME="NestJS API"
|
|||||||
API_PREFIX=api
|
API_PREFIX=api
|
||||||
FRONTEND_DOMAIN=http://localhost:3000
|
FRONTEND_DOMAIN=http://localhost:3000
|
||||||
BACKEND_DOMAIN=http://localhost:3000
|
BACKEND_DOMAIN=http://localhost:3000
|
||||||
|
LOG_RETENTION_DAYS=365
|
||||||
|
|
||||||
DATABASE_TYPE=postgres
|
DATABASE_TYPE=postgres
|
||||||
DATABASE_HOST=postgres
|
DATABASE_HOST=postgres
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,11 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
Request,
|
Request,
|
||||||
@@ -13,8 +16,12 @@ import {
|
|||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
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 { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
|
||||||
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
||||||
|
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||||
import { CashboxExportService } from './cashbox-export.service';
|
import { CashboxExportService } from './cashbox-export.service';
|
||||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||||
|
|
||||||
@@ -27,6 +34,7 @@ export class CashboxExportController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly service: CashboxExportService,
|
private readonly service: CashboxExportService,
|
||||||
private readonly subscriptionService: CashboxExportSubscriptionService,
|
private readonly subscriptionService: CashboxExportSubscriptionService,
|
||||||
|
private readonly scheduler: CashboxExportScheduler,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
@@ -66,4 +74,12 @@ export class CashboxExportController {
|
|||||||
) {
|
) {
|
||||||
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto);
|
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('admin/run')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles([RoleEnum.admin])
|
||||||
|
runDueSubscriptionsNow(): Promise<void> {
|
||||||
|
return this.scheduler.runDueSubscriptions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as request from 'supertest';
|
|||||||
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
||||||
import validationOptions from '../utils/validation-options';
|
import validationOptions from '../utils/validation-options';
|
||||||
import { CashboxExportController } from './cashbox-export.controller';
|
import { CashboxExportController } from './cashbox-export.controller';
|
||||||
|
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||||
import { CashboxExportService } from './cashbox-export.service';
|
import { CashboxExportService } from './cashbox-export.service';
|
||||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ describe('cashbox export HTTP boundary', () => {
|
|||||||
getSubscription: jest.fn(),
|
getSubscription: jest.fn(),
|
||||||
upsertSubscription: jest.fn(),
|
upsertSubscription: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const scheduler = { runDueSubscriptions: jest.fn() };
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
@@ -29,6 +31,7 @@ describe('cashbox export HTTP boundary', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: CashboxExportService, useValue: service },
|
{ provide: CashboxExportService, useValue: service },
|
||||||
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
|
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
|
||||||
|
{ provide: CashboxExportScheduler, useValue: scheduler },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideGuard(AuthGuard('jwt'))
|
.overrideGuard(AuthGuard('jwt'))
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export default registerAs('app', () => ({
|
|||||||
backendDomain: process.env.BACKEND_DOMAIN,
|
backendDomain: process.env.BACKEND_DOMAIN,
|
||||||
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
|
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
|
||||||
apiPrefix: process.env.API_PREFIX || 'api',
|
apiPrefix: process.env.API_PREFIX || 'api',
|
||||||
|
logRetentionDays: parseInt(process.env.LOG_RETENTION_DAYS, 10) || 365,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<LogEntry>,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly logger: LoggingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||||
|
async cleanupOldLogs(): Promise<void> {
|
||||||
|
const retentionDays = this.configService.get<number>('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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { LogEntry } from './entities/log-entry.entity';
|
import { LogEntry } from './entities/log-entry.entity';
|
||||||
|
import { LogRetentionScheduler } from './log-retention.scheduler';
|
||||||
import { LoggingService } from './logging.service';
|
import { LoggingService } from './logging.service';
|
||||||
|
import { LogsController } from './logs.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([LogEntry])],
|
imports: [TypeOrmModule.forFeature([LogEntry])],
|
||||||
providers: [LoggingService],
|
controllers: [LogsController],
|
||||||
|
providers: [LoggingService, LogRetentionScheduler],
|
||||||
exports: [LoggingService],
|
exports: [LoggingService],
|
||||||
})
|
})
|
||||||
export class LoggingModule {}
|
export class LoggingModule {}
|
||||||
|
|||||||
@@ -23,3 +23,96 @@ describe('LoggingService', () => {
|
|||||||
expect(defaultRepository.save).not.toHaveBeenCalled();
|
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<string, jest.Mock>) {
|
||||||
|
const builder: Record<string, jest.Mock> = {};
|
||||||
|
['andWhere', 'orderBy', 'offset', 'limit'].forEach((method) => {
|
||||||
|
builder[method] = jest.fn(() => builder);
|
||||||
|
});
|
||||||
|
return Object.assign(builder, overrides);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { EntityManager, Repository } from 'typeorm';
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
import { CreateLogDTO } from './dto/create-log.dto';
|
import { CreateLogDTO } from './dto/create-log.dto';
|
||||||
import { LogEntry } from './entities/log-entry.entity';
|
import { LogEntry } from './entities/log-entry.entity';
|
||||||
import { LOGEVENT } from './model/logging-event.type';
|
import { LOGEVENT, LOGLEVEL } from './model/logging-event.type';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LoggingService {
|
export class LoggingService {
|
||||||
@@ -95,4 +95,43 @@ export class LoggingService {
|
|||||||
};
|
};
|
||||||
await this.repository.save(e);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
myteamwallet_backend/src/database/logging/logs.controller.ts
Normal file
21
myteamwallet_backend/src/database/logging/logs.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,50 @@ export type LOGEVENT =
|
|||||||
| 'cashbox_export_download'
|
| 'cashbox_export_download'
|
||||||
| 'cashbox_export_subscription_update'
|
| 'cashbox_export_subscription_update'
|
||||||
| 'cashbox_export_subscription_run'
|
| '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 type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||||
|
|
||||||
|
export const LOGLEVEL_VALUES: LOGLEVEL[] = ['FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,8 +14,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
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 { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
|
||||||
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
|
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
|
||||||
|
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
|
||||||
import { RecurringTransactionsService } from './recurring-transactions.service';
|
import { RecurringTransactionsService } from './recurring-transactions.service';
|
||||||
|
|
||||||
type AuthenticatedRequest = { user: { id: number } };
|
type AuthenticatedRequest = { user: { id: number } };
|
||||||
@@ -24,7 +28,10 @@ type AuthenticatedRequest = { user: { id: number } };
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@Controller({ path: 'recurring-transactions', version: '1' })
|
@Controller({ path: 'recurring-transactions', version: '1' })
|
||||||
export class RecurringTransactionsController {
|
export class RecurringTransactionsController {
|
||||||
constructor(private readonly service: RecurringTransactionsService) {}
|
constructor(
|
||||||
|
private readonly service: RecurringTransactionsService,
|
||||||
|
private readonly scheduler: RecurringTransactionsScheduler,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
getTeamRecurringTransactions(
|
getTeamRecurringTransactions(
|
||||||
@@ -59,4 +66,12 @@ export class RecurringTransactionsController {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.service.deleteRecurringTransaction(id, request.user.id);
|
await this.service.deleteRecurringTransaction(id, request.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('admin/run')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles([RoleEnum.admin])
|
||||||
|
runDueRecurringTransactionsNow(): Promise<void> {
|
||||||
|
return this.scheduler.runDueRecurringTransactions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import validationOptions from '../utils/validation-options';
|
|||||||
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
|
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
|
||||||
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
|
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
|
||||||
import { RecurringTransactionsController } from './recurring-transactions.controller';
|
import { RecurringTransactionsController } from './recurring-transactions.controller';
|
||||||
|
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
|
||||||
import { RecurringTransactionsService } from './recurring-transactions.service';
|
import { RecurringTransactionsService } from './recurring-transactions.service';
|
||||||
|
|
||||||
describe('recurring transactions HTTP boundary', () => {
|
describe('recurring transactions HTTP boundary', () => {
|
||||||
@@ -31,12 +32,14 @@ describe('recurring transactions HTTP boundary', () => {
|
|||||||
updateRecurringTransaction: jest.fn(() => entry),
|
updateRecurringTransaction: jest.fn(() => entry),
|
||||||
deleteRecurringTransaction: jest.fn(),
|
deleteRecurringTransaction: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const scheduler = { runDueRecurringTransactions: jest.fn() };
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
controllers: [RecurringTransactionsController],
|
controllers: [RecurringTransactionsController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: RecurringTransactionsService, useValue: service },
|
{ provide: RecurringTransactionsService, useValue: service },
|
||||||
|
{ provide: RecurringTransactionsScheduler, useValue: scheduler },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideGuard(AuthGuard('jwt'))
|
.overrideGuard(AuthGuard('jwt'))
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/users/users').then((m) => m.Users),
|
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',
|
path: 't/:token/:playerId',
|
||||||
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
|
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
26
myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts
Normal file
26
myteamwallet_frontend_modern/src/app/core/logs/logs-api.ts
Normal file
@@ -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<LogPage> {
|
||||||
|
return this.http.get<LogPage>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,4 +43,11 @@ describe('CashboxExportApi', () => {
|
|||||||
expect(request.request.body).toEqual(update);
|
expect(request.request.body).toEqual(update);
|
||||||
request.flush({ ...update, nextRunDate: '2026-09-01T00:00:00.000Z' });
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,4 +33,8 @@ export class CashboxExportApi {
|
|||||||
): Observable<CashboxExportSubscription> {
|
): Observable<CashboxExportSubscription> {
|
||||||
return this.http.put<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`, dto);
|
return this.http.put<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
triggerRunNow(): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.baseUrl}/admin/run`, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,11 @@ describe('RecurringTransactionApi', () => {
|
|||||||
expect(request.request.method).toBe('DELETE');
|
expect(request.request.method).toBe('DELETE');
|
||||||
request.flush(null);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,4 +33,8 @@ export class RecurringTransactionApi {
|
|||||||
deleteRecurringTransaction(id: number): Observable<void> {
|
deleteRecurringTransaction(id: number): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
triggerRunNow(): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.baseUrl}/admin/run`, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
myteamwallet_frontend_modern/src/app/features/logs/logs.html
Normal file
93
myteamwallet_frontend_modern/src/app/features/logs/logs.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<main class="logs-page">
|
||||||
|
<a mat-button routerLink="/" class="back-link"><mat-icon>arrow_back</mat-icon>Zurück</a>
|
||||||
|
<header class="page-header">
|
||||||
|
<p class="eyebrow">Administration</p>
|
||||||
|
<h1>Logs</h1>
|
||||||
|
<p>System- und Admin-Ereignisse im Überblick.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!isAdmin()) {
|
||||||
|
<div class="page-state">
|
||||||
|
<mat-icon>lock</mat-icon>
|
||||||
|
<strong>Kein Zugriff</strong>
|
||||||
|
<span>Diese Seite ist nur für Administratoren sichtbar.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="cashboxRunning()"
|
||||||
|
(click)="triggerCashboxExportRun()"
|
||||||
|
>
|
||||||
|
Cashbox-Export jetzt ausführen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="recurringRunning()"
|
||||||
|
(click)="triggerRecurringTransactionsRun()"
|
||||||
|
>
|
||||||
|
Wiederkehrende Buchungen jetzt prüfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Level</mat-label>
|
||||||
|
<mat-select [value]="levelFilter()" (selectionChange)="levelFilter.set($event.value); onFilterChange()">
|
||||||
|
@for (option of levelOptions; track option.value) {
|
||||||
|
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Event</mat-label>
|
||||||
|
<mat-select [value]="eventFilter()" (selectionChange)="eventFilter.set($event.value); onFilterChange()">
|
||||||
|
@for (option of eventOptions; track option.value) {
|
||||||
|
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Von</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="date"
|
||||||
|
[value]="fromFilter()"
|
||||||
|
(change)="fromFilter.set($any($event.target).value); onFilterChange()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Bis</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="date"
|
||||||
|
[value]="toFilter()"
|
||||||
|
(change)="toFilter.set($any($event.target).value); onFilterChange()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Suche in Details</mat-label>
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
<input matInput type="search" (input)="onSearchInput($any($event.target).value)" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ag-grid-angular
|
||||||
|
class="logs-grid"
|
||||||
|
[theme]="gridTheme"
|
||||||
|
[columnDefs]="columnDefs"
|
||||||
|
[getRowId]="getRowId"
|
||||||
|
rowModelType="infinite"
|
||||||
|
[cacheBlockSize]="50"
|
||||||
|
[pagination]="true"
|
||||||
|
[paginationPageSize]="50"
|
||||||
|
(gridReady)="onGridReady($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
90
myteamwallet_frontend_modern/src/app/features/logs/logs.scss
Normal file
90
myteamwallet_frontend_modern/src/app/features/logs/logs.scss
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts
Normal file
151
myteamwallet_frontend_modern/src/app/features/logs/logs.spec.ts
Normal file
@@ -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<typeof datasource.getRows>[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<typeof datasource.getRows>[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();
|
||||||
|
});
|
||||||
|
});
|
||||||
241
myteamwallet_frontend_modern/src/app/features/logs/logs.ts
Normal file
241
myteamwallet_frontend_modern/src/app/features/logs/logs.ts
Normal file
@@ -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<string>();
|
||||||
|
private gridApi?: GridApi<LogEntry>;
|
||||||
|
|
||||||
|
protected readonly columnDefs: ColDef<LogEntry>[] = [
|
||||||
|
{
|
||||||
|
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<LogEntry>) => String(params.data.id);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.searchInput$.pipe(debounceTime(300), takeUntilDestroyed()).subscribe((value) => {
|
||||||
|
this.search.set(value);
|
||||||
|
this.reloadLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onGridReady(event: GridReadyEvent<LogEntry>): 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 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
<p class="eyebrow">Organisation</p>
|
<p class="eyebrow">Organisation</p>
|
||||||
<h1>Benutzer</h1>
|
<h1>Benutzer</h1>
|
||||||
<p>Konten und sichtbare Teamzuordnungen im Überblick.</p>
|
<p>Konten und sichtbare Teamzuordnungen im Überblick.</p>
|
||||||
|
@if (isAdmin()) {
|
||||||
|
<a mat-button routerLink="/logs"><mat-icon>receipt_long</mat-icon>Logs</a>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="directory-search" (submit)="submitSearch(); $event.preventDefault()" role="search">
|
<form class="directory-search" (submit)="submitSearch(); $event.preventDefault()" role="search">
|
||||||
|
|||||||
29
myteamwallet_frontend_modern/src/app/models/log.model.ts
Normal file
29
myteamwallet_frontend_modern/src/app/models/log.model.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export type LogLevel = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
level: LogLevel;
|
||||||
|
event: string;
|
||||||
|
details: string;
|
||||||
|
userId: number;
|
||||||
|
duration?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogQuery {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
level?: LogLevel;
|
||||||
|
event?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogPage {
|
||||||
|
data: LogEntry[];
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user