Compare commits

...

34 Commits

Author SHA1 Message Date
Bastian Wagner
caae4d955d fix: compensate remaining min-height:100dvh pages for the env banner
login, register, forgot-password, reset-password, confirm-email, users,
and logs all commit to at least one full viewport tall via
min-height:100dvh. Without compensation, the env banner's 28px pushes
their content past body's fixed one-viewport box, creating a phantom
scrollbar in the dev environment on exactly the routes where confusing
dev with prod matters most.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:10:13 +02:00
Bastian Wagner
1738a109a7 fix: commit missing .page wrapper the height-compensation CSS depends on 2026-08-05 12:53:12 +02:00
Bastian Wagner
91808a63f5 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.
2026-08-05 12:40:22 +02:00
Bastian Wagner
c441140872 fix: restore pre-existing App auth tests dropped in EnvBanner wiring 2026-08-05 12:34:41 +02:00
Bastian Wagner
fc0c1ed522 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.
2026-08-05 12:30:50 +02:00
Bastian Wagner
95a667cb98 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.
2026-08-05 12:26:46 +02:00
Bastian Wagner
77ed71cfb6 docs: add implementation plan for the environment indicator banner 2026-08-05 12:23:15 +02:00
Bastian Wagner
ed365db283 docs: add design spec for environment indicator banner
Captures the brainstormed design for a dev-environment banner shown
app-wide, plus the height-compensation needed so it doesn't reintroduce
the double-scrollbar class of bug just fixed in Shell/public pages.
2026-08-05 12:20:02 +02:00
Bastian Wagner
bd421954c5 skeleton loading 2026-08-05 11:25:36 +02:00
Bastian Wagner
8405f797d7 fix public 2026-08-05 09:10:48 +02:00
Bastian Wagner
7bfb3d07fc notifications 2026-08-04 21:08:06 +02:00
Bastian Wagner
35e6c055c0 Merge branch 'worktree-notification-center' 2026-08-04 20:51:32 +02:00
Bastian Wagner
fe523bdce1 feat: add full notifications history page and route 2026-08-04 20:26:06 +02:00
Bastian Wagner
b40af02e2f feat: add notification bell and dropdown to the app shell 2026-08-04 20:09:07 +02:00
Bastian Wagner
8ace676abf feat: add NotificationsStore 2026-08-04 20:01:30 +02:00
Bastian Wagner
6b3a9d69cc feat: add notification model, presentation helpers, and API client 2026-08-04 19:52:52 +02:00
Bastian Wagner
a7b087050c feat: add notification retention scheduler 2026-08-04 19:41:44 +02:00
Bastian Wagner
eb1173c706 feat: emit notification event on invite-link creation 2026-08-04 19:33:39 +02:00
Bastian Wagner
812061fc6c feat: emit notification event on player creation 2026-08-04 19:27:45 +02:00
Bastian Wagner
017e6445fa feat: log and emit notification events on public-access enable/rotate 2026-08-04 19:21:01 +02:00
Bastian Wagner
d6733eff0d feat: emit notification events on player active/role changes 2026-08-04 19:13:42 +02:00
Bastian Wagner
639ca651d8 feat: add NotificationsController 2026-08-04 19:05:40 +02:00
Bastian Wagner
8de4c11e24 refactor: import TeamsModule instead of duplicating TeamAccessService 2026-08-04 19:01:02 +02:00
Bastian Wagner
451b5c4e42 feat: add notification domain events, listener, and module 2026-08-04 18:53:48 +02:00
Bastian Wagner
05f4c2ddf0 chore: add and register @nestjs/event-emitter 2026-08-04 18:46:14 +02:00
Bastian Wagner
273c25eccb test: assert recipient-filter query clauses in NotificationsService.create 2026-08-04 18:42:03 +02:00
Bastian Wagner
97b0a5c19a feat: add NotificationsService 2026-08-04 18:34:42 +02:00
Bastian Wagner
7410672630 feat: add notification data model and migration 2026-08-04 18:26:50 +02:00
Bastian Wagner
ecfa847d2a docs: add notification center implementation plan
Detailed task-by-task TDD plan for the notification center feature,
derived from the approved design spec.
2026-08-04 18:18:20 +02:00
Bastian Wagner
6bda24ec9f docs: add notification center design spec
Design for a team-scoped notification center (bell icon, dropdown,
full history page) covering player/role/share-link/invite-link
events, decoupled via @nestjs/event-emitter from a central
notifications module.
2026-08-04 18:18:05 +02:00
Bastian Wagner
df634e7601 fix: update stale assertion for unconditional start/finish logging
runDueRecurringTransactions() always logs a start/finish marker for
observability, even when nothing is due, but the "does nothing" test
still asserted logger.info was never called. Pre-existing baseline
failure, unrelated to the notification-center work about to start.
2026-08-04 18:10:40 +02:00
Bastian Wagner
1fe2892ca4 docs: add notification center design spec
Design for a team-scoped notification center (bell icon, dropdown,
full history page) covering player/role/share-link/invite-link
events, decoupled via @nestjs/event-emitter from a central
notifications module.
2026-08-04 17:10:46 +02:00
Bastian Wagner
020b390953 address code review: fix inclusive to-date filter, add retention error handling
- LoggingService.findLogs(): the `to` date filter compared a date-only
  string (e.g. from a date picker) against a timestamp column, which
  parses to midnight and silently excludes the entire last day. Widen
  it to end-of-day so the range is genuinely inclusive.
- LogRetentionScheduler.cleanupOldLogs(): wrap the delete in try/catch
  and log failures via logger.error, matching the existing convention
  in CashboxExportScheduler/RecurringTransactionsScheduler. Without
  this, a failed nightly cleanup would fail silently - exactly what
  this feature exists to prevent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:34:34 +02:00
Bastian Wagner
f1b4f7e5b4 feat: add admin log viewer, log retention cleanup, and manual job triggers
Global admins couldn't see the app's event log (no read endpoint or UI
existed for it) and had no way to clean up old entries or re-run a
scheduled job without touching the database or server directly.

Backend:
- LoggingService.findLogs() + admin-only LogsController (GET
  admin/logs) with level/event/date-range/search filtering and
  pagination, mirroring AdminUsersService.findPlayers().
- LogRetentionScheduler deletes log entries older than
  LOG_RETENTION_DAYS (default 365, via app.config.ts), following the
  existing @Cron scheduler pattern.
- Admin-only POST admin/run endpoints on CashboxExportController and
  RecurringTransactionsController that invoke the existing schedulers'
  public run methods on demand - both are safe to re-run since their
  "due" queries advance nextRunDate only after a successful run.

Frontend:
- New /logs page (global-admin gated, same pattern as /users): AG-Grid
  infinite-scroll table with level/event/date-range/search filters,
  plus buttons to trigger the two jobs now and see the result land in
  the grid immediately.
- LogsApi, and triggerRunNow() added to the existing CashboxExportApi
  and RecurringTransactionApi.
- Discoverability link from /users to /logs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:21:39 +02:00
106 changed files with 7667 additions and 43 deletions

File diff suppressed because it is too large Load Diff

View 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.

View 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.

View 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.

View File

@@ -4,6 +4,7 @@ APP_NAME="NestJS API"
API_PREFIX=api API_PREFIX=api
FRONTEND_DOMAIN=http://localhost:3000 FRONTEND_DOMAIN=http://localhost:3000
BACKEND_DOMAIN=http://localhost:3000 BACKEND_DOMAIN=http://localhost:3000
LOG_RETENTION_DAYS=365
DATABASE_TYPE=postgres DATABASE_TYPE=postgres
DATABASE_HOST=postgres DATABASE_HOST=postgres

View File

