first commit
This commit is contained in:
8
myteamwallet_backend/src/teams/dto/create-team.dto.ts
Normal file
8
myteamwallet_backend/src/teams/dto/create-team.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CreateTeamDTO {
|
||||
@ApiProperty({ example: '1. Herren' })
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
}
|
||||
46
myteamwallet_backend/src/teams/dto/public-access.dto.ts
Normal file
46
myteamwallet_backend/src/teams/dto/public-access.dto.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdatePublicAccessDto {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PublicAccessStatusDto {
|
||||
enabled: boolean;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
export interface PublicPlayerDto {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
balance: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface PublicPenaltyDto {
|
||||
id: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface PublicTransactionDto {
|
||||
id: number;
|
||||
note: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
type: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
export interface PublicTeamDto {
|
||||
name: string;
|
||||
balance: number;
|
||||
outstanding: number;
|
||||
players: PublicPlayerDto[];
|
||||
penalties: PublicPenaltyDto[];
|
||||
}
|
||||
|
||||
export interface PublicPlayerHistoryDto {
|
||||
player: PublicPlayerDto;
|
||||
transactions: PublicTransactionDto[];
|
||||
}
|
||||
60
myteamwallet_backend/src/teams/entities/team.entity.ts
Normal file
60
myteamwallet_backend/src/teams/entities/team.entity.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
AfterLoad,
|
||||
BeforeInsert,
|
||||
Column,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
|
||||
|
||||
@Entity()
|
||||
export class Team extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
alias: string;
|
||||
|
||||
@Column({ default: false })
|
||||
publicAccessEnabled: boolean;
|
||||
|
||||
@Column({ length: 64, nullable: true, unique: true, select: false })
|
||||
publicAccessToken?: string | null;
|
||||
|
||||
@OneToMany(() => TeamSetting, (setting) => setting.team, {
|
||||
eager: true,
|
||||
})
|
||||
settings: TeamSetting[];
|
||||
|
||||
@OneToMany(() => TeamWalletTransaction, (transaction) => transaction.team, {
|
||||
eager: false,
|
||||
})
|
||||
transactions: TeamWalletTransaction[];
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
balance: number;
|
||||
|
||||
@OneToMany(() => Player, (player) => player.team)
|
||||
players: Player[];
|
||||
|
||||
outstanding?: number;
|
||||
|
||||
@BeforeInsert()
|
||||
setAlias() {
|
||||
if (!this.alias) {
|
||||
this.alias = Date.now() + '';
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
updateValue() {
|
||||
this.balance = Number(this.balance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type TeamSettingType = 'transaction_create_min_role' | '';
|
||||
@@ -0,0 +1,178 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PublicTeamAccessService } from './public-team-access.service';
|
||||
|
||||
describe('PublicTeamAccessService', () => {
|
||||
const teamRepository = {
|
||||
createQueryBuilder: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(async (team) => team),
|
||||
};
|
||||
const playerRepository = { find: jest.fn(), findOne: jest.fn() };
|
||||
const transactionRepository = { find: jest.fn() };
|
||||
const penaltyRepository = { find: jest.fn() };
|
||||
const access = { assertMember: jest.fn(), assertManager: jest.fn() };
|
||||
let service: PublicTeamAccessService;
|
||||
|
||||
const managedTeam = {
|
||||
id: 7,
|
||||
name: 'Team A',
|
||||
balance: 125,
|
||||
publicAccessEnabled: false,
|
||||
publicAccessToken: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
teamRepository.save.mockImplementation(async (team) => team);
|
||||
service = new PublicTeamAccessService(
|
||||
teamRepository as any,
|
||||
playerRepository as any,
|
||||
transactionRepository as any,
|
||||
penaltyRepository as any,
|
||||
access as any,
|
||||
);
|
||||
});
|
||||
|
||||
function mockManagedTeam(team = { ...managedTeam }) {
|
||||
const getOne = jest.fn().mockResolvedValue(team);
|
||||
teamRepository.createQueryBuilder.mockReturnValue({
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getOne,
|
||||
});
|
||||
return team;
|
||||
}
|
||||
|
||||
it('creates a 64 character token on first activation', async () => {
|
||||
const team = mockManagedTeam();
|
||||
|
||||
const status = await service.setEnabled(4, 7, true);
|
||||
|
||||
expect(access.assertManager).toHaveBeenCalledWith(4, 7);
|
||||
expect(status.enabled).toBe(true);
|
||||
expect(status.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(teamRepository.save).toHaveBeenCalledWith(team);
|
||||
});
|
||||
|
||||
it('keeps the existing token when re-enabled', async () => {
|
||||
mockManagedTeam({ ...managedTeam, publicAccessToken: 'a'.repeat(64) });
|
||||
|
||||
const status = await service.setEnabled(4, 7, true);
|
||||
|
||||
expect(status.token).toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('rotates the token without changing enabled state', async () => {
|
||||
mockManagedTeam({
|
||||
...managedTeam,
|
||||
publicAccessEnabled: true,
|
||||
publicAccessToken: 'a'.repeat(64),
|
||||
});
|
||||
|
||||
const status = await service.rotate(4, 7);
|
||||
|
||||
expect(status.enabled).toBe(true);
|
||||
expect(status.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(status.token).not.toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('returns only whitelisted public team fields and active players', async () => {
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 7,
|
||||
name: 'Team A',
|
||||
balance: 125,
|
||||
});
|
||||
playerRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
balance: -20,
|
||||
active: true,
|
||||
user: { email: 'must-not-leak@example.com' },
|
||||
},
|
||||
]);
|
||||
penaltyRepository.find.mockResolvedValue([
|
||||
{ id: 8, description: 'Zu spät', amount: 5, user: { email: 'hidden' } },
|
||||
]);
|
||||
|
||||
await expect(service.getPublicTeam('b'.repeat(64))).resolves.toEqual({
|
||||
name: 'Team A',
|
||||
balance: 125,
|
||||
outstanding: 20,
|
||||
players: [
|
||||
{
|
||||
id: 3,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
balance: -20,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
penalties: [{ id: 8, description: 'Zu spät', amount: 5 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a player that does not belong to the shared team', async () => {
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 7,
|
||||
name: 'Team A',
|
||||
balance: 125,
|
||||
});
|
||||
playerRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getPublicPlayerHistory('b'.repeat(64), 99),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(transactionRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns player identity and the latest 20 transactions', async () => {
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 7,
|
||||
name: 'Team A',
|
||||
balance: 125,
|
||||
});
|
||||
playerRepository.findOne.mockResolvedValue({
|
||||
id: 3,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
balance: -20,
|
||||
active: true,
|
||||
});
|
||||
transactionRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 11,
|
||||
note: 'Training',
|
||||
date: '2026-07-30',
|
||||
amount: 5,
|
||||
type: { id: 11, name: 'fine' },
|
||||
player: { id: 3 },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.getPublicPlayerHistory('b'.repeat(64), 3),
|
||||
).resolves.toEqual({
|
||||
player: {
|
||||
id: 3,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
balance: -20,
|
||||
active: true,
|
||||
},
|
||||
transactions: [
|
||||
{
|
||||
id: 11,
|
||||
note: 'Training',
|
||||
date: '2026-07-30',
|
||||
amount: 5,
|
||||
type: { id: 11, name: 'fine' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(transactionRepository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ order: { date: 'DESC' }, take: 20 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
189
myteamwallet_backend/src/teams/public-team-access.service.ts
Normal file
189
myteamwallet_backend/src/teams/public-team-access.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
||||
import { Player } from '../players/entities/player.entity';
|
||||
import { Transaction } from '../transactions/entitites/transaction.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
PublicAccessStatusDto,
|
||||
PublicPlayerDto,
|
||||
PublicPlayerHistoryDto,
|
||||
PublicTeamDto,
|
||||
PublicTransactionDto,
|
||||
} from './dto/public-access.dto';
|
||||
import { Team } from './entities/team.entity';
|
||||
import { TeamAccessService } from './team-access.service';
|
||||
|
||||
@Injectable()
|
||||
export class PublicTeamAccessService {
|
||||
constructor(
|
||||
@InjectRepository(Team)
|
||||
private readonly teamRepository: Repository<Team>,
|
||||
@InjectRepository(Player)
|
||||
private readonly playerRepository: Repository<Player>,
|
||||
@InjectRepository(Transaction)
|
||||
private readonly transactionRepository: Repository<Transaction>,
|
||||
@InjectRepository(PenaltyEntity)
|
||||
private readonly penaltyRepository: Repository<PenaltyEntity>,
|
||||
private readonly access: TeamAccessService,
|
||||
) {}
|
||||
|
||||
async getStatus(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
): Promise<PublicAccessStatusDto> {
|
||||
await this.access.assertMember(userId, teamId);
|
||||
return this.toStatus(await this.loadManagedTeam(teamId));
|
||||
}
|
||||
|
||||
async setEnabled(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
enabled: boolean,
|
||||
): Promise<PublicAccessStatusDto> {
|
||||
await this.access.assertManager(userId, teamId);
|
||||
const team = await this.loadManagedTeam(teamId);
|
||||
if (enabled && !team.publicAccessToken) {
|
||||
team.publicAccessToken = this.createToken();
|
||||
}
|
||||
team.publicAccessEnabled = enabled;
|
||||
await this.teamRepository.save(team);
|
||||
return this.toStatus(team);
|
||||
}
|
||||
|
||||
async rotate(userId: number, teamId: number): Promise<PublicAccessStatusDto> {
|
||||
await this.access.assertManager(userId, teamId);
|
||||
const team = await this.loadManagedTeam(teamId);
|
||||
team.publicAccessToken = this.createToken();
|
||||
await this.teamRepository.save(team);
|
||||
return this.toStatus(team);
|
||||
}
|
||||
|
||||
async getPublicTeam(token: string): Promise<PublicTeamDto> {
|
||||
const team = await this.loadPublicTeam(token);
|
||||
const [players, penalties] = await Promise.all([
|
||||
this.playerRepository.find({
|
||||
where: { team: { id: team.id }, active: true },
|
||||
order: { lastName: 'ASC', firstName: 'ASC' },
|
||||
}),
|
||||
this.penaltyRepository.find({
|
||||
where: { team: { id: team.id } },
|
||||
order: { description: 'ASC' },
|
||||
}),
|
||||
]);
|
||||
const publicPlayers = players.map((player) => this.toPublicPlayer(player));
|
||||
|
||||
return {
|
||||
name: team.name,
|
||||
balance: Number(team.balance),
|
||||
outstanding: publicPlayers.reduce(
|
||||
(total, player) => total - Number(player.balance),
|
||||
0,
|
||||
),
|
||||
players: publicPlayers,
|
||||
penalties: penalties.map((penalty) => ({
|
||||
id: penalty.id,
|
||||
description: penalty.description,
|
||||
amount: Number(penalty.amount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getPublicPlayerHistory(
|
||||
token: string,
|
||||
playerId: number,
|
||||
): Promise<PublicPlayerHistoryDto> {
|
||||
const team = await this.loadPublicTeam(token);
|
||||
const player = await this.playerRepository.findOne({
|
||||
where: { id: playerId, team: { id: team.id }, active: true },
|
||||
});
|
||||
if (!player) throw new NotFoundException('Freigabe nicht gefunden.');
|
||||
|
||||
return {
|
||||
player: this.toPublicPlayer(player),
|
||||
transactions: await this.loadTransactions(player.id),
|
||||
};
|
||||
}
|
||||
|
||||
async getMemberPlayerTransactions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
playerId: number,
|
||||
): Promise<PublicTransactionDto[]> {
|
||||
await this.access.assertMember(userId, teamId);
|
||||
const player = await this.playerRepository.findOne({
|
||||
where: { id: playerId, team: { id: teamId } },
|
||||
});
|
||||
if (!player) throw new NotFoundException('Spieler nicht gefunden.');
|
||||
return this.loadTransactions(player.id);
|
||||
}
|
||||
|
||||
private async loadManagedTeam(teamId: number): Promise<Team> {
|
||||
const team = await this.teamRepository
|
||||
.createQueryBuilder('team')
|
||||
.addSelect('team.publicAccessToken')
|
||||
.where('team.id = :teamId', { teamId })
|
||||
.getOne();
|
||||
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||
return team;
|
||||
}
|
||||
|
||||
private async loadPublicTeam(token: string): Promise<Team> {
|
||||
if (!/^[a-f0-9]{64}$/.test(token)) {
|
||||
throw new NotFoundException('Freigabe nicht gefunden.');
|
||||
}
|
||||
const team = await this.teamRepository.findOne({
|
||||
where: { publicAccessToken: token, publicAccessEnabled: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
balance: true,
|
||||
publicAccessEnabled: true,
|
||||
publicAccessToken: true,
|
||||
},
|
||||
});
|
||||
if (!team) throw new NotFoundException('Freigabe nicht gefunden.');
|
||||
return team;
|
||||
}
|
||||
|
||||
private async loadTransactions(
|
||||
playerId: number,
|
||||
): Promise<PublicTransactionDto[]> {
|
||||
const transactions = await this.transactionRepository.find({
|
||||
where: { player: { id: playerId } },
|
||||
order: { date: 'DESC' },
|
||||
take: 20,
|
||||
});
|
||||
return transactions.map((transaction) => ({
|
||||
id: transaction.id,
|
||||
note: transaction.note,
|
||||
date: transaction.date,
|
||||
amount: Number(transaction.amount),
|
||||
type: transaction.type
|
||||
? { id: transaction.type.id, name: transaction.type.name }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
private toPublicPlayer(player: Player): PublicPlayerDto {
|
||||
return {
|
||||
id: player.id,
|
||||
firstName: player.firstName,
|
||||
lastName: player.lastName,
|
||||
balance: Number(player.balance),
|
||||
active: player.active,
|
||||
};
|
||||
}
|
||||
|
||||
private toStatus(team: Team): PublicAccessStatusDto {
|
||||
return {
|
||||
enabled: team.publicAccessEnabled,
|
||||
token: team.publicAccessToken ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private createToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
20
myteamwallet_backend/src/teams/public-teams.controller.ts
Normal file
20
myteamwallet_backend/src/teams/public-teams.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { PublicTeamAccessService } from './public-team-access.service';
|
||||
|
||||
@Controller({ path: 'public/teams', version: '1' })
|
||||
export class PublicTeamsController {
|
||||
constructor(private readonly publicAccess: PublicTeamAccessService) {}
|
||||
|
||||
@Get(':token')
|
||||
getTeam(@Param('token') token: string) {
|
||||
return this.publicAccess.getPublicTeam(token);
|
||||
}
|
||||
|
||||
@Get(':token/players/:playerId/transactions')
|
||||
getPlayerHistory(
|
||||
@Param('token') token: string,
|
||||
@Param('playerId', ParseIntPipe) playerId: number,
|
||||
) {
|
||||
return this.publicAccess.getPublicPlayerHistory(token, playerId);
|
||||
}
|
||||
}
|
||||
84
myteamwallet_backend/src/teams/team-access.service.spec.ts
Normal file
84
myteamwallet_backend/src/teams/team-access.service.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { TeamAccessService } from './team-access.service';
|
||||
|
||||
describe('TeamAccessService', () => {
|
||||
const userRepository = { findOne: jest.fn() };
|
||||
const playerRepository = { find: jest.fn() };
|
||||
let service: TeamAccessService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
service = new TeamAccessService(
|
||||
userRepository as any,
|
||||
playerRepository as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows a global admin without a team membership', async () => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 1,
|
||||
role: { id: RoleEnum.admin },
|
||||
});
|
||||
|
||||
await expect(service.assertManager(1, 9)).resolves.toBeUndefined();
|
||||
expect(playerRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an active team member to read sharing status', async () => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
});
|
||||
playerRepository.find.mockResolvedValue([
|
||||
{ active: true, teamRole: { id: 1 } },
|
||||
]);
|
||||
|
||||
await expect(service.assertMember(2, 9)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([3, 4, 5])(
|
||||
'allows active team role %s to manage sharing',
|
||||
async (roleId) => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
});
|
||||
playerRepository.find.mockResolvedValue([
|
||||
{ active: true, teamRole: { id: roleId } },
|
||||
]);
|
||||
|
||||
await expect(service.assertManager(2, 9)).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the highest role when one user has multiple players in a team', async () => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
});
|
||||
playerRepository.find.mockResolvedValue([
|
||||
{ active: true, teamRole: { id: 1 } },
|
||||
{ active: true, teamRole: { id: 3 } },
|
||||
]);
|
||||
|
||||
await expect(service.assertManager(2, 9)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a player', [{ active: true, teamRole: { id: 1 } }]],
|
||||
['a second treasurer', [{ active: true, teamRole: { id: 2 } }]],
|
||||
['an inactive captain', [{ active: false, teamRole: { id: 3 } }]],
|
||||
['a non-member', []],
|
||||
])('rejects %s from managing sharing', async (_label, players) => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
});
|
||||
playerRepository.find.mockResolvedValue(players);
|
||||
|
||||
await expect(service.assertManager(2, 9)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
47
myteamwallet_backend/src/teams/team-access.service.ts
Normal file
47
myteamwallet_backend/src/teams/team-access.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class TeamAccessService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Player)
|
||||
private readonly playerRepository: Repository<Player>,
|
||||
) {}
|
||||
|
||||
async assertMember(userId: number, teamId: number): Promise<void> {
|
||||
await this.assertMinimumRole(userId, teamId, 1);
|
||||
}
|
||||
|
||||
async assertManager(userId: number, teamId: number): Promise<void> {
|
||||
await this.assertMinimumRole(userId, teamId, 3);
|
||||
}
|
||||
|
||||
private async assertMinimumRole(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
minimumRole: number,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (user?.role?.id === RoleEnum.admin) return;
|
||||
|
||||
const players = await this.playerRepository.find({
|
||||
where: { user: { id: userId }, team: { id: teamId } },
|
||||
});
|
||||
const highestActiveRole = players
|
||||
.filter((player) => player.active)
|
||||
.reduce(
|
||||
(highest, player) => Math.max(highest, player.teamRole?.id ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (highestActiveRole < minimumRole) {
|
||||
throw new ForbiddenException('Keine Berechtigung für dieses Team.');
|
||||
}
|
||||
}
|
||||
}
|
||||
18
myteamwallet_backend/src/teams/teams.controller.spec.ts
Normal file
18
myteamwallet_backend/src/teams/teams.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TeamsController } from './teams.controller';
|
||||
|
||||
describe('TeamsController', () => {
|
||||
let controller: TeamsController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<TeamsController>(TeamsController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
140
myteamwallet_backend/src/teams/teams.controller.ts
Normal file
140
myteamwallet_backend/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
ParseIntPipe,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from 'src/roles/roles.decorator';
|
||||
import { RoleEnum } from 'src/roles/roles.enum';
|
||||
import { RolesGuard } from 'src/roles/roles.guard';
|
||||
import { CreateTeamDTO } from './dto/create-team.dto';
|
||||
import { TeamsService } from './teams.service';
|
||||
import { PublicTeamAccessService } from './public-team-access.service';
|
||||
import { UpdatePublicAccessDto } from './dto/public-access.dto';
|
||||
|
||||
@ApiTags('Teams')
|
||||
@Controller({
|
||||
path: 'teams',
|
||||
version: '1',
|
||||
})
|
||||
export class TeamsController {
|
||||
constructor(
|
||||
private service: TeamsService,
|
||||
private publicAccess: PublicTeamAccessService,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get(':id/public-access')
|
||||
getPublicAccess(@Req() req, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.publicAccess.getStatus(Number(req.user.id), id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Patch(':id/public-access')
|
||||
setPublicAccess(
|
||||
@Req() req,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdatePublicAccessDto,
|
||||
) {
|
||||
return this.publicAccess.setEnabled(Number(req.user.id), id, body.enabled);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Post(':id/public-access/rotate')
|
||||
rotatePublicAccess(@Req() req, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.publicAccess.rotate(Number(req.user.id), id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get(':id/players/:playerId/transactions')
|
||||
getPlayerTransactions(
|
||||
@Req() req,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('playerId', ParseIntPipe) playerId: number,
|
||||
) {
|
||||
return this.publicAccess.getMemberPlayerTransactions(
|
||||
Number(req.user.id),
|
||||
id,
|
||||
playerId,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Get(':id/overview')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.getOverview(id);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Transactionen für ein Team',
|
||||
description:
|
||||
'Gibt alle Transaktionen, Spieler und Teamwallet, für ein bestimmtes Team zurück und sortiert absteigend nach Datum',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Get(':id/transactions')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
getAllTransactions(@Req() req, @Param('id') id: string) {
|
||||
const userId = req.user?.id;
|
||||
return this.service.getTeamTransactions(id, userId);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Neuen Spieler anlegen',
|
||||
description:
|
||||
'Erstellt einen neuen Spieler für das Team mit der ID aus der URL',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Post(':id/players')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
createPlayer(@Param('id') id: string, @Body() playerDto: any) {
|
||||
return this.service.createNewPlayer(id, playerDto);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@ApiOperation({
|
||||
summary: 'Spieler updaten',
|
||||
description: 'verändert den Spieler mit der team id',
|
||||
})
|
||||
@Put(':id/players')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
updatePlayer(@Param('id') id: string, @Body() playerDto: any) {
|
||||
return this.service.updatePlayer(playerDto);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Neues Team anlegen',
|
||||
description:
|
||||
'Erstellt ein neues Team mit gegebenem Namen und gibt es zurück.',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@Roles([RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
create(@Req() req, @Body() teamDto: CreateTeamDTO) {
|
||||
const userId = req.user?.id;
|
||||
return this.service.createNewTeam(teamDto, userId);
|
||||
}
|
||||
}
|
||||
35
myteamwallet_backend/src/teams/teams.module.ts
Normal file
35
myteamwallet_backend/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
||||
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
|
||||
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
|
||||
import { Transaction } from 'src/transactions/entitites/transaction.entity';
|
||||
import { Team } from './entities/team.entity';
|
||||
import { TeamsController } from './teams.controller';
|
||||
import { TeamsService } from './teams.service';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { TeamAccessService } from './team-access.service';
|
||||
import { PublicTeamAccessService } from './public-team-access.service';
|
||||
import { PublicTeamsController } from './public-teams.controller';
|
||||
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Team,
|
||||
Player,
|
||||
Transaction,
|
||||
TeamRole,
|
||||
TeamSetting,
|
||||
TeamWalletTransaction,
|
||||
User,
|
||||
PenaltyEntity,
|
||||
]),
|
||||
LoggingModule,
|
||||
],
|
||||
controllers: [TeamsController, PublicTeamsController],
|
||||
providers: [TeamsService, TeamAccessService, PublicTeamAccessService],
|
||||
})
|
||||
export class TeamsModule {}
|
||||
18
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
18
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TeamsService } from './teams.service';
|
||||
|
||||
describe('TeamsService', () => {
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [TeamsService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TeamsService>(TeamsService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
254
myteamwallet_backend/src/teams/teams.service.ts
Normal file
254
myteamwallet_backend/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
||||
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
|
||||
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
|
||||
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
|
||||
import { Transaction } from 'src/transactions/entitites/transaction.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateTeamDTO } from './dto/create-team.dto';
|
||||
import { Team } from './entities/team.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
constructor(
|
||||
@InjectRepository(Team)
|
||||
private repository: Repository<Team>,
|
||||
@InjectRepository(Player)
|
||||
private playerRepository: Repository<Player>,
|
||||
@InjectRepository(Transaction)
|
||||
private transactionsRepository: Repository<Transaction>,
|
||||
@InjectRepository(TeamRole)
|
||||
private rolesRepository: Repository<TeamRole>,
|
||||
@InjectRepository(TeamSetting)
|
||||
private settingsRepository: Repository<TeamSetting>,
|
||||
@InjectRepository(TeamWalletTransaction)
|
||||
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
|
||||
private logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async getOverview(teamId: string) {
|
||||
const id = Number(teamId);
|
||||
|
||||
const team = await this.repository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: ['players'],
|
||||
});
|
||||
|
||||
if (team.players) {
|
||||
const out = team.players
|
||||
.filter((p) => p.active)
|
||||
.reduce((acc, succ) => acc + Number(succ.balance), 0);
|
||||
team.outstanding = out * -1;
|
||||
} else {
|
||||
team.outstanding = 0;
|
||||
}
|
||||
team.balance = Number(team.balance);
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
async getOverviewByAlias(alias: string) {
|
||||
const team = await this.repository.findOneOrFail({
|
||||
where: { alias },
|
||||
relations: ['players'],
|
||||
});
|
||||
|
||||
if (team.players) {
|
||||
const out = team.players
|
||||
.filter((p) => p.active)
|
||||
.reduce((acc, succ) => acc + Number(succ.balance), 0);
|
||||
team.outstanding = out * -1;
|
||||
} else {
|
||||
team.outstanding = 0;
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async getUserTransactionsFromTeam(teamId: string, userId: string) {
|
||||
const player = await this.playerRepository.findOne({
|
||||
where: { id: Number(userId) },
|
||||
relations: ['transactions'],
|
||||
});
|
||||
|
||||
if (!player) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
errors: {
|
||||
user: 'user_not_found',
|
||||
},
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const transactions = await this.transactionsRepository.find({
|
||||
where: {
|
||||
player: {
|
||||
id: player.id,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
date: 'DESC',
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
return transactions;
|
||||
}
|
||||
|
||||
async createNewPlayer(
|
||||
id: string,
|
||||
player: { firstName: string; lastName: string; teamRole: any },
|
||||
) {
|
||||
if (typeof player.teamRole == 'string') {
|
||||
player.teamRole = await this.rolesRepository.findOneBy({
|
||||
name: player.teamRole,
|
||||
});
|
||||
} else if (typeof player.teamRole == 'number') {
|
||||
player.teamRole = await this.rolesRepository.findOneBy({
|
||||
id: player.teamRole,
|
||||
});
|
||||
} else if (!player.teamRole) {
|
||||
player.teamRole = await this.rolesRepository.findOneBy({
|
||||
id: 1, // player
|
||||
});
|
||||
}
|
||||
const team = await this.repository.findOneBy({
|
||||
id: Number(id),
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new HttpException('No Team Found', HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
const p = {
|
||||
...player,
|
||||
team,
|
||||
};
|
||||
|
||||
const created: Player = this.playerRepository.create(p);
|
||||
|
||||
const playerSaved: Player = await this.playerRepository.save(created);
|
||||
|
||||
await this.logger.info({
|
||||
event: 'player_creation',
|
||||
details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`,
|
||||
userId: Number(id),
|
||||
});
|
||||
return playerSaved;
|
||||
}
|
||||
|
||||
async createNewTeam(teamdto: CreateTeamDTO, userId: string) {
|
||||
const createTeam = this.repository.create(teamdto);
|
||||
|
||||
const team = await this.repository.save(createTeam);
|
||||
await this.generateBasicTeamSettings(team);
|
||||
|
||||
await this.logger.info({
|
||||
event: 'team_create',
|
||||
details: `created team id: ${team.id}`,
|
||||
userId: Number(userId),
|
||||
});
|
||||
return team;
|
||||
}
|
||||
|
||||
private generateBasicTeamSettings(team: Team): Promise<void> {
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const s: CreateTeamSettingDTO = {
|
||||
key: 'transaction_create_min_role',
|
||||
value: '2',
|
||||
team,
|
||||
};
|
||||
|
||||
const setting = this.settingsRepository.create(s);
|
||||
await this.settingsRepository.save(setting);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamTransactions(teamId: string | number, userId: string | number) {
|
||||
const start = new Date();
|
||||
teamId = Number(teamId);
|
||||
|
||||
const team = await this.repository.findOneOrFail({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
|
||||
const transactions = [];
|
||||
|
||||
for (const t of team.transactions) {
|
||||
transactions.push({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
amount: t.amount,
|
||||
type: t.type.name,
|
||||
note: t.note,
|
||||
isTeamWalletTransaction: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of team.players) {
|
||||
for (const t of p.transactions) {
|
||||
transactions.push({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
amount: t.amount,
|
||||
type: t.type.name,
|
||||
note: t.note,
|
||||
playerName: p.firstName + ' ' + p.lastName,
|
||||
isTeamWalletTransaction: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = transactions.sort((a, b) => (a.date < b.date ? 1 : -1));
|
||||
const duration = new Date().getTime() - start.getTime();
|
||||
|
||||
await this.logger.debug({
|
||||
event: 'team_transaction_get',
|
||||
details: `load ${result.length} transactions for TeamID: ${team.id}`,
|
||||
userId: Number(userId),
|
||||
duration,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async updatePlayer(playerDTO: any) {
|
||||
const player = await this.playerRepository.findOneOrFail({
|
||||
where: {
|
||||
id: playerDTO.id,
|
||||
},
|
||||
relations: ['teamRole', 'transactions', 'team', 'transactions.type'],
|
||||
});
|
||||
|
||||
player.active = playerDTO.active;
|
||||
player.balance = playerDTO.balance;
|
||||
player.firstName = playerDTO.firstName;
|
||||
player.lastName = playerDTO.lastName;
|
||||
|
||||
if (player.teamRole.id != playerDTO.teamRole.id) {
|
||||
player.teamRole = await this.rolesRepository.findOneByOrFail({
|
||||
id: playerDTO.reamRole.id,
|
||||
});
|
||||
}
|
||||
let balance = player.balance;
|
||||
if (!player.active && player.balance != 0) {
|
||||
player.balance = 0;
|
||||
} else if (player.active && player.balance == 0) {
|
||||
balance = player.transactions.reduce((acc, succ) => {
|
||||
let b = succ.amount;
|
||||
if (succ.type?.id > 10 && succ.amount > 0) {
|
||||
b = b * -1;
|
||||
}
|
||||
return acc + b;
|
||||
}, 0);
|
||||
player.balance = balance;
|
||||
}
|
||||
const res = await this.playerRepository.save(player);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user