Merge branch 'feature/penalty-catalog-management'
This commit is contained in:
@@ -20,6 +20,9 @@ export type LOGEVENT =
|
||||
| 'admin_user_status_update'
|
||||
| 'admin_player_assign'
|
||||
| 'admin_player_unlink'
|
||||
| 'penalty_catalog_create'
|
||||
| 'penalty_catalog_update'
|
||||
| 'penalty_catalog_delete'
|
||||
| 'team_create';
|
||||
|
||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsString,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePenaltyDTO {
|
||||
@ApiProperty({ example: 2342 })
|
||||
@IsNotEmpty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
teamId: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@IsNotEmpty()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
@Max(10000)
|
||||
amount: number;
|
||||
|
||||
team?: Team;
|
||||
|
||||
@ApiProperty({ example: 'Zu spät kommen' })
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsString()
|
||||
@Length(1, 120)
|
||||
description: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class PenaltyResponseDTO {
|
||||
id: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
19
myteamwallet_backend/src/penalty/dto/update-penalty.dto.ts
Normal file
19
myteamwallet_backend/src/penalty/dto/update-penalty.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsString, Length, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpdatePenaltyDTO {
|
||||
@ApiProperty({ example: 'Zu spät kommen' })
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsString()
|
||||
@Length(1, 120)
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
@Max(10000)
|
||||
amount: number;
|
||||
}
|
||||
@@ -1,45 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Roles } from 'src/roles/roles.decorator';
|
||||
import { RolesGuard } from 'src/roles/roles.guard';
|
||||
import { CreatePenaltyDTO } from './dto/create-penalty.dto';
|
||||
import { UpdatePenaltyDTO } from './dto/update-penalty.dto';
|
||||
import { PenaltyService } from './penalty.service';
|
||||
|
||||
type AuthenticatedRequest = { user: { id: number } };
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Controller({
|
||||
path: 'penalty',
|
||||
version: '1',
|
||||
})
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller({ path: 'penalty', version: '1' })
|
||||
export class PenaltyController {
|
||||
constructor(private service: PenaltyService) {}
|
||||
constructor(private readonly service: PenaltyService) {}
|
||||
|
||||
@Roles([])
|
||||
@Get()
|
||||
getIt(@Req() req: any) {
|
||||
const userId = req.user?.id;
|
||||
return this.service.getAll(userId);
|
||||
getUserPenalties(@Request() request: AuthenticatedRequest) {
|
||||
return this.service.getUserPenalties(request.user.id);
|
||||
}
|
||||
|
||||
@Roles([])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Get(':id')
|
||||
getTeams(@Param('id') teamId: string) {
|
||||
return this.service.getTeamPenalties(teamId);
|
||||
@Get(':teamId')
|
||||
getTeamPenalties(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
) {
|
||||
return this.service.getTeamPenalties(request.user.id, teamId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Post()
|
||||
createPenalty(@Req() req: any, @Body() createPenaltyDto: CreatePenaltyDTO) {
|
||||
const userId = req.user?.id;
|
||||
return this.service.createPenalty(createPenaltyDto, userId);
|
||||
createPenalty(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Body() dto: CreatePenaltyDTO,
|
||||
) {
|
||||
return this.service.createPenalty(dto, request.user.id);
|
||||
}
|
||||
|
||||
@Patch(':penaltyId')
|
||||
updatePenalty(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('penaltyId', ParseIntPipe) penaltyId: number,
|
||||
@Body() dto: UpdatePenaltyDTO,
|
||||
) {
|
||||
return this.service.updatePenalty(penaltyId, dto, request.user.id);
|
||||
}
|
||||
|
||||
@Delete(':penaltyId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deletePenalty(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('penaltyId', ParseIntPipe) penaltyId: number,
|
||||
): Promise<void> {
|
||||
await this.service.deletePenalty(penaltyId, request.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
126
myteamwallet_backend/src/penalty/penalty.http.spec.ts
Normal file
126
myteamwallet_backend/src/penalty/penalty.http.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
INestApplication,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import validationOptions from '../utils/validation-options';
|
||||
import { PenaltyController } from './penalty.controller';
|
||||
import { PenaltyService } from './penalty.service';
|
||||
|
||||
describe('penalty catalog HTTP boundary', () => {
|
||||
let app: INestApplication;
|
||||
const penalty = {
|
||||
id: 8,
|
||||
description: 'Zu spät',
|
||||
amount: 5,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
};
|
||||
const service = {
|
||||
getUserPenalties: jest.fn(() => [penalty]),
|
||||
getTeamPenalties: jest.fn(() => [penalty]),
|
||||
createPenalty: jest.fn(() => penalty),
|
||||
updatePenalty: jest.fn(() => penalty),
|
||||
deletePenalty: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [PenaltyController],
|
||||
providers: [{ provide: PenaltyService, useValue: service }],
|
||||
})
|
||||
.overrideGuard(AuthGuard('jwt'))
|
||||
.useValue({
|
||||
canActivate(context) {
|
||||
const httpRequest = context.switchToHttp().getRequest();
|
||||
if (httpRequest.headers.authorization !== 'Bearer user') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
httpRequest.user = { id: 42, role: { id: 2 } };
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(() => app.close());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('requires authentication and passes the actor to a team read', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/penalty/5').expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/penalty/5')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200, [penalty]);
|
||||
expect(service.getTeamPenalties).toHaveBeenCalledWith(42, 5);
|
||||
});
|
||||
|
||||
it('keeps the authenticated cross-team catalog route available', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/penalty').expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/penalty')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200, [penalty]);
|
||||
expect(service.getUserPenalties).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('validates and strips create fields before invoking the service', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/penalty')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({
|
||||
teamId: 5,
|
||||
description: ' Zu spät ',
|
||||
amount: 5.25,
|
||||
team: { id: 99 },
|
||||
})
|
||||
.expect(201, penalty);
|
||||
expect(service.createPenalty).toHaveBeenCalledWith(
|
||||
{ teamId: 5, description: 'Zu spät', amount: 5.25 },
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ teamId: 5, description: '', amount: 5 }],
|
||||
[{ teamId: 5, description: 'x'.repeat(121), amount: 5 }],
|
||||
[{ teamId: 5, description: 'Test', amount: 0 }],
|
||||
[{ teamId: 5, description: 'Test', amount: 10000.01 }],
|
||||
[{ teamId: 5, description: 'Test', amount: 1.001 }],
|
||||
[{ teamId: true, description: 'Test', amount: 1 }],
|
||||
])('rejects invalid create input: %p', async (body) => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/penalty')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send(body)
|
||||
.expect(422);
|
||||
expect(service.createPenalty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes update and delete with numeric IDs and safe payloads', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/penalty/8')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({ description: ' Neu ', amount: 2 })
|
||||
.expect(200, penalty);
|
||||
expect(service.updatePenalty).toHaveBeenCalledWith(
|
||||
8,
|
||||
{ description: 'Neu', amount: 2 },
|
||||
42,
|
||||
);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v1/penalty/8')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(204);
|
||||
expect(service.deletePenalty).toHaveBeenCalledWith(8, 42);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PenaltyController } from './penalty.controller';
|
||||
import { PenaltyService } from './penalty.service';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TeamWalletTransactionType } from 'src/team-wallet-transactions/entities/team-wallet-transaction-type.entity';
|
||||
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { Role } from 'src/roles/entities/role.entity';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
import { PenaltyController } from './penalty.controller';
|
||||
import { PenaltyEntity } from './entities/penalty.entity';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { PenaltyService } from './penalty.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PenaltyController],
|
||||
providers: [PenaltyService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
User,
|
||||
Role,
|
||||
TeamWalletTransaction,
|
||||
TeamWalletTransactionType,
|
||||
Team,
|
||||
PenaltyEntity,
|
||||
Player,
|
||||
]),
|
||||
TypeOrmModule.forFeature([Team, PenaltyEntity]),
|
||||
TeamsModule,
|
||||
LoggingModule,
|
||||
],
|
||||
})
|
||||
|
||||
223
myteamwallet_backend/src/penalty/penalty.service.spec.ts
Normal file
223
myteamwallet_backend/src/penalty/penalty.service.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { PenaltyService } from './penalty.service';
|
||||
|
||||
describe('PenaltyService catalog management', () => {
|
||||
const readRepository = { find: jest.fn(), findOne: jest.fn() };
|
||||
const teamReadRepository = { findOne: jest.fn() };
|
||||
const teamQuery = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn(),
|
||||
};
|
||||
const duplicateQuery = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn(),
|
||||
};
|
||||
const teamRepository = { createQueryBuilder: jest.fn(() => teamQuery) };
|
||||
const writeRepository = {
|
||||
createQueryBuilder: jest.fn(() => duplicateQuery),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity) =>
|
||||
entity.name === 'Team' ? teamRepository : writeRepository,
|
||||
),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
const access = { assertMember: jest.fn(), assertManager: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
let service: PenaltyService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
teamQuery.getOne.mockResolvedValue({ id: 5 });
|
||||
duplicateQuery.getCount.mockResolvedValue(0);
|
||||
teamReadRepository.findOne.mockResolvedValue({ id: 5 });
|
||||
service = new PenaltyService(
|
||||
readRepository as any,
|
||||
teamReadRepository as any,
|
||||
dataSource as any,
|
||||
access as any,
|
||||
logger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns not found instead of leaking an unknown team as an empty catalog', async () => {
|
||||
teamReadRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getTeamPenalties(42, 999)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(access.assertMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the authenticated cross-team catalog route compatible and safely mapped', async () => {
|
||||
readRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 8,
|
||||
description: 'Zu spät',
|
||||
amount: '5.50',
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
team: { id: 5, secret: 'hidden' },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.getUserPenalties(42)).resolves.toEqual([
|
||||
{
|
||||
id: 8,
|
||||
description: 'Zu spät',
|
||||
amount: 5.5,
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(readRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
team: { players: { user: { id: 42 }, active: true } },
|
||||
},
|
||||
order: { description: 'ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('authorizes team reads, sorts them, and maps only safe fields', async () => {
|
||||
readRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 8,
|
||||
description: 'Zu spät',
|
||||
amount: '5.50',
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
team: { id: 5, secret: 'hidden' },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.getTeamPenalties(42, 5)).resolves.toEqual([
|
||||
{
|
||||
id: 8,
|
||||
description: 'Zu spät',
|
||||
amount: 5.5,
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
expect(access.assertMember).toHaveBeenCalledWith(42, 5);
|
||||
expect(readRepository.find).toHaveBeenCalledWith({
|
||||
where: { team: { id: 5 } },
|
||||
order: { description: 'ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a normalized entry under a team lock and audits in the transaction', async () => {
|
||||
writeRepository.save.mockImplementation(async (value) => ({
|
||||
id: 9,
|
||||
createdAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
...value,
|
||||
}));
|
||||
|
||||
await expect(
|
||||
service.createPenalty(
|
||||
{ teamId: 5, description: ' Handy in der Kabine ', amount: 3.25 },
|
||||
42,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
id: 9,
|
||||
description: 'Handy in der Kabine',
|
||||
amount: 3.25,
|
||||
});
|
||||
|
||||
expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(access.assertManager).toHaveBeenCalledWith(42, 5, manager);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'penalty_catalog_create',
|
||||
details: 'teamId=5 penaltyId=9 action=create',
|
||||
userId: 42,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a normalized duplicate before writing or auditing', async () => {
|
||||
duplicateQuery.getCount.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
service.createPenalty(
|
||||
{ teamId: 5, description: ' ZU SPÄT ', amount: 5 },
|
||||
42,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(writeRepository.save).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates an entry in its owning team and excludes itself from duplicate detection', async () => {
|
||||
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
|
||||
writeRepository.findOne.mockResolvedValue({
|
||||
id: 8,
|
||||
team: { id: 5 },
|
||||
description: 'Alt',
|
||||
amount: 1,
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
writeRepository.save.mockImplementation(async (value) => value);
|
||||
|
||||
await expect(
|
||||
service.updatePenalty(8, { description: ' Neu ', amount: 2.5 }, 42),
|
||||
).resolves.toMatchObject({ id: 8, description: 'Neu', amount: 2.5 });
|
||||
expect(duplicateQuery.andWhere).toHaveBeenCalledWith(
|
||||
'penalty.id != :penaltyId',
|
||||
{ penaltyId: 8 },
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'penalty_catalog_update',
|
||||
details: 'teamId=5 penaltyId=8 action=update',
|
||||
userId: 42,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes an entry permanently after manager authorization and audits it', async () => {
|
||||
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
|
||||
const penalty = { id: 8, team: { id: 5 } };
|
||||
writeRepository.findOne.mockResolvedValue(penalty);
|
||||
|
||||
await service.deletePenalty(8, 42);
|
||||
|
||||
expect(writeRepository.remove).toHaveBeenCalledWith(penalty);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'penalty_catalog_delete',
|
||||
details: 'teamId=5 penaltyId=8 action=delete',
|
||||
userId: 42,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns not found when a mutation target does not exist', async () => {
|
||||
readRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deletePenalty(999, 42)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the mutation when transactional audit persistence fails', async () => {
|
||||
writeRepository.save.mockResolvedValue({
|
||||
id: 9,
|
||||
team: { id: 5 },
|
||||
description: 'Neu',
|
||||
amount: 2,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
logger.info.mockRejectedValue(new Error('audit unavailable'));
|
||||
|
||||
await expect(
|
||||
service.createPenalty({ teamId: 5, description: 'Neu', amount: 2 }, 42),
|
||||
).rejects.toThrow('audit unavailable');
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,203 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CreatePenaltyDTO } from './dto/create-penalty.dto';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { CreatePenaltyDTO } from './dto/create-penalty.dto';
|
||||
import { PenaltyResponseDTO } from './dto/penalty-response.dto';
|
||||
import { UpdatePenaltyDTO } from './dto/update-penalty.dto';
|
||||
import { PenaltyEntity } from './entities/penalty.entity';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PenaltyService {
|
||||
constructor(
|
||||
@InjectRepository(Team)
|
||||
private teamRepository: Repository<Team>,
|
||||
@InjectRepository(Player)
|
||||
private playerRepository: Repository<Player>,
|
||||
@InjectRepository(PenaltyEntity)
|
||||
private repository: Repository<PenaltyEntity>,
|
||||
private readonly repository: Repository<PenaltyEntity>,
|
||||
@InjectRepository(Team)
|
||||
private readonly teamRepository: Repository<Team>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async createPenalty(dto: CreatePenaltyDTO, userId: string) {
|
||||
const player = await this.playerRepository.findOne({
|
||||
where: { user: { id: Number(userId) }, team: { id: dto.teamId } },
|
||||
async getUserPenalties(userId: number): Promise<PenaltyResponseDTO[]> {
|
||||
const penalties = await this.repository.find({
|
||||
where: {
|
||||
team: { players: { user: { id: userId }, active: true } },
|
||||
},
|
||||
order: { description: 'ASC' },
|
||||
});
|
||||
return penalties.map((penalty) => this.toResponse(penalty));
|
||||
}
|
||||
|
||||
async getTeamPenalties(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
): Promise<PenaltyResponseDTO[]> {
|
||||
const team = await this.teamRepository.findOne({ where: { id: teamId } });
|
||||
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||
await this.access.assertMember(userId, teamId);
|
||||
const penalties = await this.repository.find({
|
||||
where: { team: { id: teamId } },
|
||||
order: { description: 'ASC' },
|
||||
});
|
||||
return penalties.map((penalty) => this.toResponse(penalty));
|
||||
}
|
||||
|
||||
createPenalty(
|
||||
dto: CreatePenaltyDTO,
|
||||
userId: number,
|
||||
): Promise<PenaltyResponseDTO> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, dto.teamId);
|
||||
await this.access.assertManager(userId, team.id, manager);
|
||||
const repository = manager.getRepository(PenaltyEntity);
|
||||
const description = dto.description.trim();
|
||||
await this.assertUniqueDescription(repository, team.id, description);
|
||||
const saved = await repository.save(
|
||||
repository.create({ team, description, amount: dto.amount }),
|
||||
);
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'penalty_catalog_create',
|
||||
details: `teamId=${team.id} penaltyId=${saved.id} action=create`,
|
||||
userId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return this.toResponse(saved);
|
||||
});
|
||||
}
|
||||
|
||||
async updatePenalty(
|
||||
penaltyId: number,
|
||||
dto: UpdatePenaltyDTO,
|
||||
userId: number,
|
||||
): Promise<PenaltyResponseDTO> {
|
||||
const owner = await this.findOwner(penaltyId);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, owner.team.id);
|
||||
await this.access.assertManager(userId, team.id, manager);
|
||||
const repository = manager.getRepository(PenaltyEntity);
|
||||
const penalty = await this.findTransactionalPenalty(
|
||||
repository,
|
||||
penaltyId,
|
||||
team.id,
|
||||
);
|
||||
const description = dto.description.trim();
|
||||
await this.assertUniqueDescription(
|
||||
repository,
|
||||
team.id,
|
||||
description,
|
||||
penaltyId,
|
||||
);
|
||||
penalty.description = description;
|
||||
penalty.amount = dto.amount;
|
||||
const saved = await repository.save(penalty);
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'penalty_catalog_update',
|
||||
details: `teamId=${team.id} penaltyId=${penaltyId} action=update`,
|
||||
userId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return this.toResponse(saved);
|
||||
});
|
||||
}
|
||||
|
||||
async deletePenalty(penaltyId: number, userId: number): Promise<void> {
|
||||
const owner = await this.findOwner(penaltyId);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, owner.team.id);
|
||||
await this.access.assertManager(userId, team.id, manager);
|
||||
const repository = manager.getRepository(PenaltyEntity);
|
||||
const penalty = await this.findTransactionalPenalty(
|
||||
repository,
|
||||
penaltyId,
|
||||
team.id,
|
||||
);
|
||||
await repository.remove(penalty);
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'penalty_catalog_delete',
|
||||
details: `teamId=${team.id} penaltyId=${penaltyId} action=delete`,
|
||||
userId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async findOwner(penaltyId: number): Promise<PenaltyEntity> {
|
||||
const penalty = await this.repository.findOne({
|
||||
where: { id: penaltyId },
|
||||
relations: ['team'],
|
||||
});
|
||||
if (!penalty?.team) throw new NotFoundException('Strafe nicht gefunden.');
|
||||
return penalty;
|
||||
}
|
||||
|
||||
if (!player || !player.team) {
|
||||
return;
|
||||
private async lockTeam(
|
||||
manager: EntityManager,
|
||||
teamId: number,
|
||||
): Promise<Team> {
|
||||
const team = await manager
|
||||
.getRepository(Team)
|
||||
.createQueryBuilder('team')
|
||||
.where('team.id = :teamId', { teamId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||
return team;
|
||||
}
|
||||
|
||||
private async findTransactionalPenalty(
|
||||
repository: Repository<PenaltyEntity>,
|
||||
penaltyId: number,
|
||||
teamId: number,
|
||||
): Promise<PenaltyEntity> {
|
||||
const penalty = await repository.findOne({
|
||||
where: { id: penaltyId, team: { id: teamId } },
|
||||
relations: ['team'],
|
||||
});
|
||||
if (!penalty) throw new NotFoundException('Strafe nicht gefunden.');
|
||||
return penalty;
|
||||
}
|
||||
|
||||
private async assertUniqueDescription(
|
||||
repository: Repository<PenaltyEntity>,
|
||||
teamId: number,
|
||||
description: string,
|
||||
penaltyId?: number,
|
||||
): Promise<void> {
|
||||
const query = repository
|
||||
.createQueryBuilder('penalty')
|
||||
.where('penalty.teamId = :teamId', { teamId })
|
||||
.andWhere('LOWER(TRIM(penalty.description)) = :description', {
|
||||
description: description.toLocaleLowerCase('de'),
|
||||
});
|
||||
if (penaltyId !== undefined) {
|
||||
query.andWhere('penalty.id != :penaltyId', { penaltyId });
|
||||
}
|
||||
if ((await query.getCount()) > 0) {
|
||||
throw new ConflictException(
|
||||
'Ein Eintrag mit dieser Beschreibung existiert bereits.',
|
||||
);
|
||||
}
|
||||
|
||||
dto.team = player.team;
|
||||
|
||||
const e = this.repository.create(dto);
|
||||
|
||||
return this.repository.save(e);
|
||||
}
|
||||
|
||||
getAll(userId: string) {
|
||||
const id = Number(userId);
|
||||
|
||||
return this.repository.find({
|
||||
where: {
|
||||
team: { players: { user: { id } } },
|
||||
},
|
||||
relations: ['team'],
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamPenalties(teamId: string | number) {
|
||||
teamId = Number(teamId);
|
||||
|
||||
const res = await this.repository.find({
|
||||
where: {
|
||||
team: { id: teamId },
|
||||
},
|
||||
});
|
||||
return res.map((r) => {
|
||||
r.amount = Number(r.amount);
|
||||
return r;
|
||||
});
|
||||
private toResponse(penalty: PenaltyEntity): PenaltyResponseDTO {
|
||||
return {
|
||||
id: penalty.id,
|
||||
description: penalty.description,
|
||||
amount: Number(penalty.amount),
|
||||
createdAt: penalty.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,30 @@ describe('TeamAccessService', () => {
|
||||
await expect(service.assertManager(2, 9)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses repositories from the supplied transaction manager', async () => {
|
||||
const transactionalUserRepository = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
}),
|
||||
};
|
||||
const transactionalPlayerRepository = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ active: true, teamRole: { id: 3 } }]),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(transactionalUserRepository)
|
||||
.mockReturnValueOnce(transactionalPlayerRepository),
|
||||
};
|
||||
|
||||
await expect(service.assertManager(2, 9, manager as any)).resolves.toBeUndefined();
|
||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(playerRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a player', [{ active: true, teamRole: { id: 1 } }]],
|
||||
['a second treasurer', [{ active: true, teamRole: { id: 2 } }]],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Player } from '../players/entities/player.entity';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class TeamAccessService {
|
||||
@@ -14,23 +14,35 @@ export class TeamAccessService {
|
||||
private readonly playerRepository: Repository<Player>,
|
||||
) {}
|
||||
|
||||
async assertMember(userId: number, teamId: number): Promise<void> {
|
||||
await this.assertMinimumRole(userId, teamId, 1);
|
||||
async assertMember(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
await this.assertMinimumRole(userId, teamId, 1, manager);
|
||||
}
|
||||
|
||||
async assertManager(userId: number, teamId: number): Promise<void> {
|
||||
await this.assertMinimumRole(userId, teamId, 3);
|
||||
async assertManager(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
await this.assertMinimumRole(userId, teamId, 3, manager);
|
||||
}
|
||||
|
||||
private async assertMinimumRole(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
minimumRole: number,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
const userRepository = manager?.getRepository(User) ?? this.userRepository;
|
||||
const playerRepository =
|
||||
manager?.getRepository(Player) ?? this.playerRepository;
|
||||
const user = await userRepository.findOne({ where: { id: userId } });
|
||||
if (user?.role?.id === RoleEnum.admin) return;
|
||||
|
||||
const players = await this.playerRepository.find({
|
||||
const players = await playerRepository.find({
|
||||
where: { user: { id: userId }, team: { id: teamId } },
|
||||
});
|
||||
const highestActiveRole = players
|
||||
|
||||
@@ -31,5 +31,6 @@ import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
||||
],
|
||||
controllers: [TeamsController, PublicTeamsController],
|
||||
providers: [TeamsService, TeamAccessService, PublicTeamAccessService],
|
||||
exports: [TeamAccessService],
|
||||
})
|
||||
export class TeamsModule {}
|
||||
|
||||
Reference in New Issue
Block a user