@@ -14,6 +14,7 @@
"@nestjs/common": "9.1.6", "@nestjs/common": "9.1.6",
"@nestjs/config": "2.2.0", "@nestjs/config": "2.2.0",
"@nestjs/core": "9.1.6", "@nestjs/core": "9.1.6",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "9.0.0", "@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0", "@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6", "@nestjs/platform-express": "9.1.6",
@@ -3304,6 +3305,19 @@
"uuid": "dist/bin/uuid" "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": { "node_modules/@nestjs/jwt": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
@@ -7379,6 +7393,12 @@
"node": ">= 0.6" "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": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -20166,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": { "@nestjs/jwt": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
@@ -23260,6 +23288,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" "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": { "events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",

View File

@@ -33,6 +33,7 @@
"@nestjs/common": "9.1.6", "@nestjs/common": "9.1.6",
"@nestjs/config": "2.2.0", "@nestjs/config": "2.2.0",
"@nestjs/core": "9.1.6", "@nestjs/core": "9.1.6",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "9.0.0", "@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0", "@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6", "@nestjs/platform-express": "9.1.6",

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import databaseConfig from './config/database.config'; import databaseConfig from './config/database.config';
@@ -25,10 +26,12 @@ import { TranslateModule } from './translate/translate.module';
import { PenaltyModule } from './penalty/penalty.module'; import { PenaltyModule } from './penalty/penalty.module';
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module'; import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
import { CashboxExportModule } from './cashbox-export/cashbox-export.module'; import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({ @Module({
imports: [ imports: [
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [databaseConfig, authConfig, appConfig, mailConfig], load: [databaseConfig, authConfig, appConfig, mailConfig],
@@ -61,6 +64,7 @@ import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
PenaltyModule, PenaltyModule,
RecurringTransactionsModule, RecurringTransactionsModule,
CashboxExportModule, CashboxExportModule,
NotificationsModule,
], ],
providers: [], providers: [],
}) })

View File

@@ -14,6 +14,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
let userRepository: any; let userRepository: any;
let service: AuthService; let service: AuthService;
let mailService: any; let mailService: any;
let eventEmitter: any;
beforeEach(() => { beforeEach(() => {
jwtService = { jwtService = {
@@ -44,6 +45,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
dataSource = { dataSource = {
transaction: jest.fn((work) => work(manager)), transaction: jest.fn((work) => work(manager)),
}; };
eventEmitter = { emit: jest.fn() };
service = new AuthService( service = new AuthService(
jwtService, jwtService,
usersService, usersService,
@@ -52,6 +54,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
logger, logger,
dataSource, dataSource,
{ assertAtLeast: jest.fn() } as any, { 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(); 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) { function user(statusId: StatusEnum) {
return { return {
id: 2, id: 2,

View File

@@ -6,6 +6,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { AuthEmailLoginDto } from './dto/auth-email-login.dto'; 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 { DataSource } from 'typeorm';
import { TeamAccessService } from 'src/teams/team-access.service'; import { TeamAccessService } from 'src/teams/team-access.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; 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() @Injectable()
export class AuthService { export class AuthService {
@@ -38,6 +41,7 @@ export class AuthService {
private logger: LoggingService, private logger: LoggingService,
private dataSource: DataSource, private dataSource: DataSource,
private teamAccess: TeamAccessService, private teamAccess: TeamAccessService,
private eventEmitter: EventEmitter2,
) {} ) {}
async validateLogin( async validateLogin(
@@ -323,6 +327,11 @@ export class AuthService {
userId: 0, userId: 0,
}); });
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.inviteLinkCreated,
new InviteLinkCreatedEvent(object.teamId, actorUserId, object.teamName),
);
return { token }; return { token };
} }

View File

@@ -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);
});
});

View File

@@ -2,8 +2,11 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpCode,
HttpStatus,
Param, Param,
ParseIntPipe, ParseIntPipe,
Post,
Put, Put,
Query, Query,
Request, Request,
@@ -13,8 +16,12 @@ import {
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger'; import { ApiBearerAuth } from '@nestjs/swagger';
import type { Response } from 'express'; 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 { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto'; import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
import { CashboxExportService } from './cashbox-export.service'; import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service'; import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
@@ -27,6 +34,7 @@ export class CashboxExportController {
constructor( constructor(
private readonly service: CashboxExportService, private readonly service: CashboxExportService,
private readonly subscriptionService: CashboxExportSubscriptionService, private readonly subscriptionService: CashboxExportSubscriptionService,
private readonly scheduler: CashboxExportScheduler,
) {} ) {}
@Get(':teamId') @Get(':teamId')
@@ -66,4 +74,12 @@ export class CashboxExportController {
) { ) {
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto); 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();
}
} }

View File

@@ -10,6 +10,7 @@ import * as request from 'supertest';
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum'; import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
import validationOptions from '../utils/validation-options'; import validationOptions from '../utils/validation-options';
import { CashboxExportController } from './cashbox-export.controller'; import { CashboxExportController } from './cashbox-export.controller';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
import { CashboxExportService } from './cashbox-export.service'; import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service'; import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
@@ -22,6 +23,7 @@ describe('cashbox export HTTP boundary', () => {
getSubscription: jest.fn(), getSubscription: jest.fn(),
upsertSubscription: jest.fn(), upsertSubscription: jest.fn(),
}; };
const scheduler = { runDueSubscriptions: jest.fn() };
beforeAll(async () => { beforeAll(async () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
@@ -29,6 +31,7 @@ describe('cashbox export HTTP boundary', () => {
providers: [ providers: [
{ provide: CashboxExportService, useValue: service }, { provide: CashboxExportService, useValue: service },
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService }, { provide: CashboxExportSubscriptionService, useValue: subscriptionService },
{ provide: CashboxExportScheduler, useValue: scheduler },
], ],
}) })
.overrideGuard(AuthGuard('jwt')) .overrideGuard(AuthGuard('jwt'))

View File

@@ -8,4 +8,5 @@ export default registerAs('app', () => ({
backendDomain: process.env.BACKEND_DOMAIN, backendDomain: process.env.BACKEND_DOMAIN,
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000, port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
apiPrefix: process.env.API_PREFIX || 'api', apiPrefix: process.env.API_PREFIX || 'api',
logRetentionDays: parseInt(process.env.LOG_RETENTION_DAYS, 10) || 365,
})); }));

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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,
});
}
}
}

View File

@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { LogEntry } from './entities/log-entry.entity'; import { LogEntry } from './entities/log-entry.entity';
import { LogRetentionScheduler } from './log-retention.scheduler';
import { LoggingService } from './logging.service'; import { LoggingService } from './logging.service';
import { LogsController } from './logs.controller';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([LogEntry])], imports: [TypeOrmModule.forFeature([LogEntry])],
providers: [LoggingService], controllers: [LogsController],
providers: [LoggingService, LogRetentionScheduler],
exports: [LoggingService], exports: [LoggingService],
}) })
export class LoggingModule {} export class LoggingModule {}

View File

@@ -23,3 +23,101 @@ describe('LoggingService', () => {
expect(defaultRepository.save).not.toHaveBeenCalled(); 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);
}
});

View File

@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm'; import { EntityManager, Repository } from 'typeorm';
import { CreateLogDTO } from './dto/create-log.dto'; import { CreateLogDTO } from './dto/create-log.dto';
import { LogEntry } from './entities/log-entry.entity'; import { LogEntry } from './entities/log-entry.entity';
import { LOGEVENT } from './model/logging-event.type'; import { LOGEVENT, LOGLEVEL } from './model/logging-event.type';
@Injectable() @Injectable()
export class LoggingService { export class LoggingService {
@@ -95,4 +95,45 @@ export class LoggingService {
}; };
await this.repository.save(e); 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,
};
}
} }

View File

@@ -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([]);
});
});

View 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);
}
}

View File

@@ -36,6 +36,62 @@ export type LOGEVENT =
| 'cashbox_export_download' | 'cashbox_export_download'
| 'cashbox_export_subscription_update' | 'cashbox_export_subscription_update'
| 'cashbox_export_subscription_run' | 'cashbox_export_subscription_run'
| 'cashbox_export_subscription_run_fail'; | '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 type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
export const LOGLEVEL_VALUES: LOGLEVEL[] = ['FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];

View File

@@ -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"`);
}
}

View File

@@ -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"']);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
export class InviteLinkCreatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly teamName: string,
) {}
}

View File

@@ -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;

View File

@@ -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,
) {}
}

View File

@@ -0,0 +1,8 @@
export class PlayerCreatedEvent {
constructor(
public readonly teamId: number,
public readonly actorUserId: number,
public readonly playerId: number,
public readonly playerName: string,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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',
];

View File

@@ -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();
});
});

View File

@@ -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,
});
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
});
});

View File

@@ -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,
});
});
});

View File

@@ -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,
});
}
}
}

View File

@@ -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 {}

View File

@@ -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();
});
});
});

View 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,
};
}
}

View 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 { 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);
});
});

View File

