Compare commits
91 Commits
27ab505aba
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caae4d955d | ||
|
|
1738a109a7 | ||
|
|
91808a63f5 | ||
|
|
c441140872 | ||
|
|
fc0c1ed522 | ||
|
|
95a667cb98 | ||
|
|
77ed71cfb6 | ||
|
|
ed365db283 | ||
|
|
bd421954c5 | ||
|
|
8405f797d7 | ||
|
|
7bfb3d07fc | ||
|
|
35e6c055c0 | ||
|
|
fe523bdce1 | ||
|
|
b40af02e2f | ||
|
|
8ace676abf | ||
|
|
6b3a9d69cc | ||
|
|
a7b087050c | ||
|
|
eb1173c706 | ||
|
|
812061fc6c | ||
|
|
017e6445fa | ||
|
|
d6733eff0d | ||
|
|
639ca651d8 | ||
|
|
8de4c11e24 | ||
|
|
451b5c4e42 | ||
|
|
05f4c2ddf0 | ||
|
|
273c25eccb | ||
|
|
97b0a5c19a | ||
|
|
7410672630 | ||
|
|
ecfa847d2a | ||
|
|
6bda24ec9f | ||
|
|
df634e7601 | ||
|
|
1fe2892ca4 | ||
|
|
020b390953 | ||
|
|
f1b4f7e5b4 | ||
|
|
24c509c0d5 | ||
|
|
4185efb83a | ||
|
|
cd9d7b165f | ||
|
|
6fceee5a07 | ||
|
|
02a4d2e59d | ||
|
|
7b499b361f | ||
|
|
0bad154971 | ||
|
|
da5998487a | ||
|
|
ce0b500d7a | ||
|
|
1c214c5f06 | ||
|
|
148269da11 | ||
|
|
db061a7ef7 | ||
|
|
11b70b9337 | ||
|
|
5c3ff52689 | ||
|
|
a65c7b35b3 | ||
|
|
72fa1d4331 | ||
|
|
d628d5e4d7 | ||
|
|
57869d5fc1 | ||
|
|
eed8266da2 | ||
|
|
5779483b21 | ||
|
|
196898b993 | ||
|
|
42e2bbc4ff | ||
|
|
a11eb73ce9 | ||
|
|
009aae1f2b | ||
|
|
18df224386 | ||
|
|
396dc29cf5 | ||
|
|
f8857f71e1 | ||
|
|
0b408f7d73 | ||
|
|
c6df6a6d38 | ||
|
|
cab04c5869 | ||
|
|
cf7c3efb0f | ||
|
|
a9df62a249 | ||
|
|
d8883d4687 | ||
|
|
15d5f1d4e4 | ||
|
|
b8bcf329c5 | ||
|
|
9e55c0549b | ||
|
|
6531f2553f | ||
|
|
b03198baa6 | ||
|
|
bb6b045a2a | ||
|
|
a05ffd0c7f | ||
|
|
886e4e6941 | ||
|
|
c1478f07f1 | ||
|
|
92eacbc5bb | ||
|
|
09da67b8ef | ||
|
|
145ebb924a | ||
|
|
560fcfc11a | ||
|
|
84bab04523 | ||
|
|
b21641f37a | ||
|
|
71d76725a4 | ||
|
|
862a1bea8d | ||
|
|
3cb8cd9a4a | ||
|
|
3d774a3455 | ||
|
|
ebfaac7590 | ||
|
|
d8f13d674d | ||
|
|
1b6ce57fbf | ||
|
|
5f619d649c | ||
|
|
fd850f372f |
2496
docs/superpowers/plans/2026-08-03-cashbox-export.md
Normal file
2496
docs/superpowers/plans/2026-08-03-cashbox-export.md
Normal file
File diff suppressed because it is too large
Load Diff
882
docs/superpowers/plans/2026-08-03-team-erstellen.md
Normal file
882
docs/superpowers/plans/2026-08-03-team-erstellen.md
Normal file
@@ -0,0 +1,882 @@
|
||||
# Team erstellen über die UI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Any logged-in user can create a new team via the Angular UI and automatically becomes its captain.
|
||||
|
||||
**Architecture:** Loosen the existing `POST /teams` backend endpoint from admin-only to any authenticated user, and extend `TeamsService.createNewTeam` to also create a `Player` membership row (role = captain) for the calling user. On the frontend, add a `createTeam` API method, a small `MatDialog` form component, and wire a persistent "Team erstellen" button into `team-select` that opens the dialog, refreshes the user's team list, and navigates into the new team.
|
||||
|
||||
**Tech Stack:** NestJS + TypeORM + Jest (backend, `myteamwallet_backend`); Angular 21 standalone components + Angular Material + signals + Vitest (frontend, `myteamwallet_frontend_modern`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-08-03-team-erstellen-design.md`.
|
||||
- Creator's team role is always `captain` (`TeamRolesEnum.captain` = `3`, `myteamwallet_backend/src/team-roles/team-roles.enum.ts`) — no role choice in the UI.
|
||||
- `CreateTeamDTO` stays `{ name: string }` — no other fields are added to the create-team form.
|
||||
- Backend: any new constructor dependency on `TeamsService` must be appended as the **last** parameter — `teams.service.spec.ts` constructs `TeamsService` positionally in two places, and reordering breaks those mocks.
|
||||
- Backend tests use Jest (`npm run test` from `myteamwallet_backend/`); frontend tests use Vitest via the Angular CLI (`npm test` from `myteamwallet_frontend_modern/`).
|
||||
- All new user-facing copy is German, matching existing screens (e.g. "Team erstellen", "Teamname", "Erstellen", "Abbrechen").
|
||||
- Out of scope (per spec): team deletion/archiving, custom alias entry, choosing the creator's role.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Loosen the `POST /teams` role guard to any logged-in user
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.controller.ts:247`
|
||||
- Test: `myteamwallet_backend/src/teams/teams.controller.spec.ts` (new file)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
- Produces: nothing new — this task only changes an authorization decorator and adds a regression test for it. `TeamsController.create(req, teamDto)` keeps calling `this.service.createNewTeam(teamDto, userId)` unchanged.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `myteamwallet_backend/src/teams/teams.controller.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { TeamsController } from './teams.controller';
|
||||
|
||||
describe('TeamsController', () => {
|
||||
const service = { createNewTeam: jest.fn() };
|
||||
const publicAccess = {};
|
||||
const teamMembers = {};
|
||||
const teamPermissions = {};
|
||||
const controller = new TeamsController(
|
||||
service as any,
|
||||
publicAccess as any,
|
||||
teamMembers as any,
|
||||
teamPermissions as any,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('allows any logged-in user (not just admins) to create a team', () => {
|
||||
expect(Reflect.getMetadata('roles', TeamsController.prototype.create)).toEqual([
|
||||
RoleEnum.user,
|
||||
RoleEnum.admin,
|
||||
]);
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.create),
|
||||
).toContain(RolesGuard);
|
||||
});
|
||||
|
||||
it('passes the authenticated user and the DTO to the service', () => {
|
||||
const req = { user: { id: 42 } };
|
||||
const dto = { name: '1. Herren' };
|
||||
|
||||
controller.create(req as any, dto as any);
|
||||
|
||||
expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_backend/`): `npx jest teams.controller.spec.ts`
|
||||
Expected: FAIL on the first test — `Reflect.getMetadata('roles', ...)` currently equals `[RoleEnum.admin]`, not `[RoleEnum.user, RoleEnum.admin]`.
|
||||
|
||||
- [ ] **Step 3: Loosen the guard**
|
||||
|
||||
In `myteamwallet_backend/src/teams/teams.controller.ts`, change line 247 from:
|
||||
|
||||
```ts
|
||||
@Roles([RoleEnum.admin])
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
```
|
||||
|
||||
(This is the only `@Roles([RoleEnum.admin])` occurrence in the file — it decorates the `create()` handler at lines 241-254.)
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx jest teams.controller.spec.ts`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_backend/src/teams/teams.controller.ts myteamwallet_backend/src/teams/teams.controller.spec.ts
|
||||
git commit -m "feat: allow any logged-in user to create a team"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Make the creator a captain of the new team
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.service.ts`
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.service.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TeamRolesEnum.captain` (`myteamwallet_backend/src/team-roles/team-roles.enum.ts`, value `3`); `User` entity (`myteamwallet_backend/src/users/entities/user.entity.ts`, has `firstName: string | null`, `lastName: string | null`); `Player` entity (`myteamwallet_backend/src/players/entities/player.entity.ts`).
|
||||
- Produces: `TeamsService.createNewTeam(teamdto: CreateTeamDTO, userId: string): Promise<Team>` now also creates a `Player` row linking the calling user to the new team with `teamRole.id === 3`. No signature change.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_backend/src/teams/teams.service.spec.ts` (new `describe` block, e.g. after the existing two blocks):
|
||||
|
||||
```ts
|
||||
describe('TeamsService#createNewTeam', () => {
|
||||
const repository = { create: jest.fn(), save: jest.fn() };
|
||||
const playerRepository = { create: jest.fn(), save: jest.fn() };
|
||||
const rolesRepository = { findOneBy: jest.fn() };
|
||||
const settingsRepository = { create: jest.fn(), save: jest.fn() };
|
||||
const usersRepository = { findOneBy: jest.fn() };
|
||||
const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
playerRepository as any,
|
||||
{} as any,
|
||||
rolesRepository as any,
|
||||
settingsRepository as any,
|
||||
{} as any,
|
||||
logger as any,
|
||||
{} as any,
|
||||
usersRepository as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the team, its default settings, and a captain membership for the creator', async () => {
|
||||
const savedTeam = { id: 7, name: '1. Herren' };
|
||||
repository.create.mockReturnValue({ name: '1. Herren' });
|
||||
repository.save.mockResolvedValue(savedTeam);
|
||||
settingsRepository.create.mockImplementation((s: unknown) => s);
|
||||
settingsRepository.save.mockResolvedValue([]);
|
||||
usersRepository.findOneBy.mockResolvedValue({
|
||||
id: 42,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
});
|
||||
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
|
||||
playerRepository.create.mockImplementation((p: unknown) => p);
|
||||
playerRepository.save.mockResolvedValue({});
|
||||
|
||||
const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');
|
||||
|
||||
expect(result).toBe(savedTeam);
|
||||
expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
|
||||
expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
|
||||
expect(playerRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
team: savedTeam,
|
||||
teamRole: { id: 3, name: 'captain' },
|
||||
}),
|
||||
);
|
||||
expect(playerRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Also update the **two existing** `new TeamsService(...)` constructions in this file (they currently pass 8 positional args; the constructor will have 9 after Step 3). Both blocks are byte-for-byte identical:
|
||||
|
||||
```ts
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
);
|
||||
```
|
||||
|
||||
Replace **both** occurrences (find-and-replace-all) with a 9th argument appended:
|
||||
|
||||
```ts
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
{} as any,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_backend/`): `npx jest teams.service.spec.ts`
|
||||
Expected: FAIL — TypeScript compile error (`Expected 9 arguments, but got 8`) on the two pre-existing constructions until Step 1's replace-all is applied, and then a runtime FAIL on the new test (`playerRepository.create` not called) until Step 3 is done.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `myteamwallet_backend/src/teams/teams.service.ts`, add two imports (near the existing entity imports):
|
||||
|
||||
```ts
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
```
|
||||
|
||||
Extend the constructor by appending a new injected repository as the **last** parameter (do not reorder the existing ones — `teams.service.spec.ts` relies on their positions):
|
||||
|
||||
```ts
|
||||
@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,
|
||||
private access: TeamAccessService,
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
) {}
|
||||
```
|
||||
|
||||
(`User` is already registered in `TeamsModule`'s `TypeOrmModule.forFeature([...])` — see `myteamwallet_backend/src/teams/teams.module.ts:31` — so no module change is needed.)
|
||||
|
||||
Replace `createNewTeam` and add a new private helper right after it:
|
||||
|
||||
```ts
|
||||
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.addCreatorAsCaptain(team, userId);
|
||||
|
||||
await this.logger.info({
|
||||
event: 'team_create',
|
||||
details: `created team id: ${team.id}`,
|
||||
userId: Number(userId),
|
||||
});
|
||||
return team;
|
||||
}
|
||||
|
||||
private async addCreatorAsCaptain(team: Team, userId: string): Promise<void> {
|
||||
const [user, captainRole] = await Promise.all([
|
||||
this.usersRepository.findOneBy({ id: Number(userId) }),
|
||||
this.rolesRepository.findOneBy({ id: TeamRolesEnum.captain }),
|
||||
]);
|
||||
|
||||
const player = this.playerRepository.create({
|
||||
firstName: user?.firstName ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
team,
|
||||
teamRole: captainRole,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
|
||||
await this.playerRepository.save(player);
|
||||
}
|
||||
```
|
||||
|
||||
(`generateBasicTeamSettings` stays exactly as-is, right below.)
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx jest teams.service.spec.ts`
|
||||
Expected: PASS (all existing tests plus the new `createNewTeam` test).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_backend/src/teams/teams.service.ts myteamwallet_backend/src/teams/teams.service.spec.ts
|
||||
git commit -m "feat: make team creator a captain of the new team"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `TeamsApi.createTeam` (frontend HTTP call)
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Team` model (`myteamwallet_frontend_modern/src/app/models/team.model.ts`: `{ id, name, alias, balance, outstanding?, players?, settings? }`).
|
||||
- Produces: `export interface CreateTeamRequest { name: string }` and `TeamsApi.createTeam(request: CreateTeamRequest): Observable<Team>` — used by Task 5's dialog component.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts` (alongside the existing per-method tests, following the same `httpMock.expectOne(...).flush(...)` pattern already in that file):
|
||||
|
||||
```ts
|
||||
it('creates a team', () => {
|
||||
const request = { name: '1. Herren' };
|
||||
service.createTeam(request).subscribe();
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}teams`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(request);
|
||||
req.flush({ id: 7, name: '1. Herren', alias: 'a', balance: 0 });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `service.createTeam is not a function`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`, add the request interface next to `CreatePlayerRequest`:
|
||||
|
||||
```ts
|
||||
export interface CreateTeamRequest {
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add the method to the `TeamsApi` class (alongside `createPlayer`):
|
||||
|
||||
```ts
|
||||
createTeam(request: CreateTeamRequest): Observable<Team> {
|
||||
return this.http.post<Team>(`${environment.apiUrl}teams`, request);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/core/team/teams-api.ts myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts
|
||||
git commit -m "feat: add TeamsApi.createTeam"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `MyTeamsStore.refresh` (force-reload after creating a team)
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TeamsApi.loadMyTeams(userId: number): Observable<UserTeamMembership[]>` (existing).
|
||||
- Produces: `MyTeamsStore.refresh(userId: number): void` — always refetches, unlike `ensureLoaded` which is a no-op once loaded for that user. Used by Task 6.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`:
|
||||
|
||||
```ts
|
||||
it('refresh always reloads, even for a user id already loaded', () => {
|
||||
store.ensureLoaded(42);
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player]);
|
||||
|
||||
store.refresh(42);
|
||||
expect(store.loading()).toBe(true);
|
||||
|
||||
const secondPlayer = { ...player, id: 2, team: { id: 6, name: 'Team B' } };
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player, secondPlayer]);
|
||||
|
||||
expect(store.loading()).toBe(false);
|
||||
expect(store.players()).toEqual([player, secondPlayer]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `store.refresh is not a function`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace the body of `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts` with:
|
||||
|
||||
```ts
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { UserTeamMembership } from '../../models/user-directory.model';
|
||||
import { TeamsApi } from './teams-api';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MyTeamsStore {
|
||||
private readonly teamsApi = inject(TeamsApi);
|
||||
|
||||
private readonly playersSignal = signal<UserTeamMembership[]>([]);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly loadedForUserId = signal<number | null>(null);
|
||||
|
||||
readonly players = this.playersSignal.asReadonly();
|
||||
readonly loading = this.loadingSignal.asReadonly();
|
||||
|
||||
ensureLoaded(userId: number): void {
|
||||
if (this.loadedForUserId() === userId || this.loadingSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetch(userId);
|
||||
}
|
||||
|
||||
refresh(userId: number): void {
|
||||
this.fetch(userId);
|
||||
}
|
||||
|
||||
private fetch(userId: number): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.teamsApi.loadMyTeams(userId).subscribe({
|
||||
next: (players) => {
|
||||
this.playersSignal.set(players);
|
||||
this.loadedForUserId.set(userId);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS (all existing `MyTeamsStore` tests plus the new one).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts
|
||||
git commit -m "feat: add MyTeamsStore.refresh for forced reloads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `CreateTeamDialog` component
|
||||
|
||||
**Files:**
|
||||
- Create: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`
|
||||
- Test: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TeamsApi.createTeam` (Task 3); `MatDialogRef<CreateTeamDialog, Team | undefined>` (Angular Material).
|
||||
- Produces: `CreateTeamDialog` standalone component. Opening it via `MatDialog.open(CreateTeamDialog)` and subscribing to `afterClosed()` yields the created `Team` on success, or `undefined` if cancelled/dismissed. Used by Task 6.
|
||||
|
||||
This introduces a new pattern for this codebase: the only existing `MatDialogRef`/`MAT_DIALOG_DATA`-injecting component is `shared/confirm-dialog/confirm-dialog.ts` (a plain confirm/cancel dialog, no form). This component follows the same injection style but uses a `ReactiveFormsModule` form, matching how `members.ts`/`penalties.ts` build their (inline, non-dialog) create forms.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { CreateTeamDialog } from './create-team-dialog';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
describe('CreateTeamDialog', () => {
|
||||
let dialogRef: { close: ReturnType<typeof vi.fn> };
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogRef = { close: vi.fn() };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CreateTeamDialog],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
}).compileComponents();
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('keeps the submit button disabled until a team name is entered', () => {
|
||||
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance['form'].invalid).toBe(true);
|
||||
|
||||
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||
expect(fixture.componentInstance['form'].invalid).toBe(false);
|
||||
});
|
||||
|
||||
it('creates the team and closes the dialog with the created team', () => {
|
||||
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||
fixture.componentInstance['submit']();
|
||||
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}teams`);
|
||||
expect(request.request.body).toEqual({ name: '1. Herren' });
|
||||
request.flush({ id: 9, name: '1. Herren', alias: 'a', balance: 0 });
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith({
|
||||
id: 9,
|
||||
name: '1. Herren',
|
||||
alias: 'a',
|
||||
balance: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('re-enables the form and keeps the dialog open when the request fails', () => {
|
||||
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||
fixture.componentInstance['submit']();
|
||||
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}teams`)
|
||||
.flush('error', { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.componentInstance['saving']()).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `create-team-dialog` module not found.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`:
|
||||
|
||||
```ts
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { Team } from '../../../models/team.model';
|
||||
import { TeamsApi } from '../../../core/team/teams-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-team-dialog',
|
||||
imports: [ReactiveFormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatInputModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Team erstellen</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Teamname</mat-label>
|
||||
<input matInput formControlName="name" />
|
||||
</mat-form-field>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
Erstellen
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CreateTeamDialog {
|
||||
protected readonly dialogRef = inject(MatDialogRef<CreateTeamDialog, Team | undefined>);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly teamsApi = inject(TeamsApi);
|
||||
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly form = this.formBuilder.nonNullable.group({
|
||||
name: ['', Validators.required],
|
||||
});
|
||||
|
||||
protected submit(): void {
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.teamsApi.createTeam(this.form.getRawValue()).subscribe({
|
||||
next: (team) => {
|
||||
this.saving.set(false);
|
||||
this.dialogRef.close(team);
|
||||
},
|
||||
error: () => this.saving.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS (3 new tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/
|
||||
git commit -m "feat: add CreateTeamDialog component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire "Team erstellen" into `team-select`
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CreateTeamDialog` (Task 5); `MyTeamsStore.refresh(userId)` (Task 4); `MatDialog` (Angular Material).
|
||||
- Produces: nothing consumed by later tasks — this is the last task.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`. First, add these imports at the top of the file:
|
||||
|
||||
```ts
|
||||
import { Subject } from 'rxjs';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Team } from '../../models/team.model';
|
||||
import { MyTeamsStore } from '../../core/team/my-teams-store';
|
||||
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
|
||||
```
|
||||
|
||||
Then replace the `describe('TeamSelect', ...)` setup (`let` declarations through the closing of `beforeEach`/`afterEach`) with:
|
||||
|
||||
```ts
|
||||
describe('TeamSelect', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let router: Router;
|
||||
let authStore: AuthStore;
|
||||
let myTeamsStore: MyTeamsStore;
|
||||
let dialogClosed: Subject<Team | undefined>;
|
||||
let dialog: { open: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
dialogClosed = new Subject<Team | undefined>();
|
||||
dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TeamSelect],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
router = TestBed.inject(Router);
|
||||
authStore = TestBed.inject(AuthStore);
|
||||
myTeamsStore = TestBed.inject(MyTeamsStore);
|
||||
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
```
|
||||
|
||||
(The existing three `it(...)` blocks stay unchanged below this.) Then add two new tests at the end of the `describe` block, before its closing `});`:
|
||||
|
||||
```ts
|
||||
it('opens the create-team dialog and navigates into the newly created team on success', async () => {
|
||||
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||
const refreshSpy = vi.spyOn(myTeamsStore, 'refresh');
|
||||
|
||||
const fixture = TestBed.createComponent(TeamSelect);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['createTeam']();
|
||||
expect(dialog.open).toHaveBeenCalledWith(CreateTeamDialog);
|
||||
|
||||
const created: Team = { id: 9, name: '1. Herren', alias: 'a', balance: 0 };
|
||||
dialogClosed.next(created);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(42);
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/team', 9, 'overview']);
|
||||
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
});
|
||||
|
||||
it('does not navigate when the create-team dialog is dismissed without a team', async () => {
|
||||
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||
|
||||
const fixture = TestBed.createComponent(TeamSelect);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['createTeam']();
|
||||
dialogClosed.next(undefined);
|
||||
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `createTeam` does not exist on `TeamSelect`, and `MatDialog` is unused/component doesn't inject it yet.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts` with:
|
||||
|
||||
```ts
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AuthStore } from '../../core/auth/auth-store';
|
||||
import { MyTeamsStore } from '../../core/team/my-teams-store';
|
||||
import { Team } from '../../models/team.model';
|
||||
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-team-select',
|
||||
imports: [RouterLink, MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
|
||||
templateUrl: './team-select.html',
|
||||
styleUrl: './team-select.scss',
|
||||
})
|
||||
export class TeamSelect {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly myTeamsStore = inject(MyTeamsStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
protected readonly players = this.myTeamsStore.players;
|
||||
protected readonly loading = this.myTeamsStore.loading;
|
||||
|
||||
constructor() {
|
||||
const userId = this.authStore.currentUser()?.id;
|
||||
if (userId) {
|
||||
this.myTeamsStore.ensureLoaded(userId);
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const players = this.myTeamsStore.players();
|
||||
if (!this.myTeamsStore.loading() && players.length === 1 && players[0].team) {
|
||||
void this.router.navigate(['/team', players[0].team.id, 'overview'], {
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected createTeam(): void {
|
||||
this.dialog
|
||||
.open(CreateTeamDialog)
|
||||
.afterClosed()
|
||||
.subscribe((team: Team | undefined) => {
|
||||
if (!team) return;
|
||||
|
||||
const userId = this.authStore.currentUser()?.id;
|
||||
if (userId) {
|
||||
this.myTeamsStore.refresh(userId);
|
||||
}
|
||||
void this.router.navigate(['/team', team.id, 'overview']);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html` with:
|
||||
|
||||
```html
|
||||
<div class="team-select-toolbar">
|
||||
<button mat-stroked-button type="button" (click)="createTeam()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Team erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="team-select-loading">
|
||||
<mat-spinner diameter="32" />
|
||||
</div>
|
||||
} @else if (players().length === 0) {
|
||||
<div class="team-select-empty">
|
||||
<p>Du bist noch keinem Team zugeordnet.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="team-select-page">
|
||||
<h1>Team auswählen</h1>
|
||||
<mat-nav-list>
|
||||
@for (player of players(); track player.id) {
|
||||
<a mat-list-item [routerLink]="['/team', player.team.id, 'overview']">
|
||||
<span matListItemTitle>{{ player.team.name }}</span>
|
||||
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
|
||||
</a>
|
||||
}
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`:
|
||||
|
||||
```scss
|
||||
.team-select-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS (all `TeamSelect` tests, including the two new ones).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/features/team-select/
|
||||
git commit -m "feat: wire create-team dialog into team-select"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual End-to-End Verification
|
||||
|
||||
After all tasks are complete:
|
||||
|
||||
1. Start the backend (`npm run start:dev` in `myteamwallet_backend/`) and frontend (`npm start` in `myteamwallet_frontend_modern/`).
|
||||
2. Log in as a regular (non-admin) user.
|
||||
3. Go to the team selection screen, click "Team erstellen", enter a name, submit.
|
||||
4. Confirm: the dialog closes, the app navigates to `/team/<newId>/overview`, and the user appears under "Mitglieder" with role "Kapitän".
|
||||
5. Go back to team selection (or log in as a user with 2+ teams) and confirm the "Team erstellen" button is visible even when teams already exist.
|
||||
6. Optionally, call `POST /api/v1/teams` directly via Swagger as a non-admin user's JWT to confirm the 403 is gone.
|
||||
431
docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md
Normal file
431
docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# Theoretischer Kassenstand Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eine zweite, gestrichelte Linie im bestehenden "Kassenstand-Verlauf"-Chart auf der Team-Übersicht zeigt pro Monat den theoretischen Kassenstand (Ist-Kassenstand + zu diesem Zeitpunkt offene, noch unbezahlte Beiträge).
|
||||
|
||||
**Architecture:** Backend (`teams.service.ts#getOverviewStats`) rekonstruiert zusätzlich zur bestehenden Team-Kassenstand-Historie eine Spieler-Saldo-Historie pro aktivem Spieler (gleiches Rückwärts-Reconstruction-Muster wie beim Team-Kassenstand, nur mit allen Buchungstypen statt nur `payment`), summiert sie pro Monat zu "offene Beiträge" und addiert das Ergebnis als `theoreticalBalance`-Feld in jeden bestehenden `balanceHistory`-Punkt. Frontend übernimmt das neue Feld unverändert strukturell als zweite Chart.js-Datenserie im bestehenden Liniendiagramm.
|
||||
|
||||
**Tech Stack:** NestJS/TypeORM (Backend, `myteamwallet_backend`), Angular 21 mit Chart.js (Frontend, `myteamwallet_frontend_modern`), Jest (Backend-Tests), Vitest (Frontend-Tests).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Referenz-Spec: `docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md` (und Basis-Feature `docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`).
|
||||
- `theoreticalBalance` wird als zusätzliches Feld in die bestehenden `balanceHistory`-Punkte eingebettet, kein separates Array.
|
||||
- Historische Rekonstruktion nutzt die *heutige* Menge aktiver Spieler (kein historisches Mitgliedschafts-Tracking) — bewusste Näherung, identisch zur bestehenden `balanceHistory`-Logik.
|
||||
- Buchungen mit Notiz-Präfix `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (aus `team-members.service.ts`) werden aus der Rekonstruktion ausgeschlossen.
|
||||
- Vorzeichen-Regel für Spieler-Buchungen: `type.id > 10` (Strafe/Umlage/Gebühr) mindert den Saldo, alle anderen Typen erhöhen ihn — exakt wie in `TeamMembersService.recomputeBalance` und `Transaction.setBalance()`.
|
||||
- Zweite Chart-Linie: gestrichelt (`borderDash: [6, 4]`), Farbe `#1d70b8`, Label „Theoretisch (inkl. offene Beiträge)". Bestehende Ist-Linie bleibt `#4f8f46`, durchgezogen.
|
||||
- `balanceChartOptions.plugins.legend.display` wechselt von `false` auf sichtbar (`position: 'bottom'`, wie beim bestehenden Flow-Chart).
|
||||
- Gating unverändert: hat das Team gar keine Kassenbewegung (`movements.length === 0`), bleibt `balanceHistory: []` (kein Chart, wie heute).
|
||||
- Bestehende Felder `monthlyFlow` und `topOutstanding` bleiben strukturell unverändert.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — `theoreticalBalance` in `getOverviewStats` berechnen
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.service.ts:1-15` (Import), `:231-334` (`getOverviewStats` + neue private Hilfsmethoden)
|
||||
- Create: `myteamwallet_backend/src/teams/teams.service.spec.ts` (existiert noch nicht)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (exportiert aus `myteamwallet_backend/src/teams/team-members.service.ts`), `Transaction`-Entity (bereits importiert in `teams.service.ts`), `Player`-Entity (bereits importiert).
|
||||
- Produces: `getOverviewStats(...)` liefert `balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]` — dieses Feld konsumiert Task 2 im Frontend (`BalanceHistoryPoint.theoreticalBalance`).
|
||||
|
||||
- [ ] **Step 1: Neue Testdatei mit fehlschlagenden Tests schreiben**
|
||||
|
||||
Erstelle `myteamwallet_backend/src/teams/teams.service.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TeamsService } from './teams.service';
|
||||
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||
|
||||
function monthKey(monthsAgo: number): string {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isoDate(monthsAgo: number, day: number): string {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
|
||||
}
|
||||
|
||||
describe('TeamsService#getOverviewStats theoretical balance', () => {
|
||||
const repository = { findOneOrFail: jest.fn() };
|
||||
const access = { assertMember: jest.fn() };
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 100,
|
||||
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
|
||||
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
|
||||
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: -30,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(2, 10),
|
||||
amount: 30,
|
||||
type: { id: 11, name: 'fine' },
|
||||
note: 'Zu spät zum Training',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
||||
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
expect(beforeFine?.balance).toBe(100);
|
||||
expect(beforeFine?.theoreticalBalance).toBe(100);
|
||||
expect(now?.balance).toBe(100);
|
||||
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
|
||||
expect(now?.theoreticalBalance).toBe(130);
|
||||
});
|
||||
|
||||
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 50,
|
||||
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: -20,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(6, 5),
|
||||
amount: 999,
|
||||
type: { id: 1, name: 'credit' },
|
||||
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
|
||||
},
|
||||
{
|
||||
date: isoDate(1, 10),
|
||||
amount: 20,
|
||||
type: { id: 11, name: 'fine' },
|
||||
note: 'Zu spät',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
// Wäre die Ausgleichsbuchung (999) nicht ausgeschlossen, würde sie hier bereits
|
||||
// durchschlagen (beforeFine liegt chronologisch nach ihrem Datum) — tut sie aber nicht.
|
||||
expect(beforeFine?.theoreticalBalance).toBe(50);
|
||||
expect(now?.theoreticalBalance).toBe(70);
|
||||
});
|
||||
|
||||
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 0,
|
||||
transactions: [],
|
||||
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
expect(result.balanceHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tests ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
|
||||
Expected: FAIL — `result.balanceHistory` Punkte haben noch kein `theoreticalBalance`-Feld (`undefined` statt der erwarteten Zahlen), erste zwei Tests schlagen fehl. Dritter Test (Leerfall) sollte bereits PASS sein (unverändertes Verhalten) — das bestätigt, dass der Testaufbau korrekt gegen die bisherige Implementierung läuft.
|
||||
|
||||
- [ ] **Step 3: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX`-Import ergänzen**
|
||||
|
||||
In `myteamwallet_backend/src/teams/teams.service.ts`, nach der bestehenden Import-Zeile für `TeamAccessService` (Zeile ~15) ergänzen:
|
||||
|
||||
```ts
|
||||
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rückgabetyp von `getOverviewStats` erweitern**
|
||||
|
||||
In `teams.service.ts`, die Signatur von `getOverviewStats` (aktuell Zeile 231-238) ändern:
|
||||
|
||||
```ts
|
||||
async getOverviewStats(
|
||||
teamId: string | number,
|
||||
actorUserId: string | number,
|
||||
): Promise<{
|
||||
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
|
||||
monthlyFlow: { month: string; income: number; expense: number }[];
|
||||
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||
}> {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Berechnung von `theoreticalBalance` einfügen**
|
||||
|
||||
In `teams.service.ts`, direkt vor dem finalen `return { balanceHistory, monthlyFlow, topOutstanding };` am Ende von `getOverviewStats` (aktuell Zeile 333) einfügen:
|
||||
|
||||
```ts
|
||||
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
||||
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
||||
...point,
|
||||
theoreticalBalance: this.round(point.balance - outstandingHistory[index]),
|
||||
}));
|
||||
|
||||
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
||||
}
|
||||
```
|
||||
|
||||
und die alte letzte Zeile ` return { balanceHistory, monthlyFlow, topOutstanding };` entfernen (sie wird durch den obigen Block ersetzt).
|
||||
|
||||
- [ ] **Step 6: Private Hilfsmethoden ergänzen**
|
||||
|
||||
In `teams.service.ts`, nach der bestehenden privaten Methode `signedFlowAmount` (aktuell Zeile 346-348) einfügen:
|
||||
|
||||
```ts
|
||||
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
|
||||
const activePlayers = players.filter((p) => p.active);
|
||||
const totals = months.map(() => 0);
|
||||
|
||||
for (const player of activePlayers) {
|
||||
const realTransactions = (player.transactions ?? []).filter(
|
||||
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
|
||||
);
|
||||
const playerHistory = this.reconstructPlayerBalanceHistory(
|
||||
months,
|
||||
Number(player.balance),
|
||||
realTransactions,
|
||||
);
|
||||
playerHistory.forEach((balance, index) => {
|
||||
totals[index] += balance;
|
||||
});
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
private reconstructPlayerBalanceHistory(
|
||||
months: string[],
|
||||
currentBalance: number,
|
||||
transactions: Transaction[],
|
||||
): number[] {
|
||||
const descendingMovements = transactions
|
||||
.map((t) => ({
|
||||
date: t.date,
|
||||
amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount),
|
||||
}))
|
||||
.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0));
|
||||
|
||||
let futureSum = 0;
|
||||
let movementIndex = 0;
|
||||
return [...months]
|
||||
.reverse()
|
||||
.map((month) => {
|
||||
while (
|
||||
movementIndex < descendingMovements.length &&
|
||||
descendingMovements[movementIndex].date.slice(0, 7) > month
|
||||
) {
|
||||
futureSum += descendingMovements[movementIndex].amount;
|
||||
movementIndex++;
|
||||
}
|
||||
return currentBalance - futureSum;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
```
|
||||
|
||||
`Transaction` und `Player` sind in `teams.service.ts` bereits importiert (Zeile 4 bzw. 10 im bestehenden Import-Block) — keine weiteren Imports nötig.
|
||||
|
||||
- [ ] **Step 7: Tests ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
|
||||
Expected: PASS — alle drei Tests grün.
|
||||
|
||||
- [ ] **Step 8: Vollständige Backend-Suite und Build laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npm test -- --silent && npm run build`
|
||||
Expected: alle bestehenden Tests weiterhin PASS (insbesondere keine Regression in anderen `teams`-Tests), Build ohne TypeScript-Fehler.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_backend
|
||||
git add src/teams/teams.service.ts src/teams/teams.service.spec.ts
|
||||
git commit -m "feat: add theoretical balance history to team overview stats
|
||||
|
||||
Reconstructs each active player's balance per month (same backward
|
||||
technique as the existing cash-balance history) so the overview stats
|
||||
endpoint can report what the team balance would be if all currently
|
||||
open dues had already been paid."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — zweite Chart-Linie im Kassenstand-Verlauf
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `theoreticalBalance` Feld aus Task 1 (`BalanceHistoryPoint.theoreticalBalance: number`, via `GET /teams/:id/overview/stats`).
|
||||
- Produces: keine neuen öffentlichen Interfaces — reine Chart-Darstellungs-Änderung innerhalb `Overview`.
|
||||
|
||||
- [ ] **Step 1: Modell erweitern**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`, `BalanceHistoryPoint` ändern:
|
||||
|
||||
```ts
|
||||
export interface BalanceHistoryPoint {
|
||||
month: string;
|
||||
balance: number;
|
||||
theoreticalBalance: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlagenden Test schreiben**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`, `sampleStats` (aktuell Zeile 22-32) um `theoreticalBalance` ergänzen:
|
||||
|
||||
```ts
|
||||
const sampleStats: TeamOverviewStats = {
|
||||
balanceHistory: [
|
||||
{ month: '2026-06', balance: 100, theoreticalBalance: 100 },
|
||||
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
|
||||
],
|
||||
monthlyFlow: [
|
||||
{ month: '2026-06', income: 50, expense: 10 },
|
||||
{ month: '2026-07', income: 40, expense: 15 },
|
||||
],
|
||||
topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }],
|
||||
};
|
||||
```
|
||||
|
||||
Im Test `'passes the loaded stats to each ChartCanvas once the request resolves'` (aktuell Zeile 181-200), nach der bestehenden Assertion `expect(balanceChart.data.labels).toHaveLength(2);` ergänzen:
|
||||
|
||||
```ts
|
||||
expect(balanceChart.data.datasets).toHaveLength(2);
|
||||
expect(balanceChart.data.datasets[0].data).toEqual([100, 125]);
|
||||
expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)');
|
||||
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
|
||||
Expected: FAIL — `balanceChart.data.datasets` hat noch Länge 1, die neuen Assertions schlagen fehl (bzw. TypeScript-Compile-Fehler, weil `sampleStats` noch nicht zum erweiterten `BalanceHistoryPoint`-Typ passt, falls Step 1 vor Step 2 gemacht wurde — in diesem Fall zunächst nur diesen Test isoliert betrachten).
|
||||
|
||||
- [ ] **Step 4: Zweite Datenserie und Legende in `overview.ts` ergänzen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`, die Farbkonstanten (aktuell Zeile 23-25) um die neue Farbe ergänzen:
|
||||
|
||||
```ts
|
||||
const BALANCE_COLOR = '#4f8f46';
|
||||
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
|
||||
const INCOME_COLOR = '#4f8f46';
|
||||
const EXPENSE_COLOR = '#c1121f';
|
||||
```
|
||||
|
||||
`balanceChartData` (aktuell Zeile 65-80) um die zweite Datenserie erweitern:
|
||||
|
||||
```ts
|
||||
protected readonly balanceChartData = computed<ChartData>(() => {
|
||||
const points = this.balanceHistory();
|
||||
return {
|
||||
labels: points.map((point) => formatMonthLabel(point.month)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Kassenstand',
|
||||
data: points.map((point) => point.balance),
|
||||
borderColor: BALANCE_COLOR,
|
||||
backgroundColor: BALANCE_COLOR,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: 'Theoretisch (inkl. offene Beiträge)',
|
||||
data: points.map((point) => point.theoreticalBalance),
|
||||
borderColor: THEORETICAL_BALANCE_COLOR,
|
||||
backgroundColor: THEORETICAL_BALANCE_COLOR,
|
||||
borderDash: [6, 4],
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
`balanceChartOptions` (aktuell Zeile 115-119) die Legende einblenden:
|
||||
|
||||
```ts
|
||||
protected readonly balanceChartOptions: ChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
|
||||
Expected: PASS — alle Tests in `overview.spec.ts` grün, inklusive der neuen Dataset-Assertions.
|
||||
|
||||
- [ ] **Step 6: Vollständige Frontend-Suite laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false`
|
||||
Expected: alle Tests PASS (keine Regression in anderen Specs durch die geänderte `BalanceHistoryPoint`-Typform).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/models/team-stats.model.ts src/app/features/team/overview/overview.ts src/app/features/team/overview/overview.spec.ts
|
||||
git commit -m "feat: show theoretical balance line in the cash-balance chart
|
||||
|
||||
Adds a second, dashed line to the existing balance-history chart that
|
||||
includes currently open player dues, so managers can see at a glance
|
||||
how far the actual cash balance lags behind what has been pledged."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec-Abdeckung:** Backend-Berechnung (Task 1, Steps 4-6), Response-Form-Änderung (Task 1, Step 4), Frontend-Chart/Legende/Farben (Task 2, Step 4), Testing-Anforderungen aus der Spec (historische Rekonstruktion, Ausgleichsbuchungs-Ausschluss, Leerfall, Frontend-Dataset-Assertions) sind je in eigenen Test-Steps abgedeckt. Manuelle Verifikation aus der Spec ist bewusst nicht als Plan-Task modelliert — bei Bedarf nach Abschluss beider Tasks manuell im Browser gegen ein Team mit unbezahlter Strafe prüfen.
|
||||
- **Typkonsistenz:** `theoreticalBalance: number` konsistent in Backend-Rückgabetyp (Task 1, Step 4), Frontend-Modell (Task 2, Step 1) und allen Test-Fixtures verwendet.
|
||||
- **Scope:** Einzelne, in sich geschlossene Erweiterung eines bereits bestehenden Features — keine weitere Zerlegung nötig.
|
||||
3287
docs/superpowers/plans/2026-08-04-notification-center.md
Normal file
3287
docs/superpowers/plans/2026-08-04-notification-center.md
Normal file
File diff suppressed because it is too large
Load Diff
355
docs/superpowers/plans/2026-08-05-env-indicator.md
Normal file
355
docs/superpowers/plans/2026-08-05-env-indicator.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Umgebungs-Indikator (EnvBanner) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ein schmaler Banner-Streifen erscheint app-weit oben, sobald `environment.production === false` (aktuell nur der lokale `ng serve`-Build), damit man Entwicklungsumgebung und echte App nie verwechselt.
|
||||
|
||||
**Architecture:** Neue Standalone-Komponente `EnvBanner` wird einmalig in `app.html` vor `<router-outlet />` eingebunden (single source of truth für alle Routen). `App` (Root-Komponente) bindet zusätzlich eine CSS-Custom-Property `--env-banner-height` auf ihr eigenes Host-Element, damit die drei `height:100dvh`-Layouts (Shell, Public-Team, Public-Player) die Banner-Höhe kompensieren können, ohne den kürzlich behobenen Doppel-Scrollbar-Bug erneut einzuführen.
|
||||
|
||||
**Tech Stack:** Angular 21 (Standalone Components, Signals, neue Control-Flow-Syntax), SCSS, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Banner-Höhe ist eine feste Konstante `ENV_BANNER_HEIGHT_PX = 28` (Pixel), exportiert aus `env-banner.ts` und in `app.ts` wiederverwendet — an genau diesen zwei Stellen referenziert, nicht dupliziert.
|
||||
- Banner-Text ist exakt `⚠ Entwicklungsumgebung` — keine zusätzlichen technischen Details (API-URL, Build-Hash).
|
||||
- Banner-Farbe ist Amber/Orange (`#f4a300` Hintergrund, `#20251F` Text) — bewusst nicht das App-Grün (`--mat-sys-primary`).
|
||||
- Kein Dismiss/Schließen-Button.
|
||||
- Kein neues Feld in den drei Environment-Dateien — einzige Quelle ist das bereits vorhandene `environment.production` (siehe `docs/superpowers/specs/2026-08-05-env-indicator-design.md`, Abschnitt „Entscheidungen aus dem Brainstorming").
|
||||
- `EnvBanner` wird ausschließlich einmal in `app.html` eingebunden, nicht zusätzlich in Shell/Public-Seiten/Auth-Seiten.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `EnvBanner`-Komponente
|
||||
|
||||
**Files:**
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.scss`
|
||||
- Test: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `environment` aus `myteamwallet_frontend_modern/src/environments/environment.ts` (Feld `production: boolean`, per Angular `fileReplacements` je Build-Konfiguration ausgetauscht — bereits vorhanden, keine Änderung nötig).
|
||||
- Produces: `export class EnvBanner` (Selector `app-env-banner`, keine Inputs) und `export const ENV_BANNER_HEIGHT_PX = 28;` — beide werden in Task 2 von `app.ts` importiert.
|
||||
|
||||
- [ ] **Step 1: Fehlschlagenden Test schreiben**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EnvBanner } from './env-banner';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('EnvBanner', () => {
|
||||
const originalProduction = environment.production;
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('shows the environment banner outside production', async () => {
|
||||
environment.production = false;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element?.textContent).toContain('Entwicklungsumgebung');
|
||||
});
|
||||
|
||||
it('renders nothing in production', async () => {
|
||||
environment.production = true;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/env-banner.spec.ts'`
|
||||
Expected: FAIL — `Cannot find module './env-banner'` (die Komponente existiert noch nicht).
|
||||
|
||||
- [ ] **Step 3: Komponente implementieren**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`:
|
||||
|
||||
```ts
|
||||
import { Component } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export const ENV_BANNER_HEIGHT_PX = 28;
|
||||
|
||||
@Component({
|
||||
selector: 'app-env-banner',
|
||||
templateUrl: './env-banner.html',
|
||||
styleUrl: './env-banner.scss',
|
||||
})
|
||||
export class EnvBanner {
|
||||
protected readonly showBanner = !environment.production;
|
||||
}
|
||||
```
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.html`:
|
||||
|
||||
```html
|
||||
@if (showBanner) {
|
||||
<div class="env-banner" role="status">⚠ Entwicklungsumgebung</div>
|
||||
}
|
||||
```
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.scss`:
|
||||
|
||||
```scss
|
||||
// Höhe muss mit ENV_BANNER_HEIGHT_PX in env-banner.ts übereinstimmen.
|
||||
.env-banner {
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f4a300;
|
||||
color: #20251f;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/env-banner.spec.ts'`
|
||||
Expected: PASS — beide Tests grün.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/shared/env-banner/
|
||||
git commit -m "feat: add EnvBanner component for non-production environments
|
||||
|
||||
Standalone component that renders a small banner whenever
|
||||
environment.production is false, so the local dev build is never
|
||||
mistaken for the real app."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Einbau in `App` (Root-Komponente) inkl. Höhen-Variable
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/app.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/app.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/app.spec.ts` (existiert noch nicht)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `EnvBanner`, `ENV_BANNER_HEIGHT_PX` aus Task 1 (`myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`); `environment` aus `myteamwallet_frontend_modern/src/environments/environment.ts`.
|
||||
- Produces: `<app-root>` setzt die Inline-Style-Custom-Property `--env-banner-height` (Wert inkl. `px`-Einheit, z. B. `"28px"` oder `"0px"`) auf seinem eigenen Host-Element. Task 3 liest diese Property per `var(--env-banner-height, 0px)`.
|
||||
|
||||
- [ ] **Step 1: Fehlschlagenden Test schreiben**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/app.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
describe('App', () => {
|
||||
const originalProduction = environment.production;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 0px and renders no banner in production', async () => {
|
||||
environment.production = true;
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('0px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 28px and renders the banner outside production', async () => {
|
||||
environment.production = false;
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('28px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/app.spec.ts'`
|
||||
Expected: FAIL — `--env-banner-height` ist leer (`''`), kein `.env-banner`-Element vorhanden.
|
||||
|
||||
- [ ] **Step 3: `App` erweitern**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/app.ts`, den bestehenden Inhalt ersetzen durch:
|
||||
|
||||
```ts
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { AuthApi } from './core/auth/auth-api';
|
||||
import { AuthStore } from './core/auth/auth-store';
|
||||
import { environment } from '../environments/environment';
|
||||
import { ENV_BANNER_HEIGHT_PX, EnvBanner } from './shared/env-banner/env-banner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, EnvBanner],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
'[style.--env-banner-height]': 'bannerHeight',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
protected readonly bannerHeight = `${environment.production ? 0 : ENV_BANNER_HEIGHT_PX}px`;
|
||||
|
||||
constructor() {
|
||||
if (this.authStore.token()) {
|
||||
this.authApi.me().subscribe({
|
||||
next: (response) => {
|
||||
const { token, ...user } = response;
|
||||
if (token) {
|
||||
this.authStore.setSession(token, user);
|
||||
} else {
|
||||
this.authStore.updateUser(user);
|
||||
}
|
||||
},
|
||||
error: () => undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Nur `imports`, `host` und die neue `bannerHeight`-Property sind neu — Konstruktor-Logik unverändert übernommen.)
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/app.html`, den bestehenden Inhalt ersetzen durch:
|
||||
|
||||
```html
|
||||
<app-env-banner />
|
||||
<router-outlet />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/app.spec.ts'`
|
||||
Expected: PASS — beide Tests grün.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/app.ts src/app/app.html src/app/app.spec.ts
|
||||
git commit -m "feat: mount EnvBanner app-wide and expose --env-banner-height
|
||||
|
||||
Renders the banner once at the app root so every route picks it up,
|
||||
and exposes its height as a CSS custom property so fixed-viewport
|
||||
layouts (Shell, public pages) can compensate for it."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Höhen-Kompensation in den `100dvh`-Layouts
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/public-team/public-team.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/public-team/public-player.scss`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--env-banner-height` Custom Property aus Task 2, gelesen per `var(--env-banner-height, 0px)` — der Fallback `0px` ist notwendig, damit `shell.spec.ts`, `public-team.spec.ts` und `public-player.spec.ts` (die diese Komponenten isoliert ohne `<app-root>`-Ancestor rendern) unverändert weiter grün bleiben.
|
||||
|
||||
Diese drei Dateien nutzen aktuell `height: 100dvh;` als feste Zusage „genau ein Bildschirm hoch" (siehe `docs/superpowers/plans/die-public-seite-kann-async-shamir.md` vom selben Tag zum Doppel-Scrollbar-Fix). Ohne Anpassung würde der neue Banner die Shell/Public-Seite um seine Höhe über den sichtbaren Bereich hinausschieben — derselbe Bugtyp wie der dort behobene.
|
||||
|
||||
- [ ] **Step 1: `shell.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss`, im `:host`-Block:
|
||||
|
||||
```scss
|
||||
// vorher: height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `public-team.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/public-team/public-team.scss`, im `:host`-Block dieselbe Änderung:
|
||||
|
||||
```scss
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `public-player.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/public-team/public-player.scss`, im `:host`-Block dieselbe Änderung:
|
||||
|
||||
```scss
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vollständige Test-Suite laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false`
|
||||
Expected: alle Tests weiterhin PASS — reine CSS-Wertänderung, `shell.spec.ts`/`public-team.spec.ts`/`public-player.spec.ts` prüfen kein Layout und bleiben unberührt.
|
||||
|
||||
- [ ] **Step 5: Build laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng build`
|
||||
Expected: Build erfolgreich (nur die bereits bekannte, unveränderte Bundle-Budget-Warnung).
|
||||
|
||||
- [ ] **Step 6: Manuelle Verifikation (kein automatisierter CSS-Layout-Test im Projekt vorhanden)**
|
||||
|
||||
`npm start`, im Browser (z. B. via Chrome DevTools) auf einer Shell-Seite mit langer Liste (`/team/:id/overview`) sowie auf `/t/:token` mit Inhalt prüfen:
|
||||
- Banner ist sichtbar, Header darunter bleibt beim Scrollen fix, Bottom-Nav ist vollständig sichtbar (nicht abgeschnitten).
|
||||
- `document.body.scrollHeight === document.body.clientHeight` (kein zusätzlicher Scrollbar auf `body`, wie beim vorherigen Fix verifiziert).
|
||||
- `ng build` (ohne `--configuration`, also production) und `ng build --configuration=container` zeigen keinen Banner (visuell/Bundle prüfen), `ng serve` (development) zeigt ihn.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/core/layout/shell/shell.scss src/app/features/public-team/public-team.scss src/app/features/public-team/public-player.scss
|
||||
git commit -m "fix: compensate 100dvh layouts for the env banner's height
|
||||
|
||||
Shell and the public pages commit to exactly one viewport tall; without
|
||||
this, the new dev-environment banner would push their bottom edge (and
|
||||
Shell's bottom nav) past the visible viewport, the same overflow-leak
|
||||
bug fixed earlier today."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec-Abdeckung:** Komponente + Sichtbarkeitslogik (Task 1), App-weite Einbindung + Höhen-Variable (Task 2), Höhen-Kompensation der drei betroffenen Layouts (Task 3) — alle Abschnitte der Spec sind abgedeckt. Kein neues Environment-Feld (bewusst, siehe Spec).
|
||||
- **Typkonsistenz:** `ENV_BANNER_HEIGHT_PX` einmal in `env-banner.ts` definiert, in `app.ts` importiert und verwendet — keine Duplikation des Zahlenwerts außer dem dokumentierten Kommentar in `env-banner.scss`. `bannerHeight` liefert einen fertigen `px`-String statt eine Zahl mit `[style.prop.px]`-Unit-Suffix, da Angulars Unit-Suffix-Syntax für CSS-Custom-Properties (`--foo`) nicht zuverlässig dokumentiert/getestet ist — sicherer, den fertigen String zu binden.
|
||||
- **Scope:** Einzelne, in sich geschlossene Erweiterung; keine weitere Zerlegung nötig.
|
||||
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Kassenbuch-Export (manuell + automatischer PDF-Versand)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-03
|
||||
|
||||
## Kontext
|
||||
|
||||
TeamWallet bietet mit dem Kassenjournal (`teams/:id/transactions/journal`, Cashbox-Seite im
|
||||
Frontend) bereits eine paginierte, filterbare Ansicht aller Buchungen. Es gibt aber keine
|
||||
Möglichkeit, diese Daten für die Vereinsbuchhaltung oder eine Kassenprüfung zu exportieren — weder
|
||||
manuell (CSV/PDF-Download) noch automatisiert (regelmäßiger Versand an Vorstand/Kassenprüfer). Beide
|
||||
Fähigkeiten fehlen komplett (kein Export-Code, `mail`-Modul aktuell nur für Login/Passwort-Reset
|
||||
genutzt).
|
||||
|
||||
Ziel: Ein Kassenwart/Captain/Coach kann (a) für einen frei wählbaren Zeitraum einen Kassenbuch-Export
|
||||
als CSV und/oder PDF herunterladen, und (b) optional einen wiederkehrenden automatischen PDF-Versand
|
||||
an beliebige E-Mail-Adressen einrichten (z.B. monatlich an den Vereinsvorstand).
|
||||
|
||||
Das Feature wurde im Brainstorming aus mehreren Optionen ausgewählt (Alternativen: Saldo-
|
||||
Erinnerungen, server-seitige Journal-Filterung, Belege-Anhang, Vier-Augen-Prinzip — diese sind nicht
|
||||
Teil dieses Plans). Ausdrücklich nicht Teil dieses oder eines zukünftigen Plans: Team
|
||||
verlassen/löschen.
|
||||
|
||||
## Fachliche Einordnung
|
||||
|
||||
Ein "Kassenbuch" bildet nur **echte Kassenbewegungen** ab — Buchungen, die laut `setBalance()`
|
||||
(`Transaction`- und `TeamWalletTransaction`-Entity) tatsächlich `team.balance` verändern:
|
||||
|
||||
- `Transaction` mit `type.id === 0` (`payment`) — Spieler zahlt echtes Geld ein.
|
||||
- **Alle** `TeamWalletTransaction`-Einträge (`credit` und `expense`) — direkte Kassenbewegungen ohne
|
||||
Spielerbezug.
|
||||
|
||||
Spieler-Fälligkeiten (`fee`/`levy`/`fine`, `type.id > 10`) verändern nur die Spielerschuld, nie den
|
||||
Kassenbestand, und werden **bewusst ausgeschlossen** (entspricht der gewählten Option "Nur
|
||||
Kassenjournal (Team-Saldo)").
|
||||
|
||||
Der Export zeigt einen **Periodensaldo** (laufende Summe ab 0, beginnend am gewählten Startdatum),
|
||||
keinen historischen Kontostand — eine Rekonstruktion des absoluten Kontostands zu einem beliebigen
|
||||
Vergangenheitszeitpunkt wäre für den ersten Wurf YAGNI.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Format**: CSV und PDF, beide.
|
||||
- **Zeitraum (manueller Export)**: frei wählbares Von/Bis-Datum.
|
||||
- **Inhalt**: nur Kassenjournal (Team-Saldo), keine Spieler-Fälligkeiten.
|
||||
- **Berechtigung**: wie Buchungen anlegen (`transaction_create_min_role`) — sowohl für den manuellen
|
||||
Export als auch für die Konfiguration des automatischen Versands.
|
||||
- **Automatischer Versand — Intervalle**: monatlich, quartalsweise, jährlich (identisch zu den
|
||||
wiederkehrenden Buchungen).
|
||||
- **Automatischer Versand — Zeitraum**: immer der jeweils **abgelaufene volle Zeitraum** (z.B. bei
|
||||
monatlichem Versand am 1. des Monats immer genau der komplette Vormonat), nicht "seit letztem
|
||||
Versand" (das wäre bei verpassten Läufen mehrdeutig).
|
||||
- **Automatischer Versand — Umfang**: **eine** Konfiguration pro Team (eine Empfängerliste, ein
|
||||
Intervall, pausierbar) statt mehrerer unabhängiger Abos.
|
||||
- **Out of Scope**: Team verlassen/löschen (bereits an anderer Stelle ausgeschlossen).
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: neues Modul `cashbox-export/`
|
||||
|
||||
Struktur analog zu `recurring-transactions/` (eigenständiges Modul statt Erweiterung von
|
||||
`teams.service.ts`, das bereits die Journal- und Statistik-Logik trägt).
|
||||
|
||||
**`cashbox-export.service.ts`**:
|
||||
- `buildRows(team: Team, from: string, to: string): CashboxExportRow[]` — reine Funktion auf einer
|
||||
bereits geladenen `Team`-Entity (inkl. `players.transactions.type`, `transactions.type`). Filtert
|
||||
auf echte Kassenbewegungen (s.o.), grenzt auf `[from, to]` ein (Ende inklusiv, Tagesende), sortiert
|
||||
chronologisch aufsteigend, berechnet laufenden `runningTotal`. Wird sowohl vom HTTP-Pfad als auch
|
||||
vom Scheduler verwendet — keine Duplikation der Filterlogik.
|
||||
- `getExportRowsForUser(teamId, userId, from, to)` — HTTP-Pfad: prüft `transaction_create_min_role`
|
||||
via `TeamAccessService.assertAtLeast`, lädt das Team, ruft `buildRows` auf.
|
||||
- `buildCsv(team, rows, from, to): string` — Semikolon-getrennt, deutsches Komma als
|
||||
Dezimaltrennzeichen (Excel-DE-Standard), RFC4180-Escaping für Notizen mit Semikolon/Anführungszeichen/
|
||||
Zeilenumbruch. Spalten: Datum, Typ, Wer (Spielername oder "Teamkasse"), Notiz, Betrag, Periodensaldo.
|
||||
Bei leerem Zeitraum: nur Kopfzeile + Hinweiszeile "Keine Buchungen im gewählten Zeitraum".
|
||||
- `buildPdf(team, rows, from, to): Buffer` — einfache Tabellen-PDF via neuer Abhängigkeit **`pdfkit`**
|
||||
(kein Chromium/Puppeteer nötig): Kopf mit Teamname + Zeitraum + Erstellungsdatum, Tabelle, Fußzeile
|
||||
mit Periodensaldo. Gleiche Leerzeitraum-Behandlung wie CSV.
|
||||
|
||||
**`cashbox-export.controller.ts`** (Pfad `cashbox-export`, `version: '1'`, nur `AuthGuard('jwt')`,
|
||||
Berechtigung im Service):
|
||||
- `GET cashbox-export/:teamId?from=&to=&format=csv|pdf` — liefert Datei über `@Res({passthrough:
|
||||
false})` mit manuell gesetzten Headern (`Content-Type`, `Content-Disposition: attachment;
|
||||
filename="kassenbuch_<teamAlias>_<from>_<to>.<ext>"`), kein globaler Response-Interceptor im
|
||||
Projekt vorhanden, der das stören würde.
|
||||
- `GET cashbox-export/:teamId/subscription` — aktuelle Versand-Konfiguration (oder Default:
|
||||
`{ recipients: [], interval: 'monthly', active: false }`).
|
||||
- `PUT cashbox-export/:teamId/subscription` — Upsert (Empfänger/Intervall/Aktiv-Status).
|
||||
|
||||
**Neue Entity `entities/cashbox-export-subscription.entity.ts`**: `id`, `team` (ManyToOne, in der
|
||||
Praxis 1:1 durch Anwendungslogik im Service erzwungen — nur eine Subscription pro Team wird gepflegt/
|
||||
aktualisiert statt neu angelegt), `recipients` (`simple-array`-Spalte, Liste von E-Mail-Strings),
|
||||
`interval` (`RecurringTransactionIntervalEnum`, wiederverwendet aus dem `recurring-transactions`-
|
||||
Modul — fachlich identisches Konzept), `active` (default `false`), `nextRunDate` (string, ISO-Datum),
|
||||
`createdAt`.
|
||||
|
||||
**DTO `UpsertCashboxExportSubscriptionDto`**: `recipients: string[]` (`@IsEmail({}, {each:true})`),
|
||||
`interval`, `active`. Validierung: `active === true` mit leerer `recipients`-Liste wird mit 400
|
||||
abgelehnt (ergibt keinen Sinn, nichts zu versenden aber "aktiv").
|
||||
|
||||
**`cashbox-export.scheduler.ts`** (`@Cron`, zeitlich versetzt zum bestehenden
|
||||
Recurring-Transactions-Job, z.B. `04:00 Uhr` statt `03:00 Uhr`, um DB-Last zu entzerren):
|
||||
1. Lädt alle `active: true`-Subscriptions mit `nextRunDate <= heute` (inkl. `team`).
|
||||
2. Pro fälliger Subscription: bestimmt den **abgelaufenen** Zeitraum passend zum `interval`
|
||||
ausgehend von `nextRunDate` (z.B. `nextRunDate = 2026-09-01`, `interval = monthly` → Zeitraum
|
||||
`2026-08-01`–`2026-08-31`), lädt das Team (inkl. Relationen), ruft `buildRows` + `buildPdf` auf
|
||||
(Wiederverwendung derselben Logik wie der manuelle Export), verschickt das PDF per
|
||||
`MailService`/`MailerService`-Attachment an alle `recipients` (neues Template
|
||||
`mail-templates/cashbox-export.hbs`, analog Aufbau zu `reset-password.hbs`), rückt `nextRunDate`
|
||||
um das Intervall vor (gleiche `setUTCMonth`-Arithmetik wie im Recurring-Transactions-Scheduler:
|
||||
`+1`/`+3`/`+12` Monate) und speichert.
|
||||
3. Ein verpasster Tag (Server-Downtime) wird beim nächsten Lauf automatisch nachgeholt (rein
|
||||
datumsbasierter Check wie beim Recurring-Transactions-Scheduler).
|
||||
|
||||
**Registrierung**: `CashboxExportModule` in `src/app.module.ts` ergänzen (analog `PenaltyModule`/
|
||||
`RecurringTransactionsModule`); `MailModule` importieren für den Versand.
|
||||
|
||||
**Logging-Events**: `cashbox_export_download`, `cashbox_export_subscription_update`,
|
||||
`cashbox_export_subscription_run` in `logging-event.type.ts` ergänzen.
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
**Cashbox-Toolbar**: neuer "Export"-Button (sichtbar nur mit `canDo(team(), 'transactionCreate')`,
|
||||
kein neuer Permission-Key) öffnet einen Dialog mit Von/Bis-Datumsfeldern und Format-Auswahl
|
||||
(CSV/PDF), löst über `CashboxExportApi.exportCashbox(teamId, from, to, format)`
|
||||
(`responseType: 'blob'`) den Download aus. Ein kleiner `FileDownloadService.save(blob, filename)`
|
||||
kapselt den Anchor-Click-Mechanismus, damit die Dialog-Komponente ohne echte DOM-Downloads getestet
|
||||
werden kann (Service wird im Test gemockt).
|
||||
|
||||
Im selben Export-Bereich zusätzlich ein Zahnrad/Link "Automatischen Versand einrichten" → eigener
|
||||
Dialog: Chip-Liste für E-Mail-Adressen (hinzufügen/entfernen, clientseitige Format-Validierung vor
|
||||
dem Speichern), Intervall-Dropdown, Aktiv/Pausiert-Toggle, Speichern-Button. Neue Methoden
|
||||
`CashboxExportApi.getSubscription(teamId)` / `updateSubscription(teamId, dto)`.
|
||||
|
||||
Neues Model `models/cashbox-export.model.ts` (`CashboxExportFormat`, `CashboxExportSubscription`,
|
||||
`UpdateCashboxExportSubscription`).
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- `from > to` → 400 (Backend), Submit-Button im Dialog zusätzlich clientseitig deaktiviert.
|
||||
- Keine Buchungen im Zeitraum → Datei wird trotzdem erzeugt (Kopfzeile + Hinweistext), kein Fehler.
|
||||
- Ungültige E-Mail-Adresse in der Empfängerliste → 400 (DTO-Validierung), Inline-Fehler im Dialog.
|
||||
- `active: true` mit leerer Empfängerliste → 400.
|
||||
- Fehlende Berechtigung → bestehender `assertAtLeast`-Wurf (403), keine neue Behandlung nötig.
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend**:
|
||||
- `cashbox-export.service.spec.ts` — Filterlogik (Ausschluss fee/levy/fine, Einschluss payment +
|
||||
alle TeamWallet-Typen), Datumsgrenzen (inklusive Tagesende), laufender Saldo, leerer Zeitraum,
|
||||
Berechtigungsdurchsetzung.
|
||||
- `cashbox-export.http.spec.ts` — Auth erforderlich, korrekte Header/Content-Type je Format, CSV-
|
||||
Inhalt exakt geprüft (String-Vergleich), PDF nur auf `%PDF-`-Signatur + Non-Empty geprüft (kein
|
||||
Byte-Vergleich).
|
||||
- `cashbox-export-subscription.service.spec.ts` — Upsert, Validierung (aktiv + leere Liste),
|
||||
Berechtigung.
|
||||
- `cashbox-export.scheduler.spec.ts` — Perioden-Berechnung je Intervall (`it.each`), PDF+Mail-
|
||||
Dispatch mit gemocktem `MailerService` (Attachment vorhanden, korrekte Empfänger/Betreff),
|
||||
`nextRunDate`-Vorrücken, überspringt inaktive/nicht-fällige Subscriptions, Downtime-Nachholung.
|
||||
|
||||
**Frontend**:
|
||||
- `cashbox-export-api.spec.ts` — korrekte HTTP-Calls (Query-Params, `responseType: 'blob'`,
|
||||
Subscription-GET/PUT).
|
||||
- Export-Dialog-Spec — Formvalidierung (`from <= to`), Permission-Gating, ruft
|
||||
`FileDownloadService.save` mit korrekten Argumenten auf.
|
||||
- Subscription-Dialog-Spec — Laden/Speichern, Chip-Validierung, Permission-Gating.
|
||||
|
||||
## Bewusst nicht enthalten (YAGNI)
|
||||
|
||||
- Kein historischer Anfangssaldo (nur Periodensaldo ab 0 innerhalb des Exportzeitraums).
|
||||
- Kein Export der Spieler-Fälligkeiten (fee/levy/fine).
|
||||
- Kein Excel-(.xlsx)-Format, nur CSV+PDF.
|
||||
- Keine mehreren Versand-Konfigurationen pro Team.
|
||||
- Kein CSV im automatischen Versand, nur PDF.
|
||||
- Keine Empfänger-Verifizierung (Double-Opt-In) für frei eingetragene Adressen.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber.
|
||||
- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber.
|
||||
- **Manuell**: Backend lokal starten, über die neue UI einen CSV- und einen PDF-Export für einen
|
||||
Zeitraum mit bekannten Testbuchungen herunterladen und Inhalt/Saldo stichprobenartig prüfen; eine
|
||||
Subscription mit `nextRunDate` = heute anlegen, Scheduler-Methode einmalig manuell aufrufen, prüfen
|
||||
dass eine E-Mail mit PDF-Anhang an alle konfigurierten Adressen geht und `nextRunDate` korrekt
|
||||
vorrückt.
|
||||
113
docs/superpowers/specs/2026-08-03-team-erstellen-design.md
Normal file
113
docs/superpowers/specs/2026-08-03-team-erstellen-design.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Team erstellen über die UI
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-03
|
||||
|
||||
## Kontext
|
||||
|
||||
Teams können in TeamWallet heute nur über einen bestehenden Backend-Endpoint
|
||||
(`POST /api/v1/teams`, `teams.controller.ts`) angelegt werden, der ausschließlich globalen Admins
|
||||
vorbehalten ist (`@Roles([RoleEnum.admin])`). Im modernen Angular-Frontend
|
||||
(`myteamwallet_frontend_modern`) existiert dafür keine UI: `core/team/teams-api.ts` hat keine
|
||||
`createTeam`-Methode, und `features/team-select/team-select.html` zeigt einem User ohne Teams nur
|
||||
den Hinweistext „Du bist noch keinem Team zugeordnet." ohne jede Handlungsoption.
|
||||
|
||||
Ziel: jeder eingeloggte User soll selbstständig ein neues Team gründen können und wird dabei
|
||||
automatisch dessen Kapitän. Das schließt eine der offensichtlichsten Lücken im Produkt
|
||||
(Selbstregistrierung eines Teams) und folgt demselben Muster, das an anderer Stelle im Code bereits
|
||||
als fehlend dokumentiert ist (`docs/plans/admin-user-management.md` merkt an, dass Admins auch noch
|
||||
keine User anlegen können — ein verwandtes, aber bewusst getrenntes Folge-Thema).
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Berechtigung**: jeder eingeloggte User (`RoleEnum.user`) darf ein Team erstellen, nicht nur
|
||||
Admins. Der Guard auf `POST /teams` wird entsprechend gelockert.
|
||||
- **Automatische Mitgliedschaft**: der Ersteller wird automatisch **Kapitän** (`captain`,
|
||||
Rollen-ID 3) des neuen Teams — die niedrigste Rollen-ID, die `TeamAccessService.assertManager`
|
||||
bereits als „Manager" behandelt (`id >= 3`). Es gibt kein explizites „Owner"-Konzept, `captain`
|
||||
ist die naheliegende Top-Rolle für den Ersteller.
|
||||
- **Formularumfang**: nur der Teamname wird abgefragt (`CreateTeamDTO` bleibt `{ name: string }`).
|
||||
Alias wird weiterhin automatisch aus dem Timestamp generiert, alles Weitere ist später über die
|
||||
Team-Einstellungen anpassbar.
|
||||
- **UI-Einstiegspunkt**: Button „Team erstellen" ist in `team-select` dauerhaft sichtbar — auch wenn
|
||||
der User bereits Teams hat (nicht nur im Leerzustand), um auch das Anlegen weiterer Teams (z. B.
|
||||
zweite Mannschaft) zu ermöglichen.
|
||||
- **UI-Pattern**: Dialog/Modal statt eigener Route, passend zum bestehenden `MatDialog`-Muster im
|
||||
Repo (z. B. `features/team/more/penalties/penalties.ts`) und angemessen für ein einzelnes
|
||||
Eingabefeld.
|
||||
- **Out of Scope**: Team löschen/archivieren ist ein eigenständiges Folge-Feature (andere
|
||||
Berechtigungen/Risiken, z. B. Umgang mit bestehenden Spielern/Transaktionen) und wird separat
|
||||
geplant.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: `teams.controller.ts`
|
||||
|
||||
Guard auf `POST /teams` von `@Roles([RoleEnum.admin])` auf `[RoleEnum.user, RoleEnum.admin]`
|
||||
erweitern — Muster wie an anderen Stellen desselben Controllers (z. B. Zeilen 113-114, 122-123).
|
||||
Der aufrufende User wird wie überall im Controller über `@Req() req` → `req.user.id` an den Service
|
||||
durchgereicht.
|
||||
|
||||
### 2. Backend: `teams.service.ts#createNewTeam`
|
||||
|
||||
Aktuell (Zeile 166-178) wird nur `Team` + Default-`TeamSetting`s (`generateBasicTeamSettings`)
|
||||
angelegt; es entsteht kein `Player`-Datensatz, der User bleibt kein Mitglied des neuen Teams.
|
||||
|
||||
Erweiterung: nach dem Speichern von Team und Settings zusätzlich einen `Player` erzeugen —
|
||||
verknüpft mit dem aufrufenden `User` (`userId`) und `teamRole = captain` (per
|
||||
`rolesRepository.findOneBy({ id: 3 })`). Vorgehen spiegelt die bestehende Join-Erzeugung in
|
||||
`createNewPlayer()` (Zeilen 122-164), dort wird bereits `Player` inkl. `TeamRole`-Verknüpfung für
|
||||
andere Spieler angelegt.
|
||||
|
||||
Audit-Logging (`this.logger.info({ event: 'team_create', ... })`) bleibt erhalten.
|
||||
|
||||
### 3. Backend: `dto/create-team.dto.ts`
|
||||
|
||||
Unverändert (`{ name: string }`).
|
||||
|
||||
### 4. Frontend: `core/team/teams-api.ts`
|
||||
|
||||
Neue Methode `createTeam(name: string)` → `POST /teams`, analog zu den bestehenden Methoden wie
|
||||
`createPlayer`.
|
||||
|
||||
### 5. Frontend: neue Dialog-Komponente
|
||||
|
||||
Kleine Standalone-Komponente mit Reactive Form (`FormBuilder`/`ReactiveFormsModule`,
|
||||
`MatFormFieldModule`), ein Pflichtfeld „Teamname" — nach dem Muster von
|
||||
`features/team/members/members.ts` (Form-Aufbau) kombiniert mit dem `MatDialog`-Öffnungsmuster aus
|
||||
`features/team/more/penalties/penalties.ts`.
|
||||
|
||||
### 6. Frontend: `features/team-select/team-select.ts` / `.html`
|
||||
|
||||
Button „Team erstellen" dauerhaft im Template ergänzen (nicht nur im Leerzustand-Block). Klick öffnet
|
||||
den Dialog über `MatDialog`; bei erfolgreichem Abschluss `MyTeamsStore` neu laden und per Router
|
||||
direkt in das neu erstellte Team navigieren.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- Serverseitige Validierungsfehler (z. B. leerer/zu langer Name) werden als Formularfehler im Dialog
|
||||
angezeigt, der Dialog bleibt offen.
|
||||
- Netzwerk-/Serverfehler laufen über den bestehenden Snackbar/Toast-Mechanismus des Repos, wie bei
|
||||
anderen Create-Flows (z. B. `createPlayer`).
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend (`teams.service.spec.ts`): `createNewTeam()` legt zusätzlich zu Team und Settings einen
|
||||
`Player` mit `teamRole = captain` und Verknüpfung zum aufrufenden User an.
|
||||
- Backend (`teams.controller.spec.ts` bzw. e2e): `POST /teams` ist für `RoleEnum.user` erlaubt (nicht
|
||||
mehr nur für `RoleEnum.admin`).
|
||||
- Frontend: Komponententest für den neuen Dialog (analog `penalties.spec.ts`) — Formularvalidierung,
|
||||
Aufruf von `teamsApi.createTeam`.
|
||||
- Frontend: Test, dass `team-select` nach erfolgreicher Erstellung `MyTeamsStore` neu lädt und in das
|
||||
neue Team navigiert.
|
||||
- Manuelle Verifikation: als normaler User (nicht Admin) über `team-select` ein Team anlegen —
|
||||
Aufruf sollte gelingen, User landet automatisch als Kapitän im neuen Team; Swagger-Aufruf von
|
||||
`POST /teams` als normaler User bestätigt den gelockerten Guard.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Team löschen/archivieren (eigenes Folge-Feature).
|
||||
- Auswahl der initialen Rolle des Erstellers (immer `captain`, keine Wahlmöglichkeit).
|
||||
- Eigene Alias-Vergabe durch den User (weiterhin automatisch generiert).
|
||||
- Selbstständiges Anlegen von Usern durch Admins (verwandte, aber separate Lücke, siehe
|
||||
`docs/plans/admin-user-management.md`).
|
||||
@@ -0,0 +1,111 @@
|
||||
# Theoretischer Kassenstand (Ist + offene Beiträge) im Kassenstand-Verlauf
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-03
|
||||
|
||||
## Kontext
|
||||
|
||||
Ergänzung zum bestehenden Kassenstand-Verlauf-Chart aus
|
||||
`docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`. Der Chart auf der Team-Übersicht
|
||||
(`features/team/overview/overview.ts` + `.html`) zeigt aktuell eine Linie „Kassenstand" über die
|
||||
letzten 12 Monate, gespeist aus `GET /teams/:id/overview/stats` bzw.
|
||||
`teams.service.ts#getOverviewStats`.
|
||||
|
||||
Auf derselben Übersicht existiert bereits eine zweite Kennzahl „Offene Beiträge" (aus
|
||||
`teams.service.ts#getOverview`, Zeile 46-53): `-(Summe der `balance` aller aktiven Spieler)`. Sie
|
||||
zeigt nur den heutigen Wert, keinen Verlauf.
|
||||
|
||||
Ziel: eine zweite Linie im bestehenden Chart, die pro Monat den theoretischen Kassenstand zeigt —
|
||||
also „was wäre in der Kasse, wenn alle offenen Beiträge bereits bezahlt worden wären" —, um Trainer/
|
||||
Kassenwarte auf einen Blick erkennen zu lassen, wie stark der Ist-Stand vom Soll-Stand abweicht.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Historie statt Snapshot**: Die zweite Linie zeigt für jeden Monat die zu diesem Zeitpunkt
|
||||
tatsächlich offenen Beiträge, nicht den heutigen Wert konstant über alle 12 Punkte addiert.
|
||||
- **Näherung wie beim bestehenden Kassenstand-Verlauf**: Es wird mit der *heutigen* Menge aktiver
|
||||
Spieler gerechnet, kein historisches Tracking von Mitgliedschaft/Aktiv-Status. Das ist dieselbe
|
||||
Vereinfachung, die `balanceHistory` bereits für den Kassenstand selbst nutzt (siehe
|
||||
`getOverviewStats`-Kommentar zu `team.balance` als Anker).
|
||||
- **Datenform**: `theoreticalBalance` wird als zusätzliches Feld direkt in jeden bestehenden
|
||||
`balanceHistory`-Punkt eingebettet (`{ month, balance, theoreticalBalance }`), kein separates
|
||||
Array — additive, nicht-brechende Erweiterung der bestehenden Response.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: `teams.service.ts#getOverviewStats`
|
||||
|
||||
Neue private Hilfsberechnung, analog zur bestehenden Rückwärts-Rekonstruktion von `balanceHistory`
|
||||
(Zeile ~290-320), aber auf Spieler-Ebene statt Team-Ebene:
|
||||
|
||||
- Datenquelle: `team.players` (bereits geladen über `relations: ['players', 'players.transactions',
|
||||
'transactions']`), gefiltert auf `player.active` — dieselbe Teilmenge, die `getOverview` für die
|
||||
heutige „Offene Beiträge"-Kachel verwendet.
|
||||
- Für jeden aktiven Spieler: dessen `transactions` (bereits geladen), **ausgenommen** Zeilen mit
|
||||
`note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX)` (Import aus
|
||||
`team-members.service.ts`) — dieselbe Ausschlussregel wie in
|
||||
`TeamMembersService.recomputeBalance`, damit synthetische Ausgleichsbuchungen die Historie nicht
|
||||
verfälschen.
|
||||
- Vorzeichen je Buchung: `type.id > 10` (Strafe/Umlage/Gebühr, IDs 11-13) mindert den Spieler-Saldo,
|
||||
alle anderen Typen (`payment`, `credit`) erhöhen ihn — identische Regel wie in
|
||||
`TeamMembersService.recomputeBalance` (Zeile ~127-133) und `TransactionsService.reverse()`
|
||||
(`type.id > 10`-Check).
|
||||
- Rekonstruktion: ausgehend von `player.balance` (aktueller, autoritativer Wert) rückwärts durch die
|
||||
nach Datum absteigend sortierten Buchungen laufen und pro Monat der letzten 12 Monate den
|
||||
rekonstruierten Saldo am Monatsende ermitteln — strukturell identisch zum bestehenden
|
||||
`descendingMovements`/`futureSum`-Muster für `balanceHistory`, nur pro Spieler statt einmal fürs
|
||||
Team.
|
||||
- Pro Monat: `outstandingAtMonth = -Σ(rekonstruierter Saldo aktiver Spieler)`,
|
||||
`theoreticalBalance = balanceHistory[monat].balance + outstandingAtMonth`.
|
||||
- Rückgabeform ändert sich zu:
|
||||
```ts
|
||||
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]
|
||||
```
|
||||
`monthlyFlow` und `topOutstanding` bleiben unverändert.
|
||||
- Gating unverändert: Ist `movements.length === 0` (keine Kassenbewegung je), bleibt
|
||||
`balanceHistory: []` wie heute — ein Team mit ausschließlich unbezahlten Strafen, aber ganz ohne
|
||||
Zahlungsbewegung, zeigt weiterhin keinen Chart (Out of Scope, siehe unten).
|
||||
|
||||
### 2. Frontend: `overview.ts` / Chart-Konfiguration
|
||||
|
||||
- `models/team-stats.model.ts`: `BalanceHistoryPoint` um `theoreticalBalance: number` erweitern.
|
||||
- `balanceChartData` (computed) bekommt eine zweite Dataset-Eintrag:
|
||||
- Label: „Theoretisch (inkl. offene Beiträge)"
|
||||
- `data: points.map((p) => p.theoreticalBalance)`
|
||||
- Gestrichelt (`borderDash: [6, 4]`), eigene Farbe `#1d70b8` (Blauton, klar unterscheidbar vom
|
||||
Grün `#4f8f46` der Ist-Linie), `fill: false`.
|
||||
- `balanceChartOptions`: `plugins.legend.display` von `false` auf `true` (bzw. `position: 'bottom'`
|
||||
wie beim Flow-Chart), da jetzt zwei Linien unterschieden werden müssen.
|
||||
- Keine Änderung an `ChartCanvas` (shared component) nötig — reine Config-/Daten-Änderung.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Unverändert zum bestehenden Muster: Fehler beim Laden der Stats führen zum bestehenden stillen
|
||||
Empty-State der Chart-Karte. Kein neuer Fehlerfall durch diese Erweiterung.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend (`teams.service.spec.ts`, Erweiterung des bestehenden `getOverviewStats`-Testblocks):
|
||||
- Sanity-Check: `balanceHistory.at(-1).theoreticalBalance === team.balance + aktuelle Summe
|
||||
offener Beiträge` (heutiger Wert, wie von `getOverview` berechnet).
|
||||
- Historische Rekonstruktion: Testfall mit einer Strafe (`fine`) in einem früheren Monat, die erst
|
||||
im aktuellen Monat bezahlt wurde — `theoreticalBalance` im früheren Monat muss die damals
|
||||
offene Strafe enthalten, `balance` (Ist) nicht.
|
||||
- Deaktivierungs-Ausgleichsbuchungen werden aus der Rekonstruktion ausgeschlossen (Testfall mit
|
||||
einem zwischenzeitlich deaktivierten und wieder aktivierten Spieler).
|
||||
- Leerfall (`movements.length === 0`) liefert weiterhin `balanceHistory: []`.
|
||||
- Frontend (`overview.spec.ts`): Erweiterung des bestehenden Chart-Daten-Tests um Assertion, dass
|
||||
`balanceChartData()` zwei Datasets enthält und die zweite Serie aus `theoreticalBalance` gespeist
|
||||
wird.
|
||||
- Manuelle Verifikation: Team mit einer unbezahlten Strafe/Umlage lokal aufrufen, prüfen dass die
|
||||
theoretische Linie sichtbar über der Ist-Linie liegt und bei vollständiger Bezahlung beide Linien
|
||||
zusammenlaufen.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Historisches Tracking von Mitgliedschaft/Aktiv-Status (Näherung mit heutiger aktiver
|
||||
Spieler-Menge, siehe oben).
|
||||
- Teams mit ausschließlich unbezahlten Strafen/Umlagen/Gebühren, aber ganz ohne Kassenbewegung —
|
||||
zeigen weiterhin keinen Chart (bestehende Einschränkung aus dem Basis-Feature, nicht neu
|
||||
eingeführt).
|
||||
- Zeitraum-Umschalter (weiterhin feste letzte 12 Monate, wie im Basis-Feature festgelegt).
|
||||
210
docs/superpowers/specs/2026-08-04-notification-center-design.md
Normal file
210
docs/superpowers/specs/2026-08-04-notification-center-design.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Notification Center (Team-Benachrichtigungen)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-04
|
||||
|
||||
## Kontext
|
||||
|
||||
TeamWallet protokolliert bereits viele team-relevante Ereignisse (Spieler hinzugefügt/deaktiviert,
|
||||
Rollenänderung, Einladungslink erstellt/eingelöst) über den globalen `LoggingService` in `LogEntry`
|
||||
— aber dieses Log ist admin-only, global (kein Team-Bezug, kein `teamId`), und kennt keinen
|
||||
Lesestatus pro Nutzer. Ein normaler Spieler erfährt aktuell nicht, wenn in seinem Team etwas
|
||||
passiert (z.B. er selbst deaktiviert wurde oder der Freigabelink rotiert wurde), außer er merkt es
|
||||
zufällig.
|
||||
|
||||
Ziel: Ein Benachrichtigungscenter (Glocke oben rechts im Header mit Ungelesen-Badge und Dropdown),
|
||||
das aktiven Team-Mitgliedern mit Login relevante Team-Ereignisse anzeigt, mit Sprung zur
|
||||
betroffenen Stelle und einer Vollansicht-Seite für die Historie.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Abgedeckte Events (v1)**: Spieler hinzugefügt/deaktiviert/reaktiviert, Team-Rolle geändert,
|
||||
Freigabelink aktiviert/rotiert, Einladungslink erstellt. Das Einlösen eines Einladungslinks selbst
|
||||
löst **keine** eigene Benachrichtigung aus (der Aufruf ist unauthentifiziert, reine
|
||||
Token-Validierung, oft nur eine Vorschau ohne tatsächlichen Beitritt) — der tatsächliche Beitritt
|
||||
wird stattdessen bereits durch das Event "Spieler hinzugefügt" abgedeckt.
|
||||
- **Empfänger**: alle aktiven Player eines Teams mit verknüpftem User-Account (analog zur
|
||||
Mitgliedschaftsprüfung in `TeamAccessService`), abzüglich des Verursachers — wer eine Aktion selbst
|
||||
auslöst, bekommt dafür keine eigene Benachrichtigung.
|
||||
- **Zustellung**: kein Echtzeit-Push (keine WebSocket/SSE-Infrastruktur im Projekt vorhanden).
|
||||
Stattdessen Polling des Ungelesen-Zählers alle 30s, passend zum bestehenden HTTP+Signal-Store-Muster
|
||||
des Frontends.
|
||||
- **Datenmodell**: Fan-out beim Schreiben (`Notification` + eine `NotificationRecipient`-Zeile pro
|
||||
Empfänger mit eigenem Lesestatus) statt eines zentralen Events mit Read-Join-Tabelle oder einer
|
||||
Erweiterung von `LogEntry` — bei den hier üblichen kleinen Teamgrößen (typischerweise < 30 Spieler)
|
||||
ist der Schreib-Overhead irrelevant, die Leseabfragen (Ungelesen zählen, Liste je Nutzer, als
|
||||
gelesen markieren) bleiben dafür trivial.
|
||||
- **Entkopplung**: Domain-Services lösen Business-Logik weiterhin unverändert aus und feuern danach
|
||||
nur ein Domain-Event über `@nestjs/event-emitter` (`EventEmitter2`) — ein zentrales
|
||||
`NotificationsModule` lauscht auf diese Events und legt die Benachrichtigungen an. Domain-Services
|
||||
kennen `NotificationsService` nicht; neue Benachrichtigungstypen erfordern nur einen neuen Listener,
|
||||
keine Änderung an bestehenden Services.
|
||||
- **Klick-Verhalten**: Klick auf eine Benachrichtigung navigiert zur betroffenen Stelle (z.B.
|
||||
Mitgliederliste) und markiert sie als gelesen.
|
||||
- **Vollansicht**: eigene, team-gescopte Seite mit paginierter Historie zusätzlich zum Dropdown
|
||||
(letzte 20 Einträge).
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: neues Modul `notifications/`
|
||||
|
||||
**Neue Entities** (`notifications/entities/`):
|
||||
|
||||
- `Notification`: `id`, `team` (ManyToOne `Team`), `event` (`NOTIFICATION_EVENT`-String-Union, eigene
|
||||
Typdatei analog `logging-event.type.ts`), `actorUserId`, `payload` (`text`-Spalte, JSON-serialisiert
|
||||
— enthält je Event die Felder für Anzeigetext + Deep-Link, z.B. `{ playerId, playerName }`),
|
||||
`createdAt`.
|
||||
- `NotificationRecipient`: `id`, `notification` (ManyToOne `Notification`, `onDelete: 'CASCADE'`),
|
||||
`userId`, `read` (boolean, default `false`), `readAt` (nullable `Date`). Index auf
|
||||
`(userId, read, createdAt via notification)` bzw. praktisch auf `(userId, notificationId)` und
|
||||
zusätzlich ein Index auf `notification.team` + `userId` für die gefilterte Team-Ansicht.
|
||||
|
||||
**Domain-Events** (`notifications/events/`): reine Datenklassen, ein File pro Event-Familie —
|
||||
`player-active-changed.event.ts`, `player-role-changed.event.ts`, `player-created.event.ts`,
|
||||
`share-link-changed.event.ts`, `invite-link-created.event.ts`. Jede trägt mindestens `teamId`,
|
||||
`actorUserId`, event-spezifische IDs/Namen für Text und Deep-Link.
|
||||
|
||||
**Emit-Punkte** (jeweils ein zusätzlicher `this.eventEmitter.emit(...)`-Aufruf **nach** erfolgreichem
|
||||
Abschluss der bestehenden Logik, ohne deren Ablauf/Transaktion zu verändern):
|
||||
|
||||
- `team-members.service.ts` `setActive()` — nach `return this.dataSource.transaction(...)` erfolgreich
|
||||
resolved hat (Emit außerhalb des Transaktions-Callbacks, damit bei Rollback nie ein Event feuert).
|
||||
- `team-members.service.ts` `setTeamRole()` — analog.
|
||||
- `teams.service.ts` Player-Erstellung (Stelle, die aktuell `player_creation` loggt) — analog.
|
||||
- `public-team-access.service.ts` `setEnabled()` / `rotate()` — hier gibt es aktuell **keine**
|
||||
Transaktion (nur `repository.save()`), Emit direkt nach erfolgreichem `save()`. Zusätzlich werden
|
||||
hier neue `LOGEVENT`-Werte `public_access_enabled`, `public_access_rotated` ergänzt (bisher fehlt an
|
||||
dieser Stelle jegliches Logging) und ein `LoggingService.info()`-Aufruf ergänzt, analog zu den
|
||||
anderen Services.
|
||||
- `auth.service.ts` `createTeamInvite()` — nach dem bestehenden `logger.info(...)`-Aufruf, mit dem
|
||||
echten `actorUserId`-Parameter der Methode (nicht dem im bestehenden Log hart codierten `userId: 0`
|
||||
— dieser bestehende Log-Aufruf selbst bleibt unverändert, das Event nutzt aber den korrekten Actor).
|
||||
|
||||
**`NotificationsListener`** (`notifications/notifications.listener.ts`): ein `@OnEvent(...)`-Handler
|
||||
pro Event-Typ, baut Anzeigetext + Deep-Link-Payload und ruft `NotificationsService.create(...)` auf.
|
||||
Fehler im Handler werden abgefangen und via `LoggingService.error()` protokolliert statt propagiert —
|
||||
ein Fehler beim Anlegen der Benachrichtigung darf die bereits committete Business-Aktion nicht
|
||||
nachträglich als fehlgeschlagen erscheinen lassen.
|
||||
|
||||
**`NotificationsService`**:
|
||||
|
||||
- `create(teamId, event, actorUserId, payload)` — ermittelt Empfänger über dasselbe Query-Muster wie
|
||||
`TeamAccessService`/`PublicTeamAccessService` (aktive `Player` mit `user.id IS NOT NULL` für das
|
||||
Team, `actorUserId` ausgeschlossen), legt `Notification` + `NotificationRecipient`-Zeilen an.
|
||||
- `listForUser(userId, teamId, cursor, limit)` — für Dropdown und Vollansicht.
|
||||
- `getUnreadCount(userId, teamId)`.
|
||||
- `markRead(recipientId, userId)` — prüft Eigentümerschaft der Recipient-Zeile.
|
||||
- `markAllRead(userId, teamId)`.
|
||||
|
||||
**`NotificationsController`** (`version: '1'`, `AuthGuard('jwt')` + `TeamAccessService.assertMember`):
|
||||
|
||||
- `GET teams/:teamId/notifications?cursor=&limit=`
|
||||
- `GET teams/:teamId/notifications/unread-count`
|
||||
- `PATCH teams/:teamId/notifications/:id/read`
|
||||
- `PATCH teams/:teamId/notifications/read-all`
|
||||
|
||||
**Retention**: `NotificationRetentionScheduler`, `@Cron(CronExpression.EVERY_DAY_AT_5AM)` (zeitlich
|
||||
versetzt zu `LogRetentionScheduler` um 4 Uhr), löscht `Notification`-Zeilen älter als
|
||||
`app.logRetentionDays` (gleiche Config wiederverwendet, kein neuer Config-Wert nötig) —
|
||||
`NotificationRecipient` fällt per `onDelete: 'CASCADE'` automatisch mit weg. Gleiches
|
||||
Fehlerbehandlung-Muster wie `LogRetentionScheduler` (try/catch, `logger.info`/`logger.error` mit
|
||||
`log_retention_cleanup_run`-artigen neuen Events `notification_retention_cleanup_run`/`_fail`).
|
||||
|
||||
**Neue Dependency**: `@nestjs/event-emitter`, registriert via `EventEmitterModule.forRoot()` in
|
||||
`app.module.ts` (neben dem bestehenden `ScheduleModule.forRoot()`).
|
||||
|
||||
**Registrierung**: `NotificationsModule` in `src/app.module.ts` ergänzen (analog
|
||||
`CashboxExportModule`), exportiert `NotificationsService`/`EventEmitter2`-Nutzung für die
|
||||
Domain-Services (bzw. Domain-Services importieren direkt `EventEmitterModule`/`EventEmitter2` aus
|
||||
`@nestjs/event-emitter`, kein Import von `NotificationsModule` nötig — das ist der Kern der
|
||||
Entkopplung).
|
||||
|
||||
**Migration**: eine neue TypeORM-Migration in `src/database/migrations` für `notification` und
|
||||
`notification_recipient` inkl. der oben genannten Indizes.
|
||||
|
||||
**Neue `LOGEVENT`-Werte** in `logging-event.type.ts`: `public_access_enabled`,
|
||||
`public_access_rotated`, `notification_retention_cleanup_run`, `notification_retention_cleanup_run_fail`.
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
**Bell im Header** (`core/layout/shell/shell.html`/`shell.ts`): `mat-icon-button` mit
|
||||
`notifications`-Icon, `matBadge` für den Ungelesen-Zähler (ausgeblendet bei 0), positioniert links
|
||||
neben dem bestehenden Team-Switcher in der `shell-header`-Toolbar, `[matMenuTriggerFor]="notificationMenu"`
|
||||
— gleiches `MatMenuModule`-Pattern wie der bestehende Team-Switcher.
|
||||
|
||||
**Dropdown** (`mat-menu`): Liste der letzten 20 Benachrichtigungen (Icon je Event-Typ, Text, relative
|
||||
Zeit via Angular `DatePipe`/eigenes Pipe), "Alle als gelesen markieren"-Button oben, "Alle
|
||||
anzeigen"-Link unten zur Vollansicht-Seite. Klick auf einen Eintrag: `markRead()` + Router-Navigation
|
||||
zum Deep-Link (z.B. `/team/:teamId/members` mit Query-Param oder Fragment zum Hervorheben des
|
||||
betroffenen Spielers, je nach Event-Typ auch andere Zielrouten wie die Team-Einstellungen für
|
||||
Freigabelink-Events).
|
||||
|
||||
**Vollansicht-Seite** (`features/notifications/notifications.ts/html`, Route
|
||||
`/team/:teamId/notifications`): einfache paginierte Liste (kein ag-grid nötig, da kein
|
||||
Admin-Filterbedarf wie bei der Logs-Seite), gleiche Klick-Navigation wie im Dropdown.
|
||||
|
||||
**State**: neuer `NotificationsStore` (Signal-Service im Team-Kontext, analog `MyTeamsStore`) hält
|
||||
`notifications`- und `unreadCount`-Signals. Pollt `unread-count` alle 30s via `interval()` +
|
||||
`switchMap`, solange ein Team aktiv ist; die volle Liste wird nur bei Dropdown-Öffnen bzw.
|
||||
Seitenaufruf der Vollansicht geladen (kein Dauer-Polling der ganzen Liste).
|
||||
|
||||
**Neues Model** (`models/notification.model.ts`): `NotificationEvent`-Union (Frontend-seitiges
|
||||
Gegenstück zu `NOTIFICATION_EVENT`), `NotificationDto`, mit Mapping-Funktion Event-Typ → Icon/Text/
|
||||
Zielroute (zentral an einer Stelle, damit neue Event-Typen nicht über die Komponente verstreut
|
||||
behandelt werden müssen).
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- Notification-Erstellung schlägt fehl → wird im `NotificationsListener` abgefangen und geloggt,
|
||||
bricht die ursprüngliche (bereits erfolgreich abgeschlossene) Aktion nicht nachträglich ab.
|
||||
- `markRead`/`markAllRead` auf fremde bzw. nicht existente Recipient-Zeile → `NotFoundException`
|
||||
bzw. stiller No-Op bei `markAllRead` (nichts zu markieren ist kein Fehlerfall).
|
||||
- Polling-Request schlägt fehl (Netzwerk) → Store behält den letzten bekannten Zählerstand, kein
|
||||
Fehler-Toast (nicht kritisch genug für eine Nutzerunterbrechung).
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend**:
|
||||
|
||||
- `notifications.service.spec.ts` — Empfänger-Ermittlung (aktive Player mit User, Actor
|
||||
ausgeschlossen), Fan-out-Erstellung, `listForUser`/`getUnreadCount`-Filterung nach `teamId`+`userId`,
|
||||
`markRead`-Eigentümerprüfung, `markAllRead`.
|
||||
- `notifications.listener.spec.ts` — pro Event-Typ: korrekter Aufruf von
|
||||
`NotificationsService.create` mit erwartetem Payload; Fehler im Service wird abgefangen und geloggt,
|
||||
nicht weitergeworfen.
|
||||
- Bestehende Specs von `team-members.service.ts`, `public-team-access.service.ts`, `auth.service.ts`
|
||||
um Assertions ergänzt, dass das jeweilige Domain-Event nach erfolgreichem Abschluss emittiert wird
|
||||
(gemockter `EventEmitter2`), und bei Rollback/Fehler **nicht** emittiert wird.
|
||||
- `notification-retention.scheduler.spec.ts` — analog `log-retention.scheduler.spec.ts`.
|
||||
- `notifications.http.spec.ts` — Auth/Team-Membership erforderlich, Pagination, `read`/`read-all`.
|
||||
|
||||
**Frontend**:
|
||||
|
||||
- `notifications-store.spec.ts` — Polling-Intervall, Unread-Count-Update, Laden der Liste.
|
||||
- `notifications-api.spec.ts` — korrekte HTTP-Calls.
|
||||
- Bell/Dropdown-Komponenten-Spec — Badge-Anzeige bei >0, Klick markiert gelesen + navigiert,
|
||||
"Alle als gelesen"-Button.
|
||||
- Vollansicht-Seiten-Spec — Pagination, Klick-Navigation.
|
||||
|
||||
## Bewusst nicht enthalten (YAGNI)
|
||||
|
||||
- Kein Echtzeit-Push (WebSocket/SSE) — Polling reicht für den Anwendungsfall und vermeidet neue
|
||||
Infrastruktur.
|
||||
- Keine Benachrichtigung beim reinen Einlösen/Validieren eines Einladungslinks (unauthentifiziert,
|
||||
kein verlässlicher Actor, oft nur Vorschau ohne Beitritt).
|
||||
- Keine Benachrichtigungseinstellungen pro Nutzer (z.B. E-Mail-Digest, Stummschalten einzelner
|
||||
Event-Typen) — alle aktiven Mitglieder mit Login sehen alle abgedeckten Events.
|
||||
- Keine rollenbasierte Einschränkung der Empfänger (z.B. "nur Manager") — alle aktiven Mitglieder mit
|
||||
Login.
|
||||
- Keine Browser-Push-Benachrichtigungen (Service Worker/Web Push) außerhalb der App.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber.
|
||||
- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber.
|
||||
- **Manuell**: Backend + Frontend lokal starten, mit zwei Test-Usern im selben Team: User A
|
||||
deaktiviert einen Spieler, User B (nicht der deaktivierte Spieler selbst, aber Mitglied) sieht die
|
||||
Badge-Zahl nach kurzer Zeit (Polling) hochgehen, öffnet das Dropdown, sieht den Eintrag, klickt
|
||||
darauf → Navigation zur Mitgliederliste + Eintrag als gelesen markiert, Badge sinkt. Gleiches
|
||||
stichprobenartig für Rollenänderung, Freigabelink-Rotation und Einladungslink-Erstellung
|
||||
durchspielen. Vollansicht-Seite aufrufen und Pagination über mehrere erzeugte Einträge prüfen.
|
||||
113
docs/superpowers/specs/2026-08-05-env-indicator-design.md
Normal file
113
docs/superpowers/specs/2026-08-05-env-indicator-design.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Umgebungs-Indikator (Entwicklungsumgebung-Banner)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-05
|
||||
|
||||
## Kontext
|
||||
|
||||
Beim Arbeiten und Testen kann leicht unklar sein, ob man gerade in der lokalen
|
||||
Entwicklungsumgebung (`ng serve`, `environment.development.ts`) oder in der echten,
|
||||
produktiven App unterwegs ist — beide sehen optisch identisch aus. Ziel: ein visueller
|
||||
Indikator, der überall in der App sofort erkennbar macht, wenn man sich in der
|
||||
Entwicklungsumgebung befindet, damit man sich beim Testen nicht vertut.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Betroffene Umgebungen**: Es gibt drei Angular-Build-Konfigurationen
|
||||
(`myteamwallet_frontend_modern/angular.json`): `production` (Standard,
|
||||
`environment.ts`, echte API auf myteamwallet.de), `development`
|
||||
(`environment.development.ts`, nur lokal via `ng serve`, `localhost:3999`) und
|
||||
`container` (`environment.container.ts`, `npm run build:container`). Der `container`-Build
|
||||
ist der reguläre Deploy-Weg der echten Produktion (z. B. self-hosted per Docker) — **kein**
|
||||
Staging-System — und setzt bereits selbst `production: true`. Der Indikator muss also nur
|
||||
`environment.production === false` erkennen; kein neues Feld in den Environment-Dateien nötig.
|
||||
- **Darstellung**: dünner Banner-Streifen ganz oben über der gesamten App (nicht nur im
|
||||
Header), warme Warnfarbe (Amber/Orange, bewusst nicht das App-Grün), Text „⚠
|
||||
Entwicklungsumgebung", zentriert, klein, kein Dismiss-Button (der Zweck ist ja gerade, ihn
|
||||
nicht wegzuklicken und zu vergessen).
|
||||
- **Inhalt**: nur der Umgebungsname, keine zusätzlichen technischen Details (API-URL o. ä.).
|
||||
- **Platzierung im Code**: einmalig in `app.html` vor `<router-outlet />`, statt in jeder
|
||||
Seite einzeln — automatisch auf jeder Route (Shell, Public-Seiten, Login, Register, Users,
|
||||
Logs, …) sichtbar, single source of truth.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Neue Komponente `EnvBanner`
|
||||
|
||||
**Ordner:** `myteamwallet_frontend_modern/src/app/shared/env-banner/`
|
||||
|
||||
Standalone-Komponente nach dem Muster bestehender Shared-Komponenten (`context-help`,
|
||||
`skeleton`) — kein Modul/Barrel, keine Inputs.
|
||||
|
||||
- `env-banner.ts`: importiert `environment` aus `../../../environments/environment` und
|
||||
exponiert `protected readonly showBanner = !environment.production;`. Exportiert außerdem
|
||||
die Konstante `export const ENV_BANNER_HEIGHT_PX = 28;` (wird von `App` für die
|
||||
Höhen-Kompensation wiederverwendet, siehe unten — ein einziger Ort für die Pixel-Zahl).
|
||||
- `env-banner.html`: `@if (showBanner) { <div class="env-banner" role="status">⚠
|
||||
Entwicklungsumgebung</div> }` — rendert in Produktion buchstäblich nichts (kein leeres
|
||||
DOM-Element).
|
||||
- `env-banner.scss`: `.env-banner { height: 28px; display:flex; align-items:center;
|
||||
justify-content:center; background: #f4a300; color:#20251F; font-weight:700; font-size:
|
||||
0.75rem; letter-spacing:0.04em; text-transform:uppercase; flex-shrink:0; }` (die `28px`
|
||||
müssen mit `ENV_BANNER_HEIGHT_PX` übereinstimmen — als Kommentar im SCSS vermerkt).
|
||||
- `env-banner.spec.ts`: rendert Text wenn `environment.production === false`, rendert nichts
|
||||
wenn `true` (Environment-Objekt im Test gemockt/überschrieben).
|
||||
|
||||
### 2. Einbau in `app.html` / `app.ts`
|
||||
|
||||
`app.html` bekommt vor `<router-outlet />` ein `<app-env-banner />`. `App` importiert
|
||||
`EnvBanner` in seine `imports`-Liste.
|
||||
|
||||
### 3. Höhen-Kompensation für `height: 100dvh`-Layouts
|
||||
|
||||
Der Banner nimmt echten Platz im normalen Fluss ein. Für Seiten, die nur `min-height:100dvh`
|
||||
nutzen und sich auf `body`s eigenen Scrollbar verlassen (Login, Register,
|
||||
Forgot/Reset-Password, Confirm-Email, Users, Logs), ist das unproblematisch — `body` gleicht
|
||||
das automatisch aus, kein Änderungsbedarf.
|
||||
|
||||
**Aber** `shell.scss`, `public-team.scss` und `public-player.scss` nutzen `height: 100dvh`
|
||||
als feste Zusage „genau ein Bildschirm hoch" (siehe
|
||||
`docs/superpowers/plans/`-Historie zum Doppel-Scrollbar-Fix vom selben Tag). Ohne Anpassung
|
||||
würde die Shell/Public-Seite exakt um die Banner-Höhe über den sichtbaren Bereich
|
||||
hinausragen (Bottom-Nav leicht abgeschnitten) — derselbe Bugtyp wie der kürzlich gefixte.
|
||||
|
||||
**Fix:** `App` (Root-Komponente) bindet eine CSS-Custom-Property auf ihr eigenes
|
||||
Host-Element. Host-Bindings werten Ausdrücke gegen die Komponenten-Instanz aus, daher als
|
||||
Instanz-Property vorhalten:
|
||||
|
||||
```ts
|
||||
host: {
|
||||
'[style.--env-banner-height.px]': 'bannerHeight',
|
||||
}
|
||||
// ...
|
||||
protected readonly bannerHeight = environment.production ? 0 : ENV_BANNER_HEIGHT_PX;
|
||||
```
|
||||
|
||||
Da `<app-root>` ein gemeinsamer Vorfahre von `EnvBanner` und allen Routen-Komponenten
|
||||
(Shell, Public-Seiten, …) ist, vererbt sich die Property automatisch nach unten. Die drei
|
||||
betroffenen SCSS-Dateien ändern:
|
||||
|
||||
```scss
|
||||
// vorher: height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
In Produktion ist die Property `0px`, `calc(100dvh - 0px)` verhält sich identisch zu vorher
|
||||
— keine Verhaltensänderung außerhalb der Entwicklungsumgebung.
|
||||
|
||||
## Testing
|
||||
|
||||
- `env-banner.spec.ts` (neu): Sichtbarkeit abhängig von `environment.production`.
|
||||
- `app.spec.ts`: Erweiterung um Assertion, dass `--env-banner-height` korrekt `0px` bzw.
|
||||
`28px` auf dem Host gesetzt wird (je nach gemocktem `environment.production`).
|
||||
- Bestehende Tests (`shell.spec.ts`, `public-team.spec.ts`, `public-player.spec.ts`) bleiben
|
||||
unverändert grün — die `calc()`-Änderung ist rein visuell/CSS, keine Verhaltensänderung.
|
||||
- Manuelle Verifikation: `ng serve` (development) zeigt den Banner, `ng build` (production)
|
||||
und `ng build --configuration=container` zeigen ihn nicht; Shell/Public-Seiten scrollen mit
|
||||
Banner weiterhin korrekt ohne abgeschnittene Bottom-Nav (per Chrome DevTools nachprüfen).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Kein neues `environmentName`-Feld in den Environment-Dateien (nicht nötig, siehe oben).
|
||||
- Kein Dismiss/Ausblenden des Banners.
|
||||
- Keine Anzeige zusätzlicher technischer Details (API-URL, Build-Hash) im Banner-Text.
|
||||
@@ -4,6 +4,7 @@ APP_NAME="NestJS API"
|
||||
API_PREFIX=api
|
||||
FRONTEND_DOMAIN=http://localhost:3000
|
||||
BACKEND_DOMAIN=http://localhost:3000
|
||||
LOG_RETENTION_DAYS=365
|
||||
|
||||
DATABASE_TYPE=postgres
|
||||
DATABASE_HOST=postgres
|
||||
|
||||
464
myteamwallet_backend/package-lock.json
generated
464
myteamwallet_backend/package-lock.json
generated
@@ -14,9 +14,11 @@
|
||||
"@nestjs/common": "9.1.6",
|
||||
"@nestjs/config": "2.2.0",
|
||||
"@nestjs/core": "9.1.6",
|
||||
"@nestjs/event-emitter": "^2.1.1",
|
||||
"@nestjs/jwt": "9.0.0",
|
||||
"@nestjs/passport": "9.0.0",
|
||||
"@nestjs/platform-express": "9.1.6",
|
||||
"@nestjs/schedule": "^2.2.3",
|
||||
"@nestjs/serve-static": "^3.0.0",
|
||||
"@nestjs/swagger": "6.1.3",
|
||||
"@nestjs/typeorm": "9.0.1",
|
||||
@@ -29,6 +31,7 @@
|
||||
"passport": "0.6.0",
|
||||
"passport-anonymous": "1.0.1",
|
||||
"passport-jwt": "4.0.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "8.8.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "3.0.2",
|
||||
@@ -48,6 +51,7 @@
|
||||
"@types/node": "16.18.3",
|
||||
"@types/passport-anonymous": "1.0.3",
|
||||
"@types/passport-jwt": "3.0.7",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "5.43.0",
|
||||
"@typescript-eslint/parser": "5.43.0",
|
||||
@@ -3301,6 +3305,19 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/event-emitter": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
|
||||
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter2": "6.4.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/jwt": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
|
||||
@@ -3402,6 +3419,31 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.2.3.tgz",
|
||||
"integrity": "sha512-PxoGdoBwZQ6SzGfFcERTk7mDxrmesNt2cfqKgtLsFpjYNpV6ZYlKw9Ku8C0ZIjdhy0tBbysj+Fsi3sYua6o6Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "2.3.1",
|
||||
"uuid": "9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
|
||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0",
|
||||
"reflect-metadata": "^0.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule/node_modules/uuid": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
|
||||
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz",
|
||||
@@ -3533,6 +3575,30 @@
|
||||
"typeorm": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3704,6 +3770,21 @@
|
||||
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
|
||||
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -3982,6 +4063,16 @@
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
||||
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prettier": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
|
||||
@@ -5186,6 +5277,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.21.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
|
||||
@@ -5944,6 +6053,15 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-2.3.1.tgz",
|
||||
"integrity": "sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -6151,6 +6269,12 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
@@ -7269,6 +7393,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter2": {
|
||||
"version": "6.4.9",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
|
||||
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
@@ -7566,8 +7696,7 @@
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.2.0",
|
||||
@@ -7762,6 +7891,32 @@
|
||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit/node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin": {
|
||||
"version": "7.2.13",
|
||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
|
||||
@@ -11974,6 +12129,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-sdsl": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
|
||||
@@ -12220,6 +12381,25 @@
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
|
||||
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -12551,6 +12731,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/macos-release": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
|
||||
@@ -14090,6 +14279,12 @@
|
||||
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
|
||||
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
|
||||
@@ -14261,6 +14456,20 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
|
||||
@@ -14427,6 +14636,14 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -15030,6 +15247,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||
@@ -16053,6 +16276,12 @@
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.231.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
|
||||
@@ -16806,6 +17035,32 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
||||
@@ -19931,6 +20186,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nestjs/event-emitter": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
|
||||
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
|
||||
"requires": {
|
||||
"eventemitter2": "6.4.9"
|
||||
}
|
||||
},
|
||||
"@nestjs/jwt": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
|
||||
@@ -19998,6 +20261,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nestjs/schedule": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.2.3.tgz",
|
||||
"integrity": "sha512-PxoGdoBwZQ6SzGfFcERTk7mDxrmesNt2cfqKgtLsFpjYNpV6ZYlKw9Ku8C0ZIjdhy0tBbysj+Fsi3sYua6o6Eg==",
|
||||
"requires": {
|
||||
"cron": "2.3.1",
|
||||
"uuid": "9.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
|
||||
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nestjs/schematics": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz",
|
||||
@@ -20091,6 +20370,16 @@
|
||||
"uuid": "8.3.2"
|
||||
}
|
||||
},
|
||||
"@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="
|
||||
},
|
||||
"@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -20220,6 +20509,21 @@
|
||||
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
|
||||
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
|
||||
},
|
||||
"@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"requires": {
|
||||
"tslib": "^2.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -20495,6 +20799,15 @@
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"@types/pdfkit": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
||||
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/prettier": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
|
||||
@@ -21414,6 +21727,22 @@
|
||||
"fill-range": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"requires": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"requires": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"version": "4.21.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
|
||||
@@ -21973,6 +22302,14 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"devOptional": true
|
||||
},
|
||||
"cron": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-2.3.1.tgz",
|
||||
"integrity": "sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==",
|
||||
"requires": {
|
||||
"luxon": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -22132,6 +22469,11 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
|
||||
},
|
||||
"diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
@@ -22946,6 +23288,11 @@
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
|
||||
},
|
||||
"eventemitter2": {
|
||||
"version": "6.4.9",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
|
||||
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
|
||||
},
|
||||
"events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
@@ -23172,8 +23519,7 @@
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"fast-diff": {
|
||||
"version": "1.2.0",
|
||||
@@ -23342,6 +23688,29 @@
|
||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
||||
"dev": true
|
||||
},
|
||||
"fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"requires": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"fork-ts-checker-webpack-plugin": {
|
||||
"version": "7.2.13",
|
||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
|
||||
@@ -26398,6 +26767,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="
|
||||
},
|
||||
"js-sdsl": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
|
||||
@@ -26605,6 +26979,22 @@
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
|
||||
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
|
||||
},
|
||||
"linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"requires": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -26872,6 +27262,11 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="
|
||||
},
|
||||
"macos-release": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
|
||||
@@ -28128,6 +28523,11 @@
|
||||
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
|
||||
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
|
||||
},
|
||||
"pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"param-case": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
|
||||
@@ -28261,6 +28661,19 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
|
||||
},
|
||||
"pdfkit": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||
"requires": {
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"pg": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
|
||||
@@ -28386,6 +28799,14 @@
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true
|
||||
},
|
||||
"png-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||
"requires": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -28866,6 +29287,11 @@
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
|
||||
},
|
||||
"ret": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||
@@ -29632,6 +30058,11 @@
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||
"dev": true
|
||||
},
|
||||
"tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
|
||||
},
|
||||
"tlds": {
|
||||
"version": "1.231.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
|
||||
@@ -30083,6 +30514,31 @@
|
||||
"which-boxed-primitive": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"requires": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"requires": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"universalify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
"@nestjs/common": "9.1.6",
|
||||
"@nestjs/config": "2.2.0",
|
||||
"@nestjs/core": "9.1.6",
|
||||
"@nestjs/event-emitter": "^2.1.1",
|
||||
"@nestjs/jwt": "9.0.0",
|
||||
"@nestjs/passport": "9.0.0",
|
||||
"@nestjs/platform-express": "9.1.6",
|
||||
"@nestjs/schedule": "^2.2.3",
|
||||
"@nestjs/serve-static": "^3.0.0",
|
||||
"@nestjs/swagger": "6.1.3",
|
||||
"@nestjs/typeorm": "9.0.1",
|
||||
@@ -48,6 +50,7 @@
|
||||
"passport": "0.6.0",
|
||||
"passport-anonymous": "1.0.1",
|
||||
"passport-jwt": "4.0.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "8.8.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "3.0.2",
|
||||
@@ -67,6 +70,7 @@
|
||||
"@types/node": "16.18.3",
|
||||
"@types/passport-anonymous": "1.0.3",
|
||||
"@types/passport-jwt": "3.0.7",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "5.43.0",
|
||||
"@typescript-eslint/parser": "5.43.0",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import databaseConfig from './config/database.config';
|
||||
@@ -22,9 +24,14 @@ import { join } from 'path';
|
||||
import { LoggingModule } from './database/logging/logging.module';
|
||||
import { TranslateModule } from './translate/translate.module';
|
||||
import { PenaltyModule } from './penalty/penalty.module';
|
||||
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
|
||||
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [databaseConfig, authConfig, appConfig, mailConfig],
|
||||
@@ -55,6 +62,9 @@ import { PenaltyModule } from './penalty/penalty.module';
|
||||
LoggingModule,
|
||||
TranslateModule,
|
||||
PenaltyModule,
|
||||
RecurringTransactionsModule,
|
||||
CashboxExportModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
let userRepository: any;
|
||||
let service: AuthService;
|
||||
let mailService: any;
|
||||
let eventEmitter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jwtService = {
|
||||
@@ -44,6 +45,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
dataSource = {
|
||||
transaction: jest.fn((work) => work(manager)),
|
||||
};
|
||||
eventEmitter = { emit: jest.fn() };
|
||||
service = new AuthService(
|
||||
jwtService,
|
||||
usersService,
|
||||
@@ -52,6 +54,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
logger,
|
||||
dataSource,
|
||||
{ assertAtLeast: jest.fn() } as any,
|
||||
eventEmitter as any,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,6 +158,19 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits an invite-link-created event after issuing the token', async () => {
|
||||
const token = await service.createTeamInvite(
|
||||
{ teamId: 10, teamName: 'Team A' } as any,
|
||||
5,
|
||||
);
|
||||
|
||||
expect(token.token).toBeDefined();
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'notifications.invite_link.created',
|
||||
expect.objectContaining({ teamId: 10, actorUserId: 5, teamName: 'Team A' }),
|
||||
);
|
||||
});
|
||||
|
||||
function user(statusId: StatusEnum) {
|
||||
return {
|
||||
id: 2,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AuthEmailLoginDto } from './dto/auth-email-login.dto';
|
||||
@@ -27,6 +28,8 @@ import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { NOTIFICATION_EVENT_NAME } from 'src/notifications/events/notification-event-names';
|
||||
import { InviteLinkCreatedEvent } from 'src/notifications/events/invite-link-created.event';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -38,6 +41,7 @@ export class AuthService {
|
||||
private logger: LoggingService,
|
||||
private dataSource: DataSource,
|
||||
private teamAccess: TeamAccessService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async validateLogin(
|
||||
@@ -323,6 +327,11 @@ export class AuthService {
|
||||
userId: 0,
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENT_NAME.inviteLinkCreated,
|
||||
new InviteLinkCreatedEvent(object.teamId, actorUserId, object.teamName),
|
||||
);
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
|
||||
describe('CashboxExportSubscriptionService', () => {
|
||||
const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) };
|
||||
const access = { assertAtLeast: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
let service: CashboxExportSubscriptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new CashboxExportSubscriptionService(repository as any, access as any, logger as any);
|
||||
});
|
||||
|
||||
it('returns a paused default when no subscription exists yet', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getSubscription(5, 42)).resolves.toEqual({
|
||||
recipients: [],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: false,
|
||||
nextRunDate: null,
|
||||
});
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
42,
|
||||
5,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the existing subscription', async () => {
|
||||
repository.findOne.mockResolvedValue({
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
active: true,
|
||||
nextRunDate: '2027-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await expect(service.getSubscription(5, 42)).resolves.toEqual({
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
active: true,
|
||||
nextRunDate: '2027-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects activating with an empty recipient list', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.upsertSubscription(5, 42, {
|
||||
recipients: [],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a new subscription and computes the next period boundary on first activation', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
|
||||
|
||||
const result = await service.upsertSubscription(5, 42, {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_subscription_update',
|
||||
details: 'teamId=5 active=true interval=monthly recipients=1',
|
||||
userId: 42,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the existing nextRunDate when editing recipients without changing interval or activation state', async () => {
|
||||
repository.findOne.mockResolvedValue({
|
||||
recipients: ['old@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await service.upsertSubscription(5, 42, {
|
||||
recipients: ['new@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('recomputes nextRunDate when the interval changes', async () => {
|
||||
repository.findOne.mockResolvedValue({
|
||||
recipients: ['a@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
|
||||
|
||||
const result = await service.upsertSubscription(5, 42, {
|
||||
recipients: ['a@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(result.nextRunDate).toBe('2027-08-01T00:00:00.000Z');
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { CashboxExportSubscriptionResponseDTO } from './dto/cashbox-export-subscription-response.dto';
|
||||
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
||||
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
||||
|
||||
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
|
||||
[RecurringTransactionIntervalEnum.monthly]: 1,
|
||||
[RecurringTransactionIntervalEnum.quarterly]: 3,
|
||||
[RecurringTransactionIntervalEnum.yearly]: 12,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashboxExportSubscriptionService {
|
||||
constructor(
|
||||
@InjectRepository(CashboxExportSubscription)
|
||||
private readonly repository: Repository<CashboxExportSubscription>,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async getSubscription(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
): Promise<CashboxExportSubscriptionResponseDTO> {
|
||||
await this.assertAccess(userId, teamId);
|
||||
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
|
||||
if (!existing) {
|
||||
return {
|
||||
recipients: [],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: false,
|
||||
nextRunDate: null,
|
||||
};
|
||||
}
|
||||
return this.toResponse(existing);
|
||||
}
|
||||
|
||||
async upsertSubscription(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
dto: UpsertCashboxExportSubscriptionDTO,
|
||||
): Promise<CashboxExportSubscriptionResponseDTO> {
|
||||
await this.assertAccess(userId, teamId);
|
||||
if (dto.active && dto.recipients.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'Ein aktivierter automatischer Versand benötigt mindestens eine Empfängeradresse.',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
|
||||
const needsNewSchedule =
|
||||
!existing || (dto.active && (!existing.active || existing.interval !== dto.interval));
|
||||
|
||||
const entity =
|
||||
existing ??
|
||||
this.repository.create({ team: { id: teamId } as any, nextRunDate: null, active: false });
|
||||
|
||||
entity.recipients = dto.recipients;
|
||||
entity.interval = dto.interval;
|
||||
entity.active = dto.active;
|
||||
if (needsNewSchedule) {
|
||||
entity.nextRunDate = this.nextBoundary(dto.interval);
|
||||
}
|
||||
|
||||
const saved = await this.repository.save(entity);
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_subscription_update',
|
||||
details: `teamId=${teamId} active=${dto.active} interval=${dto.interval} recipients=${dto.recipients.length}`,
|
||||
userId,
|
||||
});
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
private async assertAccess(userId: number, teamId: number): Promise<void> {
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
teamId,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
}
|
||||
|
||||
private nextBoundary(interval: RecurringTransactionIntervalEnum): string {
|
||||
const now = new Date();
|
||||
const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
entity: CashboxExportSubscription,
|
||||
): CashboxExportSubscriptionResponseDTO {
|
||||
return {
|
||||
recipients: entity.recipients,
|
||||
interval: entity.interval,
|
||||
active: entity.active,
|
||||
nextRunDate: entity.nextRunDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
|
||||
type AuthenticatedRequest = { user: { id: number } };
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller({ path: 'cashbox-export', version: '1' })
|
||||
export class CashboxExportController {
|
||||
constructor(
|
||||
private readonly service: CashboxExportService,
|
||||
private readonly subscriptionService: CashboxExportSubscriptionService,
|
||||
private readonly scheduler: CashboxExportScheduler,
|
||||
) {}
|
||||
|
||||
@Get(':teamId')
|
||||
async exportCashbox(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Query() query: CashboxExportQueryDto,
|
||||
@Res({ passthrough: false }) res: Response,
|
||||
): Promise<void> {
|
||||
const { buffer, contentType, filename } = await this.service.exportForUser(
|
||||
teamId,
|
||||
request.user.id,
|
||||
query.from,
|
||||
query.to,
|
||||
query.format,
|
||||
);
|
||||
res.set({
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
});
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':teamId/subscription')
|
||||
getSubscription(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
) {
|
||||
return this.subscriptionService.getSubscription(teamId, request.user.id);
|
||||
}
|
||||
|
||||
@Put(':teamId/subscription')
|
||||
upsertSubscription(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Body() dto: UpsertCashboxExportSubscriptionDTO,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
INestApplication,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Test } from '@nestjs/testing';
|
||||
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';
|
||||
|
||||
describe('cashbox export HTTP boundary', () => {
|
||||
let app: INestApplication;
|
||||
const service = {
|
||||
exportForUser: jest.fn(),
|
||||
};
|
||||
const subscriptionService = {
|
||||
getSubscription: jest.fn(),
|
||||
upsertSubscription: jest.fn(),
|
||||
};
|
||||
const scheduler = { runDueSubscriptions: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [CashboxExportController],
|
||||
providers: [
|
||||
{ provide: CashboxExportService, useValue: service },
|
||||
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
|
||||
{ provide: CashboxExportScheduler, useValue: scheduler },
|
||||
],
|
||||
})
|
||||
.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', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('rejects an invalid format', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=xls')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(422);
|
||||
expect(service.exportForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a full ISO datetime instead of a plain YYYY-MM-DD date for from', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
'/api/v1/cashbox-export/5?from=2026-08-01T12:00:00Z&to=2026-08-31&format=csv',
|
||||
)
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(422);
|
||||
expect(service.exportForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a malformed date string for to', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=not-a-date&format=csv')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(422);
|
||||
expect(service.exportForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a CSV file with correct headers and content', async () => {
|
||||
const csvBuffer = Buffer.from('Datum;Typ;Wer;Notiz;Betrag;Periodensaldo', 'utf-8');
|
||||
service.exportForUser.mockResolvedValue({
|
||||
buffer: csvBuffer,
|
||||
contentType: 'text/csv; charset=utf-8',
|
||||
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toContain('text/csv');
|
||||
expect(response.headers['content-disposition']).toContain(
|
||||
'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
||||
);
|
||||
expect(response.text).toBe(csvBuffer.toString('utf-8'));
|
||||
expect(service.exportForUser).toHaveBeenCalledWith(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
});
|
||||
|
||||
it('returns a PDF file with correct headers and binary content', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4 fake content');
|
||||
service.exportForUser.mockResolvedValue({
|
||||
buffer: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=pdf')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toBe('application/pdf');
|
||||
expect(Buffer.from(response.body).equals(pdfBuffer)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the current subscription', async () => {
|
||||
const subscription = {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
};
|
||||
subscriptionService.getSubscription.mockResolvedValue(subscription);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5/subscription')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200)
|
||||
.expect(subscription);
|
||||
expect(subscriptionService.getSubscription).toHaveBeenCalledWith(5, 42);
|
||||
});
|
||||
|
||||
it('rejects an invalid recipient email on upsert', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.put('/api/v1/cashbox-export/5/subscription')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({ recipients: ['not-an-email'], interval: 'monthly', active: true })
|
||||
.expect(422);
|
||||
expect(subscriptionService.upsertSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a valid subscription upsert', async () => {
|
||||
const subscription = {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
};
|
||||
subscriptionService.upsertSubscription.mockResolvedValue(subscription);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.put('/api/v1/cashbox-export/5/subscription')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({ recipients: ['vorstand@example.com'], interval: 'monthly', active: true })
|
||||
.expect(200)
|
||||
.expect(subscription);
|
||||
expect(subscriptionService.upsertSubscription).toHaveBeenCalledWith(5, 42, {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: 'monthly',
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { MailModule } from 'src/mail/mail.module';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
||||
import { CashboxExportController } from './cashbox-export.controller';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
|
||||
@Module({
|
||||
controllers: [CashboxExportController],
|
||||
providers: [CashboxExportService, CashboxExportSubscriptionService, CashboxExportScheduler],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Team, CashboxExportSubscription]),
|
||||
TeamsModule,
|
||||
MailModule,
|
||||
LoggingModule,
|
||||
],
|
||||
})
|
||||
export class CashboxExportModule {}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
|
||||
describe('CashboxExportScheduler', () => {
|
||||
const subscriptionRepository = { find: jest.fn(), save: jest.fn((v) => v) };
|
||||
const teamRepository = { findOne: jest.fn() };
|
||||
const mailService = { cashboxExport: jest.fn() };
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
let scheduler: CashboxExportScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
scheduler = new CashboxExportScheduler(
|
||||
subscriptionRepository as any,
|
||||
teamRepository as any,
|
||||
mailService as any,
|
||||
logger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when no subscription is due', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([]);
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(teamRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(mailService.cashboxExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emails the elapsed monthly period and advances nextRunDate', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
team: { id: 5 },
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [
|
||||
{ date: '2026-08-15T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
|
||||
],
|
||||
players: [],
|
||||
});
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(teamRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 5 },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
expect(mailService.cashboxExport).toHaveBeenCalledTimes(1);
|
||||
const [mailData, attachment, filename] = mailService.cashboxExport.mock.calls[0];
|
||||
expect(mailData).toEqual({
|
||||
to: 'vorstand@example.com',
|
||||
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
|
||||
});
|
||||
expect(Buffer.isBuffer(attachment)).toBe(true);
|
||||
expect(filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
|
||||
expect(subscriptionRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nextRunDate: '2026-10-01T00:00:00.000Z' }),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_subscription_run',
|
||||
details: 'teamId=5 recipients=1 from=2026-08-01 to=2026-08-31',
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[RecurringTransactionIntervalEnum.monthly, '2026-09-01T00:00:00.000Z', '2026-08-01', '2026-08-31', '2026-10-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.quarterly, '2026-09-01T00:00:00.000Z', '2026-06-01', '2026-08-31', '2026-12-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.yearly, '2027-01-01T00:00:00.000Z', '2026-01-01', '2026-12-31', '2028-01-01T00:00:00.000Z'],
|
||||
])(
|
||||
'computes the elapsed period and next run date for %s',
|
||||
async (interval, nextRunDate, expectedFrom, expectedTo, expectedNext) => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval, nextRunDate, active: true },
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
});
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
const [mailData] = mailService.cashboxExport.mock.calls[0];
|
||||
expect(mailData.data.from).toBe(expectedFrom);
|
||||
expect(mailData.data.to).toBe(expectedTo);
|
||||
expect(subscriptionRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nextRunDate: expectedNext }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('processes multiple due subscriptions independently', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
});
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('continues processing when one subscription fails', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
});
|
||||
mailService.cashboxExport.mockRejectedValueOnce(new Error('smtp down'));
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
|
||||
expect(subscriptionRepository.save).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
details: expect.stringContaining('subscriptionId=1 teamId=5'),
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { MailService } from 'src/mail/mail.service';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
||||
|
||||
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
|
||||
[RecurringTransactionIntervalEnum.monthly]: 1,
|
||||
[RecurringTransactionIntervalEnum.quarterly]: 3,
|
||||
[RecurringTransactionIntervalEnum.yearly]: 12,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashboxExportScheduler {
|
||||
constructor(
|
||||
@InjectRepository(CashboxExportSubscription)
|
||||
private readonly subscriptionRepository: Repository<CashboxExportSubscription>,
|
||||
@InjectRepository(Team)
|
||||
private readonly teamRepository: Repository<Team>,
|
||||
private readonly mailService: MailService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async runDueSubscriptions(): Promise<void> {
|
||||
const today = new Date().toISOString();
|
||||
const due = await this.subscriptionRepository.find({
|
||||
where: { active: true, nextRunDate: LessThanOrEqual(today) },
|
||||
relations: ['team'],
|
||||
});
|
||||
|
||||
for (const subscription of due) {
|
||||
try {
|
||||
await this.runOne(subscription);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.logger.error({
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
details: `recurring subscription failed: subscriptionId=${subscription.id} teamId=${subscription.team.id}: ${errorMessage}`,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runOne(subscription: CashboxExportSubscription): Promise<void> {
|
||||
const team = await this.teamRepository.findOne({
|
||||
where: { id: subscription.team.id },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
if (!team) return;
|
||||
|
||||
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
|
||||
const rows = buildRows(team, from, to);
|
||||
const receivableRows = buildReceivableRows(team, from, to);
|
||||
const pdf = await buildPdf(team, rows, receivableRows, from, to);
|
||||
const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`;
|
||||
|
||||
await this.mailService.cashboxExport(
|
||||
{ to: subscription.recipients.join(', '), data: { teamName: team.name, from, to } },
|
||||
pdf,
|
||||
filename,
|
||||
);
|
||||
|
||||
subscription.nextRunDate = this.advance(subscription.nextRunDate, subscription.interval);
|
||||
await this.subscriptionRepository.save(subscription);
|
||||
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_subscription_run',
|
||||
details: `teamId=${team.id} recipients=${subscription.recipients.length} from=${from} to=${to}`,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
|
||||
private periodBounds(
|
||||
nextRunDate: string,
|
||||
interval: RecurringTransactionIntervalEnum,
|
||||
): { from: string; to: string } {
|
||||
const end = new Date(nextRunDate);
|
||||
end.setUTCDate(end.getUTCDate() - 1);
|
||||
const start = new Date(nextRunDate);
|
||||
start.setUTCMonth(start.getUTCMonth() - INTERVAL_MONTHS[interval]);
|
||||
return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
private advance(nextRunDate: string, interval: RecurringTransactionIntervalEnum): string {
|
||||
const date = new Date(nextRunDate);
|
||||
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
|
||||
describe('CashboxExportService', () => {
|
||||
const teamRepository = { findOne: jest.fn() };
|
||||
const access = { assertAtLeast: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
let service: CashboxExportService;
|
||||
let callOrder: string[];
|
||||
|
||||
const team = {
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
|
||||
],
|
||||
players: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
callOrder = [];
|
||||
|
||||
access.assertAtLeast.mockImplementation(async () => {
|
||||
callOrder.push('assertAtLeast');
|
||||
});
|
||||
|
||||
teamRepository.findOne.mockImplementation(async () => {
|
||||
callOrder.push('findOne');
|
||||
return team;
|
||||
});
|
||||
|
||||
service = new CashboxExportService(teamRepository as any, access as any, logger as any);
|
||||
});
|
||||
|
||||
it('checks permission before loading data', async () => {
|
||||
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
42,
|
||||
5,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls assertAtLeast before findOne to enforce permission check ordering', async () => {
|
||||
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(callOrder).toEqual(['assertAtLeast', 'findOne']);
|
||||
});
|
||||
|
||||
it('skips team lookup when permission check rejects', async () => {
|
||||
access.assertAtLeast.mockRejectedValueOnce(new ForbiddenException('Insufficient permissions'));
|
||||
|
||||
await expect(
|
||||
service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(teamRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown team', async () => {
|
||||
teamRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
service.exportForUser(999, 42, '2026-08-01', '2026-08-31', 'csv'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects a range where from is after to without querying the team', async () => {
|
||||
await expect(
|
||||
service.exportForUser(5, 42, '2026-08-31', '2026-08-01', 'csv'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(access.assertAtLeast).not.toHaveBeenCalled();
|
||||
expect(teamRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds a CSV buffer with the correct content type and filename', async () => {
|
||||
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(result.contentType).toBe('text/csv; charset=utf-8');
|
||||
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.csv');
|
||||
expect(result.buffer.toString('utf-8')).toContain('Sponsoring');
|
||||
});
|
||||
|
||||
it('builds a PDF buffer with the correct content type and filename', async () => {
|
||||
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'pdf');
|
||||
|
||||
expect(result.contentType).toBe('application/pdf');
|
||||
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
|
||||
expect(result.buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('logs a cashbox_export_download event after a successful export', async () => {
|
||||
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_download',
|
||||
details: 'teamId=5 format=csv from=2026-08-01 to=2026-08-31',
|
||||
userId: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CashboxExportService {
|
||||
constructor(
|
||||
@InjectRepository(Team)
|
||||
private readonly teamRepository: Repository<Team>,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async exportForUser(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
format: 'csv' | 'pdf',
|
||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||
if (from > to) {
|
||||
throw new BadRequestException(
|
||||
'Der Startzeitraum darf nicht nach dem Endzeitraum liegen.',
|
||||
);
|
||||
}
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
teamId,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
const team = await this.teamRepository.findOne({
|
||||
where: { id: teamId },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||
|
||||
const rows = buildRows(team, from, to);
|
||||
|
||||
if (format === 'csv') {
|
||||
const result = {
|
||||
buffer: Buffer.from(buildCsv(rows), 'utf-8'),
|
||||
contentType: 'text/csv; charset=utf-8',
|
||||
filename: `kassenbuch_${team.alias}_${from}_${to}.csv`,
|
||||
};
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_download',
|
||||
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
|
||||
userId,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
const result = {
|
||||
buffer: await buildPdf(team, rows, buildReceivableRows(team, from, to), from, to),
|
||||
contentType: 'application/pdf',
|
||||
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
|
||||
};
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_download',
|
||||
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
|
||||
userId,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||
|
||||
// Reads the page count directly out of the raw PDF bytes instead of pulling in
|
||||
// a parser dependency. Coupled to pdfkit's current /Pages dict serialization -
|
||||
// a pdfkit upgrade that reorders/reflows it could require adjusting this regex.
|
||||
function pdfPageCount(buffer: Buffer): number {
|
||||
const match = buffer.toString('latin1').match(/\/Type\s*\/Pages[\s\S]{0,80}?\/Count\s+(\d+)/);
|
||||
if (!match) throw new Error('Could not find page count in PDF buffer');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
describe('buildRows', () => {
|
||||
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('includes team-wallet credit rows as-is and negates expense rows so they reduce the budget', () => {
|
||||
// DB stores TeamWalletTransaction.amount as a positive number even for
|
||||
// expenses (see team-wallet-transaction.entity.ts setBalance()); buildRows
|
||||
// must negate expenses itself so they subtract from the running total.
|
||||
const rows = buildRows(
|
||||
team({
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Sponsoring', type: { name: 'credit' } },
|
||||
{ date: '2026-08-10T00:00:00.000Z', amount: 20, note: 'Bälle', type: { name: 'expense' } },
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Sponsoring', amount: 100, runningTotal: 100 },
|
||||
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20, runningTotal: 80 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes only "payment" player transactions, excluding fee/levy/fine', () => {
|
||||
const rows = buildRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
transactions: [
|
||||
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
|
||||
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-03T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
|
||||
const rows = buildRows(
|
||||
team({
|
||||
transactions: [
|
||||
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'credit' } },
|
||||
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'credit' } },
|
||||
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'credit' } },
|
||||
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'credit' } },
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
|
||||
});
|
||||
|
||||
it('returns an empty array when nothing falls in range', () => {
|
||||
const rows = buildRows(team() as any, '2026-08-01', '2026-08-31');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips transactions with null type and includes valid ones', () => {
|
||||
const rows = buildRows(
|
||||
team({
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Valid credit', type: { name: 'credit' } },
|
||||
{ date: '2026-08-06T00:00:00.000Z', amount: 50, note: 'Null type team wallet', type: null },
|
||||
],
|
||||
players: [
|
||||
{
|
||||
firstName: 'Bob',
|
||||
lastName: 'Smith',
|
||||
transactions: [
|
||||
{ date: '2026-08-07T00:00:00.000Z', amount: 20, note: 'Valid payment', type: { name: 'payment' } },
|
||||
{ date: '2026-08-08T00:00:00.000Z', amount: 30, note: 'Null type player', type: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Valid credit', amount: 100, runningTotal: 100 },
|
||||
{ date: '2026-08-07T00:00:00.000Z', type: 'payment', who: 'Bob Smith', note: 'Valid payment', amount: 20, runningTotal: 120 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReceivableRows', () => {
|
||||
const team = (overrides: Partial<{ players: any[] }> = {}) => ({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
players: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('includes fine, levy and fee player transactions, excluding payment and credit', () => {
|
||||
const rows = buildReceivableRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
transactions: [
|
||||
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
|
||||
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 5, note: 'Zu spät', type: { name: 'fine' } },
|
||||
{ date: '2026-08-06T00:00:00.000Z', amount: 20, note: 'Umlage Trikots', type: { name: 'levy' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-04T00:00:00.000Z', type: 'fee', who: 'Alex Muster', note: 'Monatsbeitrag', amount: 15 },
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'fine', who: 'Alex Muster', note: 'Zu spät', amount: 5 },
|
||||
{ date: '2026-08-06T00:00:00.000Z', type: 'levy', who: 'Alex Muster', note: 'Umlage Trikots', amount: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
|
||||
const rows = buildReceivableRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Bob',
|
||||
lastName: 'Smith',
|
||||
transactions: [
|
||||
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'fine' } },
|
||||
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'fine' } },
|
||||
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'fee' } },
|
||||
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'levy' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
|
||||
});
|
||||
|
||||
it('skips transactions with null type and returns an empty array when nothing matches', () => {
|
||||
const rows = buildReceivableRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Carla',
|
||||
lastName: 'Beispiel',
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Null type', type: null },
|
||||
{ date: '2026-08-06T00:00:00.000Z', amount: 10, note: 'Zahlung', type: { name: 'payment' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCsv', () => {
|
||||
it('renders the header and formatted rows with German decimals', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
||||
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20.5, runningTotal: -10.5 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Zahlung;Alex Muster;Bar bezahlt;10,00;10,00\r\n' +
|
||||
'2026-08-10;Ausgabe;Teamkasse;Bälle;-20,50;-10,50',
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes notes containing a semicolon and escapes embedded quotes', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Spende; "danke"', amount: 5, runningTotal: 5 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Guthaben;Teamkasse;"Spende; ""danke""";5,00;5,00',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a placeholder row when there are no bookings', () => {
|
||||
const csv = buildCsv([]);
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\nKeine Buchungen im gewählten Zeitraum',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats negative-zero as positive zero', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -0.001, runningTotal: 0 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Ausgabe;Teamkasse;Test;0,00;0,00',
|
||||
);
|
||||
});
|
||||
|
||||
it('rounds half-cent boundaries correctly', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Test', amount: 1.005, runningTotal: 1.005 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Zahlung;Alex Muster;Test;1,01;1,01',
|
||||
);
|
||||
});
|
||||
|
||||
it('rounds negative half-cent boundaries correctly', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -1.005, runningTotal: -1.005 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Ausgabe;Teamkasse;Test;-1,01;-1,01',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPdf', () => {
|
||||
const cashRow = {
|
||||
date: '2026-08-05T00:00:00.000Z',
|
||||
type: 'payment',
|
||||
who: 'Alex Muster',
|
||||
note: 'Bar bezahlt',
|
||||
amount: 10,
|
||||
runningTotal: 10,
|
||||
};
|
||||
|
||||
const receivableRow = {
|
||||
date: '2026-08-06T00:00:00.000Z',
|
||||
type: 'fine',
|
||||
who: 'Bob Smith',
|
||||
note: 'Zu spät',
|
||||
amount: 5,
|
||||
};
|
||||
|
||||
it('produces a non-empty valid PDF buffer with cash rows only', async () => {
|
||||
const buffer = await buildPdf({ name: 'Team A' } as any, [cashRow], [], '2026-08-01', '2026-08-31');
|
||||
|
||||
expect(Buffer.isBuffer(buffer)).toBe(true);
|
||||
expect(buffer.length).toBeGreaterThan(100);
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('produces a valid PDF when there are only receivable rows', async () => {
|
||||
const buffer = await buildPdf({ name: 'Team A' } as any, [], [receivableRow], '2026-08-01', '2026-08-31');
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('produces a valid PDF with both cash and receivable rows', async () => {
|
||||
const buffer = await buildPdf(
|
||||
{ name: 'Team A' } as any,
|
||||
[cashRow],
|
||||
[receivableRow],
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('still produces a valid PDF when both sections are empty', async () => {
|
||||
const buffer = await buildPdf({ name: 'Team A' } as any, [], [], '2026-08-01', '2026-08-31');
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
expect(pdfPageCount(buffer)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not append blank trailing pages when content fits on a single page', async () => {
|
||||
const buffer = await buildPdf(
|
||||
{ name: 'Team A' } as any,
|
||||
[cashRow],
|
||||
[receivableRow],
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(pdfPageCount(buffer)).toBe(1);
|
||||
});
|
||||
|
||||
it('paginates correctly and stays a valid PDF for many rows', async () => {
|
||||
const manyRows = Array.from({ length: 60 }, (_, i) => ({
|
||||
...cashRow,
|
||||
date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
||||
note: `Buchung ${i}`,
|
||||
runningTotal: 10 * (i + 1),
|
||||
}));
|
||||
const manyReceivables = Array.from({ length: 60 }, (_, i) => ({
|
||||
...receivableRow,
|
||||
date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
||||
note: `Forderung ${i}`,
|
||||
}));
|
||||
|
||||
const buffer = await buildPdf(
|
||||
{ name: 'Team A' } as any,
|
||||
manyRows,
|
||||
manyReceivables,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
expect(buffer.length).toBeGreaterThan(2000);
|
||||
// Guards against the footer loop reintroducing blank trailing pages: with
|
||||
// the bug, this dataset produced 12 pages (3x the real content pages).
|
||||
expect(pdfPageCount(buffer)).toBe(4);
|
||||
});
|
||||
});
|
||||
479
myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts
Normal file
479
myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import PDFDocument = require('pdfkit');
|
||||
|
||||
export interface CashboxExportRow {
|
||||
date: string;
|
||||
type: string;
|
||||
who: string;
|
||||
note: string;
|
||||
amount: number;
|
||||
runningTotal: number;
|
||||
}
|
||||
|
||||
interface RawRow {
|
||||
date: string;
|
||||
type: string;
|
||||
who: string;
|
||||
note: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export function buildRows(team: Team, from: string, to: string): CashboxExportRow[] {
|
||||
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
||||
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
||||
|
||||
const raw: RawRow[] = [];
|
||||
|
||||
for (const transaction of team.transactions ?? []) {
|
||||
if (!transaction.type) continue;
|
||||
const amount = Number(transaction.amount);
|
||||
raw.push({
|
||||
date: transaction.date,
|
||||
type: transaction.type.name,
|
||||
who: 'Teamkasse',
|
||||
note: transaction.note,
|
||||
amount: transaction.type.name === 'expense' ? -amount : amount,
|
||||
});
|
||||
}
|
||||
|
||||
for (const player of team.players ?? []) {
|
||||
for (const transaction of player.transactions ?? []) {
|
||||
if (!transaction.type || transaction.type.name !== 'payment') continue;
|
||||
raw.push({
|
||||
date: transaction.date,
|
||||
type: transaction.type.name,
|
||||
who: `${player.firstName} ${player.lastName}`,
|
||||
note: transaction.note,
|
||||
amount: Number(transaction.amount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = raw
|
||||
.filter((row) => {
|
||||
const time = new Date(row.date).getTime();
|
||||
return time >= fromTime && time <= toTime;
|
||||
})
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
|
||||
let runningTotal = 0;
|
||||
return filtered.map((row) => {
|
||||
runningTotal += row.amount;
|
||||
return { ...row, runningTotal };
|
||||
});
|
||||
}
|
||||
|
||||
export interface CashboxReceivableRow {
|
||||
date: string;
|
||||
type: string;
|
||||
who: string;
|
||||
note: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const RECEIVABLE_TYPES = new Set(['fine', 'levy', 'fee']);
|
||||
|
||||
export function buildReceivableRows(team: Team, from: string, to: string): CashboxReceivableRow[] {
|
||||
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
||||
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
||||
|
||||
const raw: CashboxReceivableRow[] = [];
|
||||
for (const player of team.players ?? []) {
|
||||
for (const transaction of player.transactions ?? []) {
|
||||
if (!transaction.type || !RECEIVABLE_TYPES.has(transaction.type.name)) continue;
|
||||
raw.push({
|
||||
date: transaction.date,
|
||||
type: transaction.type.name,
|
||||
who: `${player.firstName} ${player.lastName}`,
|
||||
note: transaction.note,
|
||||
amount: Number(transaction.amount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return raw
|
||||
.filter((row) => {
|
||||
const time = new Date(row.date).getTime();
|
||||
return time >= fromTime && time <= toTime;
|
||||
})
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
payment: 'Zahlung',
|
||||
credit: 'Guthaben',
|
||||
expense: 'Ausgabe',
|
||||
fine: 'Strafe',
|
||||
levy: 'Umlage',
|
||||
fee: 'Gebühr',
|
||||
};
|
||||
|
||||
function formatGermanAmount(value: number): string {
|
||||
const rounded = Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * 100) / 100;
|
||||
const normalized = rounded === 0 ? 0 : rounded;
|
||||
return normalized.toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
function escapeCsvField(value: string): string {
|
||||
if (/[;"\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildCsv(rows: CashboxExportRow[]): string {
|
||||
const lines = ['Datum;Typ;Wer;Notiz;Betrag;Periodensaldo'];
|
||||
if (rows.length === 0) {
|
||||
lines.push('Keine Buchungen im gewählten Zeitraum');
|
||||
} else {
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
[
|
||||
row.date.slice(0, 10),
|
||||
TYPE_LABELS[row.type] ?? row.type,
|
||||
escapeCsvField(row.who),
|
||||
escapeCsvField(row.note),
|
||||
formatGermanAmount(row.amount),
|
||||
formatGermanAmount(row.runningTotal),
|
||||
].join(';'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 40;
|
||||
|
||||
const COLORS = {
|
||||
headerBand: '#4f8f46',
|
||||
headerText: '#ffffff',
|
||||
sectionTitle: '#3f7f3c',
|
||||
tableHeaderBg: '#e8f2e4',
|
||||
tableHeaderText: '#20251f',
|
||||
zebra: '#f7f8f2',
|
||||
border: '#dde3d8',
|
||||
text: '#20251f',
|
||||
muted: '#5b6357',
|
||||
positive: '#2e7d32',
|
||||
negative: '#c1121f',
|
||||
receivable: '#9e9e9e',
|
||||
footerText: '#8a9186',
|
||||
};
|
||||
|
||||
interface Column {
|
||||
label: string;
|
||||
width: number;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
const CASH_COLUMNS: Column[] = [
|
||||
{ label: 'Datum', width: 60 },
|
||||
{ label: 'Typ', width: 60 },
|
||||
{ label: 'Wer', width: 110 },
|
||||
{ label: 'Notiz', width: 150 },
|
||||
{ label: 'Betrag', width: 65, align: 'right' },
|
||||
{ label: 'Saldo', width: 65, align: 'right' },
|
||||
];
|
||||
|
||||
const RECEIVABLE_COLUMNS: Column[] = [
|
||||
{ label: 'Datum', width: 60 },
|
||||
{ label: 'Typ', width: 70 },
|
||||
{ label: 'Wer', width: 130 },
|
||||
{ label: 'Notiz', width: 190 },
|
||||
{ label: 'Betrag', width: 65, align: 'right' },
|
||||
];
|
||||
|
||||
const ROW_HEIGHT = 20;
|
||||
const HEADER_ROW_HEIGHT = 22;
|
||||
const CELL_PADDING = 5;
|
||||
|
||||
interface TableRow {
|
||||
cells: string[];
|
||||
cellColors?: (string | undefined)[];
|
||||
boldCells?: boolean[];
|
||||
}
|
||||
|
||||
function tableWidth(columns: Column[]): number {
|
||||
return columns.reduce((sum, col) => sum + col.width, 0);
|
||||
}
|
||||
|
||||
function formatAmount(value: number): string {
|
||||
return `${formatGermanAmount(value)} €`;
|
||||
}
|
||||
|
||||
// Character-count heuristic instead of doc.widthOfString: keeps row height
|
||||
// fixed at one line without coupling truncation to the exact font metrics
|
||||
// used at draw time.
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
function drawTableHeaderRow(doc: PDFKit.PDFDocument, columns: Column[], y: number): void {
|
||||
const width = tableWidth(columns);
|
||||
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).fill(COLORS.tableHeaderBg);
|
||||
let colX = PAGE_MARGIN;
|
||||
for (const column of columns) {
|
||||
doc
|
||||
.fillColor(COLORS.tableHeaderText)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(9)
|
||||
.text(column.label, colX + CELL_PADDING, y + 6, {
|
||||
width: column.width - CELL_PADDING * 2,
|
||||
align: column.align ?? 'left',
|
||||
lineBreak: false,
|
||||
});
|
||||
colX += column.width;
|
||||
}
|
||||
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).stroke(COLORS.border);
|
||||
}
|
||||
|
||||
function drawTable(
|
||||
doc: PDFKit.PDFDocument,
|
||||
columns: Column[],
|
||||
rows: TableRow[],
|
||||
startY: number,
|
||||
pageBottom: number,
|
||||
): number {
|
||||
const width = tableWidth(columns);
|
||||
let y = startY;
|
||||
drawTableHeaderRow(doc, columns, y);
|
||||
y += HEADER_ROW_HEIGHT;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
if (y + ROW_HEIGHT > pageBottom) {
|
||||
doc.addPage();
|
||||
y = PAGE_MARGIN;
|
||||
drawTableHeaderRow(doc, columns, y);
|
||||
y += HEADER_ROW_HEIGHT;
|
||||
}
|
||||
if (index % 2 === 1) {
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.zebra);
|
||||
}
|
||||
let colX = PAGE_MARGIN;
|
||||
row.cells.forEach((cellText, colIndex) => {
|
||||
const column = columns[colIndex];
|
||||
doc
|
||||
.fillColor(row.cellColors?.[colIndex] ?? COLORS.text)
|
||||
.font(row.boldCells?.[colIndex] ? 'Helvetica-Bold' : 'Helvetica')
|
||||
.fontSize(9)
|
||||
.text(cellText, colX + CELL_PADDING, y + 5, {
|
||||
width: column.width - CELL_PADDING * 2,
|
||||
align: column.align ?? 'left',
|
||||
lineBreak: false,
|
||||
});
|
||||
colX += column.width;
|
||||
});
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
|
||||
y += ROW_HEIGHT;
|
||||
});
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
function drawSummaryRow(
|
||||
doc: PDFKit.PDFDocument,
|
||||
columns: Column[],
|
||||
label: string,
|
||||
value: string,
|
||||
startY: number,
|
||||
pageBottom: number,
|
||||
valueColor: string,
|
||||
): number {
|
||||
let y = startY;
|
||||
if (y + ROW_HEIGHT > pageBottom) {
|
||||
doc.addPage();
|
||||
y = PAGE_MARGIN;
|
||||
}
|
||||
const width = tableWidth(columns);
|
||||
const valueColumnWidth = columns[columns.length - 1].width;
|
||||
const labelWidth = width - valueColumnWidth - CELL_PADDING * 2;
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.tableHeaderBg);
|
||||
doc
|
||||
.fillColor(COLORS.tableHeaderText)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(9)
|
||||
.text(label, PAGE_MARGIN + CELL_PADDING, y + 5, { width: labelWidth, lineBreak: false });
|
||||
doc
|
||||
.fillColor(valueColor)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(9)
|
||||
.text(value, PAGE_MARGIN + width - valueColumnWidth + CELL_PADDING, y + 5, {
|
||||
width: valueColumnWidth - CELL_PADDING * 2,
|
||||
align: 'right',
|
||||
lineBreak: false,
|
||||
});
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
|
||||
return y + ROW_HEIGHT;
|
||||
}
|
||||
|
||||
function addFooters(doc: PDFKit.PDFDocument, teamName: string): void {
|
||||
const range = doc.bufferedPageRange();
|
||||
const generatedAt = new Date().toLocaleDateString('de-DE');
|
||||
for (let i = range.start; i < range.start + range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
const footerY = doc.page.height - 25;
|
||||
// footerY sits inside the reserved bottom margin (below pdfkit's page
|
||||
// maxY()). Without an explicit `height`, pdfkit's LineWrapper measures
|
||||
// overflow against the full-page maxY() and calls addPage() here on every
|
||||
// iteration - silently appending blank trailing pages. Bounding the text
|
||||
// to its own small box (well over the 8pt single-line height needed)
|
||||
// keeps the overflow check local and stops that auto-pagination.
|
||||
doc
|
||||
.fontSize(8)
|
||||
.font('Helvetica')
|
||||
.fillColor(COLORS.footerText)
|
||||
.text(`${teamName} – Kassenbuch-Report, erstellt am ${generatedAt}`, PAGE_MARGIN, footerY, {
|
||||
width: doc.page.width - PAGE_MARGIN * 2 - 60,
|
||||
height: 20,
|
||||
lineBreak: false,
|
||||
});
|
||||
doc
|
||||
.fontSize(8)
|
||||
.fillColor(COLORS.footerText)
|
||||
.text(`Seite ${i - range.start + 1} von ${range.count}`, doc.page.width - PAGE_MARGIN - 60, footerY, {
|
||||
width: 60,
|
||||
height: 20,
|
||||
align: 'right',
|
||||
lineBreak: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPdf(
|
||||
team: Pick<Team, 'name'>,
|
||||
rows: CashboxExportRow[],
|
||||
receivableRows: CashboxReceivableRow[],
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ margin: PAGE_MARGIN, bufferPages: true, size: 'A4' });
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageBottom = doc.page.height - PAGE_MARGIN - 30;
|
||||
|
||||
doc.rect(0, 0, pageWidth, 90).fill(COLORS.headerBand);
|
||||
doc
|
||||
.fillColor(COLORS.headerText)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(20)
|
||||
.text(team.name, PAGE_MARGIN, 28, { width: pageWidth - PAGE_MARGIN * 2, lineBreak: false });
|
||||
doc.font('Helvetica').fontSize(11).text('Kassenbuch-Report', PAGE_MARGIN, 55);
|
||||
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`, PAGE_MARGIN, 70);
|
||||
|
||||
let y = 110;
|
||||
|
||||
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
|
||||
doc.text('Kassenbewegungen', PAGE_MARGIN, y);
|
||||
y += 20;
|
||||
doc
|
||||
.fontSize(9)
|
||||
.font('Helvetica')
|
||||
.fillColor(COLORS.muted)
|
||||
.text('Buchungen, die den tatsächlichen Kassenstand verändern.', PAGE_MARGIN, y);
|
||||
y += 18;
|
||||
|
||||
if (rows.length === 0) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Oblique')
|
||||
.fillColor(COLORS.muted)
|
||||
.text('Keine Buchungen im gewählten Zeitraum.', PAGE_MARGIN, y);
|
||||
y += 24;
|
||||
} else {
|
||||
const cashTableRows: TableRow[] = rows.map((row) => ({
|
||||
cells: [
|
||||
row.date.slice(0, 10),
|
||||
TYPE_LABELS[row.type] ?? row.type,
|
||||
truncate(row.who, 20),
|
||||
truncate(row.note, 26),
|
||||
formatAmount(row.amount),
|
||||
formatAmount(row.runningTotal),
|
||||
],
|
||||
cellColors: [
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
row.amount < 0 ? COLORS.negative : COLORS.positive,
|
||||
undefined,
|
||||
],
|
||||
boldCells: [false, false, false, false, false, true],
|
||||
}));
|
||||
y = drawTable(doc, CASH_COLUMNS, cashTableRows, y, pageBottom);
|
||||
|
||||
const endBalance = rows[rows.length - 1].runningTotal;
|
||||
y = drawSummaryRow(
|
||||
doc,
|
||||
CASH_COLUMNS,
|
||||
'Endsaldo Kassenbewegungen',
|
||||
formatAmount(endBalance),
|
||||
y,
|
||||
pageBottom,
|
||||
endBalance < 0 ? COLORS.negative : COLORS.positive,
|
||||
);
|
||||
y += 20;
|
||||
}
|
||||
|
||||
y += 10;
|
||||
if (y + 70 > pageBottom) {
|
||||
doc.addPage();
|
||||
y = PAGE_MARGIN;
|
||||
}
|
||||
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
|
||||
doc.text('Forderungen (Strafen, Beiträge, Umlagen)', PAGE_MARGIN, y);
|
||||
y += 20;
|
||||
doc
|
||||
.fontSize(9)
|
||||
.font('Helvetica')
|
||||
.fillColor(COLORS.muted)
|
||||
.text(
|
||||
'Im Zeitraum angelegte Forderungen gegen Mitglieder. Diese verändern den tatsächlichen Kassenstand nicht, solange sie nicht bezahlt wurden.',
|
||||
PAGE_MARGIN,
|
||||
y,
|
||||
{ width: tableWidth(RECEIVABLE_COLUMNS) },
|
||||
);
|
||||
y += 28;
|
||||
|
||||
if (receivableRows.length === 0) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Oblique')
|
||||
.fillColor(COLORS.muted)
|
||||
.text('Keine Forderungen im gewählten Zeitraum.', PAGE_MARGIN, y);
|
||||
y += 24;
|
||||
} else {
|
||||
const receivableTableRows: TableRow[] = receivableRows.map((row) => ({
|
||||
cells: [
|
||||
row.date.slice(0, 10),
|
||||
TYPE_LABELS[row.type] ?? row.type,
|
||||
truncate(row.who, 24),
|
||||
truncate(row.note, 34),
|
||||
formatAmount(row.amount),
|
||||
],
|
||||
cellColors: [undefined, undefined, undefined, undefined, COLORS.receivable],
|
||||
}));
|
||||
y = drawTable(doc, RECEIVABLE_COLUMNS, receivableTableRows, y, pageBottom);
|
||||
|
||||
const total = receivableRows.reduce((sum, row) => sum + row.amount, 0);
|
||||
y = drawSummaryRow(
|
||||
doc,
|
||||
RECEIVABLE_COLUMNS,
|
||||
'Summe Forderungen',
|
||||
formatAmount(total),
|
||||
y,
|
||||
pageBottom,
|
||||
COLORS.receivable,
|
||||
);
|
||||
}
|
||||
|
||||
addFooters(doc, team.name);
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsIn, Matches } from 'class-validator';
|
||||
|
||||
export class CashboxExportQueryDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
from: string;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
to: string;
|
||||
|
||||
@IsIn(['csv', 'pdf'])
|
||||
format: 'csv' | 'pdf';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
|
||||
export class CashboxExportSubscriptionResponseDTO {
|
||||
recipients: string[];
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
active: boolean;
|
||||
nextRunDate: string | null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsBoolean, IsEmail, IsIn } from 'class-validator';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
|
||||
export class UpsertCashboxExportSubscriptionDTO {
|
||||
@ApiProperty({ example: ['vorstand@example.com'] })
|
||||
@IsArray()
|
||||
@IsEmail({}, { each: true })
|
||||
recipients: string[];
|
||||
|
||||
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
|
||||
@IsIn(Object.values(RecurringTransactionIntervalEnum))
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
@IsBoolean()
|
||||
active: boolean;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
|
||||
@Entity()
|
||||
export class CashboxExportSubscription extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@OneToOne(() => Team, { eager: false })
|
||||
@JoinColumn()
|
||||
team: Team;
|
||||
|
||||
@Column({ type: 'simple-array' })
|
||||
recipients: string[];
|
||||
|
||||
@Column()
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
|
||||
@Column({ default: false })
|
||||
active: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
nextRunDate: string | null;
|
||||
}
|
||||
@@ -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,65 @@
|
||||
import { LessThan } from 'typeorm';
|
||||
import { LogRetentionScheduler } from './log-retention.scheduler';
|
||||
|
||||
describe('LogRetentionScheduler', () => {
|
||||
const repository = { delete: jest.fn() };
|
||||
const configService = { get: jest.fn() };
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
let scheduler: LogRetentionScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-04T12:00:00.000Z'));
|
||||
configService.get.mockReturnValue(365);
|
||||
repository.delete.mockResolvedValue({ affected: 3 });
|
||||
scheduler = new LogRetentionScheduler(repository as any, configService as any, logger as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('deletes log entries older than the configured retention window', async () => {
|
||||
await scheduler.cleanupOldLogs();
|
||||
|
||||
expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays');
|
||||
expect(repository.delete).toHaveBeenCalledWith({
|
||||
createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses whatever retention window the config reports', async () => {
|
||||
configService.get.mockReturnValue(30);
|
||||
|
||||
await scheduler.cleanupOldLogs();
|
||||
|
||||
expect(repository.delete).toHaveBeenCalledWith({
|
||||
createdAt: LessThan(new Date('2026-07-05T12:00:00.000Z')),
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the number of deleted entries', async () => {
|
||||
repository.delete.mockResolvedValue({ affected: 7 });
|
||||
|
||||
await scheduler.cleanupOldLogs();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'log_retention_cleanup_run',
|
||||
details: 'deletedCount=7 retentionDays=365',
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs and does not rethrow when the delete fails', async () => {
|
||||
repository.delete.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
await expect(scheduler.cleanupOldLogs()).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
event: 'log_retention_cleanup_run_fail',
|
||||
details: 'connection reset',
|
||||
userId: -1,
|
||||
});
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { LogEntry } from './entities/log-entry.entity';
|
||||
import { LoggingService } from './logging.service';
|
||||
|
||||
@Injectable()
|
||||
export class LogRetentionScheduler {
|
||||
constructor(
|
||||
@InjectRepository(LogEntry)
|
||||
private readonly repository: Repository<LogEntry>,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async cleanupOldLogs(): Promise<void> {
|
||||
const retentionDays = this.configService.get<number>('app.logRetentionDays');
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
|
||||
|
||||
try {
|
||||
const result = await this.repository.delete({ createdAt: LessThan(cutoff) });
|
||||
|
||||
await this.logger.info({
|
||||
event: 'log_retention_cleanup_run',
|
||||
details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`,
|
||||
userId: -1,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.logger.error({
|
||||
event: 'log_retention_cleanup_run_fail',
|
||||
details: errorMessage,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { 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,101 @@ 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' });
|
||||
// `to` is a plain date (e.g. from a <input type="date">); comparing it
|
||||
// as-is would parse to midnight and exclude the whole last day, so it
|
||||
// must be widened to the end of that day to be genuinely inclusive.
|
||||
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt <= :to', {
|
||||
to: new Date('2026-01-31T23:59:59.999Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add level/event/date filters when omitted', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20 });
|
||||
|
||||
expect(query.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters details by a case-insensitive search term', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20, search: ' TeamId=5 ' });
|
||||
|
||||
expect(query.andWhere).toHaveBeenCalledWith('LOWER(log.details) LIKE :search', {
|
||||
search: '%teamid=5%',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a blank search term', async () => {
|
||||
await service.findLogs({ page: 1, limit: 20, search: ' ' });
|
||||
|
||||
expect(query.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const builder: Record<string, jest.Mock> = {};
|
||||
['andWhere', 'orderBy', 'offset', 'limit'].forEach((method) => {
|
||||
builder[method] = jest.fn(() => builder);
|
||||
});
|
||||
return Object.assign(builder, overrides);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { 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,45 @@ 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: new Date(`${query.to}T23:59:59.999Z`) });
|
||||
}
|
||||
const term = query.search?.trim().toLocaleLowerCase();
|
||||
if (term) {
|
||||
builder.andWhere('LOWER(log.details) LIKE :search', { search: `%${term}%` });
|
||||
}
|
||||
const total = await builder.getCount();
|
||||
const data = await builder
|
||||
.orderBy('log.createdAt', 'DESC')
|
||||
.offset((query.page - 1) * query.limit)
|
||||
.limit(query.limit)
|
||||
.getMany();
|
||||
return {
|
||||
data,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total,
|
||||
hasNextPage: query.page * query.limit < total,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { RoleEnum } from '../../roles/roles.enum';
|
||||
import { RolesGuard } from '../../roles/roles.guard';
|
||||
import { AdminLogQueryDto } from './dto/admin-log-query.dto';
|
||||
import { LogsController } from './logs.controller';
|
||||
|
||||
describe('LogsController', () => {
|
||||
const service = { findLogs: jest.fn() };
|
||||
const controller = new LogsController(service as any);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('uses a separate versioned admin/logs controller guarded by the global admin role', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, LogsController)).toBe('admin/logs');
|
||||
expect(Reflect.getMetadata('roles', LogsController)).toEqual([RoleEnum.admin]);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, LogsController)).toContain(RolesGuard);
|
||||
});
|
||||
|
||||
it('passes the query straight through to the service', async () => {
|
||||
const query = { page: 2, limit: 50, level: 'ERROR' as const };
|
||||
|
||||
await controller.findLogs(query as any);
|
||||
|
||||
expect(service.findLogs).toHaveBeenCalledWith(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminLogQueryDto', () => {
|
||||
it('defaults page and limit when omitted', async () => {
|
||||
const dto = plainToInstance(AdminLogQueryDto, {});
|
||||
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
expect(dto).toMatchObject({ page: 1, limit: 50 });
|
||||
});
|
||||
|
||||
it('accepts valid level, event, and date-range filters', async () => {
|
||||
const dto = plainToInstance(AdminLogQueryDto, {
|
||||
level: 'ERROR',
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
from: '2026-01-01',
|
||||
to: '2026-01-31',
|
||||
search: 'teamId=5',
|
||||
page: '2',
|
||||
limit: '100',
|
||||
});
|
||||
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
expect(dto).toMatchObject({ page: 2, limit: 100 });
|
||||
});
|
||||
|
||||
it('rejects an unknown level or event value', async () => {
|
||||
const level = plainToInstance(AdminLogQueryDto, { level: 'NOPE' });
|
||||
const event = plainToInstance(AdminLogQueryDto, { event: 'not_a_real_event' });
|
||||
|
||||
expect(await validate(level)).not.toEqual([]);
|
||||
expect(await validate(event)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a limit above the maximum', async () => {
|
||||
const dto = plainToInstance(AdminLogQueryDto, { limit: 500 });
|
||||
|
||||
expect(await validate(dto)).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
21
myteamwallet_backend/src/database/logging/logs.controller.ts
Normal file
21
myteamwallet_backend/src/database/logging/logs.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Roles } from '../../roles/roles.decorator';
|
||||
import { RoleEnum } from '../../roles/roles.enum';
|
||||
import { RolesGuard } from '../../roles/roles.guard';
|
||||
import { AdminLogQueryDto } from './dto/admin-log-query.dto';
|
||||
import { LoggingService } from './logging.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles([RoleEnum.admin])
|
||||
@Controller({ path: 'admin/logs', version: '1' })
|
||||
export class LogsController {
|
||||
constructor(private readonly loggingService: LoggingService) {}
|
||||
|
||||
@Get()
|
||||
findLogs(@Query() query: AdminLogQueryDto) {
|
||||
return this.loggingService.findLogs(query);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,72 @@ export type LOGEVENT =
|
||||
| 'penalty_catalog_update'
|
||||
| 'penalty_catalog_delete'
|
||||
| 'team_create'
|
||||
| 'team_permissions_update';
|
||||
| 'team_permissions_update'
|
||||
| 'scheduled_recurring_transaction_check_start'
|
||||
| 'scheduled_recurring_transaction_check_finished'
|
||||
| 'recurring_transaction_create'
|
||||
| 'recurring_transaction_update'
|
||||
| 'recurring_transaction_delete'
|
||||
| 'recurring_transaction_run'
|
||||
| 'cashbox_export_download'
|
||||
| 'cashbox_export_subscription_update'
|
||||
| 'cashbox_export_subscription_run'
|
||||
| 'cashbox_export_subscription_run_fail'
|
||||
| 'log_retention_cleanup_run'
|
||||
| 'log_retention_cleanup_run_fail'
|
||||
| 'notification_create_fail'
|
||||
| 'notification_retention_cleanup_run'
|
||||
| 'notification_retention_cleanup_run_fail'
|
||||
| 'public_access_enabled'
|
||||
| 'public_access_rotated';
|
||||
|
||||
export const LOGEVENT_VALUES: LOGEVENT[] = [
|
||||
'user_create',
|
||||
'application_start',
|
||||
'transaction_create',
|
||||
'team_transaction_create',
|
||||
'team_transaction_get',
|
||||
'user_login_success',
|
||||
'user_login_fail',
|
||||
'user_token_verification_success',
|
||||
'user_token_verification_fail',
|
||||
'user_invite_link_create',
|
||||
'user_invite_link_validate',
|
||||
'user_invite_link_validate_fail',
|
||||
'transaction_create_fail',
|
||||
'transaction_reverse',
|
||||
'player_creation',
|
||||
'admin_user_profile_update',
|
||||
'admin_user_role_update',
|
||||
'admin_user_status_update',
|
||||
'admin_player_assign',
|
||||
'admin_player_unlink',
|
||||
'player_active_update',
|
||||
'player_team_role_update',
|
||||
'penalty_catalog_create',
|
||||
'penalty_catalog_update',
|
||||
'penalty_catalog_delete',
|
||||
'team_create',
|
||||
'team_permissions_update',
|
||||
'scheduled_recurring_transaction_check_start',
|
||||
'scheduled_recurring_transaction_check_finished',
|
||||
'recurring_transaction_create',
|
||||
'recurring_transaction_update',
|
||||
'recurring_transaction_delete',
|
||||
'recurring_transaction_run',
|
||||
'cashbox_export_download',
|
||||
'cashbox_export_subscription_update',
|
||||
'cashbox_export_subscription_run',
|
||||
'cashbox_export_subscription_run_fail',
|
||||
'log_retention_cleanup_run',
|
||||
'log_retention_cleanup_run_fail',
|
||||
'notification_create_fail',
|
||||
'notification_retention_cleanup_run',
|
||||
'notification_retention_cleanup_run_fail',
|
||||
'public_access_enabled',
|
||||
'public_access_rotated',
|
||||
];
|
||||
|
||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
export const LOGLEVEL_VALUES: LOGLEVEL[] = ['FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddNotificationTables1785600000000 implements MigrationInterface {
|
||||
name = 'AddNotificationTables1785600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "notification" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"teamId" integer NOT NULL,
|
||||
"event" character varying NOT NULL,
|
||||
"actorUserId" integer NOT NULL,
|
||||
"payload" text NOT NULL,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_notification_id" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_notification_team_id" ON "notification" ("teamId")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "notification"
|
||||
ADD CONSTRAINT "FK_notification_team"
|
||||
FOREIGN KEY ("teamId") REFERENCES "team"("id")
|
||||
ON DELETE CASCADE
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "notification_recipient" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"notificationId" integer NOT NULL,
|
||||
"userId" integer NOT NULL,
|
||||
"read" boolean NOT NULL DEFAULT false,
|
||||
"readAt" TIMESTAMP,
|
||||
CONSTRAINT "PK_notification_recipient_id" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_notification_recipient_notification_id" ON "notification_recipient" ("notificationId")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_notification_recipient_user_id" ON "notification_recipient" ("userId")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "notification_recipient"
|
||||
ADD CONSTRAINT "FK_notification_recipient_notification"
|
||||
FOREIGN KEY ("notificationId") REFERENCES "notification"("id")
|
||||
ON DELETE CASCADE
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "notification_recipient"`);
|
||||
await queryRunner.query(`DROP TABLE "notification"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
describe('AddNotificationTables1785600000000', () => {
|
||||
it('creates the notification and notification_recipient tables with their indexes and foreign keys', async () => {
|
||||
const migrationModule = require('./1785600000000-AddNotificationTables');
|
||||
const migration = new migrationModule.AddNotificationTables1785600000000();
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
|
||||
await migration.up(queryRunner);
|
||||
|
||||
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls).toHaveLength(7);
|
||||
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification"'))).toBe(true);
|
||||
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification_recipient"'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(calls.some((sql) => sql.includes('IDX_notification_team_id'))).toBe(true);
|
||||
expect(
|
||||
calls.some((sql) => sql.includes('IDX_notification_recipient_notification_id')),
|
||||
).toBe(true);
|
||||
expect(calls.some((sql) => sql.includes('IDX_notification_recipient_user_id'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(calls.some((sql) => sql.includes('FK_notification_team'))).toBe(true);
|
||||
expect(calls.some((sql) => sql.includes('FK_notification_recipient_notification'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('drops both tables on down, recipient first to respect the foreign key', async () => {
|
||||
const migrationModule = require('./1785600000000-AddNotificationTables');
|
||||
const migration = new migrationModule.AddNotificationTables1785600000000();
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
|
||||
await migration.down(queryRunner);
|
||||
|
||||
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls).toEqual(['DROP TABLE "notification_recipient"', 'DROP TABLE "notification"']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{{#> layout}}
|
||||
<p>Hallo,</p>
|
||||
<p>im Anhang findest du den automatischen Kassenbuch-Export für <strong>{{teamName}}</strong> für den Zeitraum {{from}} bis {{to}}.</p>
|
||||
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt und benötigt keine weitere Aktion.</p>
|
||||
{{/layout}}
|
||||
@@ -60,4 +60,20 @@ describe('mail templates rendering', () => {
|
||||
expect(html).toContain('Hallo,');
|
||||
expect(html).not.toContain('Hallo Max,');
|
||||
});
|
||||
|
||||
it('renders cashbox-export.hbs with team name and period', () => {
|
||||
const source = fs.readFileSync(path.join(templatesDir, 'cashbox-export.hbs'), 'utf-8');
|
||||
const html = Handlebars.compile(source, { strict: true })({
|
||||
title: 'Kassenbuch-Export Team A',
|
||||
year: 2026,
|
||||
teamName: 'Team A',
|
||||
from: '2026-08-01',
|
||||
to: '2026-08-31',
|
||||
});
|
||||
|
||||
expect(html).toContain('TeamWallet');
|
||||
expect(html).toContain('Team A');
|
||||
expect(html).toContain('2026-08-01');
|
||||
expect(html).toContain('2026-08-31');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,4 +58,28 @@ describe('MailService', () => {
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.context.firstName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends the cashbox export mail with the PDF attachment', async () => {
|
||||
const attachment = Buffer.from('%PDF-1.4 fake');
|
||||
|
||||
await service.cashboxExport(
|
||||
{
|
||||
to: 'vorstand@example.com, kassier@example.com',
|
||||
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
|
||||
},
|
||||
attachment,
|
||||
'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
|
||||
);
|
||||
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.to).toBe('vorstand@example.com, kassier@example.com');
|
||||
expect(call.template).toBe('cashbox-export');
|
||||
expect(call.context.teamName).toBe('Team A');
|
||||
expect(call.context.from).toBe('2026-08-01');
|
||||
expect(call.context.to).toBe('2026-08-31');
|
||||
expect(call.attachments).toEqual([
|
||||
{ filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf', content: attachment },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,4 +55,24 @@ export class MailService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async cashboxExport(
|
||||
mailData: MailData<{ teamName: string; from: string; to: string }>,
|
||||
attachment: Buffer,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
await this.mailerService.sendMail({
|
||||
to: mailData.to,
|
||||
subject: `Kassenbuch-Export ${mailData.data.teamName}`,
|
||||
template: 'cashbox-export',
|
||||
context: {
|
||||
title: `Kassenbuch-Export ${mailData.data.teamName}`,
|
||||
year: new Date().getFullYear(),
|
||||
teamName: mailData.data.teamName,
|
||||
from: mailData.data.from,
|
||||
to: mailData.data.to,
|
||||
},
|
||||
attachments: [{ filename, content: attachment }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class NotificationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { Notification } from './notification.entity';
|
||||
|
||||
@Entity()
|
||||
export class NotificationRecipient extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index('IDX_notification_recipient_notification_id')
|
||||
@ManyToOne(() => Notification, { onDelete: 'CASCADE' })
|
||||
notification: Notification;
|
||||
|
||||
@Index('IDX_notification_recipient_user_id')
|
||||
@Column()
|
||||
userId: number;
|
||||
|
||||
@Column({ default: false })
|
||||
read: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
readAt: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { NOTIFICATION_EVENT } from '../model/notification-event.type';
|
||||
|
||||
@Entity()
|
||||
export class Notification extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index('IDX_notification_team_id')
|
||||
@ManyToOne(() => Team, { eager: false })
|
||||
team: Team;
|
||||
|
||||
@Column()
|
||||
event: NOTIFICATION_EVENT;
|
||||
|
||||
@Column()
|
||||
actorUserId: number;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
payload: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class InviteLinkCreatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly teamName: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const NOTIFICATION_EVENT_NAME = {
|
||||
playerActiveChanged: 'notifications.player.active_changed',
|
||||
playerRoleChanged: 'notifications.player.role_changed',
|
||||
playerCreated: 'notifications.player.created',
|
||||
publicAccessEnabled: 'notifications.public_access.enabled',
|
||||
publicAccessRotated: 'notifications.public_access.rotated',
|
||||
inviteLinkCreated: 'notifications.invite_link.created',
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
export class PlayerActiveChangedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly playerId: number,
|
||||
public readonly playerName: string,
|
||||
public readonly active: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class PlayerCreatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly playerId: number,
|
||||
public readonly playerName: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class PlayerRoleChangedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly playerId: number,
|
||||
public readonly playerName: string,
|
||||
public readonly teamRoleId: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class PublicAccessEnabledEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class PublicAccessRotatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type NOTIFICATION_EVENT =
|
||||
| 'player_active_update'
|
||||
| 'player_team_role_update'
|
||||
| 'player_creation'
|
||||
| 'public_access_enabled'
|
||||
| 'public_access_rotated'
|
||||
| 'user_invite_link_create';
|
||||
|
||||
export const NOTIFICATION_EVENT_VALUES: NOTIFICATION_EVENT[] = [
|
||||
'player_active_update',
|
||||
'player_team_role_update',
|
||||
'player_creation',
|
||||
'public_access_enabled',
|
||||
'public_access_rotated',
|
||||
'user_invite_link_create',
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LessThan } from 'typeorm';
|
||||
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
|
||||
|
||||
describe('NotificationRetentionScheduler', () => {
|
||||
const repository = { delete: jest.fn() };
|
||||
const configService = { get: jest.fn() };
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
let scheduler: NotificationRetentionScheduler;
|
||||
|
||||
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 NotificationRetentionScheduler(repository as any, configService as any, logger as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('deletes notifications older than the configured retention window', async () => {
|
||||
await scheduler.cleanupOldNotifications();
|
||||
|
||||
expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays');
|
||||
expect(repository.delete).toHaveBeenCalledWith({
|
||||
createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')),
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the number of deleted notifications', async () => {
|
||||
repository.delete.mockResolvedValue({ affected: 7 });
|
||||
|
||||
await scheduler.cleanupOldNotifications();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'notification_retention_cleanup_run',
|
||||
details: 'deletedCount=7 retentionDays=365',
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs and does not rethrow when the delete fails', async () => {
|
||||
repository.delete.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
await expect(scheduler.cleanupOldNotifications()).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
event: 'notification_retention_cleanup_run_fail',
|
||||
details: 'connection reset',
|
||||
userId: -1,
|
||||
});
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRetentionScheduler {
|
||||
constructor(
|
||||
@InjectRepository(Notification)
|
||||
private readonly repository: Repository<Notification>,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_5AM)
|
||||
async cleanupOldNotifications(): Promise<void> {
|
||||
const retentionDays = this.configService.get<number>('app.logRetentionDays');
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
|
||||
|
||||
try {
|
||||
const result = await this.repository.delete({ createdAt: LessThan(cutoff) });
|
||||
|
||||
await this.logger.info({
|
||||
event: 'notification_retention_cleanup_run',
|
||||
details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`,
|
||||
userId: -1,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.logger.error({
|
||||
event: 'notification_retention_cleanup_run_fail',
|
||||
details: errorMessage,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { TeamAccessService } from '../teams/team-access.service';
|
||||
import { NotificationQueryDto } from './dto/notification-query.dto';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller({ path: 'teams', version: '1' })
|
||||
export class NotificationsController {
|
||||
constructor(
|
||||
private readonly service: NotificationsService,
|
||||
private readonly access: TeamAccessService,
|
||||
) {}
|
||||
|
||||
@Get(':teamId/notifications')
|
||||
async list(
|
||||
@Req() req,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Query() query: NotificationQueryDto,
|
||||
) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
return this.service.listForUser(Number(req.user.id), teamId, query.page ?? 1, query.limit ?? 20);
|
||||
}
|
||||
|
||||
@Get(':teamId/notifications/unread-count')
|
||||
async unreadCount(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
return { count: await this.service.getUnreadCount(Number(req.user.id), teamId) };
|
||||
}
|
||||
|
||||
@Patch(':teamId/notifications/:id/read')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markRead(
|
||||
@Req() req,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
await this.service.markRead(id, Number(req.user.id));
|
||||
}
|
||||
|
||||
@Patch(':teamId/notifications/read-all')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markAllRead(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
await this.service.markAllRead(Number(req.user.id), teamId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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 { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { TeamAccessService } from '../teams/team-access.service';
|
||||
|
||||
describe('notifications HTTP boundary', () => {
|
||||
let app: INestApplication;
|
||||
const service = {
|
||||
listForUser: jest.fn(),
|
||||
getUnreadCount: jest.fn(),
|
||||
markRead: jest.fn(),
|
||||
markAllRead: jest.fn(),
|
||||
};
|
||||
const access = { assertMember: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
{ provide: NotificationsService, useValue: service },
|
||||
{ provide: TeamAccessService, useValue: access },
|
||||
],
|
||||
})
|
||||
.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', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/teams/10/notifications').expect(401);
|
||||
});
|
||||
|
||||
it('lists notifications for the authenticated user after checking membership', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.listForUser.mockResolvedValue({ data: [], page: 2, limit: 5, total: 0, hasNextPage: false });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/teams/10/notifications?page=2&limit=5')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(access.assertMember).toHaveBeenCalledWith(42, 10);
|
||||
expect(service.listForUser).toHaveBeenCalledWith(42, 10, 2, 5);
|
||||
});
|
||||
|
||||
it('defaults to page 1 and limit 20 when not provided', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.listForUser.mockResolvedValue({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/teams/10/notifications')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(service.listForUser).toHaveBeenCalledWith(42, 10, 1, 20);
|
||||
});
|
||||
|
||||
it('returns the unread count', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.getUnreadCount.mockResolvedValue(4);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/teams/10/notifications/unread-count')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ count: 4 });
|
||||
});
|
||||
|
||||
it('marks a single notification as read', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.markRead.mockResolvedValue(undefined);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/teams/10/notifications/7/read')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(service.markRead).toHaveBeenCalledWith(7, 42);
|
||||
});
|
||||
|
||||
it('marks all notifications as read', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.markAllRead.mockResolvedValue(undefined);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/teams/10/notifications/read-all')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(service.markAllRead).toHaveBeenCalledWith(42, 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PlayerActiveChangedEvent } from './events/player-active-changed.event';
|
||||
import { PlayerRoleChangedEvent } from './events/player-role-changed.event';
|
||||
import { PlayerCreatedEvent } from './events/player-created.event';
|
||||
import { PublicAccessEnabledEvent, PublicAccessRotatedEvent } from './events/public-access-changed.event';
|
||||
import { InviteLinkCreatedEvent } from './events/invite-link-created.event';
|
||||
import { NotificationsListener } from './notifications.listener';
|
||||
|
||||
describe('NotificationsListener', () => {
|
||||
const notifications = { create: jest.fn() };
|
||||
const logger = { error: jest.fn() };
|
||||
let listener: NotificationsListener;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
listener = new NotificationsListener(notifications as any, logger as any);
|
||||
});
|
||||
|
||||
it('creates a player_active_update notification', async () => {
|
||||
await listener.onPlayerActiveChanged(new PlayerActiveChangedEvent(10, 5, 1, 'Ada Lovelace', false));
|
||||
|
||||
expect(notifications.create).toHaveBeenCalledWith({
|
||||
teamId: 10,
|
||||
event: 'player_active_update',
|
||||
actorUserId: 5,
|
||||
payload: { playerId: 1, playerName: 'Ada Lovelace', active: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a player_team_role_update notification', async () => {
|
||||
await listener.onPlayerRoleChanged(new PlayerRoleChangedEvent(10, 5, 1, 'Ada Lovelace', 3));
|
||||
|
||||
expect(notifications.create).toHaveBeenCalledWith({
|
||||
teamId: 10,
|
||||
event: 'player_team_role_update',
|
||||
actorUserId: 5,
|
||||
payload: { playerId: 1, playerName: 'Ada Lovelace', teamRoleId: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a player_creation notification', async () => {
|
||||
await listener.onPlayerCreated(new PlayerCreatedEvent(10, 5, 1, 'Ada Lovelace'));
|
||||
|
||||
expect(notifications.create).toHaveBeenCalledWith({
|
||||
teamId: 10,
|
||||
event: 'player_creation',
|
||||
actorUserId: 5,
|
||||
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a public_access_enabled notification', async () => {
|
||||
await listener.onPublicAccessEnabled(new PublicAccessEnabledEvent(10, 5));
|
||||
|
||||
expect(notifications.create).toHaveBeenCalledWith({
|
||||
teamId: 10,
|
||||
event: 'public_access_enabled',
|
||||
actorUserId: 5,
|
||||
payload: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a public_access_rotated notification', async () => {
|
||||
await listener.onPublicAccessRotated(new PublicAccessRotatedEvent(10, 5));
|
||||
|
||||
expect(notifications.create).toHaveBeenCalledWith({
|
||||
teamId: 10,
|
||||
event: 'public_access_rotated',
|
||||
actorUserId: 5,
|
||||
payload: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a user_invite_link_create notification', async () => {
|
||||
await listener.onInviteLinkCreated(new InviteLinkCreatedEvent(10, 5, 'Team A'));
|
||||
|
||||
expect(notifications.create).toHaveBeenCalledWith({
|
||||
teamId: 10,
|
||||
event: 'user_invite_link_create',
|
||||
actorUserId: 5,
|
||||
payload: { teamName: 'Team A' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs and swallows errors instead of throwing, so the originating action is unaffected', async () => {
|
||||
notifications.create.mockRejectedValue(new Error('db unavailable'));
|
||||
|
||||
await expect(
|
||||
listener.onPlayerCreated(new PlayerCreatedEvent(10, 5, 1, 'Ada Lovelace')),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
event: 'notification_create_fail',
|
||||
details: 'teamId=10 event=player_creation: db unavailable',
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { NOTIFICATION_EVENT } from './model/notification-event.type';
|
||||
import { NOTIFICATION_EVENT_NAME } from './events/notification-event-names';
|
||||
import { PlayerActiveChangedEvent } from './events/player-active-changed.event';
|
||||
import { PlayerRoleChangedEvent } from './events/player-role-changed.event';
|
||||
import { PlayerCreatedEvent } from './events/player-created.event';
|
||||
import { PublicAccessEnabledEvent, PublicAccessRotatedEvent } from './events/public-access-changed.event';
|
||||
import { InviteLinkCreatedEvent } from './events/invite-link-created.event';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsListener {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENT_NAME.playerActiveChanged)
|
||||
onPlayerActiveChanged(event: PlayerActiveChangedEvent): Promise<void> {
|
||||
return this.safeCreate('player_active_update', event.teamId, event.actorUserId, {
|
||||
playerId: event.playerId,
|
||||
playerName: event.playerName,
|
||||
active: event.active,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENT_NAME.playerRoleChanged)
|
||||
onPlayerRoleChanged(event: PlayerRoleChangedEvent): Promise<void> {
|
||||
return this.safeCreate('player_team_role_update', event.teamId, event.actorUserId, {
|
||||
playerId: event.playerId,
|
||||
playerName: event.playerName,
|
||||
teamRoleId: event.teamRoleId,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENT_NAME.playerCreated)
|
||||
onPlayerCreated(event: PlayerCreatedEvent): Promise<void> {
|
||||
return this.safeCreate('player_creation', event.teamId, event.actorUserId, {
|
||||
playerId: event.playerId,
|
||||
playerName: event.playerName,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessEnabled)
|
||||
onPublicAccessEnabled(event: PublicAccessEnabledEvent): Promise<void> {
|
||||
return this.safeCreate('public_access_enabled', event.teamId, event.actorUserId, {});
|
||||
}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENT_NAME.publicAccessRotated)
|
||||
onPublicAccessRotated(event: PublicAccessRotatedEvent): Promise<void> {
|
||||
return this.safeCreate('public_access_rotated', event.teamId, event.actorUserId, {});
|
||||
}
|
||||
|
||||
@OnEvent(NOTIFICATION_EVENT_NAME.inviteLinkCreated)
|
||||
onInviteLinkCreated(event: InviteLinkCreatedEvent): Promise<void> {
|
||||
return this.safeCreate('user_invite_link_create', event.teamId, event.actorUserId, {
|
||||
teamName: event.teamName,
|
||||
});
|
||||
}
|
||||
|
||||
private async safeCreate(
|
||||
event: NOTIFICATION_EVENT,
|
||||
teamId: number,
|
||||
actorUserId: number,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.notifications.create({ teamId, event, actorUserId, payload });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.logger.error({
|
||||
event: 'notification_create_fail',
|
||||
details: `teamId=${teamId} event=${event}: ${errorMessage}`,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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 { TeamsModule } from 'src/teams/teams.module';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { NotificationRecipient } from './entities/notification-recipient.entity';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsListener } from './notifications.listener';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Notification, NotificationRecipient, Player]),
|
||||
LoggingModule,
|
||||
TeamsModule,
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService, NotificationsListener, NotificationRetentionScheduler],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
describe('NotificationsService', () => {
|
||||
const notificationRepository = { create: jest.fn(), save: jest.fn() };
|
||||
const recipientRepository = {
|
||||
insert: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const playerRepository = { createQueryBuilder: jest.fn() };
|
||||
let service: NotificationsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
notificationRepository.create.mockImplementation((value) => value);
|
||||
service = new NotificationsService(
|
||||
notificationRepository as any,
|
||||
recipientRepository as any,
|
||||
playerRepository as any,
|
||||
);
|
||||
});
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const query: Record<string, jest.Mock> = {};
|
||||
['innerJoin', 'innerJoinAndSelect', 'where', 'andWhere', 'select', 'orderBy', 'offset', 'limit']
|
||||
.forEach((method) => (query[method] = jest.fn(() => query)));
|
||||
return Object.assign(query, overrides);
|
||||
}
|
||||
|
||||
describe('create', () => {
|
||||
it('does nothing when the team has no other active members with a login', async () => {
|
||||
const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([]) });
|
||||
playerRepository.createQueryBuilder.mockReturnValue(playerQuery);
|
||||
|
||||
await service.create({
|
||||
teamId: 10,
|
||||
event: 'player_creation',
|
||||
actorUserId: 5,
|
||||
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||
});
|
||||
|
||||
expect(playerQuery.where).toHaveBeenCalledWith('player.teamId = :teamId', { teamId: 10 });
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.active = :active', { active: true });
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId IS NOT NULL');
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId != :actorUserId', { actorUserId: 5 });
|
||||
|
||||
expect(notificationRepository.save).not.toHaveBeenCalled();
|
||||
expect(recipientRepository.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates one notification and fans it out to every recipient', async () => {
|
||||
const playerQuery = chain({ getRawMany: jest.fn().mockResolvedValue([{ userId: 7 }, { userId: 8 }]) });
|
||||
playerRepository.createQueryBuilder.mockReturnValue(playerQuery);
|
||||
notificationRepository.save.mockResolvedValue({ id: 99 });
|
||||
|
||||
await service.create({
|
||||
teamId: 10,
|
||||
event: 'player_creation',
|
||||
actorUserId: 5,
|
||||
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||
});
|
||||
|
||||
expect(playerQuery.where).toHaveBeenCalledWith('player.teamId = :teamId', { teamId: 10 });
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.active = :active', { active: true });
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId IS NOT NULL');
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith('player.userId != :actorUserId', { actorUserId: 5 });
|
||||
|
||||
expect(notificationRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
team: { id: 10 },
|
||||
event: 'player_creation',
|
||||
actorUserId: 5,
|
||||
payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }),
|
||||
}),
|
||||
);
|
||||
expect(recipientRepository.insert).toHaveBeenCalledWith([
|
||||
{ notification: { id: 99 }, userId: 7 },
|
||||
{ notification: { id: 99 }, userId: 8 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listForUser', () => {
|
||||
it('maps recipient rows to notification DTOs with parsed payloads', async () => {
|
||||
const query = chain({
|
||||
getCount: jest.fn().mockResolvedValue(1),
|
||||
getMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
userId: 7,
|
||||
read: false,
|
||||
notification: {
|
||||
event: 'player_creation',
|
||||
actorUserId: 5,
|
||||
payload: JSON.stringify({ playerId: 1, playerName: 'Ada Lovelace' }),
|
||||
createdAt: new Date('2026-08-04T10:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||
|
||||
const page = await service.listForUser(7, 10, 1, 20);
|
||||
|
||||
expect(page).toEqual({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
event: 'player_creation',
|
||||
actorUserId: 5,
|
||||
payload: { playerId: 1, playerName: 'Ada Lovelace' },
|
||||
read: false,
|
||||
createdAt: new Date('2026-08-04T10:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('counts only unread recipient rows for the given user and team', async () => {
|
||||
const query = chain({ getCount: jest.fn().mockResolvedValue(3) });
|
||||
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||
|
||||
await expect(service.getUnreadCount(7, 10)).resolves.toBe(3);
|
||||
expect(query.where).toHaveBeenCalledWith('recipient.userId = :userId', { userId: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('markRead', () => {
|
||||
it('marks a recipient row as read', async () => {
|
||||
recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 7, read: false, readAt: null });
|
||||
|
||||
await service.markRead(1, 7);
|
||||
|
||||
expect(recipientRepository.save).toHaveBeenCalledWith(expect.objectContaining({ read: true }));
|
||||
});
|
||||
|
||||
it('rejects marking a recipient row that belongs to another user', async () => {
|
||||
recipientRepository.findOne.mockResolvedValue({ id: 1, userId: 999, read: false });
|
||||
|
||||
await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(recipientRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects marking a recipient row that does not exist', async () => {
|
||||
recipientRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.markRead(1, 7)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllRead', () => {
|
||||
it('marks every unread recipient row for the user and team as read', async () => {
|
||||
const query = chain({ getRawMany: jest.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]) });
|
||||
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||
|
||||
await service.markAllRead(7, 10);
|
||||
|
||||
expect(recipientRepository.update).toHaveBeenCalledWith(
|
||||
[1, 2],
|
||||
expect.objectContaining({ read: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when there is nothing unread', async () => {
|
||||
const query = chain({ getRawMany: jest.fn().mockResolvedValue([]) });
|
||||
recipientRepository.createQueryBuilder.mockReturnValue(query);
|
||||
|
||||
await service.markAllRead(7, 10);
|
||||
|
||||
expect(recipientRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
150
myteamwallet_backend/src/notifications/notifications.service.ts
Normal file
150
myteamwallet_backend/src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { NotificationRecipient } from './entities/notification-recipient.entity';
|
||||
import { NOTIFICATION_EVENT } from './model/notification-event.type';
|
||||
|
||||
export interface NotificationDto {
|
||||
id: number;
|
||||
event: NOTIFICATION_EVENT;
|
||||
actorUserId: number;
|
||||
payload: Record<string, unknown>;
|
||||
read: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface NotificationPage {
|
||||
data: NotificationDto[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
@InjectRepository(Notification)
|
||||
private readonly notificationRepository: Repository<Notification>,
|
||||
@InjectRepository(NotificationRecipient)
|
||||
private readonly recipientRepository: Repository<NotificationRecipient>,
|
||||
@InjectRepository(Player)
|
||||
private readonly playerRepository: Repository<Player>,
|
||||
) {}
|
||||
|
||||
async create(params: {
|
||||
teamId: number;
|
||||
event: NOTIFICATION_EVENT;
|
||||
actorUserId: number;
|
||||
payload: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const recipientUserIds = await this.resolveRecipients(params.teamId, params.actorUserId);
|
||||
if (recipientUserIds.length === 0) return;
|
||||
|
||||
const notification = await this.notificationRepository.save(
|
||||
this.notificationRepository.create({
|
||||
team: { id: params.teamId } as Team,
|
||||
event: params.event,
|
||||
actorUserId: params.actorUserId,
|
||||
payload: JSON.stringify(params.payload),
|
||||
}),
|
||||
);
|
||||
|
||||
await this.recipientRepository.insert(
|
||||
recipientUserIds.map((userId) => ({
|
||||
notification: { id: notification.id } as Notification,
|
||||
userId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async listForUser(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<NotificationPage> {
|
||||
const builder = this.recipientRepository
|
||||
.createQueryBuilder('recipient')
|
||||
.innerJoinAndSelect('recipient.notification', 'notification')
|
||||
.where('recipient.userId = :userId', { userId })
|
||||
.andWhere('notification.teamId = :teamId', { teamId })
|
||||
.orderBy('notification.createdAt', 'DESC');
|
||||
|
||||
const total = await builder.getCount();
|
||||
const rows = await builder.offset((page - 1) * limit).limit(limit).getMany();
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDto(row)),
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
hasNextPage: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: number, teamId: number): Promise<number> {
|
||||
return this.recipientRepository
|
||||
.createQueryBuilder('recipient')
|
||||
.innerJoin('recipient.notification', 'notification')
|
||||
.where('recipient.userId = :userId', { userId })
|
||||
.andWhere('notification.teamId = :teamId', { teamId })
|
||||
.andWhere('recipient.read = false')
|
||||
.getCount();
|
||||
}
|
||||
|
||||
async markRead(recipientId: number, userId: number): Promise<void> {
|
||||
const recipient = await this.recipientRepository.findOne({ where: { id: recipientId } });
|
||||
if (!recipient || recipient.userId !== userId) {
|
||||
throw new NotFoundException('Benachrichtigung nicht gefunden.');
|
||||
}
|
||||
if (recipient.read) return;
|
||||
recipient.read = true;
|
||||
recipient.readAt = new Date();
|
||||
await this.recipientRepository.save(recipient);
|
||||
}
|
||||
|
||||
async markAllRead(userId: number, teamId: number): Promise<void> {
|
||||
const rows = await this.recipientRepository
|
||||
.createQueryBuilder('recipient')
|
||||
.innerJoin('recipient.notification', 'notification')
|
||||
.where('recipient.userId = :userId', { userId })
|
||||
.andWhere('notification.teamId = :teamId', { teamId })
|
||||
.andWhere('recipient.read = false')
|
||||
.select('recipient.id', 'id')
|
||||
.getRawMany<{ id: number }>();
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
await this.recipientRepository.update(rows.map((row) => row.id), {
|
||||
read: true,
|
||||
readAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveRecipients(teamId: number, actorUserId: number): Promise<number[]> {
|
||||
const rows = await this.playerRepository
|
||||
.createQueryBuilder('player')
|
||||
.where('player.teamId = :teamId', { teamId })
|
||||
.andWhere('player.active = :active', { active: true })
|
||||
.andWhere('player.userId IS NOT NULL')
|
||||
.andWhere('player.userId != :actorUserId', { actorUserId })
|
||||
.select('DISTINCT player.userId', 'userId')
|
||||
.getRawMany<{ userId: number }>();
|
||||
return rows.map((row) => row.userId);
|
||||
}
|
||||
|
||||
private toDto(recipient: NotificationRecipient): NotificationDto {
|
||||
return {
|
||||
id: recipient.id,
|
||||
event: recipient.notification.event,
|
||||
actorUserId: recipient.notification.actorUserId,
|
||||
payload: JSON.parse(recipient.notification.payload),
|
||||
read: recipient.read,
|
||||
createdAt: recipient.notification.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsString,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { TransactionTypeEnum } from 'src/transactions/transaction-type.enum';
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
|
||||
|
||||
const ALLOWED_TYPES = [TransactionTypeEnum.fee, TransactionTypeEnum.levy];
|
||||
|
||||
export class CreateRecurringTransactionDTO {
|
||||
@ApiProperty({ example: 2342 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
teamId: number;
|
||||
|
||||
@ApiProperty({ example: 'Monatsbeitrag' })
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsString()
|
||||
@Length(1, 120)
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
@Max(10000)
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ enum: ALLOWED_TYPES })
|
||||
@IsIn(ALLOWED_TYPES)
|
||||
type: TransactionTypeEnum;
|
||||
|
||||
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
|
||||
@IsIn(Object.values(RecurringTransactionIntervalEnum))
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
|
||||
@ApiProperty({ example: 'isotimestring' })
|
||||
@IsNotEmpty()
|
||||
startDate: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
|
||||
|
||||
export class RecurringTransactionResponseDTO {
|
||||
id: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
type: number;
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
nextRunDate: string;
|
||||
active: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsString,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { TransactionTypeEnum } from 'src/transactions/transaction-type.enum';
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
|
||||
|
||||
const ALLOWED_TYPES = [TransactionTypeEnum.fee, TransactionTypeEnum.levy];
|
||||
|
||||
export class UpdateRecurringTransactionDTO {
|
||||
@ApiProperty({ example: 'Monatsbeitrag' })
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsString()
|
||||
@Length(1, 120)
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
@Max(10000)
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ enum: ALLOWED_TYPES })
|
||||
@IsIn(ALLOWED_TYPES)
|
||||
type: TransactionTypeEnum;
|
||||
|
||||
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
|
||||
@IsIn(Object.values(RecurringTransactionIntervalEnum))
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
@IsBoolean()
|
||||
active: boolean;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TransactionType } from 'src/transactions/entitites/transaction-type.entity';
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
|
||||
|
||||
@Entity()
|
||||
export class RecurringTransaction extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@ManyToOne(() => Team, {
|
||||
eager: false,
|
||||
})
|
||||
team: Team;
|
||||
|
||||
@Column({ default: '' })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
amount: number;
|
||||
|
||||
@ManyToOne(() => TransactionType, {
|
||||
eager: true,
|
||||
})
|
||||
type: TransactionType;
|
||||
|
||||
@Column()
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
|
||||
@Column()
|
||||
nextRunDate: string;
|
||||
|
||||
@Column({ default: true })
|
||||
active: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum RecurringTransactionIntervalEnum {
|
||||
'monthly' = 'monthly',
|
||||
'quarterly' = 'quarterly',
|
||||
'yearly' = 'yearly',
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Request,
|
||||
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 { 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 } };
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller({ path: 'recurring-transactions', version: '1' })
|
||||
export class RecurringTransactionsController {
|
||||
constructor(
|
||||
private readonly service: RecurringTransactionsService,
|
||||
private readonly scheduler: RecurringTransactionsScheduler,
|
||||
) {}
|
||||
|
||||
@Get(':teamId')
|
||||
getTeamRecurringTransactions(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
) {
|
||||
return this.service.getTeamRecurringTransactions(request.user.id, teamId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
createRecurringTransaction(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Body() dto: CreateRecurringTransactionDTO,
|
||||
) {
|
||||
return this.service.createRecurringTransaction(dto, request.user.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
updateRecurringTransaction(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateRecurringTransactionDTO,
|
||||
) {
|
||||
return this.service.updateRecurringTransaction(id, dto, request.user.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteRecurringTransaction(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
): 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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 { 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', () => {
|
||||
let app: INestApplication;
|
||||
const entry = {
|
||||
id: 8,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
};
|
||||
const service = {
|
||||
getTeamRecurringTransactions: jest.fn(() => [entry]),
|
||||
createRecurringTransaction: jest.fn(() => entry),
|
||||
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'))
|
||||
.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 for team reads', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/recurring-transactions/5')
|
||||
.expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/recurring-transactions/5')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200)
|
||||
.expect([entry]);
|
||||
expect(service.getTeamRecurringTransactions).toHaveBeenCalledWith(42, 5);
|
||||
});
|
||||
|
||||
it('rejects a create payload with a disallowed transaction type', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/recurring-transactions')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({
|
||||
teamId: 5,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.payment,
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
startDate: '2026-09-01T00:00:00.000Z',
|
||||
})
|
||||
.expect(422);
|
||||
expect(service.createRecurringTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a valid create payload and forwards the actor', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/recurring-transactions')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({
|
||||
teamId: 5,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
startDate: '2026-09-01T00:00:00.000Z',
|
||||
})
|
||||
.expect(201)
|
||||
.expect(entry);
|
||||
expect(service.createRecurringTransaction).toHaveBeenCalledWith(
|
||||
{
|
||||
teamId: 5,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
startDate: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates and deletes by id', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/recurring-transactions/8')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 12,
|
||||
type: TransactionTypeEnum.levy,
|
||||
interval: RecurringTransactionIntervalEnum.quarterly,
|
||||
active: false,
|
||||
})
|
||||
.expect(200)
|
||||
.expect(entry);
|
||||
expect(service.updateRecurringTransaction).toHaveBeenCalledWith(
|
||||
8,
|
||||
{
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 12,
|
||||
type: TransactionTypeEnum.levy,
|
||||
interval: RecurringTransactionIntervalEnum.quarterly,
|
||||
active: false,
|
||||
},
|
||||
42,
|
||||
);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v1/recurring-transactions/8')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(204);
|
||||
expect(service.deleteRecurringTransaction).toHaveBeenCalledWith(8, 42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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 { Transaction } from 'src/transactions/entitites/transaction.entity';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
import { RecurringTransactionsController } from './recurring-transactions.controller';
|
||||
import { RecurringTransaction } from './entities/recurring-transaction.entity';
|
||||
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
|
||||
import { RecurringTransactionsService } from './recurring-transactions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RecurringTransactionsController],
|
||||
providers: [RecurringTransactionsService, RecurringTransactionsScheduler],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Team, RecurringTransaction, Player, Transaction]),
|
||||
TeamsModule,
|
||||
LoggingModule,
|
||||
],
|
||||
})
|
||||
export class RecurringTransactionsModule {}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
|
||||
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
|
||||
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
|
||||
|
||||
describe('RecurringTransactionsScheduler', () => {
|
||||
const dueRepository = { find: jest.fn() };
|
||||
const playerRepository = { find: jest.fn() };
|
||||
const definitionWriteRepository = { save: jest.fn((value) => value) };
|
||||
const transactionWriteRepository = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ id: 1, ...value })),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity) => {
|
||||
if (entity.name === 'Player') return playerRepository;
|
||||
if (entity.name === 'Transaction') return transactionWriteRepository;
|
||||
return definitionWriteRepository;
|
||||
}),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
const logger = { info: jest.fn() };
|
||||
let scheduler: RecurringTransactionsScheduler;
|
||||
|
||||
const player = (id: number, active: boolean) => ({ id, active, team: { id: 5 } });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function buildScheduler() {
|
||||
return new RecurringTransactionsScheduler(
|
||||
dueRepository as any,
|
||||
dataSource as any,
|
||||
logger as any,
|
||||
);
|
||||
}
|
||||
|
||||
it('does nothing when no recurring transaction is due', async () => {
|
||||
dueRepository.find.mockResolvedValue([]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_start' }),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_finished' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('books a transaction for every active player and skips inactive ones', async () => {
|
||||
dueRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
team: { id: 5 },
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-08-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
// The DB query filters by active:true itself (asserted below) — an
|
||||
// inactive player would never be returned, so the mock reflects that.
|
||||
playerRepository.find.mockResolvedValue([player(1, true), player(3, true)]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(playerRepository.find).toHaveBeenCalledWith({
|
||||
where: { team: { id: 5 }, active: true },
|
||||
});
|
||||
expect(transactionWriteRepository.save).toHaveBeenCalledTimes(2);
|
||||
expect(transactionWriteRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
amount: 10,
|
||||
note: 'Monatsbeitrag',
|
||||
player: player(1, true),
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
}),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'recurring_transaction_run',
|
||||
details:
|
||||
'recurringTransactionId=3 teamId=5 gebuchteSpieler=2',
|
||||
userId: -1,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[RecurringTransactionIntervalEnum.monthly, '2026-08-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.quarterly, '2026-08-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.yearly, '2026-08-01T00:00:00.000Z', '2027-08-01T00:00:00.000Z'],
|
||||
])(
|
||||
'advances nextRunDate by %s from %s to %s',
|
||||
async (interval, nextRunDate, expected) => {
|
||||
dueRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
team: { id: 5 },
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
interval,
|
||||
nextRunDate,
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
playerRepository.find.mockResolvedValue([player(1, true)]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(definitionWriteRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nextRunDate: expected }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('processes each due definition inside its own transaction', async () => {
|
||||
dueRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
team: { id: 5 },
|
||||
description: 'A',
|
||||
amount: 10,
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-08-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
team: { id: 6 },
|
||||
description: 'B',
|
||||
amount: 20,
|
||||
type: { id: TransactionTypeEnum.levy },
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
nextRunDate: '2026-08-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
playerRepository.find.mockResolvedValue([player(1, true)]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { Transaction } from 'src/transactions/entitites/transaction.entity';
|
||||
import { DataSource, LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
|
||||
import { RecurringTransaction } from './entities/recurring-transaction.entity';
|
||||
|
||||
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
|
||||
[RecurringTransactionIntervalEnum.monthly]: 1,
|
||||
[RecurringTransactionIntervalEnum.quarterly]: 3,
|
||||
[RecurringTransactionIntervalEnum.yearly]: 12,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RecurringTransactionsScheduler {
|
||||
constructor(
|
||||
@InjectRepository(RecurringTransaction)
|
||||
private readonly repository: Repository<RecurringTransaction>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_8AM)
|
||||
async runDueRecurringTransactions(): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'scheduled_recurring_transaction_check_start',
|
||||
details: `Starting sheduled recurring Transaction check`,
|
||||
userId: -1,
|
||||
},
|
||||
);
|
||||
|
||||
const today = new Date().toISOString();
|
||||
const due = await this.repository.find({
|
||||
where: { active: true, nextRunDate: LessThanOrEqual(today) },
|
||||
relations: ['team', 'type'],
|
||||
});
|
||||
|
||||
for (const definition of due) {
|
||||
await this.runOne(definition);
|
||||
}
|
||||
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'scheduled_recurring_transaction_check_finished',
|
||||
details: `Finished sheduled recurring Transaction check, durationMS=${Date.now() - start}`,
|
||||
userId: -1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async runOne(definition: RecurringTransaction): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const players = await manager
|
||||
.getRepository(Player)
|
||||
.find({ where: { team: { id: definition.team.id }, active: true } });
|
||||
|
||||
const transactionRepository = manager.getRepository(Transaction);
|
||||
for (const player of players) {
|
||||
const transaction = transactionRepository.create({
|
||||
amount: definition.amount,
|
||||
date: new Date().toISOString(),
|
||||
note: definition.description,
|
||||
player,
|
||||
type: definition.type,
|
||||
});
|
||||
await transactionRepository.save(transaction);
|
||||
}
|
||||
|
||||
const definitionRepository = manager.getRepository(RecurringTransaction);
|
||||
definition.nextRunDate = this.advance(
|
||||
definition.nextRunDate,
|
||||
definition.interval,
|
||||
);
|
||||
await definitionRepository.save(definition);
|
||||
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'recurring_transaction_run',
|
||||
details: `recurringTransactionId=${definition.id} teamId=${definition.team.id} gebuchteSpieler=${players.length}`,
|
||||
userId: -1,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private advance(
|
||||
nextRunDate: string,
|
||||
interval: RecurringTransactionIntervalEnum,
|
||||
): string {
|
||||
const date = new Date(nextRunDate);
|
||||
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
|
||||
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
|
||||
import { RecurringTransactionsService } from './recurring-transactions.service';
|
||||
|
||||
describe('RecurringTransactionsService 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 teamRepository = { createQueryBuilder: jest.fn(() => teamQuery) };
|
||||
const typeRepository = { findOne: jest.fn() };
|
||||
const writeRepository = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity) => {
|
||||
if (entity.name === 'Team') return teamRepository;
|
||||
if (entity.name === 'TransactionType') return typeRepository;
|
||||
return writeRepository;
|
||||
}),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
let service: RecurringTransactionsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
teamQuery.getOne.mockResolvedValue({ id: 5 });
|
||||
teamReadRepository.findOne.mockResolvedValue({ id: 5 });
|
||||
typeRepository.findOne.mockResolvedValue({
|
||||
id: TransactionTypeEnum.fee,
|
||||
name: 'fee',
|
||||
});
|
||||
service = new RecurringTransactionsService(
|
||||
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 list', async () => {
|
||||
teamReadRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getTeamRecurringTransactions(42, 999),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(access.assertMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authorizes team reads, sorts them, and maps only safe fields', async () => {
|
||||
readRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 8,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: '10.00',
|
||||
type: { id: TransactionTypeEnum.fee, name: 'fee' },
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
team: { id: 5, secret: 'hidden' },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.getTeamRecurringTransactions(42, 5),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
id: 8,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
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, resolves the type, 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.createRecurringTransaction(
|
||||
{
|
||||
teamId: 5,
|
||||
description: ' Monatsbeitrag ',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
startDate: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
42,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
id: 9,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.fee,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
42,
|
||||
5,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
manager,
|
||||
);
|
||||
expect(typeRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: TransactionTypeEnum.fee },
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'recurring_transaction_create',
|
||||
details: 'teamId=5 recurringTransactionId=9 action=create',
|
||||
userId: 42,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown transaction type before writing or auditing', async () => {
|
||||
typeRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createRecurringTransaction(
|
||||
{
|
||||
teamId: 5,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
startDate: '2026-09-01T00:00:00.000Z',
|
||||
},
|
||||
42,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(writeRepository.save).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates fields in the owning team without touching nextRunDate, and audits it', async () => {
|
||||
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
|
||||
writeRepository.findOne.mockResolvedValue({
|
||||
id: 8,
|
||||
team: { id: 5 },
|
||||
description: 'Alt',
|
||||
amount: 5,
|
||||
type: { id: TransactionTypeEnum.levy },
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
writeRepository.save.mockImplementation(async (value) => value);
|
||||
|
||||
await expect(
|
||||
service.updateRecurringTransaction(
|
||||
8,
|
||||
{
|
||||
description: ' Neu ',
|
||||
amount: 12,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.quarterly,
|
||||
active: false,
|
||||
},
|
||||
42,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
id: 8,
|
||||
description: 'Neu',
|
||||
amount: 12,
|
||||
type: TransactionTypeEnum.fee,
|
||||
interval: RecurringTransactionIntervalEnum.quarterly,
|
||||
active: false,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
42,
|
||||
5,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
manager,
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'recurring_transaction_update',
|
||||
details: 'teamId=5 recurringTransactionId=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 entry = { id: 8, team: { id: 5 } };
|
||||
writeRepository.findOne.mockResolvedValue(entry);
|
||||
|
||||
await service.deleteRecurringTransaction(8, 42);
|
||||
|
||||
expect(writeRepository.remove).toHaveBeenCalledWith(entry);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'recurring_transaction_delete',
|
||||
details: 'teamId=5 recurringTransactionId=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.deleteRecurringTransaction(999, 42),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { TransactionType } from 'src/transactions/entitites/transaction-type.entity';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
|
||||
import { RecurringTransactionResponseDTO } from './dto/recurring-transaction-response.dto';
|
||||
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
|
||||
import { RecurringTransaction } from './entities/recurring-transaction.entity';
|
||||
|
||||
@Injectable()
|
||||
export class RecurringTransactionsService {
|
||||
constructor(
|
||||
@InjectRepository(RecurringTransaction)
|
||||
private readonly repository: Repository<RecurringTransaction>,
|
||||
@InjectRepository(Team)
|
||||
private readonly teamRepository: Repository<Team>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async getTeamRecurringTransactions(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
): Promise<RecurringTransactionResponseDTO[]> {
|
||||
const team = await this.teamRepository.findOne({ where: { id: teamId } });
|
||||
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||
await this.access.assertMember(userId, teamId);
|
||||
const entries = await this.repository.find({
|
||||
where: { team: { id: teamId } },
|
||||
order: { description: 'ASC' },
|
||||
});
|
||||
return entries.map((entry) => this.toResponse(entry));
|
||||
}
|
||||
|
||||
createRecurringTransaction(
|
||||
dto: CreateRecurringTransactionDTO,
|
||||
userId: number,
|
||||
): Promise<RecurringTransactionResponseDTO> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, dto.teamId);
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
team.id,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
manager,
|
||||
);
|
||||
const type = await this.findType(manager, dto.type);
|
||||
const repository = manager.getRepository(RecurringTransaction);
|
||||
const saved = await repository.save(
|
||||
repository.create({
|
||||
team,
|
||||
description: dto.description.trim(),
|
||||
amount: dto.amount,
|
||||
type,
|
||||
interval: dto.interval,
|
||||
nextRunDate: dto.startDate,
|
||||
active: true,
|
||||
}),
|
||||
);
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'recurring_transaction_create',
|
||||
details: `teamId=${team.id} recurringTransactionId=${saved.id} action=create`,
|
||||
userId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return this.toResponse(saved);
|
||||
});
|
||||
}
|
||||
|
||||
async updateRecurringTransaction(
|
||||
id: number,
|
||||
dto: UpdateRecurringTransactionDTO,
|
||||
userId: number,
|
||||
): Promise<RecurringTransactionResponseDTO> {
|
||||
const owner = await this.findOwner(id);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, owner.team.id);
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
team.id,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
manager,
|
||||
);
|
||||
const type = await this.findType(manager, dto.type);
|
||||
const repository = manager.getRepository(RecurringTransaction);
|
||||
const entry = await this.findTransactionalEntry(repository, id, team.id);
|
||||
entry.description = dto.description.trim();
|
||||
entry.amount = dto.amount;
|
||||
entry.type = type;
|
||||
entry.interval = dto.interval;
|
||||
entry.active = dto.active;
|
||||
const saved = await repository.save(entry);
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'recurring_transaction_update',
|
||||
details: `teamId=${team.id} recurringTransactionId=${id} action=update`,
|
||||
userId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return this.toResponse(saved);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRecurringTransaction(id: number, userId: number): Promise<void> {
|
||||
const owner = await this.findOwner(id);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, owner.team.id);
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
team.id,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
manager,
|
||||
);
|
||||
const repository = manager.getRepository(RecurringTransaction);
|
||||
const entry = await this.findTransactionalEntry(repository, id, team.id);
|
||||
await repository.remove(entry);
|
||||
await this.logger.info(
|
||||
{
|
||||
event: 'recurring_transaction_delete',
|
||||
details: `teamId=${team.id} recurringTransactionId=${id} action=delete`,
|
||||
userId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async findOwner(id: number): Promise<RecurringTransaction> {
|
||||
const entry = await this.repository.findOne({
|
||||
where: { id },
|
||||
relations: ['team'],
|
||||
});
|
||||
if (!entry?.team)
|
||||
throw new NotFoundException('Wiederkehrende Buchung nicht gefunden.');
|
||||
return entry;
|
||||
}
|
||||
|
||||
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 findType(
|
||||
manager: EntityManager,
|
||||
typeId: number,
|
||||
): Promise<TransactionType> {
|
||||
const type = await manager
|
||||
.getRepository(TransactionType)
|
||||
.findOne({ where: { id: typeId } });
|
||||
if (!type) throw new NotFoundException('Buchungstyp nicht gefunden.');
|
||||
return type;
|
||||
}
|
||||
|
||||
private async findTransactionalEntry(
|
||||
repository: Repository<RecurringTransaction>,
|
||||
id: number,
|
||||
teamId: number,
|
||||
): Promise<RecurringTransaction> {
|
||||
const entry = await repository.findOne({
|
||||
where: { id, team: { id: teamId } },
|
||||
relations: ['team'],
|
||||
});
|
||||
if (!entry)
|
||||
throw new NotFoundException('Wiederkehrende Buchung nicht gefunden.');
|
||||
return entry;
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
entry: RecurringTransaction,
|
||||
): RecurringTransactionResponseDTO {
|
||||
return {
|
||||
id: entry.id,
|
||||
description: entry.description,
|
||||
amount: Number(entry.amount),
|
||||
type: entry.type.id,
|
||||
interval: entry.interval,
|
||||
nextRunDate: entry.nextRunDate,
|
||||
active: entry.active,
|
||||
createdAt: entry.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ describe('PublicTeamAccessService', () => {
|
||||
const penaltyRepository = { find: jest.fn() };
|
||||
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
|
||||
let service: PublicTeamAccessService;
|
||||
let logger: any;
|
||||
let eventEmitter: any;
|
||||
|
||||
const managedTeam = {
|
||||
id: 7,
|
||||
@@ -25,12 +27,16 @@ describe('PublicTeamAccessService', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
teamRepository.save.mockImplementation(async (team) => team);
|
||||
logger = { info: jest.fn() };
|
||||
eventEmitter = { emit: jest.fn() };
|
||||
service = new PublicTeamAccessService(
|
||||
teamRepository as any,
|
||||
playerRepository as any,
|
||||
transactionRepository as any,
|
||||
penaltyRepository as any,
|
||||
access as any,
|
||||
logger as any,
|
||||
eventEmitter as any,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -82,6 +88,47 @@ describe('PublicTeamAccessService', () => {
|
||||
expect(status.token).not.toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('logs and emits when public access is enabled', async () => {
|
||||
mockManagedTeam();
|
||||
|
||||
await service.setEnabled(4, 7, true);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'public_access_enabled',
|
||||
details: 'teamId=7',
|
||||
userId: 4,
|
||||
});
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'notifications.public_access.enabled',
|
||||
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log or emit when public access is disabled', async () => {
|
||||
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
|
||||
|
||||
await service.setEnabled(4, 7, false);
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(eventEmitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs and emits when the token is rotated', async () => {
|
||||
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
|
||||
|
||||
await service.rotate(4, 7);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'public_access_rotated',
|
||||
details: 'teamId=7',
|
||||
userId: 4,
|
||||
});
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'notifications.public_access.rotated',
|
||||
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only whitelisted public team fields and active players', async () => {
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 7,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { LoggingService } from '../database/logging/logging.service';
|
||||
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
|
||||
import {
|
||||
PublicAccessEnabledEvent,
|
||||
PublicAccessRotatedEvent,
|
||||
} from '../notifications/events/public-access-changed.event';
|
||||
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
||||
import { Player } from '../players/entities/player.entity';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
@@ -28,6 +35,8 @@ export class PublicTeamAccessService {
|
||||
@InjectRepository(PenaltyEntity)
|
||||
private readonly penaltyRepository: Repository<PenaltyEntity>,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly logger: LoggingService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async getStatus(
|
||||
@@ -55,6 +64,19 @@ export class PublicTeamAccessService {
|
||||
}
|
||||
team.publicAccessEnabled = enabled;
|
||||
await this.teamRepository.save(team);
|
||||
|
||||
if (enabled) {
|
||||
await this.logger.info({
|
||||
event: 'public_access_enabled',
|
||||
details: `teamId=${teamId}`,
|
||||
userId,
|
||||
});
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENT_NAME.publicAccessEnabled,
|
||||
new PublicAccessEnabledEvent(teamId, userId),
|
||||
);
|
||||
}
|
||||
|
||||
return this.toStatus(team);
|
||||
}
|
||||
|
||||
@@ -68,6 +90,17 @@ export class PublicTeamAccessService {
|
||||
const team = await this.loadManagedTeam(teamId);
|
||||
team.publicAccessToken = this.createToken();
|
||||
await this.teamRepository.save(team);
|
||||
|
||||
await this.logger.info({
|
||||
event: 'public_access_rotated',
|
||||
details: `teamId=${teamId}`,
|
||||
userId,
|
||||
});
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENT_NAME.publicAccessRotated,
|
||||
new PublicAccessRotatedEvent(teamId, userId),
|
||||
);
|
||||
|
||||
return this.toStatus(team);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('TeamMembersService', () => {
|
||||
let dataSource: any;
|
||||
let logger: any;
|
||||
let access: any;
|
||||
let eventEmitter: any;
|
||||
let service: TeamMembersService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -44,7 +45,8 @@ describe('TeamMembersService', () => {
|
||||
dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
logger = { info: jest.fn() };
|
||||
access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
|
||||
service = new TeamMembersService(dataSource, logger, access as any);
|
||||
eventEmitter = { emit: jest.fn() };
|
||||
service = new TeamMembersService(dataSource, logger, access as any, eventEmitter as any);
|
||||
});
|
||||
|
||||
it('checks the team-manager permission before touching the database', async () => {
|
||||
@@ -188,6 +190,53 @@ describe('TeamMembersService', () => {
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('emits a player-active-changed event after a real deactivation', async () => {
|
||||
player.balance = 42;
|
||||
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
||||
|
||||
await service.setActive(5, teamId, player.id, false);
|
||||
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'notifications.player.active_changed',
|
||||
expect.objectContaining({
|
||||
teamId,
|
||||
actorUserId: 5,
|
||||
playerId: player.id,
|
||||
playerName: 'Pat Player',
|
||||
active: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit when the active state is unchanged (idempotent)', async () => {
|
||||
player = makePlayer(101, true, TeamRolesEnum.player, 0);
|
||||
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
|
||||
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
|
||||
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
|
||||
);
|
||||
|
||||
await service.setActive(5, teamId, player.id, true);
|
||||
|
||||
expect(eventEmitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits a player-role-changed event after a real role change', async () => {
|
||||
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
||||
|
||||
await service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain);
|
||||
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'notifications.player.role_changed',
|
||||
expect.objectContaining({
|
||||
teamId,
|
||||
actorUserId: 5,
|
||||
playerId: player.id,
|
||||
playerName: 'Pat Player',
|
||||
teamRoleId: TeamRolesEnum.captain,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function makePlayer(
|
||||
id: number,
|
||||
active: boolean,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { LoggingService } from '../database/logging/logging.service';
|
||||
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
|
||||
import { PlayerActiveChangedEvent } from '../notifications/events/player-active-changed.event';
|
||||
import { PlayerRoleChangedEvent } from '../notifications/events/player-role-changed.event';
|
||||
import { Player } from '../players/entities/player.entity';
|
||||
import { TeamRole } from '../team-roles/entities/team-roles.entity';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
@@ -18,6 +22,7 @@ export class TeamMembersService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly logger: LoggingService,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async setActive(
|
||||
@@ -33,12 +38,12 @@ export class TeamMembersService {
|
||||
TeamRolesEnum.captain,
|
||||
);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const result = await this.dataSource.transaction(async (manager) => {
|
||||
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
|
||||
const playerRepository = manager.getRepository(Player);
|
||||
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
|
||||
|
||||
if (player.active === active) return player;
|
||||
if (player.active === active) return { player, changed: false };
|
||||
|
||||
const isDeactivation = player.active && !active;
|
||||
if (
|
||||
@@ -66,8 +71,23 @@ export class TeamMembersService {
|
||||
actorUserId,
|
||||
`teamId=${teamId} playerId=${playerId} active=${active}`,
|
||||
);
|
||||
return player;
|
||||
return { player, changed: true };
|
||||
});
|
||||
|
||||
if (result.changed) {
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENT_NAME.playerActiveChanged,
|
||||
new PlayerActiveChangedEvent(
|
||||
teamId,
|
||||
actorUserId,
|
||||
playerId,
|
||||
`${result.player.firstName} ${result.player.lastName}`,
|
||||
active,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result.player;
|
||||
}
|
||||
|
||||
async setTeamRole(
|
||||
@@ -83,12 +103,12 @@ export class TeamMembersService {
|
||||
TeamRolesEnum.captain,
|
||||
);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const result = await this.dataSource.transaction(async (manager) => {
|
||||
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
|
||||
const playerRepository = manager.getRepository(Player);
|
||||
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
|
||||
|
||||
if (player.teamRole?.id === teamRoleId) return player;
|
||||
if (player.teamRole?.id === teamRoleId) return { player, changed: false };
|
||||
|
||||
const isDemotionFromTreasurer =
|
||||
player.active &&
|
||||
@@ -108,8 +128,23 @@ export class TeamMembersService {
|
||||
actorUserId,
|
||||
`teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`,
|
||||
);
|
||||
return player;
|
||||
return { player, changed: true };
|
||||
});
|
||||
|
||||
if (result.changed) {
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENT_NAME.playerRoleChanged,
|
||||
new PlayerRoleChangedEvent(
|
||||
teamId,
|
||||
actorUserId,
|
||||
playerId,
|
||||
`${result.player.firstName} ${result.player.lastName}`,
|
||||
teamRoleId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result.player;
|
||||
}
|
||||
|
||||
// insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction,
|
||||
|
||||
38
myteamwallet_backend/src/teams/teams.controller.spec.ts
Normal file
38
myteamwallet_backend/src/teams/teams.controller.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { TeamsController } from './teams.controller';
|
||||
|
||||
describe('TeamsController', () => {
|
||||
const service = { createNewTeam: jest.fn() };
|
||||
const publicAccess = {};
|
||||
const teamMembers = {};
|
||||
const teamPermissions = {};
|
||||
const controller = new TeamsController(
|
||||
service as any,
|
||||
publicAccess as any,
|
||||
teamMembers as any,
|
||||
teamPermissions as any,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('allows any logged-in user (not just admins) to create a team', () => {
|
||||
expect(Reflect.getMetadata('roles', TeamsController.prototype.create)).toEqual([
|
||||
RoleEnum.user,
|
||||
RoleEnum.admin,
|
||||
]);
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.create),
|
||||
).toContain(RolesGuard);
|
||||
});
|
||||
|
||||
it('passes the authenticated user and the DTO to the service', () => {
|
||||
const req = { user: { id: 42 } };
|
||||
const dto = { name: '1. Herren' };
|
||||
|
||||
void controller.create(req as any, dto as any);
|
||||
|
||||
expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42);
|
||||
});
|
||||
});
|
||||
@@ -9,16 +9,18 @@ import {
|
||||
Put,
|
||||
Patch,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { TransactionsQueryDto } from 'src/transactions/dto/transactions-query.dto';
|
||||
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 { TeamActivityRow, TeamsService } from './teams.service';
|
||||
import { PublicTeamAccessService } from './public-team-access.service';
|
||||
import { UpdatePublicAccessDto } from './dto/public-access.dto';
|
||||
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
||||
@@ -142,6 +144,25 @@ export class TeamsController {
|
||||
return this.service.getTeamTransactions(id, userId);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Paginiertes Kassenjournal für ein Team',
|
||||
description:
|
||||
'Gibt Transaktionen (Spieler und Teamwallet) seitenweise zurück, mit serverseitiger Sortierung, Typ-Filter und Freitextsuche - für das AG-Grid-Journal.',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Get(':id/transactions/journal')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
getTransactionsJournal(
|
||||
@Req() req,
|
||||
@Param('id') id: string,
|
||||
@Query() query: TransactionsQueryDto,
|
||||
): Promise<{ data: TeamActivityRow[]; total: number }> {
|
||||
const userId = req.user?.id;
|
||||
return this.service.getTeamTransactionsJournal(id, userId, query);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Neuen Spieler anlegen',
|
||||
description:
|
||||
@@ -223,7 +244,7 @@ export class TeamsController {
|
||||
'Erstellt ein neues Team mit gegebenem Namen und gibt es zurück.',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@Roles([RoleEnum.admin])
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
|
||||
516
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
516
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
import { TeamsService } from './teams.service';
|
||||
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||
|
||||
function monthKey(monthsAgo: number): string {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isoDate(monthsAgo: number, day: number): string {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
|
||||
}
|
||||
|
||||
describe('TeamsService#getOverviewStats theoretical balance', () => {
|
||||
const repository = { findOneOrFail: jest.fn() };
|
||||
const access = { assertMember: jest.fn() };
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 100,
|
||||
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
|
||||
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
|
||||
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: -30,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(2, 10),
|
||||
amount: 30,
|
||||
type: { id: 11, name: 'fine' },
|
||||
note: 'Zu spät zum Training',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
||||
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
expect(beforeFine?.balance).toBe(100);
|
||||
expect(beforeFine?.theoreticalBalance).toBe(100);
|
||||
expect(now?.balance).toBe(100);
|
||||
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
|
||||
expect(now?.theoreticalBalance).toBe(130);
|
||||
});
|
||||
|
||||
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 50,
|
||||
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: -20,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(6, 5),
|
||||
amount: 999,
|
||||
type: { id: 1, name: 'credit' },
|
||||
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
|
||||
},
|
||||
{
|
||||
date: isoDate(1, 10),
|
||||
amount: 20,
|
||||
type: { id: 11, name: 'fine' },
|
||||
note: 'Zu spät',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
// Older than the adjustment's own month (6 months ago) — the only kind of checkpoint
|
||||
// where wrongly including the €999 adjustment would still show up as "already happened".
|
||||
const beforeAdjustment = result.balanceHistory.find((p) => p.month === monthKey(9));
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
// Without exclusion this would read 1049 (50 + the wrongly-included €999 adjustment);
|
||||
// with correct exclusion only the (not-yet-existing-at-month-9) fine is irrelevant here too,
|
||||
// so it stays at the team's flat 50.
|
||||
expect(beforeAdjustment?.theoreticalBalance).toBe(50);
|
||||
expect(now?.theoreticalBalance).toBe(70);
|
||||
});
|
||||
|
||||
it("excludes an inactive player's outstanding debt from the theoretical balance", async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 100,
|
||||
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: 0,
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
firstName: 'Bea',
|
||||
lastName: 'Beispiel',
|
||||
active: false,
|
||||
balance: -40,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(2, 10),
|
||||
amount: 40,
|
||||
type: { id: 11, name: 'fine' },
|
||||
note: 'Zu spät',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
// The inactive player's unpaid fine is real debt, but no longer counted at all —
|
||||
// the theoretical line must match the actual cash balance exactly.
|
||||
expect(now?.balance).toBe(100);
|
||||
expect(now?.theoreticalBalance).toBe(100);
|
||||
});
|
||||
|
||||
it('lowers the theoretical balance when a player has prepaid credit not yet reflected as spent', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 100,
|
||||
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: 30,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(2, 10),
|
||||
amount: 30,
|
||||
type: { id: 1, name: 'credit' },
|
||||
note: 'Vorauszahlung',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
// Prepaid credit is not yet "spent" in the reconstruction, so the theoretical
|
||||
// line dips below the actual cash balance at this checkpoint.
|
||||
expect(now?.balance).toBe(100);
|
||||
expect(now?.theoreticalBalance).toBe(70);
|
||||
expect(now!.theoreticalBalance).toBeLessThan(now!.balance);
|
||||
});
|
||||
|
||||
it('sums fine/levy/fee bookings per month into monthlyFlow.penalties, excluded from income/expense', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 100,
|
||||
transactions: [{ date: isoDate(0, 1), amount: 100, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: -45,
|
||||
transactions: [
|
||||
{ date: isoDate(0, 5), amount: 10, type: { id: 11, name: 'fine' }, note: 'Zu spät' },
|
||||
{ date: isoDate(0, 6), amount: 15, type: { id: 12, name: 'levy' }, note: 'Umlage' },
|
||||
{ date: isoDate(0, 7), amount: 5, type: { id: 13, name: 'fee' }, note: 'Gebühr' },
|
||||
{ date: isoDate(1, 8), amount: 20, type: { id: 11, name: 'fine' }, note: 'Vormonat' },
|
||||
{ date: isoDate(0, 9), amount: 12, type: { id: 0, name: 'payment' }, note: 'Beitrag' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
const now = result.monthlyFlow.find((p) => p.month === monthKey(0));
|
||||
const lastMonth = result.monthlyFlow.find((p) => p.month === monthKey(1));
|
||||
|
||||
expect(now?.penalties).toBe(30);
|
||||
expect(lastMonth?.penalties).toBe(20);
|
||||
// Payment still counts as income, fine/levy/fee never do.
|
||||
expect(now?.income).toBe(12);
|
||||
expect(now?.expense).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 0,
|
||||
transactions: [],
|
||||
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
expect(result.balanceHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamsService#getTeamTransactionsJournal', () => {
|
||||
const repository = { findOneOrFail: jest.fn() };
|
||||
const access = { assertMember: jest.fn() };
|
||||
let service: TeamsService;
|
||||
|
||||
const team = {
|
||||
id: 9,
|
||||
transactions: [
|
||||
{ id: 101, date: '2026-06-01', amount: 50, type: { name: 'credit' }, note: 'Sponsoring' },
|
||||
{ id: 102, date: '2026-06-15', amount: 20, type: { name: 'expense' }, note: 'Bälle' },
|
||||
],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
transactions: [
|
||||
{ id: 1, date: '2026-06-10', amount: 12, type: { name: 'payment' }, note: 'Beitrag' },
|
||||
{ id: 2, date: '2026-06-20', amount: 5, type: { name: 'fine' }, note: 'Zu spät' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
firstName: 'Bea',
|
||||
lastName: 'Beispiel',
|
||||
transactions: [
|
||||
{ id: 3, date: '2026-06-05', amount: 30, type: { name: 'levy' }, note: 'Turnier-Umlage' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
repository.findOneOrFail.mockResolvedValue(team);
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
});
|
||||
|
||||
function query(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
page: 1,
|
||||
limit: 25,
|
||||
sortBy: 'date',
|
||||
sortDir: 'desc',
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('checks membership before returning any data', async () => {
|
||||
await service.getTeamTransactionsJournal(9, 42, query());
|
||||
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
||||
});
|
||||
|
||||
it('returns the total count of all 5 bookings across both sources, unpaginated', async () => {
|
||||
const result = await service.getTeamTransactionsJournal(9, 42, query());
|
||||
expect(result.total).toBe(5);
|
||||
});
|
||||
|
||||
it('paginates using page/limit and slices from the already-sorted result', async () => {
|
||||
const page1 = await service.getTeamTransactionsJournal(9, 42, query({ page: 1, limit: 2 }));
|
||||
const page2 = await service.getTeamTransactionsJournal(9, 42, query({ page: 2, limit: 2 }));
|
||||
|
||||
expect(page1.data).toHaveLength(2);
|
||||
expect(page2.data).toHaveLength(2);
|
||||
expect(page1.total).toBe(5);
|
||||
expect(page2.total).toBe(5);
|
||||
// Newest first by default (date desc) - no overlap between the two pages.
|
||||
expect(page1.data.map((row) => row.id)).toEqual([2, 102]);
|
||||
expect(page2.data.map((row) => row.id)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('sorts by amount in both directions', async () => {
|
||||
const asc = await service.getTeamTransactionsJournal(
|
||||
9,
|
||||
42,
|
||||
query({ sortBy: 'amount', sortDir: 'asc', limit: 100 }),
|
||||
);
|
||||
const desc = await service.getTeamTransactionsJournal(
|
||||
9,
|
||||
42,
|
||||
query({ sortBy: 'amount', sortDir: 'desc', limit: 100 }),
|
||||
);
|
||||
|
||||
expect(asc.data.map((row) => row.amount)).toEqual([5, 12, 20, 30, 50]);
|
||||
expect(desc.data.map((row) => row.amount)).toEqual([50, 30, 20, 12, 5]);
|
||||
});
|
||||
|
||||
it('filters by exact booking type', async () => {
|
||||
const result = await service.getTeamTransactionsJournal(9, 42, query({ type: 'fine' }));
|
||||
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0].note).toBe('Zu spät');
|
||||
});
|
||||
|
||||
it('filters by a case-insensitive search across player name and note', async () => {
|
||||
const byName = await service.getTeamTransactionsJournal(9, 42, query({ search: 'bea' }));
|
||||
const byNote = await service.getTeamTransactionsJournal(9, 42, query({ search: 'BÄLLE' }));
|
||||
|
||||
expect(byName.total).toBe(1);
|
||||
expect(byName.data[0].playerName).toBe('Bea Beispiel');
|
||||
expect(byNote.total).toBe(1);
|
||||
expect(byNote.data[0].note).toBe('Bälle');
|
||||
});
|
||||
|
||||
it('rejects when the actor is not a team member', async () => {
|
||||
access.assertMember.mockRejectedValue(new Error('forbidden'));
|
||||
|
||||
await expect(service.getTeamTransactionsJournal(9, 42, query())).rejects.toThrow('forbidden');
|
||||
expect(repository.findOneOrFail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamsService#createNewTeam', () => {
|
||||
const repository = { create: jest.fn(), save: jest.fn() };
|
||||
const playerRepository = { create: jest.fn(), save: jest.fn() };
|
||||
const rolesRepository = { findOneBy: jest.fn() };
|
||||
const settingsRepository = { create: jest.fn(), save: jest.fn() };
|
||||
const usersRepository = { findOneBy: jest.fn() };
|
||||
const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
|
||||
let manager: any;
|
||||
let dataSource: any;
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
manager = {
|
||||
getRepository: jest.fn((entity: { name: string }) => {
|
||||
switch (entity.name) {
|
||||
case 'Team':
|
||||
return repository;
|
||||
case 'Player':
|
||||
return playerRepository;
|
||||
case 'TeamRole':
|
||||
return rolesRepository;
|
||||
case 'TeamSetting':
|
||||
return settingsRepository;
|
||||
case 'User':
|
||||
return usersRepository;
|
||||
default:
|
||||
throw new Error(`unexpected entity ${entity.name}`);
|
||||
}
|
||||
}),
|
||||
};
|
||||
dataSource = { transaction: jest.fn((work: any) => work(manager)) };
|
||||
service = new TeamsService(
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
logger as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
dataSource as any,
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the team, its default settings, and a captain membership for the creator, all inside one transaction', async () => {
|
||||
const savedTeam = { id: 7, name: '1. Herren' };
|
||||
repository.create.mockReturnValue({ name: '1. Herren' });
|
||||
repository.save.mockResolvedValue(savedTeam);
|
||||
settingsRepository.create.mockImplementation((s: unknown) => s);
|
||||
settingsRepository.save.mockResolvedValue([]);
|
||||
usersRepository.findOneBy.mockResolvedValue({
|
||||
id: 42,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
});
|
||||
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
|
||||
playerRepository.create.mockImplementation((p: unknown) => p);
|
||||
playerRepository.save.mockResolvedValue({});
|
||||
|
||||
const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe(savedTeam);
|
||||
expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
|
||||
expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
|
||||
expect(playerRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
team: savedTeam,
|
||||
teamRole: { id: 3, name: 'captain' },
|
||||
user: { id: 42, firstName: 'Alex', lastName: 'Muster' },
|
||||
}),
|
||||
);
|
||||
expect(playerRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create the team at all if the captain-membership write fails', async () => {
|
||||
const savedTeam = { id: 7, name: '1. Herren' };
|
||||
repository.create.mockReturnValue({ name: '1. Herren' });
|
||||
repository.save.mockResolvedValue(savedTeam);
|
||||
settingsRepository.create.mockImplementation((s: unknown) => s);
|
||||
settingsRepository.save.mockResolvedValue([]);
|
||||
usersRepository.findOneBy.mockResolvedValue({ id: 42, firstName: 'Alex', lastName: 'Muster' });
|
||||
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
|
||||
playerRepository.create.mockImplementation((p: unknown) => p);
|
||||
playerRepository.save.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.createNewTeam({ name: '1. Herren' } as any, '42'),
|
||||
).rejects.toThrow('db down');
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamsService.createNewPlayer', () => {
|
||||
const repository = { findOneBy: jest.fn() };
|
||||
const playerRepository = { create: jest.fn((value) => value), save: jest.fn() };
|
||||
const rolesRepository = { findOneBy: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
const access = { assertManager: jest.fn() };
|
||||
const eventEmitter = { emit: jest.fn() };
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
access.assertManager.mockResolvedValue(undefined);
|
||||
rolesRepository.findOneBy.mockResolvedValue({ id: 1, name: 'player' });
|
||||
repository.findOneBy.mockResolvedValue({ id: 10, name: 'Team A' });
|
||||
playerRepository.save.mockImplementation((value) =>
|
||||
Promise.resolve({ ...value, id: 55 }),
|
||||
);
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
playerRepository as any,
|
||||
{} as any,
|
||||
rolesRepository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
logger as any,
|
||||
access as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
eventEmitter as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a player-created event with the new player id and name', async () => {
|
||||
await service.createNewPlayer('10', { firstName: 'Ada', lastName: 'Lovelace', teamRole: undefined }, '5');
|
||||
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'notifications.player.created',
|
||||
expect.objectContaining({
|
||||
teamId: 10,
|
||||
actorUserId: 5,
|
||||
playerId: 55,
|
||||
playerName: 'Ada Lovelace',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
|
||||
import { PlayerCreatedEvent } from '../notifications/events/player-created.event';
|
||||
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';
|
||||
@@ -8,11 +11,28 @@ import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
|
||||
import { TEAM_SETTING_DEFAULTS } from 'src/team-settings/team-setting-defaults';
|
||||
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 { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import {
|
||||
TransactionsQueryDto,
|
||||
TransactionsSortableField,
|
||||
} from 'src/transactions/dto/transactions-query.dto';
|
||||
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';
|
||||
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||
|
||||
export interface TeamActivityRow {
|
||||
id: number;
|
||||
date: string;
|
||||
amount: number;
|
||||
type: string;
|
||||
note: string | null;
|
||||
playerName?: string;
|
||||
isTeamWalletTransaction: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
@@ -31,6 +51,10 @@ export class TeamsService {
|
||||
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
|
||||
private logger: LoggingService,
|
||||
private access: TeamAccessService,
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
private dataSource: DataSource,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async getOverview(teamId: string, actorUserId: string) {
|
||||
@@ -145,14 +169,25 @@ export class TeamsService {
|
||||
details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`,
|
||||
userId: Number(id),
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENT_NAME.playerCreated,
|
||||
new PlayerCreatedEvent(Number(id), Number(actorUserId), playerSaved.id, `${p.firstName} ${p.lastName}`),
|
||||
);
|
||||
return playerSaved;
|
||||
}
|
||||
|
||||
async createNewTeam(teamdto: CreateTeamDTO, userId: string) {
|
||||
const createTeam = this.repository.create(teamdto);
|
||||
const team = await this.dataSource.transaction(async (manager) => {
|
||||
const teamRepository = manager.getRepository(Team);
|
||||
const createTeam = teamRepository.create(teamdto);
|
||||
const savedTeam = await teamRepository.save(createTeam);
|
||||
|
||||
const team = await this.repository.save(createTeam);
|
||||
await this.generateBasicTeamSettings(team);
|
||||
await this.generateBasicTeamSettings(manager, savedTeam);
|
||||
await this.addCreatorAsCaptain(manager, savedTeam, userId);
|
||||
|
||||
return savedTeam;
|
||||
});
|
||||
|
||||
await this.logger.info({
|
||||
event: 'team_create',
|
||||
@@ -162,7 +197,33 @@ export class TeamsService {
|
||||
return team;
|
||||
}
|
||||
|
||||
private async generateBasicTeamSettings(team: Team): Promise<void> {
|
||||
private async addCreatorAsCaptain(
|
||||
manager: EntityManager,
|
||||
team: Team,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const [user, captainRole] = await Promise.all([
|
||||
manager.getRepository(User).findOneBy({ id: Number(userId) }),
|
||||
manager.getRepository(TeamRole).findOneBy({ id: TeamRolesEnum.captain }),
|
||||
]);
|
||||
|
||||
const playerRepository = manager.getRepository(Player);
|
||||
const player = playerRepository.create({
|
||||
firstName: user?.firstName ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
team,
|
||||
teamRole: captainRole,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
|
||||
await playerRepository.save(player);
|
||||
}
|
||||
|
||||
private async generateBasicTeamSettings(
|
||||
manager: EntityManager,
|
||||
team: Team,
|
||||
): Promise<void> {
|
||||
const settingsRepository = manager.getRepository(TeamSetting);
|
||||
const settings: CreateTeamSettingDTO[] = Object.entries(
|
||||
TEAM_SETTING_DEFAULTS,
|
||||
).map(([key, value]) => ({
|
||||
@@ -171,8 +232,8 @@ export class TeamsService {
|
||||
team,
|
||||
}));
|
||||
|
||||
await this.settingsRepository.save(
|
||||
settings.map((s) => this.settingsRepository.create(s)),
|
||||
await settingsRepository.save(
|
||||
settings.map((s) => settingsRepository.create(s)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,12 +289,98 @@ export class TeamsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getTeamTransactionsJournal(
|
||||
teamId: string | number,
|
||||
userId: string | number,
|
||||
query: TransactionsQueryDto,
|
||||
): Promise<{ data: TeamActivityRow[]; total: number }> {
|
||||
const id = Number(teamId);
|
||||
|
||||
await this.access.assertMember(Number(userId), id);
|
||||
|
||||
const team = await this.repository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
|
||||
const transactions: TeamActivityRow[] = [];
|
||||
|
||||
for (const t of team.transactions) {
|
||||
transactions.push({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
amount: Number(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: Number(t.amount),
|
||||
type: t.type.name,
|
||||
note: t.note,
|
||||
playerName: p.firstName + ' ' + p.lastName,
|
||||
isTeamWalletTransaction: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const search = query.search?.trim().toLowerCase();
|
||||
const filtered = transactions.filter((row) => {
|
||||
if (query.type && row.type !== query.type) return false;
|
||||
if (search) {
|
||||
const haystack = `${row.playerName ?? 'Teamkasse'} ${row.note ?? ''}`.toLowerCase();
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortBy = query.sortBy ?? 'date';
|
||||
const direction = query.sortDir === 'asc' ? 1 : -1;
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const aValue = this.transactionSortValue(a, sortBy);
|
||||
const bValue = this.transactionSortValue(b, sortBy);
|
||||
if (aValue < bValue) return -1 * direction;
|
||||
if (aValue > bValue) return 1 * direction;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 25;
|
||||
const start = (page - 1) * limit;
|
||||
const data = sorted.slice(start, start + limit);
|
||||
|
||||
return { data, total: filtered.length };
|
||||
}
|
||||
|
||||
private transactionSortValue(
|
||||
row: TeamActivityRow,
|
||||
field: TransactionsSortableField,
|
||||
): string | number {
|
||||
switch (field) {
|
||||
case 'amount':
|
||||
return row.amount;
|
||||
case 'playerName':
|
||||
return (row.playerName ?? 'Teamkasse').toLowerCase();
|
||||
case 'type':
|
||||
return row.type;
|
||||
case 'date':
|
||||
default:
|
||||
return row.date;
|
||||
}
|
||||
}
|
||||
|
||||
async getOverviewStats(
|
||||
teamId: string | number,
|
||||
actorUserId: string | number,
|
||||
): Promise<{
|
||||
balanceHistory: { month: string; balance: number }[];
|
||||
monthlyFlow: { month: string; income: number; expense: number }[];
|
||||
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
|
||||
monthlyFlow: { month: string; income: number; expense: number; penalties: number }[];
|
||||
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||
}> {
|
||||
const id = Number(teamId);
|
||||
@@ -255,6 +402,10 @@ export class TeamsService {
|
||||
// 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 }[] = [];
|
||||
// Fine/levy/fee (type.id > 10) raise a player's debt but never move real
|
||||
// cash, so they're kept out of `movements` and tracked separately here
|
||||
// purely to chart "how much was booked as penalties/levies this month".
|
||||
const penaltyMovements: { date: string; amount: number }[] = [];
|
||||
|
||||
for (const t of teamTransactions) {
|
||||
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
|
||||
@@ -264,6 +415,8 @@ export class TeamsService {
|
||||
for (const t of p.transactions ?? []) {
|
||||
if (t.type.name === 'payment') {
|
||||
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
|
||||
} else if (t.type.id > 10) {
|
||||
penaltyMovements.push({ date: t.date, amount: Number(t.amount) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,26 +451,16 @@ export class TeamsService {
|
||||
// reconstruct earlier month-end balances — this guarantees the most
|
||||
// recent point always equals team.balance by construction, regardless
|
||||
// of undocumented history.
|
||||
const descendingMovements = [...movements].sort((a, b) =>
|
||||
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
|
||||
);
|
||||
|
||||
const signedMovements = movements.map((m) => ({
|
||||
date: m.date,
|
||||
amount: this.signedFlowAmount(m),
|
||||
}));
|
||||
const currentBalance = Number(team.balance);
|
||||
let futureSum = 0;
|
||||
let movementIndex = 0;
|
||||
const balanceHistory = [...months]
|
||||
.reverse()
|
||||
.map((month) => {
|
||||
while (
|
||||
movementIndex < descendingMovements.length &&
|
||||
descendingMovements[movementIndex].date.slice(0, 7) > month
|
||||
) {
|
||||
futureSum += this.signedFlowAmount(descendingMovements[movementIndex]);
|
||||
movementIndex++;
|
||||
}
|
||||
return { month, balance: this.round(currentBalance - futureSum) };
|
||||
})
|
||||
.reverse();
|
||||
const rawBalances = this.reconstructBackward(months, currentBalance, signedMovements);
|
||||
const balanceHistory = months.map((month, index) => ({
|
||||
month,
|
||||
balance: this.round(rawBalances[index]),
|
||||
}));
|
||||
|
||||
const monthlyFlow = months.map((month) => {
|
||||
const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month);
|
||||
@@ -327,10 +470,24 @@ export class TeamsService {
|
||||
const expense = monthMovements
|
||||
.filter((m) => m.type === 'expense')
|
||||
.reduce((sum, m) => sum + m.amount, 0);
|
||||
return { month, income: this.round(income), expense: this.round(expense) };
|
||||
const penalties = penaltyMovements
|
||||
.filter((m) => m.date.slice(0, 7) === month)
|
||||
.reduce((sum, m) => sum + m.amount, 0);
|
||||
return {
|
||||
month,
|
||||
income: this.round(income),
|
||||
expense: this.round(expense),
|
||||
penalties: this.round(penalties),
|
||||
};
|
||||
});
|
||||
|
||||
return { balanceHistory, monthlyFlow, topOutstanding };
|
||||
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
||||
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
||||
...point,
|
||||
theoreticalBalance: this.round(point.balance + outstandingHistory[index]),
|
||||
}));
|
||||
|
||||
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
||||
}
|
||||
|
||||
private getLast12Months(): string[] {
|
||||
@@ -347,6 +504,65 @@ export class TeamsService {
|
||||
return movement.type === 'expense' ? -movement.amount : movement.amount;
|
||||
}
|
||||
|
||||
private reconstructBackward(
|
||||
months: string[],
|
||||
currentValue: number,
|
||||
signedMovements: { date: string; amount: number }[],
|
||||
): number[] {
|
||||
const descending = [...signedMovements].sort((a, b) =>
|
||||
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
|
||||
);
|
||||
let futureSum = 0;
|
||||
let index = 0;
|
||||
return [...months]
|
||||
.reverse()
|
||||
.map((month) => {
|
||||
while (index < descending.length && descending[index].date.slice(0, 7) > month) {
|
||||
futureSum += descending[index].amount;
|
||||
index++;
|
||||
}
|
||||
return currentValue - futureSum;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
|
||||
const activePlayers = players.filter((p) => p.active);
|
||||
const totals = months.map(() => 0);
|
||||
|
||||
for (const player of activePlayers) {
|
||||
const realTransactions = (player.transactions ?? []).filter(
|
||||
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
|
||||
);
|
||||
const playerHistory = this.reconstructPlayerBalanceHistory(
|
||||
months,
|
||||
Number(player.balance),
|
||||
realTransactions,
|
||||
);
|
||||
playerHistory.forEach((balance, index) => {
|
||||
totals[index] += balance;
|
||||
});
|
||||
}
|
||||
|
||||
// House convention (see getOverview()'s team.outstanding = out * -1): "outstanding"
|
||||
// is a positive number when players owe money, negative when they're in credit.
|
||||
return totals.map((total) => total * -1);
|
||||
}
|
||||
|
||||
private reconstructPlayerBalanceHistory(
|
||||
months: string[],
|
||||
currentBalance: number,
|
||||
transactions: Transaction[],
|
||||
): number[] {
|
||||
const signedMovements = transactions.map((t) => {
|
||||
const rawAmount = Number(t.amount);
|
||||
const amount = t.type && t.type.id > 10 && rawAmount > 0 ? -rawAmount : rawAmount;
|
||||
return { date: t.date, amount };
|
||||
});
|
||||
|
||||
return this.reconstructBackward(months, currentBalance, signedMovements);
|
||||
}
|
||||
|
||||
private round(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export const TRANSACTIONS_SORTABLE_FIELDS = ['date', 'amount', 'playerName', 'type'] as const;
|
||||
export type TransactionsSortableField = (typeof TRANSACTIONS_SORTABLE_FIELDS)[number];
|
||||
|
||||
export class TransactionsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit = 25;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(TRANSACTIONS_SORTABLE_FIELDS)
|
||||
sortBy: TransactionsSortableField = 'date';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
sortDir: 'asc' | 'desc' = 'desc';
|
||||
}
|
||||
37
myteamwallet_frontend_modern/package-lock.json
generated
37
myteamwallet_frontend_modern/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/service-worker": "^21.2.0",
|
||||
"ag-grid-angular": "^36.0.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
@@ -4236,6 +4237,42 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.2.tgz",
|
||||
"integrity": "sha512-F7ZG0g8Y+iKhJi50AfZRwEyUM/TBsNyh2IoXB0JaDN97lnbemIK8GE5kF1eBtXtN4mcC+lPXK9oZUeVXwO9EWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-grid-angular": {
|
||||
"version": "36.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-36.0.2.tgz",
|
||||
"integrity": "sha512-qBEvOmkcmioJTLZOozoJMYFMSu0/+I6fmeYJ7xrzxv3X88Vr/MoytdiR1j9GbLuhG8tusePfNiwuDnAJxEzbBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "36.0.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">= 20.0.0",
|
||||
"@angular/core": ">= 20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "36.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.0.2.tgz",
|
||||
"integrity": "sha512-TINZfuFvMY2nc3JfQHiUWT7dNIxI89ZxS5XkXIPi/rYICoNupRqpaM41KVzGPPfSkM0AwhuzTFxAiF08zEkV1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "14.0.2",
|
||||
"ag-stack": "36.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-stack": {
|
||||
"version": "36.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.0.2.tgz",
|
||||
"integrity": "sha512-YuhQExQw5YsWK0wxrksRyYBAqOU0v08lJH5uxRsKx+49ko5vkDgnJuhX4yF995BBVdLY1LKlXukLEub+olKyuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/service-worker": "^21.2.0",
|
||||
"ag-grid-angular": "^36.0.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<app-env-banner />
|
||||
<router-outlet />
|
||||
|
||||
@@ -48,6 +48,11 @@ export const routes: Routes = [
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/users/users').then((m) => m.Users),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/logs/logs').then((m) => m.Logs),
|
||||
},
|
||||
{
|
||||
path: 't/:token/:playerId',
|
||||
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
|
||||
@@ -88,6 +93,13 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/penalties/penalties').then((m) => m.Penalties),
|
||||
},
|
||||
{
|
||||
path: 'more/recurring-transactions',
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/recurring-transactions/recurring-transactions').then(
|
||||
(m) => m.RecurringTransactions,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'more/invite',
|
||||
loadComponent: () => import('./features/team/more/invite/invite').then((m) => m.Invite),
|
||||
@@ -111,6 +123,11 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/guide/guide').then((m) => m.Guide),
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
loadComponent: () =>
|
||||
import('./features/team/notifications/notifications').then((m) => m.Notifications),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,8 +5,10 @@ import { of } from 'rxjs';
|
||||
import { App } from './app';
|
||||
import { AuthApi } from './core/auth/auth-api';
|
||||
import { AuthStore } from './core/auth/auth-store';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
describe('App', () => {
|
||||
const originalProduction = environment.production;
|
||||
const token = signal<string | null>(null);
|
||||
const updateUser = vi.fn();
|
||||
const meResponse = signal<Record<string, unknown>>({
|
||||
@@ -34,6 +36,10 @@ describe('App', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
@@ -73,4 +79,22 @@ describe('App', () => {
|
||||
});
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 0px and renders no banner in production', () => {
|
||||
environment.production = true;
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('0px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 28px and renders the banner outside production', () => {
|
||||
environment.production = false;
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('28px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,22 @@ import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { AuthApi } from './core/auth/auth-api';
|
||||
import { AuthStore } from './core/auth/auth-store';
|
||||
import { environment } from '../environments/environment';
|
||||
import { ENV_BANNER_HEIGHT_PX, EnvBanner } from './shared/env-banner/env-banner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, EnvBanner],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
'[style.--env-banner-height]': 'bannerHeight',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
protected readonly bannerHeight = `${environment.production ? 0 : ENV_BANNER_HEIGHT_PX}px`;
|
||||
|
||||
constructor() {
|
||||
if (this.authStore.token()) {
|
||||
|
||||
@@ -12,6 +12,46 @@
|
||||
} @else {
|
||||
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
|
||||
}
|
||||
|
||||
<span class="shell-header-spacer"></span>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="shell-notification-bell"
|
||||
[matMenuTriggerFor]="notificationMenu"
|
||||
(menuOpened)="onNotificationsMenuOpened()"
|
||||
[matBadge]="unreadCount()"
|
||||
[matBadgeHidden]="unreadCount() === 0"
|
||||
matBadgeSize="small"
|
||||
matBadgeColor="warn"
|
||||
aria-label="Benachrichtigungen"
|
||||
>
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
<mat-menu #notificationMenu="matMenu" class="shell-notification-menu">
|
||||
<div class="shell-notification-menu__header">
|
||||
<span>Benachrichtigungen</span>
|
||||
<button mat-button (click)="onMarkAllRead()">Alle als gelesen markieren</button>
|
||||
</div>
|
||||
@if (notifications().length === 0) {
|
||||
<div class="shell-notification-menu__empty">Keine Benachrichtigungen</div>
|
||||
} @else {
|
||||
@for (item of notifications(); track item.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
class="shell-notification-menu__item"
|
||||
[class.shell-notification-menu__item--unread]="!item.read"
|
||||
(click)="onNotificationClick(item)"
|
||||
>
|
||||
<mat-icon>{{ notificationIcon(item) }}</mat-icon>
|
||||
<span>{{ notificationLabel(item) }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (currentTeamId(); as teamId) {
|
||||
<a mat-menu-item [routerLink]="['/team', teamId, 'notifications']">Alle anzeigen</a>
|
||||
}
|
||||
}
|
||||
</mat-menu>
|
||||
</mat-toolbar>
|
||||
|
||||
<main class="shell-content">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
@@ -16,13 +18,26 @@
|
||||
|
||||
.shell-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.shell-bottom-nav {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
width: calc(100% - 48px);
|
||||
align-self: center;
|
||||
border-top-right-radius: 16px;
|
||||
border-top-left-radius: 12px;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
background: var(--mat-sys-surface);
|
||||
z-index: 2;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
@@ -40,3 +55,37 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shell-header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shell-notification-bell {
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.shell-notification-menu {
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 1rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&--unread {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { Shell } from './shell';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { AuthStore } from '../../auth/auth-store';
|
||||
import { Player } from '../../../models/player.model';
|
||||
import { NotificationsStore } from '../../notifications/notifications-store';
|
||||
|
||||
describe('Shell', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let authStore: AuthStore;
|
||||
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let notificationsStore: {
|
||||
unreadCount: ReturnType<typeof signal<number>>;
|
||||
notifications: ReturnType<typeof signal<any[]>>;
|
||||
startPolling: ReturnType<typeof vi.fn>;
|
||||
loadRecent: ReturnType<typeof vi.fn>;
|
||||
markRead: ReturnType<typeof vi.fn>;
|
||||
markAllRead: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
||||
notificationsStore = {
|
||||
unreadCount: signal(3),
|
||||
notifications: signal([
|
||||
{
|
||||
id: 1,
|
||||
event: 'player_creation',
|
||||
actorUserId: 9,
|
||||
payload: { playerId: 21, playerName: 'Ada Lovelace' },
|
||||
read: false,
|
||||
createdAt: '2026-08-04T10:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
startPolling: vi.fn(),
|
||||
loadRecent: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Shell],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: NotificationsStore, useValue: notificationsStore },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { paramMap: routeParams.asObservable() },
|
||||
@@ -153,4 +181,58 @@ describe('Shell', () => {
|
||||
|
||||
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
|
||||
});
|
||||
|
||||
it('starts polling notifications for the routed team id', () => {
|
||||
const fixture = TestBed.createComponent(Shell);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
|
||||
expect(notificationsStore.startPolling).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('exposes the unread count from the notifications store', () => {
|
||||
const fixture = TestBed.createComponent(Shell);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
|
||||
expect((fixture.componentInstance as any).unreadCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('loads recent notifications when the bell menu is opened', () => {
|
||||
const fixture = TestBed.createComponent(Shell);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
|
||||
(fixture.componentInstance as any).onNotificationsMenuOpened();
|
||||
|
||||
expect(notificationsStore.loadRecent).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('marks a clicked notification as read and navigates to its target', () => {
|
||||
const fixture = TestBed.createComponent(Shell);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
|
||||
|
||||
const item = notificationsStore.notifications()[0];
|
||||
(fixture.componentInstance as any).onNotificationClick(item);
|
||||
|
||||
expect(notificationsStore.markRead).toHaveBeenCalledWith(5, 1);
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
|
||||
});
|
||||
|
||||
it('marks all notifications as read', () => {
|
||||
const fixture = TestBed.createComponent(Shell);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
|
||||
(fixture.componentInstance as any).onMarkAllRead();
|
||||
|
||||
expect(notificationsStore.markAllRead).toHaveBeenCalledWith(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RouterLinkActive,
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { AuthStore } from '../../auth/auth-store';
|
||||
import { MyTeamsStore } from '../../team/my-teams-store';
|
||||
import { TeamStore } from '../../team/team-store';
|
||||
import { NotificationsStore } from '../../notifications/notifications-store';
|
||||
import {
|
||||
notificationIcon,
|
||||
notificationLabel,
|
||||
notificationTarget,
|
||||
} from '../../notifications/notification-presentation';
|
||||
import { UserTeamReference } from '../../../models/user-directory.model';
|
||||
import { NotificationItem } from '../../../models/notification.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatButtonModule,
|
||||
MatBadgeModule,
|
||||
],
|
||||
templateUrl: './shell.html',
|
||||
styleUrl: './shell.scss',
|
||||
@@ -36,8 +45,12 @@ export class Shell {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly myTeamsStore = inject(MyTeamsStore);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private readonly notificationsStore = inject(NotificationsStore);
|
||||
|
||||
protected readonly currentTeam = this.teamStore.team;
|
||||
protected readonly currentTeamId = signal<number | null>(null);
|
||||
protected readonly unreadCount = this.notificationsStore.unreadCount;
|
||||
protected readonly notifications = this.notificationsStore.notifications;
|
||||
|
||||
protected readonly myTeams = computed(() => {
|
||||
const seen = new Set<number>();
|
||||
@@ -57,17 +70,13 @@ export class Shell {
|
||||
this.myTeamsStore.ensureLoaded(userId);
|
||||
}
|
||||
|
||||
// A direct subscription (not `effect()` + `toSignal()`) so the initial
|
||||
// team load happens synchronously during construction, exactly like
|
||||
// `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
|
||||
// current value synchronously to a new subscriber. This keeps the
|
||||
// component's behavior deterministic and trivial to test: no signal
|
||||
// effect scheduling to wait for.
|
||||
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const raw = params.get('id');
|
||||
const id = raw === null ? Number.NaN : Number(raw);
|
||||
if (Number.isInteger(id) && id > 0) {
|
||||
this.teamStore.loadTeam(id);
|
||||
this.currentTeamId.set(id);
|
||||
this.notificationsStore.startPolling(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -75,4 +84,33 @@ export class Shell {
|
||||
protected switchTeam(teamId: number): void {
|
||||
void this.router.navigate(['/team', teamId, 'overview']);
|
||||
}
|
||||
|
||||
protected notificationLabel(item: NotificationItem): string {
|
||||
return notificationLabel(item);
|
||||
}
|
||||
|
||||
protected notificationIcon(item: NotificationItem): string {
|
||||
return notificationIcon(item.event);
|
||||
}
|
||||
|
||||
protected onNotificationsMenuOpened(): void {
|
||||
const teamId = this.currentTeamId();
|
||||
if (teamId !== null) {
|
||||
this.notificationsStore.loadRecent(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
protected onNotificationClick(item: NotificationItem): void {
|
||||
const teamId = this.currentTeamId();
|
||||
if (teamId === null) return;
|
||||
this.notificationsStore.markRead(teamId, item.id);
|
||||
void this.router.navigate(notificationTarget(item, teamId));
|
||||
}
|
||||
|
||||
protected onMarkAllRead(): void {
|
||||
const teamId = this.currentTeamId();
|
||||
if (teamId !== null) {
|
||||
this.notificationsStore.markAllRead(teamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { LogsApi } from './logs-api';
|
||||
|
||||
describe('LogsApi', () => {
|
||||
let api: LogsApi;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
api = TestBed.inject(LogsApi);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('loads logs with page and limit only when no filters are set', () => {
|
||||
api.loadLogs({ page: 2, limit: 50 }).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}admin/logs?page=2&limit=50`);
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({ data: [], page: 2, limit: 50, total: 0, hasNextPage: false });
|
||||
});
|
||||
|
||||
it('includes level, event, date-range and search filters when set', () => {
|
||||
api
|
||||
.loadLogs({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
level: 'ERROR',
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
from: '2026-01-01',
|
||||
to: '2026-01-31',
|
||||
search: 'teamId=5',
|
||||
})
|
||||
.subscribe();
|
||||
const request = httpMock.expectOne(
|
||||
`${environment.apiUrl}admin/logs?level=ERROR&event=cashbox_export_subscription_run_fail&from=2026-01-01&to=2026-01-31&search=teamId=5&page=1&limit=50`,
|
||||
);
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({ data: [], page: 1, limit: 50, total: 0, hasNextPage: false });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user