diff --git a/myteamwallet_backend/src/teams/teams.controller.spec.ts b/myteamwallet_backend/src/teams/teams.controller.spec.ts index fb0f7e5..75f93d2 100644 --- a/myteamwallet_backend/src/teams/teams.controller.spec.ts +++ b/myteamwallet_backend/src/teams/teams.controller.spec.ts @@ -40,13 +40,14 @@ describe('TeamsController', () => { ).toEqual(Reflect.getMetadata('roles', TeamsController.prototype.findOne)); }); - it('delegates to service.getOverviewStats with the route id', () => { + it('delegates to service.getOverviewStats with the route id and the acting user id', () => { const stats = { balanceHistory: [], monthlyFlow: [], topOutstanding: [] }; service.getOverviewStats.mockReturnValue(stats); + const req = { user: { id: 42 } }; - const result = controller.getOverviewStats('7'); + const result = controller.getOverviewStats(req as any, '7'); - expect(service.getOverviewStats).toHaveBeenCalledWith('7'); + expect(service.getOverviewStats).toHaveBeenCalledWith('7', 42); expect(result).toBe(stats); }); }); diff --git a/myteamwallet_backend/src/teams/teams.controller.ts b/myteamwallet_backend/src/teams/teams.controller.ts index 7ffdf10..1ac20de 100644 --- a/myteamwallet_backend/src/teams/teams.controller.ts +++ b/myteamwallet_backend/src/teams/teams.controller.ts @@ -91,8 +91,9 @@ export class TeamsController { @UseGuards(AuthGuard('jwt'), RolesGuard) @Get(':id/overview/stats') @HttpCode(HttpStatus.OK) - getOverviewStats(@Param('id') id: string) { - return this.service.getOverviewStats(id); + getOverviewStats(@Req() req, @Param('id') id: string) { + const userId = req.user?.id; + return this.service.getOverviewStats(id, userId); } @ApiOperation({ diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index 94039a1..4a3232f 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -1,3 +1,4 @@ +import { ForbiddenException } from '@nestjs/common'; import { TeamsService } from './teams.service'; describe('TeamsService', () => { @@ -8,11 +9,13 @@ describe('TeamsService', () => { const settingsRepository = {}; const teamWalletTransactionRepository = {}; const logger = { info: jest.fn(), debug: jest.fn() }; + const access = { assertMember: jest.fn(), assertManager: jest.fn() }; let service: TeamsService; beforeEach(() => { jest.resetAllMocks(); + access.assertMember.mockResolvedValue(undefined); service = new TeamsService( repository as any, playerRepository as any, @@ -21,6 +24,7 @@ describe('TeamsService', () => { settingsRepository as any, teamWalletTransactionRepository as any, logger as any, + access as any, ); }); @@ -146,7 +150,7 @@ describe('TeamsService', () => { it('anchors balanceHistory to team.balance and reconstructs earlier months backward, with carry-forward and correct sign handling', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); // Same relative shape as the movements alone would produce // (100, 150, 150, 150, 150, 110, 130, 130, 130, 130, 190, 190), but @@ -173,7 +177,7 @@ describe('TeamsService', () => { const team = buildTeam(); repository.findOneOrFail.mockResolvedValue(team); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); // Sum of this fixture's movements is 190, but team.balance is 490 — // if the sanity check passes, the implementation is anchored to @@ -185,7 +189,7 @@ describe('TeamsService', () => { it('groups monthlyFlow into income (payment+credit) and expense, excluding fine/levy/fee', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); expect(result.monthlyFlow).toEqual([ { month: '2025-09', income: 100, expense: 0 }, @@ -206,7 +210,7 @@ describe('TeamsService', () => { it('limits topOutstanding to active players with negative balance, sorted by debt descending', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); expect(result.topOutstanding).toEqual([ { playerId: 102, playerName: 'Bea Berg', balance: 75 }, @@ -226,7 +230,7 @@ describe('TeamsService', () => { })); repository.findOneOrFail.mockResolvedValue(team); - const result = await service.getOverviewStats('7'); + const result = await service.getOverviewStats('7', 42); expect(result.topOutstanding).toHaveLength(10); // highest debt first @@ -240,12 +244,79 @@ describe('TeamsService', () => { it('loads the team with the same relations used by getTeamTransactions', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); - await service.getOverviewStats('7'); + await service.getOverviewStats('7', 42); expect(repository.findOneOrFail).toHaveBeenCalledWith({ where: { id: 7 }, relations: ['players', 'players.transactions', 'transactions'], }); }); + + it('checks team membership via TeamAccessService.assertMember before loading the team', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + await service.getOverviewStats('7', 42); + + expect(access.assertMember).toHaveBeenCalledWith(42, 7); + }); + + it('propagates a ForbiddenException from assertMember without loading the team', async () => { + const forbidden = new ForbiddenException('Keine Berechtigung für dieses Team.'); + access.assertMember.mockRejectedValue(forbidden); + + await expect(service.getOverviewStats('7', 42)).rejects.toBe(forbidden); + expect(repository.findOneOrFail).not.toHaveBeenCalled(); + }); + + it('returns empty balanceHistory/monthlyFlow/topOutstanding for a brand-new team with no transactions and no players, without throwing', async () => { + repository.findOneOrFail.mockResolvedValue({ + id: 9, + balance: 0, + transactions: [], + players: [], + }); + + const result = await service.getOverviewStats('9', 42); + + expect(result).toEqual({ balanceHistory: [], monthlyFlow: [], topOutstanding: [] }); + }); + + it('does not throw when the players/transactions relations come back undefined (defensive guard, matches getOverview)', async () => { + repository.findOneOrFail.mockResolvedValue({ + id: 9, + balance: 0, + transactions: undefined, + players: undefined, + }); + + const result = await service.getOverviewStats('9', 42); + + expect(result).toEqual({ balanceHistory: [], monthlyFlow: [], topOutstanding: [] }); + }); + + it('still returns a real (flat) chart, not an empty-state, when the last movement is outside the 12-month window', async () => { + // Only relevant movement is dated ~2 years ago -> outside the 12-month + // window (2025-09..2026-08), but movements.length > 0, so this must + // NOT trigger the empty-state — it's a real (if flat) history. + repository.findOneOrFail.mockResolvedValue({ + id: 9, + balance: 250, + transactions: [ + { + id: 1, + date: '2024-01-15T10:00:00.000Z', + amount: '250.00', + type: { name: 'credit' }, + }, + ], + players: [], + }); + + const result = await service.getOverviewStats('9', 42); + + expect(result.balanceHistory).toHaveLength(12); + expect(result.balanceHistory.every((entry) => entry.balance === 250)).toBe(true); + expect(result.monthlyFlow).toHaveLength(12); + }); }); }); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index e18fca5..8f1533f 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; import { CreateTeamDTO } from './dto/create-team.dto'; import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto'; import { Team } from './entities/team.entity'; +import { TeamAccessService } from './team-access.service'; @Injectable() export class TeamsService { @@ -28,6 +29,7 @@ export class TeamsService { @InjectRepository(TeamWalletTransaction) private teamWalletTransactionRepository: Repository, private logger: LoggingService, + private access: TeamAccessService, ) {} async getOverview(teamId: string) { @@ -218,30 +220,40 @@ export class TeamsService { return result; } - async getOverviewStats(teamId: string | number): Promise<{ + async getOverviewStats( + teamId: string | number, + actorUserId: string | number, + ): Promise<{ balanceHistory: { month: string; balance: number }[]; monthlyFlow: { month: string; income: number; expense: number }[]; topOutstanding: { playerId: number; playerName: string; balance: number }[]; }> { const id = Number(teamId); + // Read access: any active team member may view the KPI charts (same + // audience as the existing :id/overview page), not just managers. + await this.access.assertMember(Number(actorUserId), id); + const team = await this.repository.findOneOrFail({ where: { id }, relations: ['players', 'players.transactions', 'transactions'], }); + const players = team.players ?? []; + const teamTransactions = team.transactions ?? []; + // Only payment/credit/expense movements represent actual cash flow and // are the only types that touch team.balance (see setBalance() on // TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's // debt but never move money, so they are excluded entirely. const movements: { date: string; amount: number; type: string }[] = []; - for (const t of team.transactions) { + for (const t of teamTransactions) { movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); } - for (const p of team.players) { - for (const t of p.transactions) { + for (const p of players) { + for (const t of p.transactions ?? []) { if (t.type.name === 'payment') { movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); } @@ -250,6 +262,23 @@ export class TeamsService { movements.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + const topOutstanding = players + .filter((p) => p.active && Number(p.balance) < 0) + .sort((a, b) => Number(a.balance) - Number(b.balance)) + .slice(0, 10) + .map((p) => ({ + playerId: p.id, + playerName: p.firstName + ' ' + p.lastName, + balance: this.round(Math.abs(Number(p.balance))), + })); + + // A brand-new team with no relevant movements at all (ever, not just in + // the last 12 months) has nothing to chart — return empty arrays so the + // frontend's empty-state fires instead of rendering 12 flat zero points. + if (movements.length === 0) { + return { balanceHistory: [], monthlyFlow: [], topOutstanding }; + } + const months = this.getLast12Months(); // team.balance is the one authoritative, current value — it can include @@ -293,16 +322,6 @@ export class TeamsService { return { month, income: this.round(income), expense: this.round(expense) }; }); - const topOutstanding = team.players - .filter((p) => p.active && Number(p.balance) < 0) - .sort((a, b) => Number(a.balance) - Number(b.balance)) - .slice(0, 10) - .map((p) => ({ - playerId: p.id, - playerName: p.firstName + ' ' + p.lastName, - balance: this.round(Math.abs(Number(p.balance))), - })); - return { balanceHistory, monthlyFlow, topOutstanding }; }