@@ -14,8 +14,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger'; 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 { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto'; import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
import { RecurringTransactionsService } from './recurring-transactions.service'; import { RecurringTransactionsService } from './recurring-transactions.service';
type AuthenticatedRequest = { user: { id: number } }; type AuthenticatedRequest = { user: { id: number } };
@@ -24,7 +28,10 @@ type AuthenticatedRequest = { user: { id: number } };
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@Controller({ path: 'recurring-transactions', version: '1' }) @Controller({ path: 'recurring-transactions', version: '1' })
export class RecurringTransactionsController { export class RecurringTransactionsController {
constructor(private readonly service: RecurringTransactionsService) {} constructor(
private readonly service: RecurringTransactionsService,
private readonly scheduler: RecurringTransactionsScheduler,
) {}
@Get(':teamId') @Get(':teamId')
getTeamRecurringTransactions( getTeamRecurringTransactions(
@@ -59,4 +66,12 @@ export class RecurringTransactionsController {
): Promise<void> { ): Promise<void> {
await this.service.deleteRecurringTransaction(id, request.user.id); 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();
}
} }

View File

@@ -11,6 +11,7 @@ import validationOptions from '../utils/validation-options';
import { TransactionTypeEnum } from '../transactions/transaction-type.enum'; import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum'; import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransactionsController } from './recurring-transactions.controller'; import { RecurringTransactionsController } from './recurring-transactions.controller';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
import { RecurringTransactionsService } from './recurring-transactions.service'; import { RecurringTransactionsService } from './recurring-transactions.service';
describe('recurring transactions HTTP boundary', () => { describe('recurring transactions HTTP boundary', () => {
@@ -31,12 +32,14 @@ describe('recurring transactions HTTP boundary', () => {
updateRecurringTransaction: jest.fn(() => entry), updateRecurringTransaction: jest.fn(() => entry),
deleteRecurringTransaction: jest.fn(), deleteRecurringTransaction: jest.fn(),
}; };
const scheduler = { runDueRecurringTransactions: jest.fn() };
beforeAll(async () => { beforeAll(async () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
controllers: [RecurringTransactionsController], controllers: [RecurringTransactionsController],
providers: [ providers: [
{ provide: RecurringTransactionsService, useValue: service }, { provide: RecurringTransactionsService, useValue: service },
{ provide: RecurringTransactionsScheduler, useValue: scheduler },
], ],
}) })
.overrideGuard(AuthGuard('jwt')) .overrideGuard(AuthGuard('jwt'))

View File

@@ -42,7 +42,12 @@ describe('RecurringTransactionsScheduler', () => {
await scheduler.runDueRecurringTransactions(); await scheduler.runDueRecurringTransactions();
expect(dataSource.transaction).not.toHaveBeenCalled(); expect(dataSource.transaction).not.toHaveBeenCalled();
expect(logger.info).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 () => { it('books a transaction for every active player and skips inactive ones', async () => {

View File

@@ -13,6 +13,8 @@ describe('PublicTeamAccessService', () => {
const penaltyRepository = { find: jest.fn() }; const penaltyRepository = { find: jest.fn() };
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() }; const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
let service: PublicTeamAccessService; let service: PublicTeamAccessService;
let logger: any;
let eventEmitter: any;
const managedTeam = { const managedTeam = {
id: 7, id: 7,
@@ -25,12 +27,16 @@ describe('PublicTeamAccessService', () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
teamRepository.save.mockImplementation(async (team) => team); teamRepository.save.mockImplementation(async (team) => team);
logger = { info: jest.fn() };
eventEmitter = { emit: jest.fn() };
service = new PublicTeamAccessService( service = new PublicTeamAccessService(
teamRepository as any, teamRepository as any,
playerRepository as any, playerRepository as any,
transactionRepository as any, transactionRepository as any,
penaltyRepository as any, penaltyRepository as any,
access 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)); 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 () => { it('returns only whitelisted public team fields and active players', async () => {
teamRepository.findOne.mockResolvedValue({ teamRepository.findOne.mockResolvedValue({
id: 7, id: 7,

View File

@@ -1,6 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { randomBytes } from 'crypto'; 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 { PenaltyEntity } from '../penalty/entities/penalty.entity';
import { Player } from '../players/entities/player.entity'; import { Player } from '../players/entities/player.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { TeamRolesEnum } from '../team-roles/team-roles.enum';
@@ -28,6 +35,8 @@ export class PublicTeamAccessService {
@InjectRepository(PenaltyEntity) @InjectRepository(PenaltyEntity)
private readonly penaltyRepository: Repository<PenaltyEntity>, private readonly penaltyRepository: Repository<PenaltyEntity>,
private readonly access: TeamAccessService, private readonly access: TeamAccessService,
private readonly logger: LoggingService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
async getStatus( async getStatus(
@@ -55,6 +64,19 @@ export class PublicTeamAccessService {
} }
team.publicAccessEnabled = enabled; team.publicAccessEnabled = enabled;
await this.teamRepository.save(team); 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); return this.toStatus(team);
} }
@@ -68,6 +90,17 @@ export class PublicTeamAccessService {
const team = await this.loadManagedTeam(teamId); const team = await this.loadManagedTeam(teamId);
team.publicAccessToken = this.createToken(); team.publicAccessToken = this.createToken();
await this.teamRepository.save(team); 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); return this.toStatus(team);
} }

View File

@@ -18,6 +18,7 @@ describe('TeamMembersService', () => {
let dataSource: any; let dataSource: any;
let logger: any; let logger: any;
let access: any; let access: any;
let eventEmitter: any;
let service: TeamMembersService; let service: TeamMembersService;
beforeEach(() => { beforeEach(() => {
@@ -44,7 +45,8 @@ describe('TeamMembersService', () => {
dataSource = { transaction: jest.fn((work) => work(manager)) }; dataSource = { transaction: jest.fn((work) => work(manager)) };
logger = { info: jest.fn() }; logger = { info: jest.fn() };
access = { assertAtLeast: jest.fn(() => Promise.resolve()) }; 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 () => { it('checks the team-manager permission before touching the database', async () => {
@@ -188,6 +190,53 @@ describe('TeamMembersService', () => {
).rejects.toBeInstanceOf(NotFoundException); ).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( function makePlayer(
id: number, id: number,
active: boolean, active: boolean,

View File

@@ -1,6 +1,10 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, EntityManager, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { LoggingService } from '../database/logging/logging.service'; 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 { Player } from '../players/entities/player.entity';
import { TeamRole } from '../team-roles/entities/team-roles.entity'; import { TeamRole } from '../team-roles/entities/team-roles.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { TeamRolesEnum } from '../team-roles/team-roles.enum';
@@ -18,6 +22,7 @@ export class TeamMembersService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly logger: LoggingService, private readonly logger: LoggingService,
private readonly access: TeamAccessService, private readonly access: TeamAccessService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
async setActive( async setActive(
@@ -33,12 +38,12 @@ export class TeamMembersService {
TeamRolesEnum.captain, TeamRolesEnum.captain,
); );
return this.dataSource.transaction(async (manager) => { const result = await this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player); const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId); 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; const isDeactivation = player.active && !active;
if ( if (
@@ -66,8 +71,23 @@ export class TeamMembersService {
actorUserId, actorUserId,
`teamId=${teamId} playerId=${playerId} active=${active}`, `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( async setTeamRole(
@@ -83,12 +103,12 @@ export class TeamMembersService {
TeamRolesEnum.captain, TeamRolesEnum.captain,
); );
return this.dataSource.transaction(async (manager) => { const result = await this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player); const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId); 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 = const isDemotionFromTreasurer =
player.active && player.active &&
@@ -108,8 +128,23 @@ export class TeamMembersService {
actorUserId, actorUserId,
`teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`, `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, // insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction,

View File

@@ -31,6 +31,7 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
access as any, access as any,
{} as any, {} as any,
{} as any, {} as any,
{ emit: jest.fn() } as any,
); );
}); });
@@ -290,6 +291,7 @@ describe('TeamsService#getTeamTransactionsJournal', () => {
access as any, access as any,
{} as any, {} as any,
{} as any, {} as any,
{ emit: jest.fn() } as any,
); );
}); });
@@ -410,6 +412,7 @@ describe('TeamsService#createNewTeam', () => {
{} as any, {} as any,
{} as any, {} as any,
dataSource as any, dataSource as any,
{ emit: jest.fn() } as any,
); );
}); });
@@ -464,3 +467,50 @@ describe('TeamsService#createNewTeam', () => {
expect(logger.info).not.toHaveBeenCalled(); 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',
}),
);
});
});

View File

@@ -1,6 +1,9 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { LoggingService } from 'src/database/logging/logging.service'; 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 { Player } from 'src/players/entities/player.entity';
import { TeamRole } from 'src/team-roles/entities/team-roles.entity'; import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto'; import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
@@ -51,6 +54,7 @@ export class TeamsService {
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, private usersRepository: Repository<User>,
private dataSource: DataSource, private dataSource: DataSource,
private eventEmitter: EventEmitter2,
) {} ) {}
async getOverview(teamId: string, actorUserId: string) { async getOverview(teamId: string, actorUserId: string) {
@@ -165,6 +169,11 @@ export class TeamsService {
details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`, details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`,
userId: Number(id), userId: Number(id),
}); });
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.playerCreated,
new PlayerCreatedEvent(Number(id), Number(actorUserId), playerSaved.id, `${p.firstName} ${p.lastName}`),
);
return playerSaved; return playerSaved;
} }

View File

@@ -1 +1,2 @@
<app-env-banner />
<router-outlet /> <router-outlet />

View File

@@ -48,6 +48,11 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('./features/users/users').then((m) => m.Users), 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', path: 't/:token/:playerId',
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer), loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
@@ -118,6 +123,11 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/team/more/guide/guide').then((m) => m.Guide), import('./features/team/more/guide/guide').then((m) => m.Guide),
}, },
{
path: 'notifications',
loadComponent: () =>
import('./features/team/notifications/notifications').then((m) => m.Notifications),
},
], ],
}, },
{ {

View File

@@ -5,8 +5,10 @@ import { of } from 'rxjs';
import { App } from './app'; import { App } from './app';
import { AuthApi } from './core/auth/auth-api'; import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store'; import { AuthStore } from './core/auth/auth-store';
import { environment } from '../environments/environment';
describe('App', () => { describe('App', () => {
const originalProduction = environment.production;
const token = signal<string | null>(null); const token = signal<string | null>(null);
const updateUser = vi.fn(); const updateUser = vi.fn();
const meResponse = signal<Record<string, unknown>>({ const meResponse = signal<Record<string, unknown>>({
@@ -34,6 +36,10 @@ describe('App', () => {
}).compileComponents(); }).compileComponents();
}); });
afterEach(() => {
environment.production = originalProduction;
});
it('should create the app', () => { it('should create the app', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
expect(fixture.componentInstance).toBeTruthy(); expect(fixture.componentInstance).toBeTruthy();
@@ -73,4 +79,22 @@ describe('App', () => {
}); });
expect(updateUser).not.toHaveBeenCalled(); 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();
});
}); });

View File

@@ -2,16 +2,22 @@ import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { AuthApi } from './core/auth/auth-api'; import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store'; 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({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet, EnvBanner],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
host: {
'[style.--env-banner-height]': 'bannerHeight',
},
}) })
export class App { export class App {
private readonly authApi = inject(AuthApi); private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
protected readonly bannerHeight = `${environment.production ? 0 : ENV_BANNER_HEIGHT_PX}px`;
constructor() { constructor() {
if (this.authStore.token()) { if (this.authStore.token()) {

View File

@@ -12,6 +12,46 @@
} @else { } @else {
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span> <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> </mat-toolbar>
<main class="shell-content"> <main class="shell-content">

View File

@@ -1,7 +1,8 @@
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100dvh; height: calc(100dvh - var(--env-banner-height, 0px));
overflow: hidden;
position: relative; position: relative;
} }
@@ -17,6 +18,7 @@
.shell-content { .shell-content {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -53,3 +55,37 @@ main {
} }
} }
} }
.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;
}
}
}

View File

