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:
@@ -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,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Request,
|
||||
@@ -13,8 +16,12 @@ import {
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { Roles } from '../roles/roles.decorator';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
|
||||
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
|
||||
@@ -27,6 +34,7 @@ export class CashboxExportController {
|
||||
constructor(
|
||||
private readonly service: CashboxExportService,
|
||||
private readonly subscriptionService: CashboxExportSubscriptionService,
|
||||
private readonly scheduler: CashboxExportScheduler,
|
||||
) {}
|
||||
|
||||
@Get(':teamId')
|
||||
@@ -66,4 +74,12 @@ export class CashboxExportController {
|
||||
) {
|
||||
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto);
|
||||
}
|
||||
|
||||
@Post('admin/run')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles([RoleEnum.admin])
|
||||
runDueSubscriptionsNow(): Promise<void> {
|
||||
return this.scheduler.runDueSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as request from 'supertest';
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
||||
import validationOptions from '../utils/validation-options';
|
||||
import { CashboxExportController } from './cashbox-export.controller';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
|
||||
@@ -22,6 +23,7 @@ describe('cashbox export HTTP boundary', () => {
|
||||
getSubscription: jest.fn(),
|
||||
upsertSubscription: jest.fn(),
|
||||
};
|
||||
const scheduler = { runDueSubscriptions: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
@@ -29,6 +31,7 @@ describe('cashbox export HTTP boundary', () => {
|
||||
providers: [
|
||||
{ provide: CashboxExportService, useValue: service },
|
||||
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
|
||||
{ provide: CashboxExportScheduler, useValue: scheduler },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard('jwt'))
|
||||
|
||||
@@ -8,4 +8,5 @@ export default registerAs('app', () => ({
|
||||
backendDomain: process.env.BACKEND_DOMAIN,
|
||||
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
|
||||
apiPrefix: process.env.API_PREFIX || 'api',
|
||||
logRetentionDays: parseInt(process.env.LOG_RETENTION_DAYS, 10) || 365,
|
||||
}));
|
||||
|
||||
@@ -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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LogEntry } from './entities/log-entry.entity';
|
||||
import { LogRetentionScheduler } from './log-retention.scheduler';
|
||||
import { LoggingService } from './logging.service';
|
||||
import { LogsController } from './logs.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LogEntry])],
|
||||
providers: [LoggingService],
|
||||
controllers: [LogsController],
|
||||
providers: [LoggingService, LogRetentionScheduler],
|
||||
exports: [LoggingService],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
|
||||
@@ -23,3 +23,96 @@ describe('LoggingService', () => {
|
||||
expect(defaultRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoggingService.findLogs', () => {
|
||||
let rows: any[];
|
||||
let total: number;
|
||||
let query: any;
|
||||
let repository: any;
|
||||
let service: LoggingService;
|
||||
|
||||
beforeEach(() => {
|
||||
rows = [];
|
||||
total = 0;
|
||||
query = chain({
|
||||
getMany: jest.fn(() => rows),
|
||||
getCount: jest.fn(() => total),
|
||||
});
|
||||
repository = {
|
||||
createQueryBuilder: jest.fn(() => query),
|
||||
};
|
||||
service = new LoggingService(repository);
|
||||
});
|
||||
|
||||
it('returns a paginated page with data, total and hasNextPage', async () => {
|
||||
rows = [
|
||||
{ id: 1, level: 'INFO', event: 'team_create', details: 'teamId=5', userId: 3, createdAt: new Date('2026-08-01') },
|
||||
];
|
||||
total = 21;
|
||||
|
||||
const result = await service.findLogs({ page: 1, limit: 20 });
|
||||
|
||||
expect(result).toEqual({ data: rows, page: 1, limit: 20, total: 21, hasNextPage: true });
|
||||
expect(query.orderBy).toHaveBeenCalledWith('log.createdAt', 'DESC');
|
||||
expect(query.offset).toHaveBeenCalledWith(0);
|
||||
expect(query.limit).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('reports hasNextPage=false on the last page', async () => {
|
||||
total = 20;
|
||||
|
||||
const result = await service.findLogs({ page: 1, limit: 20 });
|
||||
|
||||
expect(result.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it('offsets by (page - 1) * limit', async () => {
|
||||
await service.findLogs({ page: 3, limit: 10 });
|
||||
|
||||
expect(query.offset).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('filters by level and event when provided', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20, level: 'ERROR', event: 'cashbox_export_subscription_run_fail' });
|
||||
|
||||
expect(query.andWhere).toHaveBeenCalledWith('log.level = :level', { level: 'ERROR' });
|
||||
expect(query.andWhere).toHaveBeenCalledWith('log.event = :event', {
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by an inclusive date range when from/to are provided', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20, from: '2026-01-01', to: '2026-01-31' });
|
||||
|
||||
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt >= :from', { from: '2026-01-01' });
|
||||
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt <= :to', { to: '2026-01-31' });
|
||||
});
|
||||
|
||||
it('does not add level/event/date filters when omitted', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20 });
|
||||
|
||||
expect(query.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters details by a case-insensitive search term', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20, search: ' TeamId=5 ' });
|
||||
|
||||
expect(query.andWhere).toHaveBeenCalledWith('LOWER(log.details) LIKE :search', {
|
||||
search: '%teamid=5%',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a blank search term', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20, search: ' ' });
|
||||
|
||||
expect(query.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function chain(overrides: Record<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 { CreateLogDTO } from './dto/create-log.dto';
|
||||
import { LogEntry } from './entities/log-entry.entity';
|
||||
import { LOGEVENT } from './model/logging-event.type';
|
||||
import { LOGEVENT, LOGLEVEL } from './model/logging-event.type';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingService {
|
||||
@@ -95,4 +95,43 @@ export class LoggingService {
|
||||
};
|
||||
await this.repository.save(e);
|
||||
}
|
||||
|
||||
async findLogs(query: {
|
||||
page: number;
|
||||
limit: number;
|
||||
level?: LOGLEVEL;
|
||||
event?: LOGEVENT;
|
||||
from?: string;
|
||||
to?: string;
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
data: LogEntry[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}> {
|
||||
const builder = this.repository.createQueryBuilder('log');
|
||||
if (query.level) builder.andWhere('log.level = :level', { level: query.level });
|
||||
if (query.event) builder.andWhere('log.event = :event', { event: query.event });
|
||||
if (query.from) builder.andWhere('log.createdAt >= :from', { from: query.from });
|
||||
if (query.to) builder.andWhere('log.createdAt <= :to', { to: query.to });
|
||||
const term = query.search?.trim().toLocaleLowerCase();
|
||||
if (term) {
|
||||
builder.andWhere('LOWER(log.details) LIKE :search', { search: `%${term}%` });
|
||||
}
|
||||
const total = await builder.getCount();
|
||||
const data = await builder
|
||||
.orderBy('log.createdAt', 'DESC')
|
||||
.offset((query.page - 1) * query.limit)
|
||||
.limit(query.limit)
|
||||
.getMany();
|
||||
return {
|
||||
data,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total,
|
||||
hasNextPage: query.page * query.limit < total,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_subscription_update'
|
||||
| 'cashbox_export_subscription_run'
|
||||
| 'cashbox_export_subscription_run_fail';
|
||||
| 'cashbox_export_subscription_run_fail'
|
||||
| 'log_retention_cleanup_run';
|
||||
|
||||
export const LOGEVENT_VALUES: LOGEVENT[] = [
|
||||
'user_create',
|
||||
'application_start',
|
||||
'transaction_create',
|
||||
'team_transaction_create',
|
||||
'team_transaction_get',
|
||||
'user_login_success',
|
||||
'user_login_fail',
|
||||
'user_token_verification_success',
|
||||
'user_token_verification_fail',
|
||||
'user_invite_link_create',
|
||||
'user_invite_link_validate',
|
||||
'user_invite_link_validate_fail',
|
||||
'transaction_create_fail',
|
||||
'transaction_reverse',
|
||||
'player_creation',
|
||||
'admin_user_profile_update',
|
||||
'admin_user_role_update',
|
||||
'admin_user_status_update',
|
||||
'admin_player_assign',
|
||||
'admin_player_unlink',
|
||||
'player_active_update',
|
||||
'player_team_role_update',
|
||||
'penalty_catalog_create',
|
||||
'penalty_catalog_update',
|
||||
'penalty_catalog_delete',
|
||||
'team_create',
|
||||
'team_permissions_update',
|
||||
'scheduled_recurring_transaction_check_start',
|
||||
'scheduled_recurring_transaction_check_finished',
|
||||
'recurring_transaction_create',
|
||||
'recurring_transaction_update',
|
||||
'recurring_transaction_delete',
|
||||
'recurring_transaction_run',
|
||||
'cashbox_export_download',
|
||||
'cashbox_export_subscription_update',
|
||||
'cashbox_export_subscription_run',
|
||||
'cashbox_export_subscription_run_fail',
|
||||
'log_retention_cleanup_run',
|
||||
];
|
||||
|
||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
export const LOGLEVEL_VALUES: LOGLEVEL[] = ['FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];
|
||||
|
||||
@@ -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';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Roles } from '../roles/roles.decorator';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
|
||||
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
|
||||
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
|
||||
import { RecurringTransactionsService } from './recurring-transactions.service';
|
||||
|
||||
type AuthenticatedRequest = { user: { id: number } };
|
||||
@@ -24,7 +28,10 @@ type AuthenticatedRequest = { user: { id: number } };
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller({ path: 'recurring-transactions', version: '1' })
|
||||
export class RecurringTransactionsController {
|
||||
constructor(private readonly service: RecurringTransactionsService) {}
|
||||
constructor(
|
||||
private readonly service: RecurringTransactionsService,
|
||||
private readonly scheduler: RecurringTransactionsScheduler,
|
||||
) {}
|
||||
|
||||
@Get(':teamId')
|
||||
getTeamRecurringTransactions(
|
||||
@@ -59,4 +66,12 @@ export class RecurringTransactionsController {
|
||||
): Promise<void> {
|
||||
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 { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
|
||||
import { RecurringTransactionsController } from './recurring-transactions.controller';
|
||||
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
|
||||
import { RecurringTransactionsService } from './recurring-transactions.service';
|
||||
|
||||
describe('recurring transactions HTTP boundary', () => {
|
||||
@@ -31,12 +32,14 @@ describe('recurring transactions HTTP boundary', () => {
|
||||
updateRecurringTransaction: jest.fn(() => entry),
|
||||
deleteRecurringTransaction: jest.fn(),
|
||||
};
|
||||
const scheduler = { runDueRecurringTransactions: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [RecurringTransactionsController],
|
||||
providers: [
|
||||
{ provide: RecurringTransactionsService, useValue: service },
|
||||
{ provide: RecurringTransactionsScheduler, useValue: scheduler },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard('jwt'))
|
||||
|
||||
Reference in New Issue
Block a user