Compare commits
2 Commits
24c509c0d5
...
020b390953
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
020b390953 | ||
|
|
f1b4f7e5b4 |
@@ -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,65 @@
|
|||||||
|
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(), error: 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs and does not rethrow when the delete fails', async () => {
|
||||||
|
repository.delete.mockRejectedValue(new Error('connection reset'));
|
||||||
|
|
||||||
|
await expect(scheduler.cleanupOldLogs()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({
|
||||||
|
event: 'log_retention_cleanup_run_fail',
|
||||||
|
details: 'connection reset',
|
||||||
|
userId: -1,
|
||||||
|
});
|
||||||
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.logger.error({
|
||||||
|
event: 'log_retention_cleanup_run_fail',
|
||||||
|
details: errorMessage,
|
||||||
|
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,101 @@ 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' });
|
||||||
|
// `to` is a plain date (e.g. from a <input type="date">); comparing it
|
||||||
|
// as-is would parse to midnight and exclude the whole last day, so it
|
||||||
|
// must be widened to the end of that day to be genuinely inclusive.
|
||||||
|
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt <= :to', {
|
||||||
|
to: new Date('2026-01-31T23:59:59.999Z'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,45 @@ 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: new Date(`${query.to}T23:59:59.999Z`) });
|
||||||
|
}
|
||||||
|
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,52 @@ 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'
|
||||||
|
| 'log_retention_cleanup_run_fail';
|
||||||
|
|
||||||
|
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',
|
||||||
|
'log_retention_cleanup_run_fail',
|
||||||
|
];
|
||||||
|
|
||||||
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