@@ -1,27 +1,55 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; 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 { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Shell } from './shell'; import { Shell } from './shell';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../auth/auth-store'; import { AuthStore } from '../../auth/auth-store';
import { Player } from '../../../models/player.model'; import { Player } from '../../../models/player.model';
import { NotificationsStore } from '../../notifications/notifications-store';
describe('Shell', () => { describe('Shell', () => {
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let authStore: AuthStore; let authStore: AuthStore;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>; 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 () => { beforeEach(async () => {
localStorage.clear(); localStorage.clear();
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' })); 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({ await TestBed.configureTestingModule({
imports: [Shell], imports: [Shell],
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
provideRouter([]), provideRouter([]),
{ provide: NotificationsStore, useValue: notificationsStore },
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { paramMap: routeParams.asObservable() }, useValue: { paramMap: routeParams.asObservable() },
@@ -153,4 +181,58 @@ describe('Shell', () => {
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0); 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);
});
}); });

View File

@@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
ActivatedRoute, ActivatedRoute,
@@ -7,6 +7,7 @@ import {
RouterLinkActive, RouterLinkActive,
RouterOutlet, RouterOutlet,
} from '@angular/router'; } from '@angular/router';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store'; import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store'; import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-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 { UserTeamReference } from '../../../models/user-directory.model';
import { NotificationItem } from '../../../models/notification.model';
@Component({ @Component({
selector: 'app-shell', selector: 'app-shell',
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
MatIconModule, MatIconModule,
MatMenuModule, MatMenuModule,
MatButtonModule, MatButtonModule,
MatBadgeModule,
], ],
templateUrl: './shell.html', templateUrl: './shell.html',
styleUrl: './shell.scss', styleUrl: './shell.scss',
@@ -36,8 +45,12 @@ export class Shell {
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore); private readonly myTeamsStore = inject(MyTeamsStore);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
private readonly notificationsStore = inject(NotificationsStore);
protected readonly currentTeam = this.teamStore.team; 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(() => { protected readonly myTeams = computed(() => {
const seen = new Set<number>(); const seen = new Set<number>();
@@ -57,17 +70,13 @@ export class Shell {
this.myTeamsStore.ensureLoaded(userId); 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) => { this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const raw = params.get('id'); const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw); const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0) { if (Number.isInteger(id) && id > 0) {
this.teamStore.loadTeam(id); this.teamStore.loadTeam(id);
this.currentTeamId.set(id);
this.notificationsStore.startPolling(id);
} }
}); });
} }
@@ -75,4 +84,33 @@ export class Shell {
protected switchTeam(teamId: number): void { protected switchTeam(teamId: number): void {
void this.router.navigate(['/team', teamId, 'overview']); 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);
}
}
} }

View File

@@ -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 });
});
});

View File

@@ -0,0 +1,26 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { LogPage, LogQuery } from '../../models/log.model';
@Injectable({ providedIn: 'root' })
export class LogsApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}admin/logs`;
loadLogs(query: LogQuery): Observable<LogPage> {
return this.http.get<LogPage>(this.baseUrl, { params: this.toParams(query) });
}
private toParams(query: LogQuery): HttpParams {
let params = new HttpParams();
if (query.level) params = params.set('level', query.level);
if (query.event) params = params.set('event', query.event);
if (query.from) params = params.set('from', query.from);
if (query.to) params = params.set('to', query.to);
if (query.search) params = params.set('search', query.search);
params = params.set('page', query.page).set('limit', query.limit);
return params;
}
}

View File

@@ -0,0 +1,75 @@
import { NotificationItem } from '../../models/notification.model';
import { notificationIcon, notificationLabel, notificationTarget } from './notification-presentation';
function item(overrides: Partial<NotificationItem>): NotificationItem {
return {
id: 1,
event: 'player_creation',
actorUserId: 9,
payload: {},
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
...overrides,
};
}
describe('notification-presentation', () => {
it('describes an active-state change', () => {
expect(
notificationLabel(item({ event: 'player_active_update', payload: { playerName: 'Ada Lovelace', active: false } })),
).toBe('Ada Lovelace wurde deaktiviert');
expect(
notificationLabel(item({ event: 'player_active_update', payload: { playerName: 'Ada Lovelace', active: true } })),
).toBe('Ada Lovelace wurde aktiviert');
});
it('describes a role change', () => {
expect(
notificationLabel(item({ event: 'player_team_role_update', payload: { playerName: 'Ada Lovelace' } })),
).toBe('Team-Rolle von Ada Lovelace wurde geändert');
});
it('describes a new player', () => {
expect(
notificationLabel(item({ event: 'player_creation', payload: { playerName: 'Ada Lovelace' } })),
).toBe('Ada Lovelace wurde zum Team hinzugefügt');
});
it('describes share-link events', () => {
expect(notificationLabel(item({ event: 'public_access_enabled' }))).toBe('Der Freigabelink wurde aktiviert');
expect(notificationLabel(item({ event: 'public_access_rotated' }))).toBe('Der Freigabelink wurde erneuert');
});
it('describes a new invite link', () => {
expect(notificationLabel(item({ event: 'user_invite_link_create' }))).toBe(
'Ein neuer Einladungslink wurde erstellt',
);
});
it('maps each event to an icon', () => {
expect(notificationIcon('player_active_update')).toBe('person');
expect(notificationIcon('player_team_role_update')).toBe('badge');
expect(notificationIcon('player_creation')).toBe('person_add');
expect(notificationIcon('public_access_enabled')).toBe('link');
expect(notificationIcon('public_access_rotated')).toBe('link');
expect(notificationIcon('user_invite_link_create')).toBe('mail');
});
it('routes player-related notifications to the member detail page', () => {
expect(notificationTarget(item({ event: 'player_creation', payload: { playerId: 21 } }), 5)).toEqual([
'/team', 5, 'members', 21,
]);
});
it('routes share-link notifications to the public-access settings page', () => {
expect(notificationTarget(item({ event: 'public_access_rotated' }), 5)).toEqual([
'/team', 5, 'more', 'public-access',
]);
});
it('routes invite-link notifications to the invite page', () => {
expect(notificationTarget(item({ event: 'user_invite_link_create' }), 5)).toEqual([
'/team', 5, 'more', 'invite',
]);
});
});

View File

@@ -0,0 +1,50 @@
import { NotificationEvent, NotificationItem } from '../../models/notification.model';
export function notificationLabel(item: NotificationItem): string {
switch (item.event) {
case 'player_active_update':
return item.payload.active
? `${item.payload.playerName} wurde aktiviert`
: `${item.payload.playerName} wurde deaktiviert`;
case 'player_team_role_update':
return `Team-Rolle von ${item.payload.playerName} wurde geändert`;
case 'player_creation':
return `${item.payload.playerName} wurde zum Team hinzugefügt`;
case 'public_access_enabled':
return 'Der Freigabelink wurde aktiviert';
case 'public_access_rotated':
return 'Der Freigabelink wurde erneuert';
case 'user_invite_link_create':
return 'Ein neuer Einladungslink wurde erstellt';
}
}
export function notificationIcon(event: NotificationEvent): string {
switch (event) {
case 'player_active_update':
return 'person';
case 'player_team_role_update':
return 'badge';
case 'player_creation':
return 'person_add';
case 'public_access_enabled':
case 'public_access_rotated':
return 'link';
case 'user_invite_link_create':
return 'mail';
}
}
export function notificationTarget(item: NotificationItem, teamId: number): (string | number)[] {
switch (item.event) {
case 'player_active_update':
case 'player_team_role_update':
case 'player_creation':
return ['/team', teamId, 'members', item.payload.playerId ?? 0];
case 'public_access_enabled':
case 'public_access_rotated':
return ['/team', teamId, 'more', 'public-access'];
case 'user_invite_link_create':
return ['/team', teamId, 'more', 'invite'];
}
}

View File

@@ -0,0 +1,48 @@
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 { NotificationsApi } from './notifications-api';
describe('NotificationsApi', () => {
let api: NotificationsApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(NotificationsApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads a page of notifications for a team', () => {
api.loadNotifications(5, { page: 2, limit: 20 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications?page=2&limit=20`);
expect(request.request.method).toBe('GET');
request.flush({ data: [], page: 2, limit: 20, total: 0, hasNextPage: false });
});
it('loads the unread count for a team', () => {
api.loadUnreadCount(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/unread-count`);
expect(request.request.method).toBe('GET');
request.flush({ count: 0 });
});
it('marks a single notification as read', () => {
api.markRead(5, 7).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/7/read`);
expect(request.request.method).toBe('PATCH');
request.flush(null);
});
it('marks all notifications as read', () => {
api.markAllRead(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/notifications/read-all`);
expect(request.request.method).toBe('PATCH');
request.flush(null);
});
});

View File

@@ -0,0 +1,31 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { NotificationPage, NotificationQuery } from '../../models/notification.model';
@Injectable({ providedIn: 'root' })
export class NotificationsApi {
private readonly http = inject(HttpClient);
loadNotifications(teamId: number, query: NotificationQuery): Observable<NotificationPage> {
const params = new HttpParams().set('page', query.page).set('limit', query.limit);
return this.http.get<NotificationPage>(`${environment.apiUrl}teams/${teamId}/notifications`, {
params,
});
}
loadUnreadCount(teamId: number): Observable<{ count: number }> {
return this.http.get<{ count: number }>(
`${environment.apiUrl}teams/${teamId}/notifications/unread-count`,
);
}
markRead(teamId: number, id: number): Observable<void> {
return this.http.patch<void>(`${environment.apiUrl}teams/${teamId}/notifications/${id}/read`, {});
}
markAllRead(teamId: number): Observable<void> {
return this.http.patch<void>(`${environment.apiUrl}teams/${teamId}/notifications/read-all`, {});
}
}

View File

@@ -0,0 +1,102 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { NotificationsApi } from './notifications-api';
import { NotificationsStore } from './notifications-store';
describe('NotificationsStore', () => {
let api: {
loadUnreadCount: ReturnType<typeof vi.fn>;
loadNotifications: ReturnType<typeof vi.fn>;
markRead: ReturnType<typeof vi.fn>;
markAllRead: ReturnType<typeof vi.fn>;
};
let store: NotificationsStore;
beforeEach(() => {
api = {
loadUnreadCount: vi.fn().mockReturnValue(of({ count: 0 })),
loadNotifications: vi.fn().mockReturnValue(of({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false })),
markRead: vi.fn().mockReturnValue(of(undefined)),
markAllRead: vi.fn().mockReturnValue(of(undefined)),
};
TestBed.configureTestingModule({ providers: [{ provide: NotificationsApi, useValue: api }] });
store = TestBed.inject(NotificationsStore);
});
it('polls the unread count immediately when polling starts for a team', () => {
api.loadUnreadCount.mockReturnValue(of({ count: 4 }));
store.startPolling(10);
expect(api.loadUnreadCount).toHaveBeenCalledWith(10);
expect(store.unreadCount()).toBe(4);
});
it('does not start a second poll loop for the same team id', () => {
store.startPolling(10);
store.startPolling(10);
expect(api.loadUnreadCount).toHaveBeenCalledTimes(1);
});
it('switches polling to a newly routed team', () => {
store.startPolling(10);
api.loadUnreadCount.mockReturnValue(of({ count: 7 }));
store.startPolling(11);
expect(api.loadUnreadCount).toHaveBeenCalledWith(11);
expect(store.unreadCount()).toBe(7);
});
it('loads the recent notification list', () => {
const data = [
{
id: 1,
event: 'player_creation' as const,
actorUserId: 9,
payload: { playerId: 21, playerName: 'Ada Lovelace' },
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
},
];
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
store.loadRecent(10);
expect(api.loadNotifications).toHaveBeenCalledWith(10, { page: 1, limit: 20 });
expect(store.notifications()).toEqual(data);
});
it('marks a notification as read locally and decrements the unread count', () => {
api.loadUnreadCount.mockReturnValue(of({ count: 3 }));
store.startPolling(10);
const data = [
{ id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' },
];
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
store.loadRecent(10);
store.markRead(10, 1);
expect(api.markRead).toHaveBeenCalledWith(10, 1);
expect(store.notifications()[0].read).toBe(true);
expect(store.unreadCount()).toBe(2);
});
it('marks all notifications as read locally and zeroes the unread count', () => {
api.loadUnreadCount.mockReturnValue(of({ count: 5 }));
store.startPolling(10);
const data = [
{ id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' },
];
api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false }));
store.loadRecent(10);
store.markAllRead(10);
expect(api.markAllRead).toHaveBeenCalledWith(10);
expect(store.notifications()[0].read).toBe(true);
expect(store.unreadCount()).toBe(0);
});
});

View File

@@ -0,0 +1,71 @@
import { Injectable, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, interval } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import { NotificationItem } from '../../models/notification.model';
import { NotificationsApi } from './notifications-api';
const POLL_INTERVAL_MS = 30000;
const DROPDOWN_PAGE_SIZE = 20;
@Injectable({ providedIn: 'root' })
export class NotificationsStore {
private readonly api = inject(NotificationsApi);
private readonly unreadCountSignal = signal(0);
private readonly notificationsSignal = signal<NotificationItem[]>([]);
private readonly loadingSignal = signal(false);
private readonly pollingTeamId = signal<number | null>(null);
private readonly pollRequests = new Subject<number>();
readonly unreadCount = this.unreadCountSignal.asReadonly();
readonly notifications = this.notificationsSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
constructor() {
this.pollRequests
.pipe(
switchMap((teamId) =>
interval(POLL_INTERVAL_MS).pipe(
startWith(-1),
switchMap(() => this.api.loadUnreadCount(teamId)),
),
),
takeUntilDestroyed(),
)
.subscribe((result) => this.unreadCountSignal.set(result.count));
}
startPolling(teamId: number): void {
if (this.pollingTeamId() === teamId) return;
this.pollingTeamId.set(teamId);
this.pollRequests.next(teamId);
}
loadRecent(teamId: number): void {
this.loadingSignal.set(true);
this.api.loadNotifications(teamId, { page: 1, limit: DROPDOWN_PAGE_SIZE }).subscribe({
next: (page) => {
this.notificationsSignal.set(page.data);
this.loadingSignal.set(false);
},
error: () => this.loadingSignal.set(false),
});
}
markRead(teamId: number, id: number): void {
this.api.markRead(teamId, id).subscribe(() => {
this.notificationsSignal.update((items) =>
items.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
this.unreadCountSignal.update((count) => Math.max(0, count - 1));
});
}
markAllRead(teamId: number): void {
this.api.markAllRead(teamId).subscribe(() => {
this.notificationsSignal.update((items) => items.map((item) => ({ ...item, read: true })));
this.unreadCountSignal.set(0);
});
}
}

View File

@@ -43,4 +43,11 @@ describe('CashboxExportApi', () => {
expect(request.request.body).toEqual(update); expect(request.request.body).toEqual(update);
request.flush({ ...update, nextRunDate: '2026-09-01T00:00:00.000Z' }); request.flush({ ...update, nextRunDate: '2026-09-01T00:00:00.000Z' });
}); });
it('triggers the due-subscriptions run now', () => {
api.triggerRunNow().subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}cashbox-export/admin/run`);
expect(request.request.method).toBe('POST');
request.flush(null);
});
}); });

View File

@@ -33,4 +33,8 @@ export class CashboxExportApi {
): Observable<CashboxExportSubscription> { ): Observable<CashboxExportSubscription> {
return this.http.put<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`, dto); return this.http.put<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`, dto);
} }
triggerRunNow(): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/admin/run`, null);
}
} }

View File

@@ -62,4 +62,11 @@ describe('RecurringTransactionApi', () => {
expect(request.request.method).toBe('DELETE'); expect(request.request.method).toBe('DELETE');
request.flush(null); request.flush(null);
}); });
it('triggers the due-recurring-transactions run now', () => {
api.triggerRunNow().subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/admin/run`);
expect(request.request.method).toBe('POST');
request.flush(null);
});
}); });

View File

@@ -33,4 +33,8 @@ export class RecurringTransactionApi {
deleteRecurringTransaction(id: number): Observable<void> { deleteRecurringTransaction(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`); return this.http.delete<void>(`${this.baseUrl}/${id}`);
} }
triggerRunNow(): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/admin/run`, null);
}
} }

View File

@@ -1,5 +1,5 @@
.auth-page { .auth-page {
min-height: 100dvh; min-height: calc(100dvh - var(--env-banner-height, 0px));
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;

View File

@@ -1,5 +1,5 @@
.auth-page { .auth-page {
min-height: 100dvh; min-height: calc(100dvh - var(--env-banner-height, 0px));
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;

View File

@@ -2,7 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100dvh; min-height: calc(100dvh - var(--env-banner-height, 0px));
padding: 1rem; padding: 1rem;
} }

View File

@@ -1,5 +1,5 @@
.auth-page { .auth-page {
min-height: 100dvh; min-height: calc(100dvh - var(--env-banner-height, 0px));
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;

View File

@@ -1,5 +1,5 @@
.auth-page { .auth-page {
min-height: 100dvh; min-height: calc(100dvh - var(--env-banner-height, 0px));
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;

View File

@@ -0,0 +1,93 @@
<main class="logs-page">
<a mat-button routerLink="/" class="back-link"><mat-icon>arrow_back</mat-icon>Zurück</a>
<header class="page-header">
<p class="eyebrow">Administration</p>
<h1>Logs</h1>
<p>System- und Admin-Ereignisse im Überblick.</p>
</header>
@if (!isAdmin()) {
<div class="page-state">
<mat-icon>lock</mat-icon>
<strong>Kein Zugriff</strong>
<span>Diese Seite ist nur für Administratoren sichtbar.</span>
</div>
} @else {
<div class="admin-actions">
<button
mat-stroked-button
type="button"
[disabled]="cashboxRunning()"
(click)="triggerCashboxExportRun()"
>
Cashbox-Export jetzt ausführen
</button>
<button
mat-stroked-button
type="button"
[disabled]="recurringRunning()"
(click)="triggerRecurringTransactionsRun()"
>
Wiederkehrende Buchungen jetzt prüfen
</button>
</div>
<div class="filters">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Level</mat-label>
<mat-select [value]="levelFilter()" (selectionChange)="levelFilter.set($event.value); onFilterChange()">
@for (option of levelOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Event</mat-label>
<mat-select [value]="eventFilter()" (selectionChange)="eventFilter.set($event.value); onFilterChange()">
@for (option of eventOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Von</mat-label>
<input
matInput
type="date"
[value]="fromFilter()"
(change)="fromFilter.set($any($event.target).value); onFilterChange()"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Bis</mat-label>
<input
matInput
type="date"
[value]="toFilter()"
(change)="toFilter.set($any($event.target).value); onFilterChange()"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Suche in Details</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input matInput type="search" (input)="onSearchInput($any($event.target).value)" />
</mat-form-field>
</div>
<ag-grid-angular
class="logs-grid"
[theme]="gridTheme"
[columnDefs]="columnDefs"
[getRowId]="getRowId"
rowModelType="infinite"
[cacheBlockSize]="50"
[pagination]="true"
[paginationPageSize]="50"
(gridReady)="onGridReady($event)"
/>
}
</main>

View File

@@ -0,0 +1,90 @@
:host {
display: block;
min-height: calc(100dvh - var(--env-banner-height, 0px));
background: var(--mat-sys-surface);
}
.logs-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px 28px 40px;
}
.back-link {
margin-left: -12px;
}
.page-header {
margin: 20px 0 26px;
}
h1,
p {
margin-top: 0;
}
h1 {
margin-bottom: 8px;
font-size: clamp(2rem, 4vw, 3rem);
}
.page-header > p:last-child {
color: var(--mat-sys-on-surface-variant);
}
.eyebrow {
margin-bottom: 6px;
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-state {
min-height: 240px;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
color: var(--mat-sys-on-surface-variant);
text-align: center;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.filters mat-form-field {
min-width: 160px;
}
.logs-grid {
height: 640px;
width: 100%;
}
@media (max-width: 700px) {
.logs-page {
padding: 20px 16px 32px;
}
.admin-actions {
flex-direction: column;
}
.admin-actions button {
width: 100%;
}
}

View File

@@ -0,0 +1,151 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AuthStore } from '../../core/auth/auth-store';
import { CashboxExportApi } from '../../core/team/cashbox-export-api';
import { RecurringTransactionApi } from '../../core/team/recurring-transaction-api';
import { LogsApi } from '../../core/logs/logs-api';
import { Logs } from './logs';
describe('Logs', () => {
const entries = [
{
id: 1,
level: 'ERROR' as const,
event: 'cashbox_export_subscription_run_fail',
details: 'subscriptionId=1 teamId=5: smtp down',
userId: -1,
createdAt: '2026-08-04T04:00:00.000Z',
},
];
async function setup(isAdmin = true) {
const loadLogs = vi.fn(() => of({ data: entries, page: 1, limit: 50, total: 1, hasNextPage: false }));
const triggerCashboxRun = vi.fn(() => of(undefined));
const triggerRecurringRun = vi.fn(() => of(undefined));
const snackBarOpen = vi.fn();
await TestBed.configureTestingModule({
imports: [Logs],
providers: [
provideRouter([]),
{ provide: AuthStore, useValue: { isGlobalAdmin: signal(isAdmin) } },
{ provide: LogsApi, useValue: { loadLogs } },
{ provide: CashboxExportApi, useValue: { triggerRunNow: triggerCashboxRun } },
{ provide: RecurringTransactionApi, useValue: { triggerRunNow: triggerRecurringRun } },
{ provide: MatSnackBar, useValue: { open: snackBarOpen } },
],
}).compileComponents();
const fixture = TestBed.createComponent(Logs);
fixture.detectChanges();
return {
fixture,
component: fixture.componentInstance,
loadLogs,
triggerCashboxRun,
triggerRecurringRun,
snackBarOpen,
};
}
// AG Grid's real component initialization (layout/ResizeObserver setup) can
// run slower under the full suite's parallel load than in isolation, so this
// gets a longer timeout rather than the vitest default 5s.
it('shows the log grid and trigger buttons to a global admin', async () => {
const { fixture } = await setup(true);
expect(fixture.nativeElement.querySelector('ag-grid-angular')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Cashbox-Export jetzt ausführen');
expect(fixture.nativeElement.textContent).toContain('Wiederkehrende Buchungen jetzt prüfen');
}, 15000);
it('hides the grid and shows no access for a non-admin', async () => {
const { fixture } = await setup(false);
expect(fixture.nativeElement.querySelector('ag-grid-angular')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Kein Zugriff');
});
it('builds a logs datasource sorted newest first with page/limit only when no filters are set', async () => {
const { component, loadLogs } = await setup();
const successCallback = vi.fn();
const datasource = component['buildLogsDatasource']();
datasource.getRows({
startRow: 0,
endRow: 50,
sortModel: [],
filterModel: {},
successCallback,
failCallback: vi.fn(),
} as unknown as Parameters<typeof datasource.getRows>[0]);
expect(loadLogs).toHaveBeenCalledWith({ page: 1, limit: 50 });
expect(successCallback).toHaveBeenCalledWith(entries, 1);
});
it('applies level, event, date-range and search filters to the datasource query', async () => {
const { component, loadLogs } = await setup();
component['levelFilter'].set('ERROR');
component['eventFilter'].set('cashbox_export_subscription_run_fail');
component['fromFilter'].set('2026-01-01');
component['toFilter'].set('2026-01-31');
component['search'].set('teamId=5');
const datasource = component['buildLogsDatasource']();
datasource.getRows({
startRow: 50,
endRow: 100,
sortModel: [],
filterModel: {},
successCallback: vi.fn(),
failCallback: vi.fn(),
} as unknown as Parameters<typeof datasource.getRows>[0]);
expect(loadLogs).toHaveBeenCalledWith({
page: 2,
limit: 50,
level: 'ERROR',
event: 'cashbox_export_subscription_run_fail',
from: '2026-01-01',
to: '2026-01-31',
search: 'teamId=5',
});
});
it('triggers the cashbox export run and reloads the grid on success', async () => {
const { component, triggerCashboxRun, snackBarOpen } = await setup();
const reloadSpy = vi.spyOn(component as any, 'reloadLogs');
component['triggerCashboxExportRun']();
expect(triggerCashboxRun).toHaveBeenCalledTimes(1);
expect(snackBarOpen).toHaveBeenCalled();
expect(reloadSpy).toHaveBeenCalled();
});
it('shows an error message when the cashbox export trigger fails', async () => {
const { component, snackBarOpen } = await setup();
(component as any).cashboxExportApi.triggerRunNow = vi.fn(() =>
throwError(() => new Error('boom')),
);
component['triggerCashboxExportRun']();
expect(snackBarOpen).toHaveBeenCalled();
});
it('triggers the recurring-transactions run and reloads the grid on success', async () => {
const { component, triggerRecurringRun, snackBarOpen } = await setup();
const reloadSpy = vi.spyOn(component as any, 'reloadLogs');
component['triggerRecurringTransactionsRun']();
expect(triggerRecurringRun).toHaveBeenCalledTimes(1);
expect(snackBarOpen).toHaveBeenCalled();
expect(reloadSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,241 @@
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AgGridAngular } from 'ag-grid-angular';
import type {
ColDef,
GetRowIdParams,
GridApi,
GridReadyEvent,
IDatasource,
IGetRowsParams,
} from 'ag-grid-community';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { AuthStore } from '../../core/auth/auth-store';
import { CashboxExportApi } from '../../core/team/cashbox-export-api';
import { RecurringTransactionApi } from '../../core/team/recurring-transaction-api';
import { LogsApi } from '../../core/logs/logs-api';
import { LogEntry, LogLevel, LogQuery } from '../../models/log.model';
import '../../shared/ag-grid/ag-grid-modules';
import { teamwalletGridTheme } from '../../shared/ag-grid/ag-grid-theme';
const LOG_LEVEL_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: 'Alle Level' },
{ value: 'FATAL', label: 'FATAL' },
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' },
{ value: 'INFO', label: 'INFO' },
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'TRACE', label: 'TRACE' },
];
// Kept in sync manually with LOGEVENT_VALUES (myteamwallet_backend/src/database/logging/model/logging-event.type.ts),
// the same way transaction type labels are already duplicated on the frontend elsewhere in this app.
const LOG_EVENT_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: 'Alle Events' },
{ value: 'user_create', label: 'user_create' },
{ value: 'application_start', label: 'application_start' },
{ value: 'transaction_create', label: 'transaction_create' },
{ value: 'team_transaction_create', label: 'team_transaction_create' },
{ value: 'team_transaction_get', label: 'team_transaction_get' },
{ value: 'user_login_success', label: 'user_login_success' },
{ value: 'user_login_fail', label: 'user_login_fail' },
{ value: 'user_token_verification_success', label: 'user_token_verification_success' },
{ value: 'user_token_verification_fail', label: 'user_token_verification_fail' },
{ value: 'user_invite_link_create', label: 'user_invite_link_create' },
{ value: 'user_invite_link_validate', label: 'user_invite_link_validate' },
{ value: 'user_invite_link_validate_fail', label: 'user_invite_link_validate_fail' },
{ value: 'transaction_create_fail', label: 'transaction_create_fail' },
{ value: 'transaction_reverse', label: 'transaction_reverse' },
{ value: 'player_creation', label: 'player_creation' },
{ value: 'admin_user_profile_update', label: 'admin_user_profile_update' },
{ value: 'admin_user_role_update', label: 'admin_user_role_update' },
{ value: 'admin_user_status_update', label: 'admin_user_status_update' },
{ value: 'admin_player_assign', label: 'admin_player_assign' },
{ value: 'admin_player_unlink', label: 'admin_player_unlink' },
{ value: 'player_active_update', label: 'player_active_update' },
{ value: 'player_team_role_update', label: 'player_team_role_update' },
{ value: 'penalty_catalog_create', label: 'penalty_catalog_create' },
{ value: 'penalty_catalog_update', label: 'penalty_catalog_update' },
{ value: 'penalty_catalog_delete', label: 'penalty_catalog_delete' },
{ value: 'team_create', label: 'team_create' },
{ value: 'team_permissions_update', label: 'team_permissions_update' },
{ value: 'scheduled_recurring_transaction_check_start', label: 'scheduled_recurring_transaction_check_start' },
{
value: 'scheduled_recurring_transaction_check_finished',
label: 'scheduled_recurring_transaction_check_finished',
},
{ value: 'recurring_transaction_create', label: 'recurring_transaction_create' },
{ value: 'recurring_transaction_update', label: 'recurring_transaction_update' },
{ value: 'recurring_transaction_delete', label: 'recurring_transaction_delete' },
{ value: 'recurring_transaction_run', label: 'recurring_transaction_run' },
{ value: 'cashbox_export_download', label: 'cashbox_export_download' },
{ value: 'cashbox_export_subscription_update', label: 'cashbox_export_subscription_update' },
{ value: 'cashbox_export_subscription_run', label: 'cashbox_export_subscription_run' },
{ value: 'cashbox_export_subscription_run_fail', label: 'cashbox_export_subscription_run_fail' },
{ value: 'log_retention_cleanup_run', label: 'log_retention_cleanup_run' },
];
@Component({
selector: 'app-logs',
imports: [
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
AgGridAngular,
],
templateUrl: './logs.html',
styleUrl: './logs.scss',
})
export class Logs {
private readonly authStore = inject(AuthStore);
private readonly logsApi = inject(LogsApi);
private readonly cashboxExportApi = inject(CashboxExportApi);
private readonly recurringTransactionApi = inject(RecurringTransactionApi);
private readonly snackBar = inject(MatSnackBar);
protected readonly isAdmin = this.authStore.isGlobalAdmin;
protected readonly gridTheme = teamwalletGridTheme;
protected readonly levelOptions = LOG_LEVEL_OPTIONS;
protected readonly eventOptions = LOG_EVENT_OPTIONS;
protected readonly levelFilter = signal('');
protected readonly eventFilter = signal('');
protected readonly fromFilter = signal('');
protected readonly toFilter = signal('');
protected readonly search = signal('');
protected readonly cashboxRunning = signal(false);
protected readonly recurringRunning = signal(false);
private readonly searchInput$ = new Subject<string>();
private gridApi?: GridApi<LogEntry>;
protected readonly columnDefs: ColDef<LogEntry>[] = [
{
headerName: 'Zeitpunkt',
field: 'createdAt',
width: 170,
valueFormatter: (params) =>
params.value
? new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'medium' }).format(
new Date(params.value),
)
: '',
},
{
headerName: 'Level',
field: 'level',
width: 100,
cellClass: (params) => `log-level log-level--${(params.value ?? '').toLowerCase()}`,
},
{ headerName: 'Event', field: 'event', minWidth: 220, flex: 1 },
{
headerName: 'Wer',
field: 'userId',
width: 90,
valueFormatter: (params) => (params.value === -1 ? 'System' : `#${params.value}`),
},
{ headerName: 'Details', field: 'details', minWidth: 260, flex: 2 },
{
headerName: 'Dauer',
field: 'duration',
width: 90,
valueFormatter: (params) => (params.value != null ? `${params.value} ms` : ''),
},
];
protected readonly getRowId = (params: GetRowIdParams<LogEntry>) => String(params.data.id);
constructor() {
this.searchInput$.pipe(debounceTime(300), takeUntilDestroyed()).subscribe((value) => {
this.search.set(value);
this.reloadLogs();
});
}
protected onGridReady(event: GridReadyEvent<LogEntry>): void {
this.gridApi = event.api;
this.reloadLogs();
}
protected onFilterChange(): void {
this.reloadLogs();
}
protected onSearchInput(value: string): void {
this.searchInput$.next(value);
}
private reloadLogs(): void {
this.gridApi?.setGridOption('datasource', this.buildLogsDatasource());
}
private buildLogsDatasource(): IDatasource {
return {
getRows: (params: IGetRowsParams) => {
const limit = Math.max(1, params.endRow - params.startRow);
const page = Math.floor(params.startRow / limit) + 1;
const query: LogQuery = {
page,
limit,
...(this.levelFilter() ? { level: this.levelFilter() as LogLevel } : {}),
...(this.eventFilter() ? { event: this.eventFilter() } : {}),
...(this.fromFilter() ? { from: this.fromFilter() } : {}),
...(this.toFilter() ? { to: this.toFilter() } : {}),
...(this.search().trim() ? { search: this.search().trim() } : {}),
};
this.logsApi.loadLogs(query).subscribe({
next: (result) => params.successCallback(result.data, result.total),
error: () => params.failCallback(),
});
},
};
}
protected triggerCashboxExportRun(): void {
if (this.cashboxRunning()) return;
this.cashboxRunning.set(true);
this.cashboxExportApi.triggerRunNow().subscribe({
next: () => {
this.cashboxRunning.set(false);
this.snackBar.open('Cashbox-Export wurde ausgeführt.', undefined, { duration: 4000 });
this.reloadLogs();
},
error: () => {
this.cashboxRunning.set(false);
this.snackBar.open('Cashbox-Export konnte nicht ausgeführt werden.', undefined, { duration: 5000 });
},
});
}
protected triggerRecurringTransactionsRun(): void {
if (this.recurringRunning()) return;
this.recurringRunning.set(true);
this.recurringTransactionApi.triggerRunNow().subscribe({
next: () => {
this.recurringRunning.set(false);
this.snackBar.open('Wiederkehrende Buchungen wurden geprüft.', undefined, { duration: 4000 });
this.reloadLogs();
},
error: () => {
this.recurringRunning.set(false);
this.snackBar.open(
'Wiederkehrende Buchungen konnten nicht geprüft werden.',
undefined,
{ duration: 5000 },
);
},
});
}
}

View File

@@ -4,14 +4,25 @@
><a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a> ><a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
</header> </header>
<main> <main>
<div class="page">
<a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a> <a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a>
<header class="page-title"> <header class="page-title">
@if (loading()) {
<app-skeleton width="100px" height="12px" />
<app-skeleton width="45%" height="40px" />
<app-skeleton width="70%" height="14px" />
} @else {
<p class="eyebrow">Öffentliche Ansicht</p> <p class="eyebrow">Öffentliche Ansicht</p>
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1> <h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
<p>Die letzten Buchungen dieses Mitglieds.</p> <p>Die letzten Buchungen dieses Mitglieds.</p>
}
</header> </header>
@if (loading()) { @if (loading()) {
<div class="state"><mat-spinner diameter="40" /></div> <div role="status" aria-label="Verlauf wird geladen" class="transactions">
@for (row of skeletonRows; track row) {
<app-skeleton height="60px" radius="17px" />
}
</div>
} @else if (notFound()) { } @else if (notFound()) {
<div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div> <div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div>
} @else if (transactions().length === 0) { } @else if (transactions().length === 0) {
@@ -41,4 +52,5 @@
} }
</div> </div>
} }
</div>
</main> </main>

View File

@@ -1,9 +1,12 @@
:host { :host {
display: block; display: flex;
min-height: 100%; flex-direction: column;
height: calc(100dvh - var(--env-banner-height, 0px));
overflow: hidden;
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface)); // background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
} }
.public-header { .public-header {
flex-shrink: 0;
height: 64px; height: 64px;
padding: 0 max(20px, calc((100vw - 900px) / 2)); padding: 0 max(20px, calc((100vw - 900px) / 2));
display: flex; display: flex;
@@ -22,6 +25,11 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
main { main {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.page {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 28px 24px 64px; padding: 28px 24px 64px;
@@ -37,6 +45,9 @@ main {
.page-title p { .page-title p {
margin-top: 0; margin-top: 0;
} }
.page-title app-skeleton + app-skeleton {
margin-top: 10px;
}
.eyebrow { .eyebrow {
color: var(--mat-sys-primary); color: var(--mat-sys-primary);
font-size: 0.75rem; font-size: 0.75rem;
@@ -83,7 +94,7 @@ main {
color: var(--mat-sys-on-surface-variant); color: var(--mat-sys-on-surface-variant);
} }
@media (max-width: 600px) { @media (max-width: 600px) {
main { .page {
padding: 22px 16px 48px; padding: 22px 16px 48px;
} }
} }

View File

@@ -5,10 +5,10 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api'; import { PublicTeamApi } from '../../core/team/public-team-api';
import { PlayerTransaction } from '../../models/transaction.model'; import { PlayerTransaction } from '../../models/transaction.model';
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model'; import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
import { Skeleton } from '../../shared/skeleton/skeleton';
import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount'; import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount';
registerLocaleData(localeDe); registerLocaleData(localeDe);
@@ -21,7 +21,7 @@ registerLocaleData(localeDe);
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatIconModule, MatIconModule,
MatProgressSpinnerModule, Skeleton,
TransactionAmount, TransactionAmount,
], ],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }], providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
@@ -36,6 +36,7 @@ export class PublicPlayer {
protected readonly transactions = signal<PlayerTransaction[]>([]); protected readonly transactions = signal<PlayerTransaction[]>([]);
protected readonly loading = signal(true); protected readonly loading = signal(true);
protected readonly notFound = signal(false); protected readonly notFound = signal(false);
protected readonly skeletonRows = [0, 1, 2, 3, 4];
constructor() { constructor() {
const playerId = Number(this.route.snapshot.paramMap.get('playerId')); const playerId = Number(this.route.snapshot.paramMap.get('playerId'));

View File

@@ -5,8 +5,39 @@
<a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a> <a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
</header> </header>
<main> <main>
<div class="page">
@if (loading()) { @if (loading()) {
<div class="state"><mat-spinner diameter="42" /><span>Team wird geladen …</span></div> <div role="status" aria-label="Team wird geladen">
<section class="hero">
<app-skeleton width="160px" height="12px" />
<app-skeleton width="45%" height="44px" />
<div class="balance-grid">
<app-skeleton height="94px" radius="20px" />
<app-skeleton height="94px" radius="20px" />
<app-skeleton height="94px" radius="20px" />
</div>
</section>
<section class="content-grid">
<div>
<app-skeleton width="90px" height="12px" />
<app-skeleton width="140px" height="26px" />
<div class="list-skeleton">
@for (row of skeletonMemberRows; track row) {
<app-skeleton height="54px" />
}
</div>
</div>
<aside>
<app-skeleton width="110px" height="12px" />
<app-skeleton width="160px" height="26px" />
<div class="list-skeleton">
@for (row of skeletonPenaltyRows; track row) {
<app-skeleton height="60px" radius="16px" />
}
</div>
</aside>
</section>
</div>
} @else if (notFound() || !team()) { } @else if (notFound() || !team()) {
<div class="state"> <div class="state">
<mat-icon>search_off</mat-icon> <mat-icon>search_off</mat-icon>
@@ -73,4 +104,5 @@
</aside> </aside>
</section> </section>
} }
</div>
</main> </main>

View File

@@ -1,9 +1,12 @@
:host { :host {
display: block; display: flex;
min-height: 100%; flex-direction: column;
height: calc(100dvh - var(--env-banner-height, 0px));
overflow: hidden;
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface)); // background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
} }
.public-header { .public-header {
flex-shrink: 0;
height: 64px; height: 64px;
padding: 0 max(20px, calc((100vw - 1180px) / 2)); padding: 0 max(20px, calc((100vw - 1180px) / 2));
display: flex; display: flex;
@@ -22,6 +25,11 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
main { main {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.page {
max-width: 1180px; max-width: 1180px;
margin: 0 auto; margin: 0 auto;
padding: 40px 24px 64px; padding: 40px 24px 64px;
@@ -39,6 +47,13 @@ main {
text-transform: uppercase; text-transform: uppercase;
margin: 0 0 6px; margin: 0 0 6px;
} }
.hero app-skeleton + app-skeleton {
margin: 8px 0 20px;
}
.content-grid > div > app-skeleton,
.content-grid > aside > app-skeleton {
margin-bottom: 8px;
}
.balance-grid { .balance-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -81,6 +96,10 @@ aside h2 {
.search { .search {
width: 100%; width: 100%;
} }
.list-skeleton {
display: grid;
gap: 10px;
}
.player-list { .player-list {
overflow: hidden; overflow: hidden;
border: 1px solid var(--mat-sys-outline-variant); border: 1px solid var(--mat-sys-outline-variant);
@@ -135,7 +154,7 @@ aside h2 {
.content-grid { .content-grid {
gap: 32px; gap: 32px;
} }
main { .page {
padding: 28px 16px 48px; padding: 28px 16px 48px;
} }
} }

View File

@@ -7,10 +7,10 @@ import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api'; import { PublicTeamApi } from '../../core/team/public-team-api';
import { Penalty } from '../../models/penalty.model'; import { Penalty } from '../../models/penalty.model';
import { PublicTeamOverview } from '../../models/public-access.model'; import { PublicTeamOverview } from '../../models/public-access.model';
import { Skeleton } from '../../shared/skeleton/skeleton';
registerLocaleData(localeDe); registerLocaleData(localeDe);
@@ -24,7 +24,7 @@ registerLocaleData(localeDe);
MatFormFieldModule, MatFormFieldModule,
MatIconModule, MatIconModule,
MatInputModule, MatInputModule,
MatProgressSpinnerModule, Skeleton,
], ],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }], providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './public-team.html', templateUrl: './public-team.html',
@@ -38,6 +38,8 @@ export class PublicTeam {
protected readonly loading = signal(true); protected readonly loading = signal(true);
protected readonly notFound = signal(false); protected readonly notFound = signal(false);
protected readonly search = signal(''); protected readonly search = signal('');
protected readonly skeletonMemberRows = [0, 1, 2, 3, 4];
protected readonly skeletonPenaltyRows = [0, 1, 2];
protected readonly players = computed(() => { protected readonly players = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de'); const query = this.search().trim().toLocaleLowerCase('de');
return (this.team()?.players ?? []) return (this.team()?.players ?? [])

View File

@@ -0,0 +1,29 @@
<div class="notifications-page">
<h1>Benachrichtigungen</h1>
@if (items().length === 0 && !loading()) {
<p class="notifications-page__empty">Keine Benachrichtigungen vorhanden.</p>
}
<mat-nav-list>
@for (item of items(); track item.id) {
<a
mat-list-item
class="notifications-page__item"
[class.notifications-page__item--unread]="!item.read"
(click)="onItemClick(item)"
>
<mat-icon matListItemIcon>{{ notificationIcon(item) }}</mat-icon>
<span matListItemTitle>{{ notificationLabel(item) }}</span>
</a>
}
</mat-nav-list>
@if (loading()) {
<mat-spinner diameter="32" class="notifications-page__spinner" />
}
@if (hasNextPage() && !loading()) {
<button mat-button (click)="loadMore()">Weitere laden</button>
}
</div>

View File

@@ -0,0 +1,15 @@
.notifications-page {
padding: 1rem;
&__empty {
color: var(--mat-sys-on-surface-variant);
}
&__item--unread {
font-weight: 600;
}
&__spinner {
margin: 1rem auto;
}
}

View File

@@ -0,0 +1,74 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { Notifications } from './notifications';
import { NotificationsApi } from '../../../core/notifications/notifications-api';
import { NotificationsStore } from '../../../core/notifications/notifications-store';
import { NotificationItem } from '../../../models/notification.model';
describe('Notifications', () => {
let routeParams: BehaviorSubject<ParamMap>;
let fixture: ComponentFixture<Notifications>;
let api: { loadNotifications: ReturnType<typeof vi.fn> };
let store: { markRead: ReturnType<typeof vi.fn> };
const item: NotificationItem = {
id: 1,
event: 'player_creation',
actorUserId: 9,
payload: { playerId: 21, playerName: 'Ada Lovelace' },
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
};
beforeEach(async () => {
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
api = { loadNotifications: vi.fn() };
store = { markRead: vi.fn() };
await TestBed.configureTestingModule({
imports: [Notifications],
providers: [
provideRouter([]),
{ provide: NotificationsApi, useValue: api },
{ provide: NotificationsStore, useValue: store },
{ provide: ActivatedRoute, useValue: { parent: { paramMap: routeParams } } },
],
}).compileComponents();
fixture = TestBed.createComponent(Notifications);
});
it('loads the first page for the routed team id', () => {
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
fixture.detectChanges();
expect(api.loadNotifications).toHaveBeenCalledWith(5, { page: 1, limit: 20 });
expect((fixture.componentInstance as any).items()).toEqual([item]);
});
it('loads the next page and appends results', () => {
api.loadNotifications
.mockReturnValueOnce(of({ data: [item], page: 1, limit: 20, total: 21, hasNextPage: true }))
.mockReturnValueOnce(of({ data: [{ ...item, id: 2 }], page: 2, limit: 20, total: 21, hasNextPage: false }));
fixture.detectChanges();
(fixture.componentInstance as any).loadMore();
expect(api.loadNotifications).toHaveBeenLastCalledWith(5, { page: 2, limit: 20 });
expect((fixture.componentInstance as any).items().length).toBe(2);
});
it('marks a clicked item as read and navigates to its target', () => {
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate');
(fixture.componentInstance as any).onItemClick(item);
expect(store.markRead).toHaveBeenCalledWith(5, 1);
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
});
});

View File

@@ -0,0 +1,92 @@
import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NotificationItem } from '../../../models/notification.model';
import { NotificationsApi } from '../../../core/notifications/notifications-api';
import { NotificationsStore } from '../../../core/notifications/notifications-store';
import {
notificationIcon,
notificationLabel,
notificationTarget,
} from '../../../core/notifications/notification-presentation';
const PAGE_SIZE = 20;
@Component({
selector: 'app-notifications',
imports: [MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
templateUrl: './notifications.html',
styleUrl: './notifications.scss',
})
export class Notifications implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(NotificationsApi);
private readonly notificationsStore = inject(NotificationsStore);
private readonly destroyRef = inject(DestroyRef);
protected readonly items = signal<NotificationItem[]>([]);
protected readonly loading = signal(false);
protected readonly hasNextPage = signal(false);
private teamId: number | null = null;
private page = 1;
ngOnInit(): void {
const parentRoute = this.route.parent;
if (!parentRoute) return;
parentRoute.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0 && id !== this.teamId) {
this.teamId = id;
this.page = 1;
this.items.set([]);
this.hasNextPage.set(false);
this.loadPage();
}
});
}
protected notificationLabel(item: NotificationItem): string {
return notificationLabel(item);
}
protected notificationIcon(item: NotificationItem): string {
return notificationIcon(item.event);
}
protected loadMore(): void {
this.page += 1;
this.loadPage();
}
protected onItemClick(item: NotificationItem): void {
if (this.teamId === null) return;
const teamId = this.teamId;
this.notificationsStore.markRead(teamId, item.id);
this.items.update((current) =>
current.map((entry) => (entry.id === item.id ? { ...entry, read: true } : entry)),
);
void this.router.navigate(notificationTarget(item, teamId));
}
private loadPage(): void {
if (this.teamId === null) return;
const teamId = this.teamId;
this.loading.set(true);
this.api.loadNotifications(teamId, { page: this.page, limit: PAGE_SIZE }).subscribe({
next: (result) => {
this.items.update((current) => [...current, ...result.data]);
this.hasNextPage.set(result.hasNextPage);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
}

View File

@@ -4,6 +4,9 @@
<p class="eyebrow">Organisation</p> <p class="eyebrow">Organisation</p>
<h1>Benutzer</h1> <h1>Benutzer</h1>
<p>Konten und sichtbare Teamzuordnungen im Überblick.</p> <p>Konten und sichtbare Teamzuordnungen im Überblick.</p>
@if (isAdmin()) {
<a mat-button routerLink="/logs"><mat-icon>receipt_long</mat-icon>Logs</a>
}
</header> </header>
<form class="directory-search" (submit)="submitSearch(); $event.preventDefault()" role="search"> <form class="directory-search" (submit)="submitSearch(); $event.preventDefault()" role="search">

View File

@@ -1,6 +1,6 @@
:host { :host {
display: block; display: block;
min-height: 100dvh; min-height: calc(100dvh - var(--env-banner-height, 0px));
background: var(--mat-sys-surface); background: var(--mat-sys-surface);
} }

View File

@@ -0,0 +1,29 @@
export type LogLevel = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
export interface LogEntry {
id: number;
level: LogLevel;
event: string;
details: string;
userId: number;
duration?: number;
createdAt: string;
}
export interface LogQuery {
page: number;
limit: number;
level?: LogLevel;
event?: string;
from?: string;
to?: string;
search?: string;
}
export interface LogPage {
data: LogEntry[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}

View File

@@ -0,0 +1,37 @@
export type NotificationEvent =
| 'player_active_update'
| 'player_team_role_update'
| 'player_creation'
| 'public_access_enabled'
| 'public_access_rotated'
| 'user_invite_link_create';
export interface NotificationPayload {
playerId?: number;
playerName?: string;
active?: boolean;
teamRoleId?: number;
teamName?: string;
}
export interface NotificationItem {
id: number;
event: NotificationEvent;
actorUserId: number;
payload: NotificationPayload;
read: boolean;
createdAt: string;
}
export interface NotificationQuery {
page: number;
limit: number;
}
export interface NotificationPage {
data: NotificationItem[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}

View File

@@ -0,0 +1,3 @@
@if (showBanner) {
<div class="env-banner" role="status">⚠ Entwicklungsumgebung</div>
}

View File

@@ -0,0 +1,14 @@
// 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;
}

View File

@@ -0,0 +1,31 @@
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();
});
});

Some files were not shown because too many files have changed in this diff Show More