Compare commits

...

91 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
Bastian Wagner
24c509c0d5 address code review: assert page count in multi-page test, document fixes
- Lock in the exact expected page count (4) for the 60+60-row pagination
  test, which previously only checked the buffer was non-trivial. This is
  the scenario most likely to expose a footer/pagination regression.
- Add short comments explaining the footerY height-bound fix and the
  pdfPageCount() regex's coupling to pdfkit's serialization format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:33:48 +02:00
Bastian Wagner
4185efb83a fix: negate expense amounts and stop blank trailing pages in cashbox PDF export
Expenses were stored as positive amounts (DB convention) and buildRows()
never negated them, so they were added to the running budget total
instead of subtracted. Negate expense amounts for team-wallet
transactions, mirroring the existing signedFlowAmount() convention in
teams.service.ts.

Separately, addFooters() placed footer text inside the reserved bottom
margin without an explicit height option, which made pdfkit's
LineWrapper treat every footer draw as overflowing the page and call
continueOnNewPage() twice per page - inflating page counts 3x with
blank trailing pages. Bounding the footer text to its own small height
box prevents pdfkit's automatic pagination from firing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:26:45 +02:00
Bastian Wagner
cd9d7b165f pdf export 2026-08-04 13:11:14 +02:00
Bastian Wagner
6fceee5a07 feat: wire receivables into manual and recurring cashbox PDF export
Both the manual download endpoint and the recurring email subscription
now pass buildReceivableRows() output into buildPdf, so every PDF
report includes the Forderungen section regardless of how it was
generated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 12:26:23 +02:00
Bastian Wagner
02a4d2e59d feat: redesign cashbox PDF report with styled tables and receivables section
The PDF export was an unformatted list of doc.text() lines and only
showed real cash movements (payment type). Rebuilds it as a proper
two-section report: a branded header band, a bordered/zebra-striped
table with colored amounts and bold running balance for cash
movements, and a second "Forderungen" section listing fine/levy/fee
entries created in the period with their own total. Tables paginate
across pages and every page gets a footer with page numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 12:20:33 +02:00
Bastian Wagner
7b499b361f feat: add buildReceivableRows for fine/levy/fee entries
Cashbox export previously only saw payment transactions. This adds
the query for fine/levy/fee entries (Forderungen) that the redesigned
PDF report will show in a separate section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 10:22:59 +02:00
Bastian Wagner
0bad154971 fix: remove invalid default on CashboxExportSubscription.recipients
MySQL/MariaDB reject a DEFAULT value on TEXT-backed columns (TypeORM's
simple-array maps to TEXT), so table creation failed with
"BLOB, TEXT, GEOMETRY or JSON column 'recipients' can't have a default
value". The service always assigns recipients before saving, so no
DB-level default was ever needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 09:56:39 +02:00
Bastian Wagner
da5998487a fix: address cashbox-export whole-branch review findings
- Reject dates that are not strict YYYY-MM-DD (was accepting full ISO
  datetimes, which silently produced empty exports instead of a 400)
  and reject from > to with a 400 before touching the team/DB.
- Emit the cashbox_export_download and cashbox_export_subscription_update
  audit log events that were declared but never fired, matching the
  audit trail every sibling feature already has.
- Restore full type checking on the pdfkit import via `import = require()`
  instead of an untyped require() with an eslint-disable.
- Tighten a cashbox.spec.ts assertion to check the exact dialog class
  instead of expect.anything(), so it can't pass with the wrong dialog
  wired to the Export button.
- Style and announce the export dialogs' error messages using this
  codebase's established error-message/role=alert pattern.
2026-08-04 09:20:44 +02:00
Bastian Wagner
ce0b500d7a fix: re-check canBook() inside export dialog methods (defense in depth)
openExportDialog()/openExportSubscriptionDialog() only guarded on teamId
truthiness, relying solely on the template @if for permission gating.
Every other permission-gated method in Cashbox (submitPlayerBooking,
reverseBooking) re-checks the permission internally too. Add the same
guard here, plus a test asserting direct invocation without booking
rights does not call dialog.open.
2026-08-04 08:59:49 +02:00
Bastian Wagner
1c214c5f06 feat: wire cashbox export and subscription dialogs into Cashbox page
Adds an Export button (opens CashboxExportDialog directly) plus a
secondary menu (opens CashboxExportSubscriptionDialog) to the journal
toolbar, gated by the existing canBook permission signal.
2026-08-04 08:53:05 +02:00
Bastian Wagner
148269da11 fix: add error handling to CashboxExportSubscriptionDialog 2026-08-04 08:39:10 +02:00
Bastian Wagner
db061a7ef7 feat: add CashboxExportSubscriptionDialog 2026-08-04 08:34:01 +02:00
Bastian Wagner
11b70b9337 feat: add error handling to CashboxExportDialog 2026-08-04 08:29:22 +02:00
Bastian Wagner
5c3ff52689 feat: add CashboxExportDialog 2026-08-04 08:24:57 +02:00
Bastian Wagner
a65c7b35b3 feat: add CashboxExportApi 2026-08-04 08:19:09 +02:00
Bastian Wagner
72fa1d4331 feat: add cashbox export model and FileDownloadService 2026-08-04 08:14:58 +02:00
Bastian Wagner
d628d5e4d7 fix: add LoggingModule import to CashboxExportModule 2026-08-04 08:10:04 +02:00
Bastian Wagner
57869d5fc1 feat: register CashboxExportModule 2026-08-04 08:03:29 +02:00
Bastian Wagner
eed8266da2 fix: add error handling for CashboxExportScheduler subscription processing
- Wrap runOne(subscription) in try/catch to ensure one subscription failure doesn't block remaining subscriptions
- Log failed subscriptions with new 'cashbox_export_subscription_run_fail' event
- Add new LOGEVENT type for subscription run failures
- Add test to verify second subscription processes even when first fails (continues processing independently)
- All 7 tests passing: 6 original + 1 new failure handling test

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 07:56:39 +02:00
Bastian Wagner
5779483b21 feat: add CashboxExportScheduler for recurring PDF mailing
- Implement CashboxExportScheduler with @Cron(EVERY_DAY_AT_4AM)
- Query due subscriptions (active=true, nextRunDate <= today)
- For each subscription: fetch team, build PDF, send email, advance nextRunDate
- Support monthly/quarterly/yearly intervals via INTERVAL_MONTHS map
- Add cashbox_export_subscription_run to LOGEVENT type for logging
- All 6 tests passing: empty state, monthly/quarterly/yearly periods, multiple subscriptions

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 07:49:58 +02:00
Bastian Wagner
196898b993 feat: add MailService.cashboxExport and email template 2026-08-04 07:44:12 +02:00
Bastian Wagner
42e2bbc4ff feat: add cashbox export subscription endpoints
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 07:39:20 +02:00
Bastian Wagner
a11eb73ce9 feat: add CashboxExportSubscription entity and service 2026-08-04 07:28:01 +02:00
Bastian Wagner
009aae1f2b feat: add cashbox export download endpoint 2026-08-03 21:37:14 +02:00
Bastian Wagner
18df224386 test: enforce permission check ordering in CashboxExportService 2026-08-03 21:33:36 +02:00
Bastian Wagner
396dc29cf5 feat: add CashboxExportService.exportForUser 2026-08-03 21:30:22 +02:00
Bastian Wagner
f8857f71e1 feat: add buildPdf for cashbox export
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:20:57 +02:00
Bastian Wagner
0b408f7d73 fix: use symmetric rounding for negative amounts in formatGermanAmount 2026-08-03 21:16:05 +02:00
Bastian Wagner
c6df6a6d38 fix: handle negative-zero and half-cent rounding in formatGermanAmount 2026-08-03 21:12:27 +02:00
Bastian Wagner
cab04c5869 feat: add buildCsv for cashbox export 2026-08-03 21:08:03 +02:00
Bastian Wagner
cf7c3efb0f fix: add null type guards to buildRows to prevent crashes on missing types 2026-08-03 21:02:52 +02:00
Bastian Wagner
a9df62a249 feat: add buildRows for cashbox export row filtering 2026-08-03 20:57:36 +02:00
Bastian Wagner
d8883d4687 chore: add pdfkit for cashbox PDF export 2026-08-03 20:51:48 +02:00
Bastian Wagner
15d5f1d4e4 docs: add implementation plan for cashbox export feature
Task-by-task TDD plan covering manual CSV/PDF export, the recurring
PDF-mailing subscription, and the frontend wiring, grounded in the
existing recurring-transactions module and mail service as precedent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:46:42 +02:00
Bastian Wagner
b8bcf329c5 docs: add design spec for cashbox export + recurring PDF mailing
Covers on-demand CSV/PDF export of real cash-affecting transactions
and an optional per-team recurring PDF mailing to arbitrary email
addresses, following the same brainstorming process used for
recurring transactions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:35:19 +02:00
Bastian Wagner
9e55c0549b feat: add recurring transactions (Wiederkehrende Buchungen)
Lets treasurers/captains/coaches define recurring fee/levy dues that
are automatically booked for all active players on a monthly,
quarterly, or yearly schedule via a daily cron job, instead of having
to book them manually every cycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:20:57 +02:00
Bastian Wagner
6531f2553f fix: wrap team creation in a transaction, add more-menu entry point, fix lint
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:27:23 +02:00
Bastian Wagner
b03198baa6 feat: wire create-team dialog into team-select 2026-08-03 16:19:05 +02:00
Bastian Wagner
bb6b045a2a feat: add CreateTeamDialog component 2026-08-03 16:04:31 +02:00
Bastian Wagner
a05ffd0c7f feat: add MyTeamsStore.refresh for forced reloads 2026-08-03 15:56:43 +02:00
Bastian Wagner
886e4e6941 feat: add TeamsApi.createTeam 2026-08-03 15:51:38 +02:00
Bastian Wagner
c1478f07f1 feat: make team creator a captain of the new team 2026-08-03 15:44:12 +02:00
Bastian Wagner
92eacbc5bb feat: allow any logged-in user to create a team 2026-08-03 15:38:43 +02:00
Bastian Wagner
09da67b8ef Fix Mobile Layout 2026-08-03 15:25:37 +02:00
Bastian Wagner
145ebb924a docs: add implementation plan for team creation via UI
Task-by-task plan covering the loosened create-team role guard,
auto-captain membership on creation, the new TeamsApi/MyTeamsStore
methods, the CreateTeamDialog component, and wiring it into
team-select.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 15:14:47 +02:00
Bastian Wagner
560fcfc11a docs: add design spec for team creation via UI
Brainstormed with the user: any logged-in user should be able to
self-service create a team and becomes its captain, via a dialog on
team-select. Team deletion/archiving is scoped out as a separate
follow-up feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 15:03:59 +02:00
Bastian Wagner
84bab04523 fix export type 2026-08-03 15:01:49 +02:00
Bastian Wagner
b21641f37a ag grid 2026-08-03 14:49:35 +02:00
Bastian Wagner
71d76725a4 euro symbol 2026-08-03 11:40:42 +02:00
Bastian Wagner
862a1bea8d Merge branch 'worktree-theoretischer-kassenstand' 2026-08-03 11:32:36 +02:00
Bastian Wagner
3cb8cd9a4a fix: strengthen deactivation-exclusion test and align sign rule with canonical logic
- teams.service.spec.ts: pick a checkpoint older than the adjustment's own
  month so the exclusion test actually fails without the exclusion filter
- teams.service.ts: only negate fine/levy/fee amounts when positive,
  matching TeamMembersService.recomputeBalance and Transaction.setBalance()
  exactly, instead of negating unconditionally
- teams.service.ts: outstanding-history helper now returns a positive
  value when players owe money, matching the house convention already
  established by getOverview()'s team.outstanding
- overview.spec.ts: assert the balance chart's legend becomes visible
- teams.service.spec.ts: add coverage for inactive-player exclusion and
  a positive-balance (prepaid credit) case

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 11:24:34 +02:00
Bastian Wagner
3d774a3455 overflow 2026-08-03 11:16:15 +02:00
Bastian Wagner
ebfaac7590 feat: show theoretical balance line in the cash-balance chart
Adds a second, dashed line to the existing balance-history chart that
includes currently open player dues, so managers can see at a glance
how far the actual cash balance lags behind what has been pledged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 11:05:11 +02:00
Bastian Wagner
d8f13d674d refactor: extract shared backward-reconstruction logic
Extract the month-by-month backward-walk algorithm (used for both
team-level cash balance and player-level debt history) into a single
shared private helper `reconstructBackward()`. This eliminates code
duplication while preserving behavior:

- Team-level balanceHistory: maps movements through signedFlowAmount(),
  rounds each point, calls the shared helper
- Player-level balance history: maps transactions using type.id rule,
  skips rounding (only rounds at final merge to avoid compounding
  errors), calls the shared helper

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 10:58:48 +02:00
Bastian Wagner
1b6ce57fbf feat: add theoretical balance history to team overview stats
Reconstructs each active player's balance per month (same backward
technique as the existing cash-balance history) so the overview stats
endpoint can report what the team balance would be if all currently
open dues had already been paid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 10:44:22 +02:00
Bastian Wagner
5f619d649c docs: add spec and plan for theoretical cash balance line
Recreated from the main checkout, where these were committed to local
master but not yet pushed and thus missing from this fresh worktree.
2026-08-03 10:31:53 +02:00
Bastian Wagner
fd850f372f docs: add design spec for theoretical cash balance line in overview chart
Adds a second historical line to the existing cash-balance chart showing
what the balance would be if all outstanding player dues were paid,
reconstructed per month the same way the existing balance line is.
2026-08-03 10:21:06 +02:00
188 changed files with 18789 additions and 219 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,882 @@
# Team erstellen über die UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Any logged-in user can create a new team via the Angular UI and automatically becomes its captain.
**Architecture:** Loosen the existing `POST /teams` backend endpoint from admin-only to any authenticated user, and extend `TeamsService.createNewTeam` to also create a `Player` membership row (role = captain) for the calling user. On the frontend, add a `createTeam` API method, a small `MatDialog` form component, and wire a persistent "Team erstellen" button into `team-select` that opens the dialog, refreshes the user's team list, and navigates into the new team.
**Tech Stack:** NestJS + TypeORM + Jest (backend, `myteamwallet_backend`); Angular 21 standalone components + Angular Material + signals + Vitest (frontend, `myteamwallet_frontend_modern`).
## Global Constraints
- Spec: `docs/superpowers/specs/2026-08-03-team-erstellen-design.md`.
- Creator's team role is always `captain` (`TeamRolesEnum.captain` = `3`, `myteamwallet_backend/src/team-roles/team-roles.enum.ts`) — no role choice in the UI.
- `CreateTeamDTO` stays `{ name: string }` — no other fields are added to the create-team form.
- Backend: any new constructor dependency on `TeamsService` must be appended as the **last** parameter — `teams.service.spec.ts` constructs `TeamsService` positionally in two places, and reordering breaks those mocks.
- Backend tests use Jest (`npm run test` from `myteamwallet_backend/`); frontend tests use Vitest via the Angular CLI (`npm test` from `myteamwallet_frontend_modern/`).
- All new user-facing copy is German, matching existing screens (e.g. "Team erstellen", "Teamname", "Erstellen", "Abbrechen").
- Out of scope (per spec): team deletion/archiving, custom alias entry, choosing the creator's role.
---
### Task 1: Loosen the `POST /teams` role guard to any logged-in user
**Files:**
- Modify: `myteamwallet_backend/src/teams/teams.controller.ts:247`
- Test: `myteamwallet_backend/src/teams/teams.controller.spec.ts` (new file)
**Interfaces:**
- Consumes: nothing new.
- Produces: nothing new — this task only changes an authorization decorator and adds a regression test for it. `TeamsController.create(req, teamDto)` keeps calling `this.service.createNewTeam(teamDto, userId)` unchanged.
- [ ] **Step 1: Write the failing test**
Create `myteamwallet_backend/src/teams/teams.controller.spec.ts`:
```ts
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import { TeamsController } from './teams.controller';
describe('TeamsController', () => {
const service = { createNewTeam: jest.fn() };
const publicAccess = {};
const teamMembers = {};
const teamPermissions = {};
const controller = new TeamsController(
service as any,
publicAccess as any,
teamMembers as any,
teamPermissions as any,
);
beforeEach(() => jest.clearAllMocks());
it('allows any logged-in user (not just admins) to create a team', () => {
expect(Reflect.getMetadata('roles', TeamsController.prototype.create)).toEqual([
RoleEnum.user,
RoleEnum.admin,
]);
expect(
Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.create),
).toContain(RolesGuard);
});
it('passes the authenticated user and the DTO to the service', () => {
const req = { user: { id: 42 } };
const dto = { name: '1. Herren' };
controller.create(req as any, dto as any);
expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (from `myteamwallet_backend/`): `npx jest teams.controller.spec.ts`
Expected: FAIL on the first test — `Reflect.getMetadata('roles', ...)` currently equals `[RoleEnum.admin]`, not `[RoleEnum.user, RoleEnum.admin]`.
- [ ] **Step 3: Loosen the guard**
In `myteamwallet_backend/src/teams/teams.controller.ts`, change line 247 from:
```ts
@Roles([RoleEnum.admin])
```
to:
```ts
@Roles([RoleEnum.user, RoleEnum.admin])
```
(This is the only `@Roles([RoleEnum.admin])` occurrence in the file — it decorates the `create()` handler at lines 241-254.)
- [ ] **Step 4: Run test to verify it passes**
Run: `npx jest teams.controller.spec.ts`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add myteamwallet_backend/src/teams/teams.controller.ts myteamwallet_backend/src/teams/teams.controller.spec.ts
git commit -m "feat: allow any logged-in user to create a team"
```
---
### Task 2: Make the creator a captain of the new team
**Files:**
- Modify: `myteamwallet_backend/src/teams/teams.service.ts`
- Modify: `myteamwallet_backend/src/teams/teams.service.spec.ts`
**Interfaces:**
- Consumes: `TeamRolesEnum.captain` (`myteamwallet_backend/src/team-roles/team-roles.enum.ts`, value `3`); `User` entity (`myteamwallet_backend/src/users/entities/user.entity.ts`, has `firstName: string | null`, `lastName: string | null`); `Player` entity (`myteamwallet_backend/src/players/entities/player.entity.ts`).
- Produces: `TeamsService.createNewTeam(teamdto: CreateTeamDTO, userId: string): Promise<Team>` now also creates a `Player` row linking the calling user to the new team with `teamRole.id === 3`. No signature change.
- [ ] **Step 1: Write the failing test**
Add to `myteamwallet_backend/src/teams/teams.service.spec.ts` (new `describe` block, e.g. after the existing two blocks):
```ts
describe('TeamsService#createNewTeam', () => {
const repository = { create: jest.fn(), save: jest.fn() };
const playerRepository = { create: jest.fn(), save: jest.fn() };
const rolesRepository = { findOneBy: jest.fn() };
const settingsRepository = { create: jest.fn(), save: jest.fn() };
const usersRepository = { findOneBy: jest.fn() };
const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
let service: TeamsService;
beforeEach(() => {
jest.resetAllMocks();
service = new TeamsService(
repository as any,
playerRepository as any,
{} as any,
rolesRepository as any,
settingsRepository as any,
{} as any,
logger as any,
{} as any,
usersRepository as any,
);
});
it('creates the team, its default settings, and a captain membership for the creator', async () => {
const savedTeam = { id: 7, name: '1. Herren' };
repository.create.mockReturnValue({ name: '1. Herren' });
repository.save.mockResolvedValue(savedTeam);
settingsRepository.create.mockImplementation((s: unknown) => s);
settingsRepository.save.mockResolvedValue([]);
usersRepository.findOneBy.mockResolvedValue({
id: 42,
firstName: 'Alex',
lastName: 'Muster',
});
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
playerRepository.create.mockImplementation((p: unknown) => p);
playerRepository.save.mockResolvedValue({});
const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');
expect(result).toBe(savedTeam);
expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
expect(playerRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
firstName: 'Alex',
lastName: 'Muster',
team: savedTeam,
teamRole: { id: 3, name: 'captain' },
}),
);
expect(playerRepository.save).toHaveBeenCalled();
});
});
```
Also update the **two existing** `new TeamsService(...)` constructions in this file (they currently pass 8 positional args; the constructor will have 9 after Step 3). Both blocks are byte-for-byte identical:
```ts
service = new TeamsService(
repository as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any,
);
```
Replace **both** occurrences (find-and-replace-all) with a 9th argument appended:
```ts
service = new TeamsService(
repository as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any,
{} as any,
);
```
- [ ] **Step 2: Run test to verify it fails**
Run (from `myteamwallet_backend/`): `npx jest teams.service.spec.ts`
Expected: FAIL — TypeScript compile error (`Expected 9 arguments, but got 8`) on the two pre-existing constructions until Step 1's replace-all is applied, and then a runtime FAIL on the new test (`playerRepository.create` not called) until Step 3 is done.
- [ ] **Step 3: Implement**
In `myteamwallet_backend/src/teams/teams.service.ts`, add two imports (near the existing entity imports):
```ts
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { User } from 'src/users/entities/user.entity';
```
Extend the constructor by appending a new injected repository as the **last** parameter (do not reorder the existing ones — `teams.service.spec.ts` relies on their positions):
```ts
@Injectable()
export class TeamsService {
constructor(
@InjectRepository(Team)
private repository: Repository<Team>,
@InjectRepository(Player)
private playerRepository: Repository<Player>,
@InjectRepository(Transaction)
private transactionsRepository: Repository<Transaction>,
@InjectRepository(TeamRole)
private rolesRepository: Repository<TeamRole>,
@InjectRepository(TeamSetting)
private settingsRepository: Repository<TeamSetting>,
@InjectRepository(TeamWalletTransaction)
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
private logger: LoggingService,
private access: TeamAccessService,
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
```
(`User` is already registered in `TeamsModule`'s `TypeOrmModule.forFeature([...])` — see `myteamwallet_backend/src/teams/teams.module.ts:31` — so no module change is needed.)
Replace `createNewTeam` and add a new private helper right after it:
```ts
async createNewTeam(teamdto: CreateTeamDTO, userId: string) {
const createTeam = this.repository.create(teamdto);
const team = await this.repository.save(createTeam);
await this.generateBasicTeamSettings(team);
await this.addCreatorAsCaptain(team, userId);
await this.logger.info({
event: 'team_create',
details: `created team id: ${team.id}`,
userId: Number(userId),
});
return team;
}
private async addCreatorAsCaptain(team: Team, userId: string): Promise<void> {
const [user, captainRole] = await Promise.all([
this.usersRepository.findOneBy({ id: Number(userId) }),
this.rolesRepository.findOneBy({ id: TeamRolesEnum.captain }),
]);
const player = this.playerRepository.create({
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
team,
teamRole: captainRole,
user: user ?? undefined,
});
await this.playerRepository.save(player);
}
```
(`generateBasicTeamSettings` stays exactly as-is, right below.)
- [ ] **Step 4: Run test to verify it passes**
Run: `npx jest teams.service.spec.ts`
Expected: PASS (all existing tests plus the new `createNewTeam` test).
- [ ] **Step 5: Commit**
```bash
git add myteamwallet_backend/src/teams/teams.service.ts myteamwallet_backend/src/teams/teams.service.spec.ts
git commit -m "feat: make team creator a captain of the new team"
```
---
### Task 3: `TeamsApi.createTeam` (frontend HTTP call)
**Files:**
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts`
**Interfaces:**
- Consumes: `Team` model (`myteamwallet_frontend_modern/src/app/models/team.model.ts`: `{ id, name, alias, balance, outstanding?, players?, settings? }`).
- Produces: `export interface CreateTeamRequest { name: string }` and `TeamsApi.createTeam(request: CreateTeamRequest): Observable<Team>` — used by Task 5's dialog component.
- [ ] **Step 1: Write the failing test**
Add to `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts` (alongside the existing per-method tests, following the same `httpMock.expectOne(...).flush(...)` pattern already in that file):
```ts
it('creates a team', () => {
const request = { name: '1. Herren' };
service.createTeam(request).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}teams`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(request);
req.flush({ id: 7, name: '1. Herren', alias: 'a', balance: 0 });
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (from `myteamwallet_frontend_modern/`): `npm test`
Expected: FAIL — `service.createTeam is not a function`.
- [ ] **Step 3: Implement**
In `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`, add the request interface next to `CreatePlayerRequest`:
```ts
export interface CreateTeamRequest {
name: string;
}
```
Add the method to the `TeamsApi` class (alongside `createPlayer`):
```ts
createTeam(request: CreateTeamRequest): Observable<Team> {
return this.http.post<Team>(`${environment.apiUrl}teams`, request);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add myteamwallet_frontend_modern/src/app/core/team/teams-api.ts myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts
git commit -m "feat: add TeamsApi.createTeam"
```
---
### Task 4: `MyTeamsStore.refresh` (force-reload after creating a team)
**Files:**
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts`
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`
**Interfaces:**
- Consumes: `TeamsApi.loadMyTeams(userId: number): Observable<UserTeamMembership[]>` (existing).
- Produces: `MyTeamsStore.refresh(userId: number): void` — always refetches, unlike `ensureLoaded` which is a no-op once loaded for that user. Used by Task 6.
- [ ] **Step 1: Write the failing test**
Add to `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`:
```ts
it('refresh always reloads, even for a user id already loaded', () => {
store.ensureLoaded(42);
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player]);
store.refresh(42);
expect(store.loading()).toBe(true);
const secondPlayer = { ...player, id: 2, team: { id: 6, name: 'Team B' } };
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player, secondPlayer]);
expect(store.loading()).toBe(false);
expect(store.players()).toEqual([player, secondPlayer]);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (from `myteamwallet_frontend_modern/`): `npm test`
Expected: FAIL — `store.refresh is not a function`.
- [ ] **Step 3: Implement**
Replace the body of `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts` with:
```ts
import { Injectable, inject, signal } from '@angular/core';
import { UserTeamMembership } from '../../models/user-directory.model';
import { TeamsApi } from './teams-api';
@Injectable({ providedIn: 'root' })
export class MyTeamsStore {
private readonly teamsApi = inject(TeamsApi);
private readonly playersSignal = signal<UserTeamMembership[]>([]);
private readonly loadingSignal = signal(false);
private readonly loadedForUserId = signal<number | null>(null);
readonly players = this.playersSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
ensureLoaded(userId: number): void {
if (this.loadedForUserId() === userId || this.loadingSignal()) {
return;
}
this.fetch(userId);
}
refresh(userId: number): void {
this.fetch(userId);
}
private fetch(userId: number): void {
this.loadingSignal.set(true);
this.teamsApi.loadMyTeams(userId).subscribe({
next: (players) => {
this.playersSignal.set(players);
this.loadedForUserId.set(userId);
this.loadingSignal.set(false);
},
error: () => {
this.loadingSignal.set(false);
},
});
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS (all existing `MyTeamsStore` tests plus the new one).
- [ ] **Step 5: Commit**
```bash
git add myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts
git commit -m "feat: add MyTeamsStore.refresh for forced reloads"
```
---
### Task 5: `CreateTeamDialog` component
**Files:**
- Create: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`
- Test: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`
**Interfaces:**
- Consumes: `TeamsApi.createTeam` (Task 3); `MatDialogRef<CreateTeamDialog, Team | undefined>` (Angular Material).
- Produces: `CreateTeamDialog` standalone component. Opening it via `MatDialog.open(CreateTeamDialog)` and subscribing to `afterClosed()` yields the created `Team` on success, or `undefined` if cancelled/dismissed. Used by Task 6.
This introduces a new pattern for this codebase: the only existing `MatDialogRef`/`MAT_DIALOG_DATA`-injecting component is `shared/confirm-dialog/confirm-dialog.ts` (a plain confirm/cancel dialog, no form). This component follows the same injection style but uses a `ReactiveFormsModule` form, matching how `members.ts`/`penalties.ts` build their (inline, non-dialog) create forms.
- [ ] **Step 1: Write the failing test**
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`:
```ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { MatDialogRef } from '@angular/material/dialog';
import { CreateTeamDialog } from './create-team-dialog';
import { environment } from '../../../../environments/environment';
describe('CreateTeamDialog', () => {
let dialogRef: { close: ReturnType<typeof vi.fn> };
let httpMock: HttpTestingController;
beforeEach(async () => {
dialogRef = { close: vi.fn() };
await TestBed.configureTestingModule({
imports: [CreateTeamDialog],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MatDialogRef, useValue: dialogRef },
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('keeps the submit button disabled until a team name is entered', () => {
const fixture = TestBed.createComponent(CreateTeamDialog);
fixture.detectChanges();
expect(fixture.componentInstance['form'].invalid).toBe(true);
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
expect(fixture.componentInstance['form'].invalid).toBe(false);
});
it('creates the team and closes the dialog with the created team', () => {
const fixture = TestBed.createComponent(CreateTeamDialog);
fixture.detectChanges();
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
fixture.componentInstance['submit']();
const request = httpMock.expectOne(`${environment.apiUrl}teams`);
expect(request.request.body).toEqual({ name: '1. Herren' });
request.flush({ id: 9, name: '1. Herren', alias: 'a', balance: 0 });
expect(dialogRef.close).toHaveBeenCalledWith({
id: 9,
name: '1. Herren',
alias: 'a',
balance: 0,
});
});
it('re-enables the form and keeps the dialog open when the request fails', () => {
const fixture = TestBed.createComponent(CreateTeamDialog);
fixture.detectChanges();
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
fixture.componentInstance['submit']();
httpMock
.expectOne(`${environment.apiUrl}teams`)
.flush('error', { status: 500, statusText: 'Server Error' });
expect(dialogRef.close).not.toHaveBeenCalled();
expect(fixture.componentInstance['saving']()).toBe(false);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (from `myteamwallet_frontend_modern/`): `npm test`
Expected: FAIL — `create-team-dialog` module not found.
- [ ] **Step 3: Implement**
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`:
```ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Team } from '../../../models/team.model';
import { TeamsApi } from '../../../core/team/teams-api';
@Component({
selector: 'app-create-team-dialog',
imports: [ReactiveFormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatInputModule],
template: `
<h2 mat-dialog-title>Team erstellen</h2>
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-dialog-content>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Teamname</mat-label>
<input matInput formControlName="name" />
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
Erstellen
</button>
</mat-dialog-actions>
</form>
`,
styles: [
`
.full-width {
width: 100%;
}
`,
],
})
export class CreateTeamDialog {
protected readonly dialogRef = inject(MatDialogRef<CreateTeamDialog, Team | undefined>);
private readonly formBuilder = inject(FormBuilder);
private readonly teamsApi = inject(TeamsApi);
protected readonly saving = signal(false);
protected readonly form = this.formBuilder.nonNullable.group({
name: ['', Validators.required],
});
protected submit(): void {
if (this.form.invalid || this.saving()) return;
this.saving.set(true);
this.teamsApi.createTeam(this.form.getRawValue()).subscribe({
next: (team) => {
this.saving.set(false);
this.dialogRef.close(team);
},
error: () => this.saving.set(false),
});
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS (3 new tests).
- [ ] **Step 5: Commit**
```bash
git add myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/
git commit -m "feat: add CreateTeamDialog component"
```
---
### Task 6: Wire "Team erstellen" into `team-select`
**Files:**
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts`
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html`
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`
**Interfaces:**
- Consumes: `CreateTeamDialog` (Task 5); `MyTeamsStore.refresh(userId)` (Task 4); `MatDialog` (Angular Material).
- Produces: nothing consumed by later tasks — this is the last task.
- [ ] **Step 1: Write the failing test**
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`. First, add these imports at the top of the file:
```ts
import { Subject } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { Team } from '../../models/team.model';
import { MyTeamsStore } from '../../core/team/my-teams-store';
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
```
Then replace the `describe('TeamSelect', ...)` setup (`let` declarations through the closing of `beforeEach`/`afterEach`) with:
```ts
describe('TeamSelect', () => {
let httpMock: HttpTestingController;
let router: Router;
let authStore: AuthStore;
let myTeamsStore: MyTeamsStore;
let dialogClosed: Subject<Team | undefined>;
let dialog: { open: ReturnType<typeof vi.fn> };
beforeEach(async () => {
localStorage.clear();
dialogClosed = new Subject<Team | undefined>();
dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
await TestBed.configureTestingModule({
imports: [TeamSelect],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: MatDialog, useValue: dialog },
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
authStore = TestBed.inject(AuthStore);
myTeamsStore = TestBed.inject(MyTeamsStore);
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
});
afterEach(() => {
httpMock.verify();
});
```
(The existing three `it(...)` blocks stay unchanged below this.) Then add two new tests at the end of the `describe` block, before its closing `});`:
```ts
it('opens the create-team dialog and navigates into the newly created team on success', async () => {
const navigateSpy = vi.spyOn(router, 'navigate');
const refreshSpy = vi.spyOn(myTeamsStore, 'refresh');
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
await fixture.whenStable();
fixture.detectChanges();
fixture.componentInstance['createTeam']();
expect(dialog.open).toHaveBeenCalledWith(CreateTeamDialog);
const created: Team = { id: 9, name: '1. Herren', alias: 'a', balance: 0 };
dialogClosed.next(created);
expect(refreshSpy).toHaveBeenCalledWith(42);
expect(navigateSpy).toHaveBeenCalledWith(['/team', 9, 'overview']);
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
});
it('does not navigate when the create-team dialog is dismissed without a team', async () => {
const navigateSpy = vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
await fixture.whenStable();
fixture.detectChanges();
fixture.componentInstance['createTeam']();
dialogClosed.next(undefined);
expect(navigateSpy).not.toHaveBeenCalled();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run (from `myteamwallet_frontend_modern/`): `npm test`
Expected: FAIL — `createTeam` does not exist on `TeamSelect`, and `MatDialog` is unused/component doesn't inject it yet.
- [ ] **Step 3: Implement**
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts` with:
```ts
import { Component, effect, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthStore } from '../../core/auth/auth-store';
import { MyTeamsStore } from '../../core/team/my-teams-store';
import { Team } from '../../models/team.model';
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
@Component({
selector: 'app-team-select',
imports: [RouterLink, MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
templateUrl: './team-select.html',
styleUrl: './team-select.scss',
})
export class TeamSelect {
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
protected readonly players = this.myTeamsStore.players;
protected readonly loading = this.myTeamsStore.loading;
constructor() {
const userId = this.authStore.currentUser()?.id;
if (userId) {
this.myTeamsStore.ensureLoaded(userId);
}
effect(() => {
const players = this.myTeamsStore.players();
if (!this.myTeamsStore.loading() && players.length === 1 && players[0].team) {
void this.router.navigate(['/team', players[0].team.id, 'overview'], {
replaceUrl: true,
});
}
});
}
protected createTeam(): void {
this.dialog
.open(CreateTeamDialog)
.afterClosed()
.subscribe((team: Team | undefined) => {
if (!team) return;
const userId = this.authStore.currentUser()?.id;
if (userId) {
this.myTeamsStore.refresh(userId);
}
void this.router.navigate(['/team', team.id, 'overview']);
});
}
}
```
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html` with:
```html
<div class="team-select-toolbar">
<button mat-stroked-button type="button" (click)="createTeam()">
<mat-icon>add</mat-icon>
Team erstellen
</button>
</div>
@if (loading()) {
<div class="team-select-loading">
<mat-spinner diameter="32" />
</div>
} @else if (players().length === 0) {
<div class="team-select-empty">
<p>Du bist noch keinem Team zugeordnet.</p>
</div>
} @else {
<div class="team-select-page">
<h1>Team auswählen</h1>
<mat-nav-list>
@for (player of players(); track player.id) {
<a mat-list-item [routerLink]="['/team', player.team.id, 'overview']">
<span matListItemTitle>{{ player.team.name }}</span>
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
</a>
}
</mat-nav-list>
</div>
}
```
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`:
```scss
.team-select-toolbar {
display: flex;
justify-content: flex-end;
padding: 1rem 1rem 0;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS (all `TeamSelect` tests, including the two new ones).
- [ ] **Step 5: Commit**
```bash
git add myteamwallet_frontend_modern/src/app/features/team-select/
git commit -m "feat: wire create-team dialog into team-select"
```
---
## Manual End-to-End Verification
After all tasks are complete:
1. Start the backend (`npm run start:dev` in `myteamwallet_backend/`) and frontend (`npm start` in `myteamwallet_frontend_modern/`).
2. Log in as a regular (non-admin) user.
3. Go to the team selection screen, click "Team erstellen", enter a name, submit.
4. Confirm: the dialog closes, the app navigates to `/team/<newId>/overview`, and the user appears under "Mitglieder" with role "Kapitän".
5. Go back to team selection (or log in as a user with 2+ teams) and confirm the "Team erstellen" button is visible even when teams already exist.
6. Optionally, call `POST /api/v1/teams` directly via Swagger as a non-admin user's JWT to confirm the 403 is gone.

View File

@@ -0,0 +1,431 @@
# Theoretischer Kassenstand Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eine zweite, gestrichelte Linie im bestehenden "Kassenstand-Verlauf"-Chart auf der Team-Übersicht zeigt pro Monat den theoretischen Kassenstand (Ist-Kassenstand + zu diesem Zeitpunkt offene, noch unbezahlte Beiträge).
**Architecture:** Backend (`teams.service.ts#getOverviewStats`) rekonstruiert zusätzlich zur bestehenden Team-Kassenstand-Historie eine Spieler-Saldo-Historie pro aktivem Spieler (gleiches Rückwärts-Reconstruction-Muster wie beim Team-Kassenstand, nur mit allen Buchungstypen statt nur `payment`), summiert sie pro Monat zu "offene Beiträge" und addiert das Ergebnis als `theoreticalBalance`-Feld in jeden bestehenden `balanceHistory`-Punkt. Frontend übernimmt das neue Feld unverändert strukturell als zweite Chart.js-Datenserie im bestehenden Liniendiagramm.
**Tech Stack:** NestJS/TypeORM (Backend, `myteamwallet_backend`), Angular 21 mit Chart.js (Frontend, `myteamwallet_frontend_modern`), Jest (Backend-Tests), Vitest (Frontend-Tests).
## Global Constraints
- Referenz-Spec: `docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md` (und Basis-Feature `docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`).
- `theoreticalBalance` wird als zusätzliches Feld in die bestehenden `balanceHistory`-Punkte eingebettet, kein separates Array.
- Historische Rekonstruktion nutzt die *heutige* Menge aktiver Spieler (kein historisches Mitgliedschafts-Tracking) — bewusste Näherung, identisch zur bestehenden `balanceHistory`-Logik.
- Buchungen mit Notiz-Präfix `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (aus `team-members.service.ts`) werden aus der Rekonstruktion ausgeschlossen.
- Vorzeichen-Regel für Spieler-Buchungen: `type.id > 10` (Strafe/Umlage/Gebühr) mindert den Saldo, alle anderen Typen erhöhen ihn — exakt wie in `TeamMembersService.recomputeBalance` und `Transaction.setBalance()`.
- Zweite Chart-Linie: gestrichelt (`borderDash: [6, 4]`), Farbe `#1d70b8`, Label „Theoretisch (inkl. offene Beiträge)". Bestehende Ist-Linie bleibt `#4f8f46`, durchgezogen.
- `balanceChartOptions.plugins.legend.display` wechselt von `false` auf sichtbar (`position: 'bottom'`, wie beim bestehenden Flow-Chart).
- Gating unverändert: hat das Team gar keine Kassenbewegung (`movements.length === 0`), bleibt `balanceHistory: []` (kein Chart, wie heute).
- Bestehende Felder `monthlyFlow` und `topOutstanding` bleiben strukturell unverändert.
---
### Task 1: Backend — `theoreticalBalance` in `getOverviewStats` berechnen
**Files:**
- Modify: `myteamwallet_backend/src/teams/teams.service.ts:1-15` (Import), `:231-334` (`getOverviewStats` + neue private Hilfsmethoden)
- Create: `myteamwallet_backend/src/teams/teams.service.spec.ts` (existiert noch nicht)
**Interfaces:**
- Consumes: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (exportiert aus `myteamwallet_backend/src/teams/team-members.service.ts`), `Transaction`-Entity (bereits importiert in `teams.service.ts`), `Player`-Entity (bereits importiert).
- Produces: `getOverviewStats(...)` liefert `balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]` — dieses Feld konsumiert Task 2 im Frontend (`BalanceHistoryPoint.theoreticalBalance`).
- [ ] **Step 1: Neue Testdatei mit fehlschlagenden Tests schreiben**
Erstelle `myteamwallet_backend/src/teams/teams.service.spec.ts`:
```ts
import { TeamsService } from './teams.service';
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
function monthKey(monthsAgo: number): string {
const now = new Date();
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function isoDate(monthsAgo: number, day: number): string {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
}
describe('TeamsService#getOverviewStats theoretical balance', () => {
const repository = { findOneOrFail: jest.fn() };
const access = { assertMember: jest.fn() };
let service: TeamsService;
beforeEach(() => {
jest.resetAllMocks();
access.assertMember.mockResolvedValue(undefined);
service = new TeamsService(
repository as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any,
);
});
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 100,
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: -30,
transactions: [
{
date: isoDate(2, 10),
amount: 30,
type: { id: 11, name: 'fine' },
note: 'Zu spät zum Training',
},
],
},
],
});
const result = await service.getOverviewStats(9, 42);
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
expect(beforeFine?.balance).toBe(100);
expect(beforeFine?.theoreticalBalance).toBe(100);
expect(now?.balance).toBe(100);
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
expect(now?.theoreticalBalance).toBe(130);
});
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 50,
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: -20,
transactions: [
{
date: isoDate(6, 5),
amount: 999,
type: { id: 1, name: 'credit' },
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
},
{
date: isoDate(1, 10),
amount: 20,
type: { id: 11, name: 'fine' },
note: 'Zu spät',
},
],
},
],
});
const result = await service.getOverviewStats(9, 42);
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
// Wäre die Ausgleichsbuchung (999) nicht ausgeschlossen, würde sie hier bereits
// durchschlagen (beforeFine liegt chronologisch nach ihrem Datum) — tut sie aber nicht.
expect(beforeFine?.theoreticalBalance).toBe(50);
expect(now?.theoreticalBalance).toBe(70);
});
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 0,
transactions: [],
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
});
const result = await service.getOverviewStats(9, 42);
expect(result.balanceHistory).toEqual([]);
});
});
```
- [ ] **Step 2: Tests ausführen und Fehlschlag bestätigen**
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
Expected: FAIL — `result.balanceHistory` Punkte haben noch kein `theoreticalBalance`-Feld (`undefined` statt der erwarteten Zahlen), erste zwei Tests schlagen fehl. Dritter Test (Leerfall) sollte bereits PASS sein (unverändertes Verhalten) — das bestätigt, dass der Testaufbau korrekt gegen die bisherige Implementierung läuft.
- [ ] **Step 3: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX`-Import ergänzen**
In `myteamwallet_backend/src/teams/teams.service.ts`, nach der bestehenden Import-Zeile für `TeamAccessService` (Zeile ~15) ergänzen:
```ts
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
```
- [ ] **Step 4: Rückgabetyp von `getOverviewStats` erweitern**
In `teams.service.ts`, die Signatur von `getOverviewStats` (aktuell Zeile 231-238) ändern:
```ts
async getOverviewStats(
teamId: string | number,
actorUserId: string | number,
): Promise<{
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
monthlyFlow: { month: string; income: number; expense: number }[];
topOutstanding: { playerId: number; playerName: string; balance: number }[];
}> {
```
- [ ] **Step 5: Berechnung von `theoreticalBalance` einfügen**
In `teams.service.ts`, direkt vor dem finalen `return { balanceHistory, monthlyFlow, topOutstanding };` am Ende von `getOverviewStats` (aktuell Zeile 333) einfügen:
```ts
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
...point,
theoreticalBalance: this.round(point.balance - outstandingHistory[index]),
}));
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
}
```
und die alte letzte Zeile ` return { balanceHistory, monthlyFlow, topOutstanding };` entfernen (sie wird durch den obigen Block ersetzt).
- [ ] **Step 6: Private Hilfsmethoden ergänzen**
In `teams.service.ts`, nach der bestehenden privaten Methode `signedFlowAmount` (aktuell Zeile 346-348) einfügen:
```ts
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
const activePlayers = players.filter((p) => p.active);
const totals = months.map(() => 0);
for (const player of activePlayers) {
const realTransactions = (player.transactions ?? []).filter(
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
);
const playerHistory = this.reconstructPlayerBalanceHistory(
months,
Number(player.balance),
realTransactions,
);
playerHistory.forEach((balance, index) => {
totals[index] += balance;
});
}
return totals;
}
private reconstructPlayerBalanceHistory(
months: string[],
currentBalance: number,
transactions: Transaction[],
): number[] {
const descendingMovements = transactions
.map((t) => ({
date: t.date,
amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount),
}))
.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0));
let futureSum = 0;
let movementIndex = 0;
return [...months]
.reverse()
.map((month) => {
while (
movementIndex < descendingMovements.length &&
descendingMovements[movementIndex].date.slice(0, 7) > month
) {
futureSum += descendingMovements[movementIndex].amount;
movementIndex++;
}
return currentBalance - futureSum;
})
.reverse();
}
```
`Transaction` und `Player` sind in `teams.service.ts` bereits importiert (Zeile 4 bzw. 10 im bestehenden Import-Block) — keine weiteren Imports nötig.
- [ ] **Step 7: Tests ausführen und Erfolg bestätigen**
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
Expected: PASS — alle drei Tests grün.
- [ ] **Step 8: Vollständige Backend-Suite und Build laufen lassen**
Run: `cd myteamwallet_backend && npm test -- --silent && npm run build`
Expected: alle bestehenden Tests weiterhin PASS (insbesondere keine Regression in anderen `teams`-Tests), Build ohne TypeScript-Fehler.
- [ ] **Step 9: Commit**
```bash
cd myteamwallet_backend
git add src/teams/teams.service.ts src/teams/teams.service.spec.ts
git commit -m "feat: add theoretical balance history to team overview stats
Reconstructs each active player's balance per month (same backward
technique as the existing cash-balance history) so the overview stats
endpoint can report what the team balance would be if all currently
open dues had already been paid."
```
---
### Task 2: Frontend — zweite Chart-Linie im Kassenstand-Verlauf
**Files:**
- Modify: `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`
**Interfaces:**
- Consumes: `theoreticalBalance` Feld aus Task 1 (`BalanceHistoryPoint.theoreticalBalance: number`, via `GET /teams/:id/overview/stats`).
- Produces: keine neuen öffentlichen Interfaces — reine Chart-Darstellungs-Änderung innerhalb `Overview`.
- [ ] **Step 1: Modell erweitern**
In `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`, `BalanceHistoryPoint` ändern:
```ts
export interface BalanceHistoryPoint {
month: string;
balance: number;
theoreticalBalance: number;
}
```
- [ ] **Step 2: Fehlschlagenden Test schreiben**
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`, `sampleStats` (aktuell Zeile 22-32) um `theoreticalBalance` ergänzen:
```ts
const sampleStats: TeamOverviewStats = {
balanceHistory: [
{ month: '2026-06', balance: 100, theoreticalBalance: 100 },
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
],
monthlyFlow: [
{ month: '2026-06', income: 50, expense: 10 },
{ month: '2026-07', income: 40, expense: 15 },
],
topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }],
};
```
Im Test `'passes the loaded stats to each ChartCanvas once the request resolves'` (aktuell Zeile 181-200), nach der bestehenden Assertion `expect(balanceChart.data.labels).toHaveLength(2);` ergänzen:
```ts
expect(balanceChart.data.datasets).toHaveLength(2);
expect(balanceChart.data.datasets[0].data).toEqual([100, 125]);
expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)');
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
```
- [ ] **Step 3: Test ausführen und Fehlschlag bestätigen**
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
Expected: FAIL — `balanceChart.data.datasets` hat noch Länge 1, die neuen Assertions schlagen fehl (bzw. TypeScript-Compile-Fehler, weil `sampleStats` noch nicht zum erweiterten `BalanceHistoryPoint`-Typ passt, falls Step 1 vor Step 2 gemacht wurde — in diesem Fall zunächst nur diesen Test isoliert betrachten).
- [ ] **Step 4: Zweite Datenserie und Legende in `overview.ts` ergänzen**
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`, die Farbkonstanten (aktuell Zeile 23-25) um die neue Farbe ergänzen:
```ts
const BALANCE_COLOR = '#4f8f46';
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
const INCOME_COLOR = '#4f8f46';
const EXPENSE_COLOR = '#c1121f';
```
`balanceChartData` (aktuell Zeile 65-80) um die zweite Datenserie erweitern:
```ts
protected readonly balanceChartData = computed<ChartData>(() => {
const points = this.balanceHistory();
return {
labels: points.map((point) => formatMonthLabel(point.month)),
datasets: [
{
label: 'Kassenstand',
data: points.map((point) => point.balance),
borderColor: BALANCE_COLOR,
backgroundColor: BALANCE_COLOR,
tension: 0.3,
fill: false,
},
{
label: 'Theoretisch (inkl. offene Beiträge)',
data: points.map((point) => point.theoreticalBalance),
borderColor: THEORETICAL_BALANCE_COLOR,
backgroundColor: THEORETICAL_BALANCE_COLOR,
borderDash: [6, 4],
tension: 0.3,
fill: false,
},
],
};
});
```
`balanceChartOptions` (aktuell Zeile 115-119) die Legende einblenden:
```ts
protected readonly balanceChartOptions: ChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } },
};
```
- [ ] **Step 5: Test ausführen und Erfolg bestätigen**
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
Expected: PASS — alle Tests in `overview.spec.ts` grün, inklusive der neuen Dataset-Assertions.
- [ ] **Step 6: Vollständige Frontend-Suite laufen lassen**
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false`
Expected: alle Tests PASS (keine Regression in anderen Specs durch die geänderte `BalanceHistoryPoint`-Typform).
- [ ] **Step 7: Commit**
```bash
cd myteamwallet_frontend_modern
git add src/app/models/team-stats.model.ts src/app/features/team/overview/overview.ts src/app/features/team/overview/overview.spec.ts
git commit -m "feat: show theoretical balance line in the cash-balance chart
Adds a second, dashed line to the existing balance-history chart that
includes currently open player dues, so managers can see at a glance
how far the actual cash balance lags behind what has been pledged."
```
---
## Self-Review Notes
- **Spec-Abdeckung:** Backend-Berechnung (Task 1, Steps 4-6), Response-Form-Änderung (Task 1, Step 4), Frontend-Chart/Legende/Farben (Task 2, Step 4), Testing-Anforderungen aus der Spec (historische Rekonstruktion, Ausgleichsbuchungs-Ausschluss, Leerfall, Frontend-Dataset-Assertions) sind je in eigenen Test-Steps abgedeckt. Manuelle Verifikation aus der Spec ist bewusst nicht als Plan-Task modelliert — bei Bedarf nach Abschluss beider Tasks manuell im Browser gegen ein Team mit unbezahlter Strafe prüfen.
- **Typkonsistenz:** `theoreticalBalance: number` konsistent in Backend-Rückgabetyp (Task 1, Step 4), Frontend-Modell (Task 2, Step 1) und allen Test-Fixtures verwendet.
- **Scope:** Einzelne, in sich geschlossene Erweiterung eines bereits bestehenden Features — keine weitere Zerlegung nötig.

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,185 @@
# Kassenbuch-Export (manuell + automatischer PDF-Versand)
Status: approved
Datum: 2026-08-03
## Kontext
TeamWallet bietet mit dem Kassenjournal (`teams/:id/transactions/journal`, Cashbox-Seite im
Frontend) bereits eine paginierte, filterbare Ansicht aller Buchungen. Es gibt aber keine
Möglichkeit, diese Daten für die Vereinsbuchhaltung oder eine Kassenprüfung zu exportieren — weder
manuell (CSV/PDF-Download) noch automatisiert (regelmäßiger Versand an Vorstand/Kassenprüfer). Beide
Fähigkeiten fehlen komplett (kein Export-Code, `mail`-Modul aktuell nur für Login/Passwort-Reset
genutzt).
Ziel: Ein Kassenwart/Captain/Coach kann (a) für einen frei wählbaren Zeitraum einen Kassenbuch-Export
als CSV und/oder PDF herunterladen, und (b) optional einen wiederkehrenden automatischen PDF-Versand
an beliebige E-Mail-Adressen einrichten (z.B. monatlich an den Vereinsvorstand).
Das Feature wurde im Brainstorming aus mehreren Optionen ausgewählt (Alternativen: Saldo-
Erinnerungen, server-seitige Journal-Filterung, Belege-Anhang, Vier-Augen-Prinzip — diese sind nicht
Teil dieses Plans). Ausdrücklich nicht Teil dieses oder eines zukünftigen Plans: Team
verlassen/löschen.
## Fachliche Einordnung
Ein "Kassenbuch" bildet nur **echte Kassenbewegungen** ab — Buchungen, die laut `setBalance()`
(`Transaction`- und `TeamWalletTransaction`-Entity) tatsächlich `team.balance` verändern:
- `Transaction` mit `type.id === 0` (`payment`) — Spieler zahlt echtes Geld ein.
- **Alle** `TeamWalletTransaction`-Einträge (`credit` und `expense`) — direkte Kassenbewegungen ohne
Spielerbezug.
Spieler-Fälligkeiten (`fee`/`levy`/`fine`, `type.id > 10`) verändern nur die Spielerschuld, nie den
Kassenbestand, und werden **bewusst ausgeschlossen** (entspricht der gewählten Option "Nur
Kassenjournal (Team-Saldo)").
Der Export zeigt einen **Periodensaldo** (laufende Summe ab 0, beginnend am gewählten Startdatum),
keinen historischen Kontostand — eine Rekonstruktion des absoluten Kontostands zu einem beliebigen
Vergangenheitszeitpunkt wäre für den ersten Wurf YAGNI.
## Entscheidungen aus dem Brainstorming
- **Format**: CSV und PDF, beide.
- **Zeitraum (manueller Export)**: frei wählbares Von/Bis-Datum.
- **Inhalt**: nur Kassenjournal (Team-Saldo), keine Spieler-Fälligkeiten.
- **Berechtigung**: wie Buchungen anlegen (`transaction_create_min_role`) — sowohl für den manuellen
Export als auch für die Konfiguration des automatischen Versands.
- **Automatischer Versand — Intervalle**: monatlich, quartalsweise, jährlich (identisch zu den
wiederkehrenden Buchungen).
- **Automatischer Versand — Zeitraum**: immer der jeweils **abgelaufene volle Zeitraum** (z.B. bei
monatlichem Versand am 1. des Monats immer genau der komplette Vormonat), nicht "seit letztem
Versand" (das wäre bei verpassten Läufen mehrdeutig).
- **Automatischer Versand — Umfang**: **eine** Konfiguration pro Team (eine Empfängerliste, ein
Intervall, pausierbar) statt mehrerer unabhängiger Abos.
- **Out of Scope**: Team verlassen/löschen (bereits an anderer Stelle ausgeschlossen).
## Architektur / Komponenten
### 1. Backend: neues Modul `cashbox-export/`
Struktur analog zu `recurring-transactions/` (eigenständiges Modul statt Erweiterung von
`teams.service.ts`, das bereits die Journal- und Statistik-Logik trägt).
**`cashbox-export.service.ts`**:
- `buildRows(team: Team, from: string, to: string): CashboxExportRow[]` — reine Funktion auf einer
bereits geladenen `Team`-Entity (inkl. `players.transactions.type`, `transactions.type`). Filtert
auf echte Kassenbewegungen (s.o.), grenzt auf `[from, to]` ein (Ende inklusiv, Tagesende), sortiert
chronologisch aufsteigend, berechnet laufenden `runningTotal`. Wird sowohl vom HTTP-Pfad als auch
vom Scheduler verwendet — keine Duplikation der Filterlogik.
- `getExportRowsForUser(teamId, userId, from, to)` — HTTP-Pfad: prüft `transaction_create_min_role`
via `TeamAccessService.assertAtLeast`, lädt das Team, ruft `buildRows` auf.
- `buildCsv(team, rows, from, to): string` — Semikolon-getrennt, deutsches Komma als
Dezimaltrennzeichen (Excel-DE-Standard), RFC4180-Escaping für Notizen mit Semikolon/Anführungszeichen/
Zeilenumbruch. Spalten: Datum, Typ, Wer (Spielername oder "Teamkasse"), Notiz, Betrag, Periodensaldo.
Bei leerem Zeitraum: nur Kopfzeile + Hinweiszeile "Keine Buchungen im gewählten Zeitraum".
- `buildPdf(team, rows, from, to): Buffer` — einfache Tabellen-PDF via neuer Abhängigkeit **`pdfkit`**
(kein Chromium/Puppeteer nötig): Kopf mit Teamname + Zeitraum + Erstellungsdatum, Tabelle, Fußzeile
mit Periodensaldo. Gleiche Leerzeitraum-Behandlung wie CSV.
**`cashbox-export.controller.ts`** (Pfad `cashbox-export`, `version: '1'`, nur `AuthGuard('jwt')`,
Berechtigung im Service):
- `GET cashbox-export/:teamId?from=&to=&format=csv|pdf` — liefert Datei über `@Res({passthrough:
false})` mit manuell gesetzten Headern (`Content-Type`, `Content-Disposition: attachment;
filename="kassenbuch_<teamAlias>_<from>_<to>.<ext>"`), kein globaler Response-Interceptor im
Projekt vorhanden, der das stören würde.
- `GET cashbox-export/:teamId/subscription` — aktuelle Versand-Konfiguration (oder Default:
`{ recipients: [], interval: 'monthly', active: false }`).
- `PUT cashbox-export/:teamId/subscription` — Upsert (Empfänger/Intervall/Aktiv-Status).
**Neue Entity `entities/cashbox-export-subscription.entity.ts`**: `id`, `team` (ManyToOne, in der
Praxis 1:1 durch Anwendungslogik im Service erzwungen — nur eine Subscription pro Team wird gepflegt/
aktualisiert statt neu angelegt), `recipients` (`simple-array`-Spalte, Liste von E-Mail-Strings),
`interval` (`RecurringTransactionIntervalEnum`, wiederverwendet aus dem `recurring-transactions`-
Modul — fachlich identisches Konzept), `active` (default `false`), `nextRunDate` (string, ISO-Datum),
`createdAt`.
**DTO `UpsertCashboxExportSubscriptionDto`**: `recipients: string[]` (`@IsEmail({}, {each:true})`),
`interval`, `active`. Validierung: `active === true` mit leerer `recipients`-Liste wird mit 400
abgelehnt (ergibt keinen Sinn, nichts zu versenden aber "aktiv").
**`cashbox-export.scheduler.ts`** (`@Cron`, zeitlich versetzt zum bestehenden
Recurring-Transactions-Job, z.B. `04:00 Uhr` statt `03:00 Uhr`, um DB-Last zu entzerren):
1. Lädt alle `active: true`-Subscriptions mit `nextRunDate <= heute` (inkl. `team`).
2. Pro fälliger Subscription: bestimmt den **abgelaufenen** Zeitraum passend zum `interval`
ausgehend von `nextRunDate` (z.B. `nextRunDate = 2026-09-01`, `interval = monthly` → Zeitraum
`2026-08-01``2026-08-31`), lädt das Team (inkl. Relationen), ruft `buildRows` + `buildPdf` auf
(Wiederverwendung derselben Logik wie der manuelle Export), verschickt das PDF per
`MailService`/`MailerService`-Attachment an alle `recipients` (neues Template
`mail-templates/cashbox-export.hbs`, analog Aufbau zu `reset-password.hbs`), rückt `nextRunDate`
um das Intervall vor (gleiche `setUTCMonth`-Arithmetik wie im Recurring-Transactions-Scheduler:
`+1`/`+3`/`+12` Monate) und speichert.
3. Ein verpasster Tag (Server-Downtime) wird beim nächsten Lauf automatisch nachgeholt (rein
datumsbasierter Check wie beim Recurring-Transactions-Scheduler).
**Registrierung**: `CashboxExportModule` in `src/app.module.ts` ergänzen (analog `PenaltyModule`/
`RecurringTransactionsModule`); `MailModule` importieren für den Versand.
**Logging-Events**: `cashbox_export_download`, `cashbox_export_subscription_update`,
`cashbox_export_subscription_run` in `logging-event.type.ts` ergänzen.
### 2. Frontend
**Cashbox-Toolbar**: neuer "Export"-Button (sichtbar nur mit `canDo(team(), 'transactionCreate')`,
kein neuer Permission-Key) öffnet einen Dialog mit Von/Bis-Datumsfeldern und Format-Auswahl
(CSV/PDF), löst über `CashboxExportApi.exportCashbox(teamId, from, to, format)`
(`responseType: 'blob'`) den Download aus. Ein kleiner `FileDownloadService.save(blob, filename)`
kapselt den Anchor-Click-Mechanismus, damit die Dialog-Komponente ohne echte DOM-Downloads getestet
werden kann (Service wird im Test gemockt).
Im selben Export-Bereich zusätzlich ein Zahnrad/Link "Automatischen Versand einrichten" → eigener
Dialog: Chip-Liste für E-Mail-Adressen (hinzufügen/entfernen, clientseitige Format-Validierung vor
dem Speichern), Intervall-Dropdown, Aktiv/Pausiert-Toggle, Speichern-Button. Neue Methoden
`CashboxExportApi.getSubscription(teamId)` / `updateSubscription(teamId, dto)`.
Neues Model `models/cashbox-export.model.ts` (`CashboxExportFormat`, `CashboxExportSubscription`,
`UpdateCashboxExportSubscription`).
## Fehlerbehandlung
- `from > to` → 400 (Backend), Submit-Button im Dialog zusätzlich clientseitig deaktiviert.
- Keine Buchungen im Zeitraum → Datei wird trotzdem erzeugt (Kopfzeile + Hinweistext), kein Fehler.
- Ungültige E-Mail-Adresse in der Empfängerliste → 400 (DTO-Validierung), Inline-Fehler im Dialog.
- `active: true` mit leerer Empfängerliste → 400.
- Fehlende Berechtigung → bestehender `assertAtLeast`-Wurf (403), keine neue Behandlung nötig.
## Testing
**Backend**:
- `cashbox-export.service.spec.ts` — Filterlogik (Ausschluss fee/levy/fine, Einschluss payment +
alle TeamWallet-Typen), Datumsgrenzen (inklusive Tagesende), laufender Saldo, leerer Zeitraum,
Berechtigungsdurchsetzung.
- `cashbox-export.http.spec.ts` — Auth erforderlich, korrekte Header/Content-Type je Format, CSV-
Inhalt exakt geprüft (String-Vergleich), PDF nur auf `%PDF-`-Signatur + Non-Empty geprüft (kein
Byte-Vergleich).
- `cashbox-export-subscription.service.spec.ts` — Upsert, Validierung (aktiv + leere Liste),
Berechtigung.
- `cashbox-export.scheduler.spec.ts` — Perioden-Berechnung je Intervall (`it.each`), PDF+Mail-
Dispatch mit gemocktem `MailerService` (Attachment vorhanden, korrekte Empfänger/Betreff),
`nextRunDate`-Vorrücken, überspringt inaktive/nicht-fällige Subscriptions, Downtime-Nachholung.
**Frontend**:
- `cashbox-export-api.spec.ts` — korrekte HTTP-Calls (Query-Params, `responseType: 'blob'`,
Subscription-GET/PUT).
- Export-Dialog-Spec — Formvalidierung (`from <= to`), Permission-Gating, ruft
`FileDownloadService.save` mit korrekten Argumenten auf.
- Subscription-Dialog-Spec — Laden/Speichern, Chip-Validierung, Permission-Gating.
## Bewusst nicht enthalten (YAGNI)
- Kein historischer Anfangssaldo (nur Periodensaldo ab 0 innerhalb des Exportzeitraums).
- Kein Export der Spieler-Fälligkeiten (fee/levy/fine).
- Kein Excel-(.xlsx)-Format, nur CSV+PDF.
- Keine mehreren Versand-Konfigurationen pro Team.
- Kein CSV im automatischen Versand, nur PDF.
- Keine Empfänger-Verifizierung (Double-Opt-In) für frei eingetragene Adressen.
## Verifikation
- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber.
- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber.
- **Manuell**: Backend lokal starten, über die neue UI einen CSV- und einen PDF-Export für einen
Zeitraum mit bekannten Testbuchungen herunterladen und Inhalt/Saldo stichprobenartig prüfen; eine
Subscription mit `nextRunDate` = heute anlegen, Scheduler-Methode einmalig manuell aufrufen, prüfen
dass eine E-Mail mit PDF-Anhang an alle konfigurierten Adressen geht und `nextRunDate` korrekt
vorrückt.

View File

@@ -0,0 +1,113 @@
# Team erstellen über die UI
Status: approved
Datum: 2026-08-03
## Kontext
Teams können in TeamWallet heute nur über einen bestehenden Backend-Endpoint
(`POST /api/v1/teams`, `teams.controller.ts`) angelegt werden, der ausschließlich globalen Admins
vorbehalten ist (`@Roles([RoleEnum.admin])`). Im modernen Angular-Frontend
(`myteamwallet_frontend_modern`) existiert dafür keine UI: `core/team/teams-api.ts` hat keine
`createTeam`-Methode, und `features/team-select/team-select.html` zeigt einem User ohne Teams nur
den Hinweistext „Du bist noch keinem Team zugeordnet." ohne jede Handlungsoption.
Ziel: jeder eingeloggte User soll selbstständig ein neues Team gründen können und wird dabei
automatisch dessen Kapitän. Das schließt eine der offensichtlichsten Lücken im Produkt
(Selbstregistrierung eines Teams) und folgt demselben Muster, das an anderer Stelle im Code bereits
als fehlend dokumentiert ist (`docs/plans/admin-user-management.md` merkt an, dass Admins auch noch
keine User anlegen können — ein verwandtes, aber bewusst getrenntes Folge-Thema).
## Entscheidungen aus dem Brainstorming
- **Berechtigung**: jeder eingeloggte User (`RoleEnum.user`) darf ein Team erstellen, nicht nur
Admins. Der Guard auf `POST /teams` wird entsprechend gelockert.
- **Automatische Mitgliedschaft**: der Ersteller wird automatisch **Kapitän** (`captain`,
Rollen-ID 3) des neuen Teams — die niedrigste Rollen-ID, die `TeamAccessService.assertManager`
bereits als „Manager" behandelt (`id >= 3`). Es gibt kein explizites „Owner"-Konzept, `captain`
ist die naheliegende Top-Rolle für den Ersteller.
- **Formularumfang**: nur der Teamname wird abgefragt (`CreateTeamDTO` bleibt `{ name: string }`).
Alias wird weiterhin automatisch aus dem Timestamp generiert, alles Weitere ist später über die
Team-Einstellungen anpassbar.
- **UI-Einstiegspunkt**: Button „Team erstellen" ist in `team-select` dauerhaft sichtbar — auch wenn
der User bereits Teams hat (nicht nur im Leerzustand), um auch das Anlegen weiterer Teams (z. B.
zweite Mannschaft) zu ermöglichen.
- **UI-Pattern**: Dialog/Modal statt eigener Route, passend zum bestehenden `MatDialog`-Muster im
Repo (z. B. `features/team/more/penalties/penalties.ts`) und angemessen für ein einzelnes
Eingabefeld.
- **Out of Scope**: Team löschen/archivieren ist ein eigenständiges Folge-Feature (andere
Berechtigungen/Risiken, z. B. Umgang mit bestehenden Spielern/Transaktionen) und wird separat
geplant.
## Architektur / Komponenten
### 1. Backend: `teams.controller.ts`
Guard auf `POST /teams` von `@Roles([RoleEnum.admin])` auf `[RoleEnum.user, RoleEnum.admin]`
erweitern — Muster wie an anderen Stellen desselben Controllers (z. B. Zeilen 113-114, 122-123).
Der aufrufende User wird wie überall im Controller über `@Req() req``req.user.id` an den Service
durchgereicht.
### 2. Backend: `teams.service.ts#createNewTeam`
Aktuell (Zeile 166-178) wird nur `Team` + Default-`TeamSetting`s (`generateBasicTeamSettings`)
angelegt; es entsteht kein `Player`-Datensatz, der User bleibt kein Mitglied des neuen Teams.
Erweiterung: nach dem Speichern von Team und Settings zusätzlich einen `Player` erzeugen —
verknüpft mit dem aufrufenden `User` (`userId`) und `teamRole = captain` (per
`rolesRepository.findOneBy({ id: 3 })`). Vorgehen spiegelt die bestehende Join-Erzeugung in
`createNewPlayer()` (Zeilen 122-164), dort wird bereits `Player` inkl. `TeamRole`-Verknüpfung für
andere Spieler angelegt.
Audit-Logging (`this.logger.info({ event: 'team_create', ... })`) bleibt erhalten.
### 3. Backend: `dto/create-team.dto.ts`
Unverändert (`{ name: string }`).
### 4. Frontend: `core/team/teams-api.ts`
Neue Methode `createTeam(name: string)``POST /teams`, analog zu den bestehenden Methoden wie
`createPlayer`.
### 5. Frontend: neue Dialog-Komponente
Kleine Standalone-Komponente mit Reactive Form (`FormBuilder`/`ReactiveFormsModule`,
`MatFormFieldModule`), ein Pflichtfeld „Teamname" — nach dem Muster von
`features/team/members/members.ts` (Form-Aufbau) kombiniert mit dem `MatDialog`-Öffnungsmuster aus
`features/team/more/penalties/penalties.ts`.
### 6. Frontend: `features/team-select/team-select.ts` / `.html`
Button „Team erstellen" dauerhaft im Template ergänzen (nicht nur im Leerzustand-Block). Klick öffnet
den Dialog über `MatDialog`; bei erfolgreichem Abschluss `MyTeamsStore` neu laden und per Router
direkt in das neu erstellte Team navigieren.
## Fehlerbehandlung
- Serverseitige Validierungsfehler (z. B. leerer/zu langer Name) werden als Formularfehler im Dialog
angezeigt, der Dialog bleibt offen.
- Netzwerk-/Serverfehler laufen über den bestehenden Snackbar/Toast-Mechanismus des Repos, wie bei
anderen Create-Flows (z. B. `createPlayer`).
## Testing
- Backend (`teams.service.spec.ts`): `createNewTeam()` legt zusätzlich zu Team und Settings einen
`Player` mit `teamRole = captain` und Verknüpfung zum aufrufenden User an.
- Backend (`teams.controller.spec.ts` bzw. e2e): `POST /teams` ist für `RoleEnum.user` erlaubt (nicht
mehr nur für `RoleEnum.admin`).
- Frontend: Komponententest für den neuen Dialog (analog `penalties.spec.ts`) — Formularvalidierung,
Aufruf von `teamsApi.createTeam`.
- Frontend: Test, dass `team-select` nach erfolgreicher Erstellung `MyTeamsStore` neu lädt und in das
neue Team navigiert.
- Manuelle Verifikation: als normaler User (nicht Admin) über `team-select` ein Team anlegen —
Aufruf sollte gelingen, User landet automatisch als Kapitän im neuen Team; Swagger-Aufruf von
`POST /teams` als normaler User bestätigt den gelockerten Guard.
## Out of Scope
- Team löschen/archivieren (eigenes Folge-Feature).
- Auswahl der initialen Rolle des Erstellers (immer `captain`, keine Wahlmöglichkeit).
- Eigene Alias-Vergabe durch den User (weiterhin automatisch generiert).
- Selbstständiges Anlegen von Usern durch Admins (verwandte, aber separate Lücke, siehe
`docs/plans/admin-user-management.md`).

View File

@@ -0,0 +1,111 @@
# Theoretischer Kassenstand (Ist + offene Beiträge) im Kassenstand-Verlauf
Status: approved
Datum: 2026-08-03
## Kontext
Ergänzung zum bestehenden Kassenstand-Verlauf-Chart aus
`docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`. Der Chart auf der Team-Übersicht
(`features/team/overview/overview.ts` + `.html`) zeigt aktuell eine Linie „Kassenstand" über die
letzten 12 Monate, gespeist aus `GET /teams/:id/overview/stats` bzw.
`teams.service.ts#getOverviewStats`.
Auf derselben Übersicht existiert bereits eine zweite Kennzahl „Offene Beiträge" (aus
`teams.service.ts#getOverview`, Zeile 46-53): `-(Summe der `balance` aller aktiven Spieler)`. Sie
zeigt nur den heutigen Wert, keinen Verlauf.
Ziel: eine zweite Linie im bestehenden Chart, die pro Monat den theoretischen Kassenstand zeigt —
also „was wäre in der Kasse, wenn alle offenen Beiträge bereits bezahlt worden wären" —, um Trainer/
Kassenwarte auf einen Blick erkennen zu lassen, wie stark der Ist-Stand vom Soll-Stand abweicht.
## Entscheidungen aus dem Brainstorming
- **Historie statt Snapshot**: Die zweite Linie zeigt für jeden Monat die zu diesem Zeitpunkt
tatsächlich offenen Beiträge, nicht den heutigen Wert konstant über alle 12 Punkte addiert.
- **Näherung wie beim bestehenden Kassenstand-Verlauf**: Es wird mit der *heutigen* Menge aktiver
Spieler gerechnet, kein historisches Tracking von Mitgliedschaft/Aktiv-Status. Das ist dieselbe
Vereinfachung, die `balanceHistory` bereits für den Kassenstand selbst nutzt (siehe
`getOverviewStats`-Kommentar zu `team.balance` als Anker).
- **Datenform**: `theoreticalBalance` wird als zusätzliches Feld direkt in jeden bestehenden
`balanceHistory`-Punkt eingebettet (`{ month, balance, theoreticalBalance }`), kein separates
Array — additive, nicht-brechende Erweiterung der bestehenden Response.
## Architektur / Komponenten
### 1. Backend: `teams.service.ts#getOverviewStats`
Neue private Hilfsberechnung, analog zur bestehenden Rückwärts-Rekonstruktion von `balanceHistory`
(Zeile ~290-320), aber auf Spieler-Ebene statt Team-Ebene:
- Datenquelle: `team.players` (bereits geladen über `relations: ['players', 'players.transactions',
'transactions']`), gefiltert auf `player.active` — dieselbe Teilmenge, die `getOverview` für die
heutige „Offene Beiträge"-Kachel verwendet.
- Für jeden aktiven Spieler: dessen `transactions` (bereits geladen), **ausgenommen** Zeilen mit
`note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX)` (Import aus
`team-members.service.ts`) — dieselbe Ausschlussregel wie in
`TeamMembersService.recomputeBalance`, damit synthetische Ausgleichsbuchungen die Historie nicht
verfälschen.
- Vorzeichen je Buchung: `type.id > 10` (Strafe/Umlage/Gebühr, IDs 11-13) mindert den Spieler-Saldo,
alle anderen Typen (`payment`, `credit`) erhöhen ihn — identische Regel wie in
`TeamMembersService.recomputeBalance` (Zeile ~127-133) und `TransactionsService.reverse()`
(`type.id > 10`-Check).
- Rekonstruktion: ausgehend von `player.balance` (aktueller, autoritativer Wert) rückwärts durch die
nach Datum absteigend sortierten Buchungen laufen und pro Monat der letzten 12 Monate den
rekonstruierten Saldo am Monatsende ermitteln — strukturell identisch zum bestehenden
`descendingMovements`/`futureSum`-Muster für `balanceHistory`, nur pro Spieler statt einmal fürs
Team.
- Pro Monat: `outstandingAtMonth = -Σ(rekonstruierter Saldo aktiver Spieler)`,
`theoreticalBalance = balanceHistory[monat].balance + outstandingAtMonth`.
- Rückgabeform ändert sich zu:
```ts
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]
```
`monthlyFlow` und `topOutstanding` bleiben unverändert.
- Gating unverändert: Ist `movements.length === 0` (keine Kassenbewegung je), bleibt
`balanceHistory: []` wie heute — ein Team mit ausschließlich unbezahlten Strafen, aber ganz ohne
Zahlungsbewegung, zeigt weiterhin keinen Chart (Out of Scope, siehe unten).
### 2. Frontend: `overview.ts` / Chart-Konfiguration
- `models/team-stats.model.ts`: `BalanceHistoryPoint` um `theoreticalBalance: number` erweitern.
- `balanceChartData` (computed) bekommt eine zweite Dataset-Eintrag:
- Label: „Theoretisch (inkl. offene Beiträge)"
- `data: points.map((p) => p.theoreticalBalance)`
- Gestrichelt (`borderDash: [6, 4]`), eigene Farbe `#1d70b8` (Blauton, klar unterscheidbar vom
Grün `#4f8f46` der Ist-Linie), `fill: false`.
- `balanceChartOptions`: `plugins.legend.display` von `false` auf `true` (bzw. `position: 'bottom'`
wie beim Flow-Chart), da jetzt zwei Linien unterschieden werden müssen.
- Keine Änderung an `ChartCanvas` (shared component) nötig — reine Config-/Daten-Änderung.
## Fehlerbehandlung
Unverändert zum bestehenden Muster: Fehler beim Laden der Stats führen zum bestehenden stillen
Empty-State der Chart-Karte. Kein neuer Fehlerfall durch diese Erweiterung.
## Testing
- Backend (`teams.service.spec.ts`, Erweiterung des bestehenden `getOverviewStats`-Testblocks):
- Sanity-Check: `balanceHistory.at(-1).theoreticalBalance === team.balance + aktuelle Summe
offener Beiträge` (heutiger Wert, wie von `getOverview` berechnet).
- Historische Rekonstruktion: Testfall mit einer Strafe (`fine`) in einem früheren Monat, die erst
im aktuellen Monat bezahlt wurde — `theoreticalBalance` im früheren Monat muss die damals
offene Strafe enthalten, `balance` (Ist) nicht.
- Deaktivierungs-Ausgleichsbuchungen werden aus der Rekonstruktion ausgeschlossen (Testfall mit
einem zwischenzeitlich deaktivierten und wieder aktivierten Spieler).
- Leerfall (`movements.length === 0`) liefert weiterhin `balanceHistory: []`.
- Frontend (`overview.spec.ts`): Erweiterung des bestehenden Chart-Daten-Tests um Assertion, dass
`balanceChartData()` zwei Datasets enthält und die zweite Serie aus `theoreticalBalance` gespeist
wird.
- Manuelle Verifikation: Team mit einer unbezahlten Strafe/Umlage lokal aufrufen, prüfen dass die
theoretische Linie sichtbar über der Ist-Linie liegt und bei vollständiger Bezahlung beide Linien
zusammenlaufen.
## Out of Scope
- Historisches Tracking von Mitgliedschaft/Aktiv-Status (Näherung mit heutiger aktiver
Spieler-Menge, siehe oben).
- Teams mit ausschließlich unbezahlten Strafen/Umlagen/Gebühren, aber ganz ohne Kassenbewegung —
zeigen weiterhin keinen Chart (bestehende Einschränkung aus dem Basis-Feature, nicht neu
eingeführt).
- Zeitraum-Umschalter (weiterhin feste letzte 12 Monate, wie im Basis-Feature festgelegt).

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
FRONTEND_DOMAIN=http://localhost:3000
BACKEND_DOMAIN=http://localhost:3000
LOG_RETENTION_DAYS=365
DATABASE_TYPE=postgres
DATABASE_HOST=postgres

View File

@@ -14,9 +14,11 @@
"@nestjs/common": "9.1.6",
"@nestjs/config": "2.2.0",
"@nestjs/core": "9.1.6",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6",
"@nestjs/schedule": "^2.2.3",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "6.1.3",
"@nestjs/typeorm": "9.0.1",
@@ -29,6 +31,7 @@
"passport": "0.6.0",
"passport-anonymous": "1.0.1",
"passport-jwt": "4.0.0",
"pdfkit": "^0.19.1",
"pg": "8.8.0",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
@@ -48,6 +51,7 @@
"@types/node": "16.18.3",
"@types/passport-anonymous": "1.0.3",
"@types/passport-jwt": "3.0.7",
"@types/pdfkit": "^0.17.6",
"@types/supertest": "2.0.12",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",
@@ -3301,6 +3305,19 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nestjs/event-emitter": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/jwt": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
@@ -3402,6 +3419,31 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"node_modules/@nestjs/schedule": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.2.3.tgz",
"integrity": "sha512-PxoGdoBwZQ6SzGfFcERTk7mDxrmesNt2cfqKgtLsFpjYNpV6ZYlKw9Ku8C0ZIjdhy0tBbysj+Fsi3sYua6o6Eg==",
"license": "MIT",
"dependencies": {
"cron": "2.3.1",
"uuid": "9.0.0"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0",
"reflect-metadata": "^0.1.12"
}
},
"node_modules/@nestjs/schedule/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nestjs/schematics": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz",
@@ -3533,6 +3575,30 @@
"typeorm": "^0.3.0"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3704,6 +3770,21 @@
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@swc/helpers/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -3982,6 +4063,16 @@
"@types/passport": "*"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
@@ -5186,6 +5277,24 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": {
"version": "4.21.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
@@ -5944,6 +6053,15 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
},
"node_modules/cron": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-2.3.1.tgz",
"integrity": "sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -6151,6 +6269,12 @@
"wrappy": "1"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -7269,6 +7393,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -7566,8 +7696,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-diff": {
"version": "1.2.0",
@@ -7762,6 +7891,32 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fork-ts-checker-webpack-plugin": {
"version": "7.2.13",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
@@ -11974,6 +12129,12 @@
"node": ">=10"
}
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-sdsl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
@@ -12220,6 +12381,25 @@
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -12551,6 +12731,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/macos-release": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
@@ -14090,6 +14279,12 @@
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/param-case": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
@@ -14261,6 +14456,20 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
},
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
@@ -14427,6 +14636,14 @@
"node": ">=4"
}
},
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -15030,6 +15247,12 @@
"node": ">=8"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
@@ -16053,6 +16276,12 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tlds": {
"version": "1.231.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
@@ -16806,6 +17035,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
@@ -19931,6 +20186,14 @@
}
}
},
"@nestjs/event-emitter": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz",
"integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==",
"requires": {
"eventemitter2": "6.4.9"
}
},
"@nestjs/jwt": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz",
@@ -19998,6 +20261,22 @@
}
}
},
"@nestjs/schedule": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.2.3.tgz",
"integrity": "sha512-PxoGdoBwZQ6SzGfFcERTk7mDxrmesNt2cfqKgtLsFpjYNpV6ZYlKw9Ku8C0ZIjdhy0tBbysj+Fsi3sYua6o6Eg==",
"requires": {
"cron": "2.3.1",
"uuid": "9.0.0"
},
"dependencies": {
"uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
}
}
},
"@nestjs/schematics": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz",
@@ -20091,6 +20370,16 @@
"uuid": "8.3.2"
}
},
"@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="
},
"@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
},
"@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -20220,6 +20509,21 @@
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
},
"@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"requires": {
"tslib": "^2.8.0"
},
"dependencies": {
"tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -20495,6 +20799,15 @@
"@types/passport": "*"
}
},
"@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
@@ -21414,6 +21727,22 @@
"fill-range": "^7.0.1"
}
},
"brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"requires": {
"base64-js": "^1.1.2"
}
},
"browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"requires": {
"pako": "~1.0.5"
}
},
"browserslist": {
"version": "4.21.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
@@ -21973,6 +22302,14 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
},
"cron": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-2.3.1.tgz",
"integrity": "sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==",
"requires": {
"luxon": "^3.2.1"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -22132,6 +22469,11 @@
"wrappy": "1"
}
},
"dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -22946,6 +23288,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
},
"eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg=="
},
"events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -23172,8 +23519,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-diff": {
"version": "1.2.0",
@@ -23342,6 +23688,29 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"requires": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
},
"dependencies": {
"clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="
}
}
},
"fork-ts-checker-webpack-plugin": {
"version": "7.2.13",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
@@ -26398,6 +26767,11 @@
}
}
},
"js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="
},
"js-sdsl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
@@ -26605,6 +26979,22 @@
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
},
"linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"requires": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
},
"dependencies": {
"base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="
}
}
},
"lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -26872,6 +27262,11 @@
"yallist": "^3.0.2"
}
},
"luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="
},
"macos-release": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
@@ -28128,6 +28523,11 @@
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"param-case": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
@@ -28261,6 +28661,19 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
},
"pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"requires": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
@@ -28386,6 +28799,14 @@
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true
},
"png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"requires": {
"browserify-zlib": "^0.2.0"
}
},
"postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -28866,6 +29287,11 @@
"signal-exit": "^3.0.2"
}
},
"restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
},
"ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
@@ -29632,6 +30058,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
},
"tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"tlds": {
"version": "1.231.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
@@ -30083,6 +30514,31 @@
"which-boxed-primitive": "^1.0.2"
}
},
"unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"requires": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"requires": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
},
"dependencies": {
"pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
}
}
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",

View File

@@ -33,9 +33,11 @@
"@nestjs/common": "9.1.6",
"@nestjs/config": "2.2.0",
"@nestjs/core": "9.1.6",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6",
"@nestjs/schedule": "^2.2.3",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "6.1.3",
"@nestjs/typeorm": "9.0.1",
@@ -48,6 +50,7 @@
"passport": "0.6.0",
"passport-anonymous": "1.0.1",
"passport-jwt": "4.0.0",
"pdfkit": "^0.19.1",
"pg": "8.8.0",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
@@ -67,6 +70,7 @@
"@types/node": "16.18.3",
"@types/passport-anonymous": "1.0.3",
"@types/passport-jwt": "3.0.7",
"@types/pdfkit": "^0.17.6",
"@types/supertest": "2.0.12",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",

View File

@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import databaseConfig from './config/database.config';
@@ -22,9 +24,14 @@ import { join } from 'path';
import { LoggingModule } from './database/logging/logging.module';
import { TranslateModule } from './translate/translate.module';
import { PenaltyModule } from './penalty/penalty.module';
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({
imports: [
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, authConfig, appConfig, mailConfig],
@@ -55,6 +62,9 @@ import { PenaltyModule } from './penalty/penalty.module';
LoggingModule,
TranslateModule,
PenaltyModule,
RecurringTransactionsModule,
CashboxExportModule,
NotificationsModule,
],
providers: [],
})

View File

@@ -14,6 +14,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
let userRepository: any;
let service: AuthService;
let mailService: any;
let eventEmitter: any;
beforeEach(() => {
jwtService = {
@@ -44,6 +45,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
dataSource = {
transaction: jest.fn((work) => work(manager)),
};
eventEmitter = { emit: jest.fn() };
service = new AuthService(
jwtService,
usersService,
@@ -52,6 +54,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
logger,
dataSource,
{ assertAtLeast: jest.fn() } as any,
eventEmitter as any,
);
});
@@ -155,6 +158,19 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
});
it('emits an invite-link-created event after issuing the token', async () => {
const token = await service.createTeamInvite(
{ teamId: 10, teamName: 'Team A' } as any,
5,
);
expect(token.token).toBeDefined();
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.invite_link.created',
expect.objectContaining({ teamId: 10, actorUserId: 5, teamName: 'Team A' }),
);
});
function user(statusId: StatusEnum) {
return {
id: 2,

View File

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

View File

@@ -0,0 +1,125 @@
import { BadRequestException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
describe('CashboxExportSubscriptionService', () => {
const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) };
const access = { assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: CashboxExportSubscriptionService;
beforeEach(() => {
jest.clearAllMocks();
service = new CashboxExportSubscriptionService(repository as any, access as any, logger as any);
});
it('returns a paused default when no subscription exists yet', async () => {
repository.findOne.mockResolvedValue(null);
await expect(service.getSubscription(5, 42)).resolves.toEqual({
recipients: [],
interval: RecurringTransactionIntervalEnum.monthly,
active: false,
nextRunDate: null,
});
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
});
it('returns the existing subscription', async () => {
repository.findOne.mockResolvedValue({
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.yearly,
active: true,
nextRunDate: '2027-01-01T00:00:00.000Z',
});
await expect(service.getSubscription(5, 42)).resolves.toEqual({
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.yearly,
active: true,
nextRunDate: '2027-01-01T00:00:00.000Z',
});
});
it('rejects activating with an empty recipient list', async () => {
repository.findOne.mockResolvedValue(null);
await expect(
service.upsertSubscription(5, 42, {
recipients: [],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.save).not.toHaveBeenCalled();
});
it('creates a new subscription and computes the next period boundary on first activation', async () => {
repository.findOne.mockResolvedValue(null);
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
const result = await service.upsertSubscription(5, 42, {
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
});
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
expect(repository.save).toHaveBeenCalledWith(
expect.objectContaining({
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
}),
);
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_subscription_update',
details: 'teamId=5 active=true interval=monthly recipients=1',
userId: 42,
});
jest.useRealTimers();
});
it('keeps the existing nextRunDate when editing recipients without changing interval or activation state', async () => {
repository.findOne.mockResolvedValue({
recipients: ['old@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
});
const result = await service.upsertSubscription(5, 42, {
recipients: ['new@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
});
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
});
it('recomputes nextRunDate when the interval changes', async () => {
repository.findOne.mockResolvedValue({
recipients: ['a@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
});
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
const result = await service.upsertSubscription(5, 42, {
recipients: ['a@example.com'],
interval: RecurringTransactionIntervalEnum.yearly,
active: true,
});
expect(result.nextRunDate).toBe('2027-08-01T00:00:00.000Z');
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,106 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { TeamAccessService } from 'src/teams/team-access.service';
import { Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
import { CashboxExportSubscriptionResponseDTO } from './dto/cashbox-export-subscription-response.dto';
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class CashboxExportSubscriptionService {
constructor(
@InjectRepository(CashboxExportSubscription)
private readonly repository: Repository<CashboxExportSubscription>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async getSubscription(
teamId: number,
userId: number,
): Promise<CashboxExportSubscriptionResponseDTO> {
await this.assertAccess(userId, teamId);
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
if (!existing) {
return {
recipients: [],
interval: RecurringTransactionIntervalEnum.monthly,
active: false,
nextRunDate: null,
};
}
return this.toResponse(existing);
}
async upsertSubscription(
teamId: number,
userId: number,
dto: UpsertCashboxExportSubscriptionDTO,
): Promise<CashboxExportSubscriptionResponseDTO> {
await this.assertAccess(userId, teamId);
if (dto.active && dto.recipients.length === 0) {
throw new BadRequestException(
'Ein aktivierter automatischer Versand benötigt mindestens eine Empfängeradresse.',
);
}
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
const needsNewSchedule =
!existing || (dto.active && (!existing.active || existing.interval !== dto.interval));
const entity =
existing ??
this.repository.create({ team: { id: teamId } as any, nextRunDate: null, active: false });
entity.recipients = dto.recipients;
entity.interval = dto.interval;
entity.active = dto.active;
if (needsNewSchedule) {
entity.nextRunDate = this.nextBoundary(dto.interval);
}
const saved = await this.repository.save(entity);
await this.logger.info({
event: 'cashbox_export_subscription_update',
details: `teamId=${teamId} active=${dto.active} interval=${dto.interval} recipients=${dto.recipients.length}`,
userId,
});
return this.toResponse(saved);
}
private async assertAccess(userId: number, teamId: number): Promise<void> {
await this.access.assertAtLeast(
userId,
teamId,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
}
private nextBoundary(interval: RecurringTransactionIntervalEnum): string {
const now = new Date();
const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
private toResponse(
entity: CashboxExportSubscription,
): CashboxExportSubscriptionResponseDTO {
return {
recipients: entity.recipients,
interval: entity.interval,
active: entity.active,
nextRunDate: entity.nextRunDate,
};
}
}

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

@@ -0,0 +1,85 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Put,
Query,
Request,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger';
import type { Response } from 'express';
import { Roles } from '../roles/roles.decorator';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
type AuthenticatedRequest = { user: { id: number } };
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller({ path: 'cashbox-export', version: '1' })
export class CashboxExportController {
constructor(
private readonly service: CashboxExportService,
private readonly subscriptionService: CashboxExportSubscriptionService,
private readonly scheduler: CashboxExportScheduler,
) {}
@Get(':teamId')
async exportCashbox(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
@Query() query: CashboxExportQueryDto,
@Res({ passthrough: false }) res: Response,
): Promise<void> {
const { buffer, contentType, filename } = await this.service.exportForUser(
teamId,
request.user.id,
query.from,
query.to,
query.format,
);
res.set({
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.send(buffer);
}
@Get(':teamId/subscription')
getSubscription(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
) {
return this.subscriptionService.getSubscription(teamId, request.user.id);
}
@Put(':teamId/subscription')
upsertSubscription(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
@Body() dto: UpsertCashboxExportSubscriptionDTO,
) {
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto);
}
@Post('admin/run')
@HttpCode(HttpStatus.OK)
@UseGuards(RolesGuard)
@Roles([RoleEnum.admin])
runDueSubscriptionsNow(): Promise<void> {
return this.scheduler.runDueSubscriptions();
}
}

View File

@@ -0,0 +1,176 @@
import {
INestApplication,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
import validationOptions from '../utils/validation-options';
import { CashboxExportController } from './cashbox-export.controller';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
describe('cashbox export HTTP boundary', () => {
let app: INestApplication;
const service = {
exportForUser: jest.fn(),
};
const subscriptionService = {
getSubscription: jest.fn(),
upsertSubscription: jest.fn(),
};
const scheduler = { runDueSubscriptions: jest.fn() };
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [CashboxExportController],
providers: [
{ provide: CashboxExportService, useValue: service },
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
{ provide: CashboxExportScheduler, useValue: scheduler },
],
})
.overrideGuard(AuthGuard('jwt'))
.useValue({
canActivate(context) {
const httpRequest = context.switchToHttp().getRequest();
if (httpRequest.headers.authorization !== 'Bearer user') {
throw new UnauthorizedException();
}
httpRequest.user = { id: 42, role: { id: 2 } };
return true;
},
})
.compile();
app = module.createNestApplication();
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
app.useGlobalPipes(new ValidationPipe(validationOptions));
await app.init();
});
afterAll(() => app.close());
beforeEach(() => jest.clearAllMocks());
it('requires authentication', async () => {
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
.expect(401);
});
it('rejects an invalid format', async () => {
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=xls')
.set('Authorization', 'Bearer user')
.expect(422);
expect(service.exportForUser).not.toHaveBeenCalled();
});
it('rejects a full ISO datetime instead of a plain YYYY-MM-DD date for from', async () => {
await request(app.getHttpServer())
.get(
'/api/v1/cashbox-export/5?from=2026-08-01T12:00:00Z&to=2026-08-31&format=csv',
)
.set('Authorization', 'Bearer user')
.expect(422);
expect(service.exportForUser).not.toHaveBeenCalled();
});
it('rejects a malformed date string for to', async () => {
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=not-a-date&format=csv')
.set('Authorization', 'Bearer user')
.expect(422);
expect(service.exportForUser).not.toHaveBeenCalled();
});
it('returns a CSV file with correct headers and content', async () => {
const csvBuffer = Buffer.from('Datum;Typ;Wer;Notiz;Betrag;Periodensaldo', 'utf-8');
service.exportForUser.mockResolvedValue({
buffer: csvBuffer,
contentType: 'text/csv; charset=utf-8',
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
});
const response = await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
.set('Authorization', 'Bearer user')
.expect(200);
expect(response.headers['content-type']).toContain('text/csv');
expect(response.headers['content-disposition']).toContain(
'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
);
expect(response.text).toBe(csvBuffer.toString('utf-8'));
expect(service.exportForUser).toHaveBeenCalledWith(5, 42, '2026-08-01', '2026-08-31', 'csv');
});
it('returns a PDF file with correct headers and binary content', async () => {
const pdfBuffer = Buffer.from('%PDF-1.4 fake content');
service.exportForUser.mockResolvedValue({
buffer: pdfBuffer,
contentType: 'application/pdf',
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
});
const response = await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=pdf')
.set('Authorization', 'Bearer user')
.expect(200);
expect(response.headers['content-type']).toBe('application/pdf');
expect(Buffer.from(response.body).equals(pdfBuffer)).toBe(true);
});
it('reads the current subscription', async () => {
const subscription = {
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
};
subscriptionService.getSubscription.mockResolvedValue(subscription);
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5/subscription')
.set('Authorization', 'Bearer user')
.expect(200)
.expect(subscription);
expect(subscriptionService.getSubscription).toHaveBeenCalledWith(5, 42);
});
it('rejects an invalid recipient email on upsert', async () => {
await request(app.getHttpServer())
.put('/api/v1/cashbox-export/5/subscription')
.set('Authorization', 'Bearer user')
.send({ recipients: ['not-an-email'], interval: 'monthly', active: true })
.expect(422);
expect(subscriptionService.upsertSubscription).not.toHaveBeenCalled();
});
it('accepts a valid subscription upsert', async () => {
const subscription = {
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
};
subscriptionService.upsertSubscription.mockResolvedValue(subscription);
await request(app.getHttpServer())
.put('/api/v1/cashbox-export/5/subscription')
.set('Authorization', 'Bearer user')
.send({ recipients: ['vorstand@example.com'], interval: 'monthly', active: true })
.expect(200)
.expect(subscription);
expect(subscriptionService.upsertSubscription).toHaveBeenCalledWith(5, 42, {
recipients: ['vorstand@example.com'],
interval: 'monthly',
active: true,
});
});
});

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggingModule } from 'src/database/logging/logging.module';
import { MailModule } from 'src/mail/mail.module';
import { Team } from 'src/teams/entities/team.entity';
import { TeamsModule } from 'src/teams/teams.module';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
import { CashboxExportController } from './cashbox-export.controller';
import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
@Module({
controllers: [CashboxExportController],
providers: [CashboxExportService, CashboxExportSubscriptionService, CashboxExportScheduler],
imports: [
TypeOrmModule.forFeature([Team, CashboxExportSubscription]),
TeamsModule,
MailModule,
LoggingModule,
],
})
export class CashboxExportModule {}

View File

@@ -0,0 +1,146 @@
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
describe('CashboxExportScheduler', () => {
const subscriptionRepository = { find: jest.fn(), save: jest.fn((v) => v) };
const teamRepository = { findOne: jest.fn() };
const mailService = { cashboxExport: jest.fn() };
const logger = { info: jest.fn(), error: jest.fn() };
let scheduler: CashboxExportScheduler;
beforeEach(() => {
jest.clearAllMocks();
scheduler = new CashboxExportScheduler(
subscriptionRepository as any,
teamRepository as any,
mailService as any,
logger as any,
);
});
it('does nothing when no subscription is due', async () => {
subscriptionRepository.find.mockResolvedValue([]);
await scheduler.runDueSubscriptions();
expect(teamRepository.findOne).not.toHaveBeenCalled();
expect(mailService.cashboxExport).not.toHaveBeenCalled();
});
it('emails the elapsed monthly period and advances nextRunDate', async () => {
subscriptionRepository.find.mockResolvedValue([
{
id: 1,
team: { id: 5 },
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
},
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [
{ date: '2026-08-15T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
],
players: [],
});
await scheduler.runDueSubscriptions();
expect(teamRepository.findOne).toHaveBeenCalledWith({
where: { id: 5 },
relations: ['players', 'players.transactions', 'transactions'],
});
expect(mailService.cashboxExport).toHaveBeenCalledTimes(1);
const [mailData, attachment, filename] = mailService.cashboxExport.mock.calls[0];
expect(mailData).toEqual({
to: 'vorstand@example.com',
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
});
expect(Buffer.isBuffer(attachment)).toBe(true);
expect(filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
expect(subscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ nextRunDate: '2026-10-01T00:00:00.000Z' }),
);
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_subscription_run',
details: 'teamId=5 recipients=1 from=2026-08-01 to=2026-08-31',
userId: -1,
});
});
it.each([
[RecurringTransactionIntervalEnum.monthly, '2026-09-01T00:00:00.000Z', '2026-08-01', '2026-08-31', '2026-10-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.quarterly, '2026-09-01T00:00:00.000Z', '2026-06-01', '2026-08-31', '2026-12-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.yearly, '2027-01-01T00:00:00.000Z', '2026-01-01', '2026-12-31', '2028-01-01T00:00:00.000Z'],
])(
'computes the elapsed period and next run date for %s',
async (interval, nextRunDate, expectedFrom, expectedTo, expectedNext) => {
subscriptionRepository.find.mockResolvedValue([
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval, nextRunDate, active: true },
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
});
await scheduler.runDueSubscriptions();
const [mailData] = mailService.cashboxExport.mock.calls[0];
expect(mailData.data.from).toBe(expectedFrom);
expect(mailData.data.to).toBe(expectedTo);
expect(subscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ nextRunDate: expectedNext }),
);
},
);
it('processes multiple due subscriptions independently', async () => {
subscriptionRepository.find.mockResolvedValue([
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
});
await scheduler.runDueSubscriptions();
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
});
it('continues processing when one subscription fails', async () => {
subscriptionRepository.find.mockResolvedValue([
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
});
mailService.cashboxExport.mockRejectedValueOnce(new Error('smtp down'));
await scheduler.runDueSubscriptions();
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
expect(subscriptionRepository.save).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith({
event: 'cashbox_export_subscription_run_fail',
details: expect.stringContaining('subscriptionId=1 teamId=5'),
userId: -1,
});
});
});

View File

@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Team } from 'src/teams/entities/team.entity';
import { MailService } from 'src/mail/mail.service';
import { LessThanOrEqual, Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
import { buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class CashboxExportScheduler {
constructor(
@InjectRepository(CashboxExportSubscription)
private readonly subscriptionRepository: Repository<CashboxExportSubscription>,
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly mailService: MailService,
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async runDueSubscriptions(): Promise<void> {
const today = new Date().toISOString();
const due = await this.subscriptionRepository.find({
where: { active: true, nextRunDate: LessThanOrEqual(today) },
relations: ['team'],
});
for (const subscription of due) {
try {
await this.runOne(subscription);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.logger.error({
event: 'cashbox_export_subscription_run_fail',
details: `recurring subscription failed: subscriptionId=${subscription.id} teamId=${subscription.team.id}: ${errorMessage}`,
userId: -1,
});
}
}
}
private async runOne(subscription: CashboxExportSubscription): Promise<void> {
const team = await this.teamRepository.findOne({
where: { id: subscription.team.id },
relations: ['players', 'players.transactions', 'transactions'],
});
if (!team) return;
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
const rows = buildRows(team, from, to);
const receivableRows = buildReceivableRows(team, from, to);
const pdf = await buildPdf(team, rows, receivableRows, from, to);
const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`;
await this.mailService.cashboxExport(
{ to: subscription.recipients.join(', '), data: { teamName: team.name, from, to } },
pdf,
filename,
);
subscription.nextRunDate = this.advance(subscription.nextRunDate, subscription.interval);
await this.subscriptionRepository.save(subscription);
await this.logger.info({
event: 'cashbox_export_subscription_run',
details: `teamId=${team.id} recipients=${subscription.recipients.length} from=${from} to=${to}`,
userId: -1,
});
}
private periodBounds(
nextRunDate: string,
interval: RecurringTransactionIntervalEnum,
): { from: string; to: string } {
const end = new Date(nextRunDate);
end.setUTCDate(end.getUTCDate() - 1);
const start = new Date(nextRunDate);
start.setUTCMonth(start.getUTCMonth() - INTERVAL_MONTHS[interval]);
return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) };
}
private advance(nextRunDate: string, interval: RecurringTransactionIntervalEnum): string {
const date = new Date(nextRunDate);
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
}

View File

@@ -0,0 +1,107 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { CashboxExportService } from './cashbox-export.service';
describe('CashboxExportService', () => {
const teamRepository = { findOne: jest.fn() };
const access = { assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: CashboxExportService;
let callOrder: string[];
const team = {
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
],
players: [],
};
beforeEach(() => {
jest.clearAllMocks();
callOrder = [];
access.assertAtLeast.mockImplementation(async () => {
callOrder.push('assertAtLeast');
});
teamRepository.findOne.mockImplementation(async () => {
callOrder.push('findOne');
return team;
});
service = new CashboxExportService(teamRepository as any, access as any, logger as any);
});
it('checks permission before loading data', async () => {
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
});
it('calls assertAtLeast before findOne to enforce permission check ordering', async () => {
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(callOrder).toEqual(['assertAtLeast', 'findOne']);
});
it('skips team lookup when permission check rejects', async () => {
access.assertAtLeast.mockRejectedValueOnce(new ForbiddenException('Insufficient permissions'));
await expect(
service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv'),
).rejects.toBeInstanceOf(ForbiddenException);
expect(teamRepository.findOne).not.toHaveBeenCalled();
});
it('throws NotFoundException for an unknown team', async () => {
teamRepository.findOne.mockResolvedValueOnce(null);
await expect(
service.exportForUser(999, 42, '2026-08-01', '2026-08-31', 'csv'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a range where from is after to without querying the team', async () => {
await expect(
service.exportForUser(5, 42, '2026-08-31', '2026-08-01', 'csv'),
).rejects.toBeInstanceOf(BadRequestException);
expect(access.assertAtLeast).not.toHaveBeenCalled();
expect(teamRepository.findOne).not.toHaveBeenCalled();
});
it('builds a CSV buffer with the correct content type and filename', async () => {
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(result.contentType).toBe('text/csv; charset=utf-8');
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.csv');
expect(result.buffer.toString('utf-8')).toContain('Sponsoring');
});
it('builds a PDF buffer with the correct content type and filename', async () => {
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'pdf');
expect(result.contentType).toBe('application/pdf');
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
expect(result.buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
it('logs a cashbox_export_download event after a successful export', async () => {
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_download',
details: 'teamId=5 format=csv from=2026-08-01 to=2026-08-31',
userId: 42,
});
});
});

View File

@@ -0,0 +1,70 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { Team } from 'src/teams/entities/team.entity';
import { TeamAccessService } from 'src/teams/team-access.service';
import { Repository } from 'typeorm';
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
@Injectable()
export class CashboxExportService {
constructor(
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async exportForUser(
teamId: number,
userId: number,
from: string,
to: string,
format: 'csv' | 'pdf',
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
if (from > to) {
throw new BadRequestException(
'Der Startzeitraum darf nicht nach dem Endzeitraum liegen.',
);
}
await this.access.assertAtLeast(
userId,
teamId,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
const team = await this.teamRepository.findOne({
where: { id: teamId },
relations: ['players', 'players.transactions', 'transactions'],
});
if (!team) throw new NotFoundException('Team nicht gefunden.');
const rows = buildRows(team, from, to);
if (format === 'csv') {
const result = {
buffer: Buffer.from(buildCsv(rows), 'utf-8'),
contentType: 'text/csv; charset=utf-8',
filename: `kassenbuch_${team.alias}_${from}_${to}.csv`,
};
await this.logger.info({
event: 'cashbox_export_download',
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
userId,
});
return result;
}
const result = {
buffer: await buildPdf(team, rows, buildReceivableRows(team, from, to), from, to),
contentType: 'application/pdf',
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
};
await this.logger.info({
event: 'cashbox_export_download',
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
userId,
});
return result;
}
}

View File

@@ -0,0 +1,354 @@
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
// Reads the page count directly out of the raw PDF bytes instead of pulling in
// a parser dependency. Coupled to pdfkit's current /Pages dict serialization -
// a pdfkit upgrade that reorders/reflows it could require adjusting this regex.
function pdfPageCount(buffer: Buffer): number {
const match = buffer.toString('latin1').match(/\/Type\s*\/Pages[\s\S]{0,80}?\/Count\s+(\d+)/);
if (!match) throw new Error('Could not find page count in PDF buffer');
return Number(match[1]);
}
describe('buildRows', () => {
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
...overrides,
});
it('includes team-wallet credit rows as-is and negates expense rows so they reduce the budget', () => {
// DB stores TeamWalletTransaction.amount as a positive number even for
// expenses (see team-wallet-transaction.entity.ts setBalance()); buildRows
// must negate expenses itself so they subtract from the running total.
const rows = buildRows(
team({
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Sponsoring', type: { name: 'credit' } },
{ date: '2026-08-10T00:00:00.000Z', amount: 20, note: 'Bälle', type: { name: 'expense' } },
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Sponsoring', amount: 100, runningTotal: 100 },
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20, runningTotal: 80 },
]);
});
it('includes only "payment" player transactions, excluding fee/levy/fine', () => {
const rows = buildRows(
team({
players: [
{
firstName: 'Alex',
lastName: 'Muster',
transactions: [
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-03T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
]);
});
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
const rows = buildRows(
team({
transactions: [
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'credit' } },
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'credit' } },
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'credit' } },
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'credit' } },
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
});
it('returns an empty array when nothing falls in range', () => {
const rows = buildRows(team() as any, '2026-08-01', '2026-08-31');
expect(rows).toEqual([]);
});
it('skips transactions with null type and includes valid ones', () => {
const rows = buildRows(
team({
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Valid credit', type: { name: 'credit' } },
{ date: '2026-08-06T00:00:00.000Z', amount: 50, note: 'Null type team wallet', type: null },
],
players: [
{
firstName: 'Bob',
lastName: 'Smith',
transactions: [
{ date: '2026-08-07T00:00:00.000Z', amount: 20, note: 'Valid payment', type: { name: 'payment' } },
{ date: '2026-08-08T00:00:00.000Z', amount: 30, note: 'Null type player', type: null },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Valid credit', amount: 100, runningTotal: 100 },
{ date: '2026-08-07T00:00:00.000Z', type: 'payment', who: 'Bob Smith', note: 'Valid payment', amount: 20, runningTotal: 120 },
]);
});
});
describe('buildReceivableRows', () => {
const team = (overrides: Partial<{ players: any[] }> = {}) => ({
id: 5,
name: 'Team A',
alias: 'team-a',
players: [],
...overrides,
});
it('includes fine, levy and fee player transactions, excluding payment and credit', () => {
const rows = buildReceivableRows(
team({
players: [
{
firstName: 'Alex',
lastName: 'Muster',
transactions: [
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
{ date: '2026-08-05T00:00:00.000Z', amount: 5, note: 'Zu spät', type: { name: 'fine' } },
{ date: '2026-08-06T00:00:00.000Z', amount: 20, note: 'Umlage Trikots', type: { name: 'levy' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-04T00:00:00.000Z', type: 'fee', who: 'Alex Muster', note: 'Monatsbeitrag', amount: 15 },
{ date: '2026-08-05T00:00:00.000Z', type: 'fine', who: 'Alex Muster', note: 'Zu spät', amount: 5 },
{ date: '2026-08-06T00:00:00.000Z', type: 'levy', who: 'Alex Muster', note: 'Umlage Trikots', amount: 20 },
]);
});
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
const rows = buildReceivableRows(
team({
players: [
{
firstName: 'Bob',
lastName: 'Smith',
transactions: [
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'fine' } },
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'fine' } },
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'fee' } },
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'levy' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
});
it('skips transactions with null type and returns an empty array when nothing matches', () => {
const rows = buildReceivableRows(
team({
players: [
{
firstName: 'Carla',
lastName: 'Beispiel',
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Null type', type: null },
{ date: '2026-08-06T00:00:00.000Z', amount: 10, note: 'Zahlung', type: { name: 'payment' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([]);
});
});
describe('buildCsv', () => {
it('renders the header and formatted rows with German decimals', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20.5, runningTotal: -10.5 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Zahlung;Alex Muster;Bar bezahlt;10,00;10,00\r\n' +
'2026-08-10;Ausgabe;Teamkasse;Bälle;-20,50;-10,50',
);
});
it('quotes notes containing a semicolon and escapes embedded quotes', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Spende; "danke"', amount: 5, runningTotal: 5 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Guthaben;Teamkasse;"Spende; ""danke""";5,00;5,00',
);
});
it('shows a placeholder row when there are no bookings', () => {
const csv = buildCsv([]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\nKeine Buchungen im gewählten Zeitraum',
);
});
it('formats negative-zero as positive zero', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -0.001, runningTotal: 0 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Ausgabe;Teamkasse;Test;0,00;0,00',
);
});
it('rounds half-cent boundaries correctly', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Test', amount: 1.005, runningTotal: 1.005 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Zahlung;Alex Muster;Test;1,01;1,01',
);
});
it('rounds negative half-cent boundaries correctly', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -1.005, runningTotal: -1.005 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Ausgabe;Teamkasse;Test;-1,01;-1,01',
);
});
});
describe('buildPdf', () => {
const cashRow = {
date: '2026-08-05T00:00:00.000Z',
type: 'payment',
who: 'Alex Muster',
note: 'Bar bezahlt',
amount: 10,
runningTotal: 10,
};
const receivableRow = {
date: '2026-08-06T00:00:00.000Z',
type: 'fine',
who: 'Bob Smith',
note: 'Zu spät',
amount: 5,
};
it('produces a non-empty valid PDF buffer with cash rows only', async () => {
const buffer = await buildPdf({ name: 'Team A' } as any, [cashRow], [], '2026-08-01', '2026-08-31');
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.length).toBeGreaterThan(100);
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
it('produces a valid PDF when there are only receivable rows', async () => {
const buffer = await buildPdf({ name: 'Team A' } as any, [], [receivableRow], '2026-08-01', '2026-08-31');
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
it('produces a valid PDF with both cash and receivable rows', async () => {
const buffer = await buildPdf(
{ name: 'Team A' } as any,
[cashRow],
[receivableRow],
'2026-08-01',
'2026-08-31',
);
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
it('still produces a valid PDF when both sections are empty', async () => {
const buffer = await buildPdf({ name: 'Team A' } as any, [], [], '2026-08-01', '2026-08-31');
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
expect(pdfPageCount(buffer)).toBe(1);
});
it('does not append blank trailing pages when content fits on a single page', async () => {
const buffer = await buildPdf(
{ name: 'Team A' } as any,
[cashRow],
[receivableRow],
'2026-08-01',
'2026-08-31',
);
expect(pdfPageCount(buffer)).toBe(1);
});
it('paginates correctly and stays a valid PDF for many rows', async () => {
const manyRows = Array.from({ length: 60 }, (_, i) => ({
...cashRow,
date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
note: `Buchung ${i}`,
runningTotal: 10 * (i + 1),
}));
const manyReceivables = Array.from({ length: 60 }, (_, i) => ({
...receivableRow,
date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
note: `Forderung ${i}`,
}));
const buffer = await buildPdf(
{ name: 'Team A' } as any,
manyRows,
manyReceivables,
'2026-08-01',
'2026-08-31',
);
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
expect(buffer.length).toBeGreaterThan(2000);
// Guards against the footer loop reintroducing blank trailing pages: with
// the bug, this dataset produced 12 pages (3x the real content pages).
expect(pdfPageCount(buffer)).toBe(4);
});
});

View File

@@ -0,0 +1,479 @@
import { Team } from 'src/teams/entities/team.entity';
import PDFDocument = require('pdfkit');
export interface CashboxExportRow {
date: string;
type: string;
who: string;
note: string;
amount: number;
runningTotal: number;
}
interface RawRow {
date: string;
type: string;
who: string;
note: string;
amount: number;
}
export function buildRows(team: Team, from: string, to: string): CashboxExportRow[] {
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
const raw: RawRow[] = [];
for (const transaction of team.transactions ?? []) {
if (!transaction.type) continue;
const amount = Number(transaction.amount);
raw.push({
date: transaction.date,
type: transaction.type.name,
who: 'Teamkasse',
note: transaction.note,
amount: transaction.type.name === 'expense' ? -amount : amount,
});
}
for (const player of team.players ?? []) {
for (const transaction of player.transactions ?? []) {
if (!transaction.type || transaction.type.name !== 'payment') continue;
raw.push({
date: transaction.date,
type: transaction.type.name,
who: `${player.firstName} ${player.lastName}`,
note: transaction.note,
amount: Number(transaction.amount),
});
}
}
const filtered = raw
.filter((row) => {
const time = new Date(row.date).getTime();
return time >= fromTime && time <= toTime;
})
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
let runningTotal = 0;
return filtered.map((row) => {
runningTotal += row.amount;
return { ...row, runningTotal };
});
}
export interface CashboxReceivableRow {
date: string;
type: string;
who: string;
note: string;
amount: number;
}
const RECEIVABLE_TYPES = new Set(['fine', 'levy', 'fee']);
export function buildReceivableRows(team: Team, from: string, to: string): CashboxReceivableRow[] {
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
const raw: CashboxReceivableRow[] = [];
for (const player of team.players ?? []) {
for (const transaction of player.transactions ?? []) {
if (!transaction.type || !RECEIVABLE_TYPES.has(transaction.type.name)) continue;
raw.push({
date: transaction.date,
type: transaction.type.name,
who: `${player.firstName} ${player.lastName}`,
note: transaction.note,
amount: Number(transaction.amount),
});
}
}
return raw
.filter((row) => {
const time = new Date(row.date).getTime();
return time >= fromTime && time <= toTime;
})
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
}
const TYPE_LABELS: Record<string, string> = {
payment: 'Zahlung',
credit: 'Guthaben',
expense: 'Ausgabe',
fine: 'Strafe',
levy: 'Umlage',
fee: 'Gebühr',
};
function formatGermanAmount(value: number): string {
const rounded = Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * 100) / 100;
const normalized = rounded === 0 ? 0 : rounded;
return normalized.toFixed(2).replace('.', ',');
}
function escapeCsvField(value: string): string {
if (/[;"\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function buildCsv(rows: CashboxExportRow[]): string {
const lines = ['Datum;Typ;Wer;Notiz;Betrag;Periodensaldo'];
if (rows.length === 0) {
lines.push('Keine Buchungen im gewählten Zeitraum');
} else {
for (const row of rows) {
lines.push(
[
row.date.slice(0, 10),
TYPE_LABELS[row.type] ?? row.type,
escapeCsvField(row.who),
escapeCsvField(row.note),
formatGermanAmount(row.amount),
formatGermanAmount(row.runningTotal),
].join(';'),
);
}
}
return lines.join('\r\n');
}
const PAGE_MARGIN = 40;
const COLORS = {
headerBand: '#4f8f46',
headerText: '#ffffff',
sectionTitle: '#3f7f3c',
tableHeaderBg: '#e8f2e4',
tableHeaderText: '#20251f',
zebra: '#f7f8f2',
border: '#dde3d8',
text: '#20251f',
muted: '#5b6357',
positive: '#2e7d32',
negative: '#c1121f',
receivable: '#9e9e9e',
footerText: '#8a9186',
};
interface Column {
label: string;
width: number;
align?: 'left' | 'right';
}
const CASH_COLUMNS: Column[] = [
{ label: 'Datum', width: 60 },
{ label: 'Typ', width: 60 },
{ label: 'Wer', width: 110 },
{ label: 'Notiz', width: 150 },
{ label: 'Betrag', width: 65, align: 'right' },
{ label: 'Saldo', width: 65, align: 'right' },
];
const RECEIVABLE_COLUMNS: Column[] = [
{ label: 'Datum', width: 60 },
{ label: 'Typ', width: 70 },
{ label: 'Wer', width: 130 },
{ label: 'Notiz', width: 190 },
{ label: 'Betrag', width: 65, align: 'right' },
];
const ROW_HEIGHT = 20;
const HEADER_ROW_HEIGHT = 22;
const CELL_PADDING = 5;
interface TableRow {
cells: string[];
cellColors?: (string | undefined)[];
boldCells?: boolean[];
}
function tableWidth(columns: Column[]): number {
return columns.reduce((sum, col) => sum + col.width, 0);
}
function formatAmount(value: number): string {
return `${formatGermanAmount(value)}`;
}
// Character-count heuristic instead of doc.widthOfString: keeps row height
// fixed at one line without coupling truncation to the exact font metrics
// used at draw time.
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 1)}`;
}
function drawTableHeaderRow(doc: PDFKit.PDFDocument, columns: Column[], y: number): void {
const width = tableWidth(columns);
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).fill(COLORS.tableHeaderBg);
let colX = PAGE_MARGIN;
for (const column of columns) {
doc
.fillColor(COLORS.tableHeaderText)
.font('Helvetica-Bold')
.fontSize(9)
.text(column.label, colX + CELL_PADDING, y + 6, {
width: column.width - CELL_PADDING * 2,
align: column.align ?? 'left',
lineBreak: false,
});
colX += column.width;
}
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).stroke(COLORS.border);
}
function drawTable(
doc: PDFKit.PDFDocument,
columns: Column[],
rows: TableRow[],
startY: number,
pageBottom: number,
): number {
const width = tableWidth(columns);
let y = startY;
drawTableHeaderRow(doc, columns, y);
y += HEADER_ROW_HEIGHT;
rows.forEach((row, index) => {
if (y + ROW_HEIGHT > pageBottom) {
doc.addPage();
y = PAGE_MARGIN;
drawTableHeaderRow(doc, columns, y);
y += HEADER_ROW_HEIGHT;
}
if (index % 2 === 1) {
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.zebra);
}
let colX = PAGE_MARGIN;
row.cells.forEach((cellText, colIndex) => {
const column = columns[colIndex];
doc
.fillColor(row.cellColors?.[colIndex] ?? COLORS.text)
.font(row.boldCells?.[colIndex] ? 'Helvetica-Bold' : 'Helvetica')
.fontSize(9)
.text(cellText, colX + CELL_PADDING, y + 5, {
width: column.width - CELL_PADDING * 2,
align: column.align ?? 'left',
lineBreak: false,
});
colX += column.width;
});
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
y += ROW_HEIGHT;
});
return y;
}
function drawSummaryRow(
doc: PDFKit.PDFDocument,
columns: Column[],
label: string,
value: string,
startY: number,
pageBottom: number,
valueColor: string,
): number {
let y = startY;
if (y + ROW_HEIGHT > pageBottom) {
doc.addPage();
y = PAGE_MARGIN;
}
const width = tableWidth(columns);
const valueColumnWidth = columns[columns.length - 1].width;
const labelWidth = width - valueColumnWidth - CELL_PADDING * 2;
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.tableHeaderBg);
doc
.fillColor(COLORS.tableHeaderText)
.font('Helvetica-Bold')
.fontSize(9)
.text(label, PAGE_MARGIN + CELL_PADDING, y + 5, { width: labelWidth, lineBreak: false });
doc
.fillColor(valueColor)
.font('Helvetica-Bold')
.fontSize(9)
.text(value, PAGE_MARGIN + width - valueColumnWidth + CELL_PADDING, y + 5, {
width: valueColumnWidth - CELL_PADDING * 2,
align: 'right',
lineBreak: false,
});
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
return y + ROW_HEIGHT;
}
function addFooters(doc: PDFKit.PDFDocument, teamName: string): void {
const range = doc.bufferedPageRange();
const generatedAt = new Date().toLocaleDateString('de-DE');
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
const footerY = doc.page.height - 25;
// footerY sits inside the reserved bottom margin (below pdfkit's page
// maxY()). Without an explicit `height`, pdfkit's LineWrapper measures
// overflow against the full-page maxY() and calls addPage() here on every
// iteration - silently appending blank trailing pages. Bounding the text
// to its own small box (well over the 8pt single-line height needed)
// keeps the overflow check local and stops that auto-pagination.
doc
.fontSize(8)
.font('Helvetica')
.fillColor(COLORS.footerText)
.text(`${teamName} Kassenbuch-Report, erstellt am ${generatedAt}`, PAGE_MARGIN, footerY, {
width: doc.page.width - PAGE_MARGIN * 2 - 60,
height: 20,
lineBreak: false,
});
doc
.fontSize(8)
.fillColor(COLORS.footerText)
.text(`Seite ${i - range.start + 1} von ${range.count}`, doc.page.width - PAGE_MARGIN - 60, footerY, {
width: 60,
height: 20,
align: 'right',
lineBreak: false,
});
}
}
export function buildPdf(
team: Pick<Team, 'name'>,
rows: CashboxExportRow[],
receivableRows: CashboxReceivableRow[],
from: string,
to: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: PAGE_MARGIN, bufferPages: true, size: 'A4' });
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const pageWidth = doc.page.width;
const pageBottom = doc.page.height - PAGE_MARGIN - 30;
doc.rect(0, 0, pageWidth, 90).fill(COLORS.headerBand);
doc
.fillColor(COLORS.headerText)
.font('Helvetica-Bold')
.fontSize(20)
.text(team.name, PAGE_MARGIN, 28, { width: pageWidth - PAGE_MARGIN * 2, lineBreak: false });
doc.font('Helvetica').fontSize(11).text('Kassenbuch-Report', PAGE_MARGIN, 55);
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`, PAGE_MARGIN, 70);
let y = 110;
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
doc.text('Kassenbewegungen', PAGE_MARGIN, y);
y += 20;
doc
.fontSize(9)
.font('Helvetica')
.fillColor(COLORS.muted)
.text('Buchungen, die den tatsächlichen Kassenstand verändern.', PAGE_MARGIN, y);
y += 18;
if (rows.length === 0) {
doc
.fontSize(10)
.font('Helvetica-Oblique')
.fillColor(COLORS.muted)
.text('Keine Buchungen im gewählten Zeitraum.', PAGE_MARGIN, y);
y += 24;
} else {
const cashTableRows: TableRow[] = rows.map((row) => ({
cells: [
row.date.slice(0, 10),
TYPE_LABELS[row.type] ?? row.type,
truncate(row.who, 20),
truncate(row.note, 26),
formatAmount(row.amount),
formatAmount(row.runningTotal),
],
cellColors: [
undefined,
undefined,
undefined,
undefined,
row.amount < 0 ? COLORS.negative : COLORS.positive,
undefined,
],
boldCells: [false, false, false, false, false, true],
}));
y = drawTable(doc, CASH_COLUMNS, cashTableRows, y, pageBottom);
const endBalance = rows[rows.length - 1].runningTotal;
y = drawSummaryRow(
doc,
CASH_COLUMNS,
'Endsaldo Kassenbewegungen',
formatAmount(endBalance),
y,
pageBottom,
endBalance < 0 ? COLORS.negative : COLORS.positive,
);
y += 20;
}
y += 10;
if (y + 70 > pageBottom) {
doc.addPage();
y = PAGE_MARGIN;
}
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
doc.text('Forderungen (Strafen, Beiträge, Umlagen)', PAGE_MARGIN, y);
y += 20;
doc
.fontSize(9)
.font('Helvetica')
.fillColor(COLORS.muted)
.text(
'Im Zeitraum angelegte Forderungen gegen Mitglieder. Diese verändern den tatsächlichen Kassenstand nicht, solange sie nicht bezahlt wurden.',
PAGE_MARGIN,
y,
{ width: tableWidth(RECEIVABLE_COLUMNS) },
);
y += 28;
if (receivableRows.length === 0) {
doc
.fontSize(10)
.font('Helvetica-Oblique')
.fillColor(COLORS.muted)
.text('Keine Forderungen im gewählten Zeitraum.', PAGE_MARGIN, y);
y += 24;
} else {
const receivableTableRows: TableRow[] = receivableRows.map((row) => ({
cells: [
row.date.slice(0, 10),
TYPE_LABELS[row.type] ?? row.type,
truncate(row.who, 24),
truncate(row.note, 34),
formatAmount(row.amount),
],
cellColors: [undefined, undefined, undefined, undefined, COLORS.receivable],
}));
y = drawTable(doc, RECEIVABLE_COLUMNS, receivableTableRows, y, pageBottom);
const total = receivableRows.reduce((sum, row) => sum + row.amount, 0);
y = drawSummaryRow(
doc,
RECEIVABLE_COLUMNS,
'Summe Forderungen',
formatAmount(total),
y,
pageBottom,
COLORS.receivable,
);
}
addFooters(doc, team.name);
doc.end();
});
}

View File

@@ -0,0 +1,12 @@
import { IsIn, Matches } from 'class-validator';
export class CashboxExportQueryDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
from: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
to: string;
@IsIn(['csv', 'pdf'])
format: 'csv' | 'pdf';
}

View File

@@ -0,0 +1,8 @@
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
export class CashboxExportSubscriptionResponseDTO {
recipients: string[];
interval: RecurringTransactionIntervalEnum;
active: boolean;
nextRunDate: string | null;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsEmail, IsIn } from 'class-validator';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
export class UpsertCashboxExportSubscriptionDTO {
@ApiProperty({ example: ['vorstand@example.com'] })
@IsArray()
@IsEmail({}, { each: true })
recipients: string[];
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
@IsIn(Object.values(RecurringTransactionIntervalEnum))
interval: RecurringTransactionIntervalEnum;
@ApiProperty({ example: true })
@IsBoolean()
active: boolean;
}

View File

@@ -0,0 +1,26 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { Team } from 'src/teams/entities/team.entity';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
@Entity()
export class CashboxExportSubscription extends EntityHelper {
@PrimaryGeneratedColumn()
id: number;
@OneToOne(() => Team, { eager: false })
@JoinColumn()
team: Team;
@Column({ type: 'simple-array' })
recipients: string[];
@Column()
interval: RecurringTransactionIntervalEnum;
@Column({ default: false })
active: boolean;
@Column({ nullable: true })
nextRunDate: string | null;
}

View File

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

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 { TypeOrmModule } from '@nestjs/typeorm';
import { LogEntry } from './entities/log-entry.entity';
import { LogRetentionScheduler } from './log-retention.scheduler';
import { LoggingService } from './logging.service';
import { LogsController } from './logs.controller';
@Module({
imports: [TypeOrmModule.forFeature([LogEntry])],
providers: [LoggingService],
controllers: [LogsController],
providers: [LoggingService, LogRetentionScheduler],
exports: [LoggingService],
})
export class LoggingModule {}

View File

@@ -23,3 +23,101 @@ describe('LoggingService', () => {
expect(defaultRepository.save).not.toHaveBeenCalled();
});
});
describe('LoggingService.findLogs', () => {
let rows: any[];
let total: number;
let query: any;
let repository: any;
let service: LoggingService;
beforeEach(() => {
rows = [];
total = 0;
query = chain({
getMany: jest.fn(() => rows),
getCount: jest.fn(() => total),
});
repository = {
createQueryBuilder: jest.fn(() => query),
};
service = new LoggingService(repository);
});
it('returns a paginated page with data, total and hasNextPage', async () => {
rows = [
{ id: 1, level: 'INFO', event: 'team_create', details: 'teamId=5', userId: 3, createdAt: new Date('2026-08-01') },
];
total = 21;
const result = await service.findLogs({ page: 1, limit: 20 });
expect(result).toEqual({ data: rows, page: 1, limit: 20, total: 21, hasNextPage: true });
expect(query.orderBy).toHaveBeenCalledWith('log.createdAt', 'DESC');
expect(query.offset).toHaveBeenCalledWith(0);
expect(query.limit).toHaveBeenCalledWith(20);
});
it('reports hasNextPage=false on the last page', async () => {
total = 20;
const result = await service.findLogs({ page: 1, limit: 20 });
expect(result.hasNextPage).toBe(false);
});
it('offsets by (page - 1) * limit', async () => {
await service.findLogs({ page: 3, limit: 10 });
expect(query.offset).toHaveBeenCalledWith(20);
});
it('filters by level and event when provided', async () => {
await service.findLogs({ page: 1, limit: 20, level: 'ERROR', event: 'cashbox_export_subscription_run_fail' });
expect(query.andWhere).toHaveBeenCalledWith('log.level = :level', { level: 'ERROR' });
expect(query.andWhere).toHaveBeenCalledWith('log.event = :event', {
event: 'cashbox_export_subscription_run_fail',
});
});
it('filters by an inclusive date range when from/to are provided', async () => {
await service.findLogs({ page: 1, limit: 20, from: '2026-01-01', to: '2026-01-31' });
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt >= :from', { from: '2026-01-01' });
// `to` is a plain date (e.g. from a <input type="date">); comparing it
// as-is would parse to midnight and exclude the whole last day, so it
// must be widened to the end of that day to be genuinely inclusive.
expect(query.andWhere).toHaveBeenCalledWith('log.createdAt <= :to', {
to: new Date('2026-01-31T23:59:59.999Z'),
});
});
it('does not add level/event/date filters when omitted', async () => {
await service.findLogs({ page: 1, limit: 20 });
expect(query.andWhere).not.toHaveBeenCalled();
});
it('filters details by a case-insensitive search term', async () => {
await service.findLogs({ page: 1, limit: 20, search: ' TeamId=5 ' });
expect(query.andWhere).toHaveBeenCalledWith('LOWER(log.details) LIKE :search', {
search: '%teamid=5%',
});
});
it('ignores a blank search term', async () => {
await service.findLogs({ page: 1, limit: 20, search: ' ' });
expect(query.andWhere).not.toHaveBeenCalled();
});
function chain(overrides: Record<string, jest.Mock>) {
const builder: Record<string, jest.Mock> = {};
['andWhere', 'orderBy', 'offset', 'limit'].forEach((method) => {
builder[method] = jest.fn(() => builder);
});
return Object.assign(builder, overrides);
}
});

View File

@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { CreateLogDTO } from './dto/create-log.dto';
import { LogEntry } from './entities/log-entry.entity';
import { LOGEVENT } from './model/logging-event.type';
import { LOGEVENT, LOGLEVEL } from './model/logging-event.type';
@Injectable()
export class LoggingService {
@@ -95,4 +95,45 @@ export class LoggingService {
};
await this.repository.save(e);
}
async findLogs(query: {
page: number;
limit: number;
level?: LOGLEVEL;
event?: LOGEVENT;
from?: string;
to?: string;
search?: string;
}): Promise<{
data: LogEntry[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}> {
const builder = this.repository.createQueryBuilder('log');
if (query.level) builder.andWhere('log.level = :level', { level: query.level });
if (query.event) builder.andWhere('log.event = :event', { event: query.event });
if (query.from) builder.andWhere('log.createdAt >= :from', { from: query.from });
if (query.to) {
builder.andWhere('log.createdAt <= :to', { to: new Date(`${query.to}T23:59:59.999Z`) });
}
const term = query.search?.trim().toLocaleLowerCase();
if (term) {
builder.andWhere('LOWER(log.details) LIKE :search', { search: `%${term}%` });
}
const total = await builder.getCount();
const data = await builder
.orderBy('log.createdAt', 'DESC')
.offset((query.page - 1) * query.limit)
.limit(query.limit)
.getMany();
return {
data,
page: query.page,
limit: query.limit,
total,
hasNextPage: query.page * query.limit < total,
};
}
}

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

@@ -26,6 +26,72 @@ export type LOGEVENT =
| 'penalty_catalog_update'
| 'penalty_catalog_delete'
| 'team_create'
| 'team_permissions_update';
| 'team_permissions_update'
| 'scheduled_recurring_transaction_check_start'
| 'scheduled_recurring_transaction_check_finished'
| 'recurring_transaction_create'
| 'recurring_transaction_update'
| 'recurring_transaction_delete'
| 'recurring_transaction_run'
| 'cashbox_export_download'
| 'cashbox_export_subscription_update'
| 'cashbox_export_subscription_run'
| 'cashbox_export_subscription_run_fail'
| 'log_retention_cleanup_run'
| 'log_retention_cleanup_run_fail'
| 'notification_create_fail'
| 'notification_retention_cleanup_run'
| 'notification_retention_cleanup_run_fail'
| 'public_access_enabled'
| 'public_access_rotated';
export const LOGEVENT_VALUES: LOGEVENT[] = [
'user_create',
'application_start',
'transaction_create',
'team_transaction_create',
'team_transaction_get',
'user_login_success',
'user_login_fail',
'user_token_verification_success',
'user_token_verification_fail',
'user_invite_link_create',
'user_invite_link_validate',
'user_invite_link_validate_fail',
'transaction_create_fail',
'transaction_reverse',
'player_creation',
'admin_user_profile_update',
'admin_user_role_update',
'admin_user_status_update',
'admin_player_assign',
'admin_player_unlink',
'player_active_update',
'player_team_role_update',
'penalty_catalog_create',
'penalty_catalog_update',
'penalty_catalog_delete',
'team_create',
'team_permissions_update',
'scheduled_recurring_transaction_check_start',
'scheduled_recurring_transaction_check_finished',
'recurring_transaction_create',
'recurring_transaction_update',
'recurring_transaction_delete',
'recurring_transaction_run',
'cashbox_export_download',
'cashbox_export_subscription_update',
'cashbox_export_subscription_run',
'cashbox_export_subscription_run_fail',
'log_retention_cleanup_run',
'log_retention_cleanup_run_fail',
'notification_create_fail',
'notification_retention_cleanup_run',
'notification_retention_cleanup_run_fail',
'public_access_enabled',
'public_access_rotated',
];
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
export const LOGLEVEL_VALUES: LOGLEVEL[] = ['FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];

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,5 @@
{{#> layout}}
<p>Hallo,</p>
<p>im Anhang findest du den automatischen Kassenbuch-Export für <strong>{{teamName}}</strong> für den Zeitraum {{from}} bis {{to}}.</p>
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt und benötigt keine weitere Aktion.</p>
{{/layout}}

View File

@@ -60,4 +60,20 @@ describe('mail templates rendering', () => {
expect(html).toContain('Hallo,');
expect(html).not.toContain('Hallo Max,');
});
it('renders cashbox-export.hbs with team name and period', () => {
const source = fs.readFileSync(path.join(templatesDir, 'cashbox-export.hbs'), 'utf-8');
const html = Handlebars.compile(source, { strict: true })({
title: 'Kassenbuch-Export Team A',
year: 2026,
teamName: 'Team A',
from: '2026-08-01',
to: '2026-08-31',
});
expect(html).toContain('TeamWallet');
expect(html).toContain('Team A');
expect(html).toContain('2026-08-01');
expect(html).toContain('2026-08-31');
});
});

View File

@@ -58,4 +58,28 @@ describe('MailService', () => {
const call = sendMail.mock.calls[0][0];
expect(call.context.firstName).toBeUndefined();
});
it('sends the cashbox export mail with the PDF attachment', async () => {
const attachment = Buffer.from('%PDF-1.4 fake');
await service.cashboxExport(
{
to: 'vorstand@example.com, kassier@example.com',
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
},
attachment,
'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
);
expect(sendMail).toHaveBeenCalledTimes(1);
const call = sendMail.mock.calls[0][0];
expect(call.to).toBe('vorstand@example.com, kassier@example.com');
expect(call.template).toBe('cashbox-export');
expect(call.context.teamName).toBe('Team A');
expect(call.context.from).toBe('2026-08-01');
expect(call.context.to).toBe('2026-08-31');
expect(call.attachments).toEqual([
{ filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf', content: attachment },
]);
});
});

View File

@@ -55,4 +55,24 @@ export class MailService {
},
});
}
async cashboxExport(
mailData: MailData<{ teamName: string; from: string; to: string }>,
attachment: Buffer,
filename: string,
): Promise<void> {
await this.mailerService.sendMail({
to: mailData.to,
subject: `Kassenbuch-Export ${mailData.data.teamName}`,
template: 'cashbox-export',
context: {
title: `Kassenbuch-Export ${mailData.data.teamName}`,
year: new Date().getFullYear(),
teamName: mailData.data.teamName,
from: mailData.data.from,
to: mailData.data.to,
},
attachments: [{ filename, content: attachment }],
});
}
}

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,49 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsString,
Length,
Max,
Min,
} from 'class-validator';
import { TransactionTypeEnum } from 'src/transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
const ALLOWED_TYPES = [TransactionTypeEnum.fee, TransactionTypeEnum.levy];
export class CreateRecurringTransactionDTO {
@ApiProperty({ example: 2342 })
@IsInt()
@Min(1)
teamId: number;
@ApiProperty({ example: 'Monatsbeitrag' })
@Transform(({ value }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@Length(1, 120)
description: string;
@ApiProperty({ example: 10 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(10000)
amount: number;
@ApiProperty({ enum: ALLOWED_TYPES })
@IsIn(ALLOWED_TYPES)
type: TransactionTypeEnum;
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
@IsIn(Object.values(RecurringTransactionIntervalEnum))
interval: RecurringTransactionIntervalEnum;
@ApiProperty({ example: 'isotimestring' })
@IsNotEmpty()
startDate: string;
}

View File

@@ -0,0 +1,12 @@
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
export class RecurringTransactionResponseDTO {
id: number;
description: string;
amount: number;
type: number;
interval: RecurringTransactionIntervalEnum;
nextRunDate: string;
active: boolean;
createdAt: Date;
}

View File

@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsNumber,
IsString,
Length,
Max,
Min,
} from 'class-validator';
import { TransactionTypeEnum } from 'src/transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
const ALLOWED_TYPES = [TransactionTypeEnum.fee, TransactionTypeEnum.levy];
export class UpdateRecurringTransactionDTO {
@ApiProperty({ example: 'Monatsbeitrag' })
@Transform(({ value }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@Length(1, 120)
description: string;
@ApiProperty({ example: 10 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(10000)
amount: number;
@ApiProperty({ enum: ALLOWED_TYPES })
@IsIn(ALLOWED_TYPES)
type: TransactionTypeEnum;
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
@IsIn(Object.values(RecurringTransactionIntervalEnum))
interval: RecurringTransactionIntervalEnum;
@ApiProperty({ example: true })
@IsBoolean()
active: boolean;
}

View File

@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { Team } from 'src/teams/entities/team.entity';
import { TransactionType } from 'src/transactions/entitites/transaction-type.entity';
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
@Entity()
export class RecurringTransaction extends EntityHelper {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Team, {
eager: false,
})
team: Team;
@Column({ default: '' })
description: string;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
amount: number;
@ManyToOne(() => TransactionType, {
eager: true,
})
type: TransactionType;
@Column()
interval: RecurringTransactionIntervalEnum;
@Column()
nextRunDate: string;
@Column({ default: true })
active: boolean;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -0,0 +1,5 @@
export enum RecurringTransactionIntervalEnum {
'monthly' = 'monthly',
'quarterly' = 'quarterly',
'yearly' = 'yearly',
}

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

@@ -0,0 +1,77 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger';
import { Roles } from '../roles/roles.decorator';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
import { RecurringTransactionsService } from './recurring-transactions.service';
type AuthenticatedRequest = { user: { id: number } };
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller({ path: 'recurring-transactions', version: '1' })
export class RecurringTransactionsController {
constructor(
private readonly service: RecurringTransactionsService,
private readonly scheduler: RecurringTransactionsScheduler,
) {}
@Get(':teamId')
getTeamRecurringTransactions(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
) {
return this.service.getTeamRecurringTransactions(request.user.id, teamId);
}
@Post()
createRecurringTransaction(
@Request() request: AuthenticatedRequest,
@Body() dto: CreateRecurringTransactionDTO,
) {
return this.service.createRecurringTransaction(dto, request.user.id);
}
@Patch(':id')
updateRecurringTransaction(
@Request() request: AuthenticatedRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateRecurringTransactionDTO,
) {
return this.service.updateRecurringTransaction(id, dto, request.user.id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteRecurringTransaction(
@Request() request: AuthenticatedRequest,
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
await this.service.deleteRecurringTransaction(id, request.user.id);
}
@Post('admin/run')
@HttpCode(HttpStatus.OK)
@UseGuards(RolesGuard)
@Roles([RoleEnum.admin])
runDueRecurringTransactionsNow(): Promise<void> {
return this.scheduler.runDueRecurringTransactions();
}
}

View File

@@ -0,0 +1,153 @@
import {
INestApplication,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import validationOptions from '../utils/validation-options';
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransactionsController } from './recurring-transactions.controller';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
import { RecurringTransactionsService } from './recurring-transactions.service';
describe('recurring transactions HTTP boundary', () => {
let app: INestApplication;
const entry = {
id: 8,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: '2026-01-02T00:00:00.000Z',
};
const service = {
getTeamRecurringTransactions: jest.fn(() => [entry]),
createRecurringTransaction: jest.fn(() => entry),
updateRecurringTransaction: jest.fn(() => entry),
deleteRecurringTransaction: jest.fn(),
};
const scheduler = { runDueRecurringTransactions: jest.fn() };
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [RecurringTransactionsController],
providers: [
{ provide: RecurringTransactionsService, useValue: service },
{ provide: RecurringTransactionsScheduler, useValue: scheduler },
],
})
.overrideGuard(AuthGuard('jwt'))
.useValue({
canActivate(context) {
const httpRequest = context.switchToHttp().getRequest();
if (httpRequest.headers.authorization !== 'Bearer user') {
throw new UnauthorizedException();
}
httpRequest.user = { id: 42, role: { id: 2 } };
return true;
},
})
.compile();
app = module.createNestApplication();
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
app.useGlobalPipes(new ValidationPipe(validationOptions));
await app.init();
});
afterAll(() => app.close());
beforeEach(() => jest.clearAllMocks());
it('requires authentication for team reads', async () => {
await request(app.getHttpServer())
.get('/api/v1/recurring-transactions/5')
.expect(401);
await request(app.getHttpServer())
.get('/api/v1/recurring-transactions/5')
.set('Authorization', 'Bearer user')
.expect(200)
.expect([entry]);
expect(service.getTeamRecurringTransactions).toHaveBeenCalledWith(42, 5);
});
it('rejects a create payload with a disallowed transaction type', async () => {
await request(app.getHttpServer())
.post('/api/v1/recurring-transactions')
.set('Authorization', 'Bearer user')
.send({
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.payment,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
})
.expect(422);
expect(service.createRecurringTransaction).not.toHaveBeenCalled();
});
it('accepts a valid create payload and forwards the actor', async () => {
await request(app.getHttpServer())
.post('/api/v1/recurring-transactions')
.set('Authorization', 'Bearer user')
.send({
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
})
.expect(201)
.expect(entry);
expect(service.createRecurringTransaction).toHaveBeenCalledWith(
{
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
},
42,
);
});
it('updates and deletes by id', async () => {
await request(app.getHttpServer())
.patch('/api/v1/recurring-transactions/8')
.set('Authorization', 'Bearer user')
.send({
description: 'Monatsbeitrag',
amount: 12,
type: TransactionTypeEnum.levy,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
})
.expect(200)
.expect(entry);
expect(service.updateRecurringTransaction).toHaveBeenCalledWith(
8,
{
description: 'Monatsbeitrag',
amount: 12,
type: TransactionTypeEnum.levy,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
},
42,
);
await request(app.getHttpServer())
.delete('/api/v1/recurring-transactions/8')
.set('Authorization', 'Bearer user')
.expect(204);
expect(service.deleteRecurringTransaction).toHaveBeenCalledWith(8, 42);
});
});

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 { Transaction } from 'src/transactions/entitites/transaction.entity';
import { Team } from 'src/teams/entities/team.entity';
import { TeamsModule } from 'src/teams/teams.module';
import { RecurringTransactionsController } from './recurring-transactions.controller';
import { RecurringTransaction } from './entities/recurring-transaction.entity';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
import { RecurringTransactionsService } from './recurring-transactions.service';
@Module({
controllers: [RecurringTransactionsController],
providers: [RecurringTransactionsService, RecurringTransactionsScheduler],
imports: [
TypeOrmModule.forFeature([Team, RecurringTransaction, Player, Transaction]),
TeamsModule,
LoggingModule,
],
})
export class RecurringTransactionsModule {}

View File

@@ -0,0 +1,156 @@
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
describe('RecurringTransactionsScheduler', () => {
const dueRepository = { find: jest.fn() };
const playerRepository = { find: jest.fn() };
const definitionWriteRepository = { save: jest.fn((value) => value) };
const transactionWriteRepository = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ id: 1, ...value })),
};
const manager = {
getRepository: jest.fn((entity) => {
if (entity.name === 'Player') return playerRepository;
if (entity.name === 'Transaction') return transactionWriteRepository;
return definitionWriteRepository;
}),
};
const dataSource = { transaction: jest.fn((work) => work(manager)) };
const logger = { info: jest.fn() };
let scheduler: RecurringTransactionsScheduler;
const player = (id: number, active: boolean) => ({ id, active, team: { id: 5 } });
beforeEach(() => {
jest.clearAllMocks();
});
function buildScheduler() {
return new RecurringTransactionsScheduler(
dueRepository as any,
dataSource as any,
logger as any,
);
}
it('does nothing when no recurring transaction is due', async () => {
dueRepository.find.mockResolvedValue([]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_start' }),
);
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ event: 'scheduled_recurring_transaction_check_finished' }),
);
});
it('books a transaction for every active player and skips inactive ones', async () => {
dueRepository.find.mockResolvedValue([
{
id: 3,
team: { id: 5 },
description: 'Monatsbeitrag',
amount: 10,
type: { id: TransactionTypeEnum.fee },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-08-01T00:00:00.000Z',
active: true,
},
]);
// The DB query filters by active:true itself (asserted below) — an
// inactive player would never be returned, so the mock reflects that.
playerRepository.find.mockResolvedValue([player(1, true), player(3, true)]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(playerRepository.find).toHaveBeenCalledWith({
where: { team: { id: 5 }, active: true },
});
expect(transactionWriteRepository.save).toHaveBeenCalledTimes(2);
expect(transactionWriteRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
amount: 10,
note: 'Monatsbeitrag',
player: player(1, true),
type: { id: TransactionTypeEnum.fee },
}),
);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_run',
details:
'recurringTransactionId=3 teamId=5 gebuchteSpieler=2',
userId: -1,
},
manager,
);
});
it.each([
[RecurringTransactionIntervalEnum.monthly, '2026-08-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.quarterly, '2026-08-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.yearly, '2026-08-01T00:00:00.000Z', '2027-08-01T00:00:00.000Z'],
])(
'advances nextRunDate by %s from %s to %s',
async (interval, nextRunDate, expected) => {
dueRepository.find.mockResolvedValue([
{
id: 3,
team: { id: 5 },
description: 'Monatsbeitrag',
amount: 10,
type: { id: TransactionTypeEnum.fee },
interval,
nextRunDate,
active: true,
},
]);
playerRepository.find.mockResolvedValue([player(1, true)]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(definitionWriteRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ nextRunDate: expected }),
);
},
);
it('processes each due definition inside its own transaction', async () => {
dueRepository.find.mockResolvedValue([
{
id: 3,
team: { id: 5 },
description: 'A',
amount: 10,
type: { id: TransactionTypeEnum.fee },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-08-01T00:00:00.000Z',
active: true,
},
{
id: 4,
team: { id: 6 },
description: 'B',
amount: 20,
type: { id: TransactionTypeEnum.levy },
interval: RecurringTransactionIntervalEnum.yearly,
nextRunDate: '2026-08-01T00:00:00.000Z',
active: true,
},
]);
playerRepository.find.mockResolvedValue([player(1, true)]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(dataSource.transaction).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Player } from 'src/players/entities/player.entity';
import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { DataSource, LessThanOrEqual, Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransaction } from './entities/recurring-transaction.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class RecurringTransactionsScheduler {
constructor(
@InjectRepository(RecurringTransaction)
private readonly repository: Repository<RecurringTransaction>,
private readonly dataSource: DataSource,
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_8AM)
async runDueRecurringTransactions(): Promise<void> {
const start = Date.now();
await this.logger.info(
{
event: 'scheduled_recurring_transaction_check_start',
details: `Starting sheduled recurring Transaction check`,
userId: -1,
},
);
const today = new Date().toISOString();
const due = await this.repository.find({
where: { active: true, nextRunDate: LessThanOrEqual(today) },
relations: ['team', 'type'],
});
for (const definition of due) {
await this.runOne(definition);
}
await this.logger.info(
{
event: 'scheduled_recurring_transaction_check_finished',
details: `Finished sheduled recurring Transaction check, durationMS=${Date.now() - start}`,
userId: -1,
},
);
}
private async runOne(definition: RecurringTransaction): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const players = await manager
.getRepository(Player)
.find({ where: { team: { id: definition.team.id }, active: true } });
const transactionRepository = manager.getRepository(Transaction);
for (const player of players) {
const transaction = transactionRepository.create({
amount: definition.amount,
date: new Date().toISOString(),
note: definition.description,
player,
type: definition.type,
});
await transactionRepository.save(transaction);
}
const definitionRepository = manager.getRepository(RecurringTransaction);
definition.nextRunDate = this.advance(
definition.nextRunDate,
definition.interval,
);
await definitionRepository.save(definition);
await this.logger.info(
{
event: 'recurring_transaction_run',
details: `recurringTransactionId=${definition.id} teamId=${definition.team.id} gebuchteSpieler=${players.length}`,
userId: -1,
},
manager,
);
});
}
private advance(
nextRunDate: string,
interval: RecurringTransactionIntervalEnum,
): string {
const date = new Date(nextRunDate);
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
}

View File

@@ -0,0 +1,245 @@
import { NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransactionsService } from './recurring-transactions.service';
describe('RecurringTransactionsService catalog management', () => {
const readRepository = { find: jest.fn(), findOne: jest.fn() };
const teamReadRepository = { findOne: jest.fn() };
const teamQuery = {
where: jest.fn().mockReturnThis(),
setLock: jest.fn().mockReturnThis(),
getOne: jest.fn(),
};
const teamRepository = { createQueryBuilder: jest.fn(() => teamQuery) };
const typeRepository = { findOne: jest.fn() };
const writeRepository = {
create: jest.fn((value) => value),
save: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
};
const manager = {
getRepository: jest.fn((entity) => {
if (entity.name === 'Team') return teamRepository;
if (entity.name === 'TransactionType') return typeRepository;
return writeRepository;
}),
};
const dataSource = { transaction: jest.fn((work) => work(manager)) };
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: RecurringTransactionsService;
beforeEach(() => {
jest.clearAllMocks();
teamQuery.getOne.mockResolvedValue({ id: 5 });
teamReadRepository.findOne.mockResolvedValue({ id: 5 });
typeRepository.findOne.mockResolvedValue({
id: TransactionTypeEnum.fee,
name: 'fee',
});
service = new RecurringTransactionsService(
readRepository as any,
teamReadRepository as any,
dataSource as any,
access as any,
logger as any,
);
});
it('returns not found instead of leaking an unknown team as an empty list', async () => {
teamReadRepository.findOne.mockResolvedValue(null);
await expect(
service.getTeamRecurringTransactions(42, 999),
).rejects.toBeInstanceOf(NotFoundException);
expect(access.assertMember).not.toHaveBeenCalled();
});
it('authorizes team reads, sorts them, and maps only safe fields', async () => {
readRepository.find.mockResolvedValue([
{
id: 8,
description: 'Monatsbeitrag',
amount: '10.00',
type: { id: TransactionTypeEnum.fee, name: 'fee' },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: new Date('2026-01-02T00:00:00.000Z'),
team: { id: 5, secret: 'hidden' },
},
]);
await expect(
service.getTeamRecurringTransactions(42, 5),
).resolves.toEqual([
{
id: 8,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: new Date('2026-01-02T00:00:00.000Z'),
},
]);
expect(access.assertMember).toHaveBeenCalledWith(42, 5);
expect(readRepository.find).toHaveBeenCalledWith({
where: { team: { id: 5 } },
order: { description: 'ASC' },
});
});
it('creates a normalized entry under a team lock, resolves the type, and audits in the transaction', async () => {
writeRepository.save.mockImplementation(async (value) => ({
id: 9,
createdAt: new Date('2026-01-03T00:00:00.000Z'),
...value,
}));
await expect(
service.createRecurringTransaction(
{
teamId: 5,
description: ' Monatsbeitrag ',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
},
42,
),
).resolves.toMatchObject({
id: 9,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
});
expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
expect(typeRepository.findOne).toHaveBeenCalledWith({
where: { id: TransactionTypeEnum.fee },
});
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_create',
details: 'teamId=5 recurringTransactionId=9 action=create',
userId: 42,
},
manager,
);
});
it('rejects an unknown transaction type before writing or auditing', async () => {
typeRepository.findOne.mockResolvedValue(null);
await expect(
service.createRecurringTransaction(
{
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
},
42,
),
).rejects.toBeInstanceOf(NotFoundException);
expect(writeRepository.save).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
it('updates fields in the owning team without touching nextRunDate, and audits it', async () => {
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
writeRepository.findOne.mockResolvedValue({
id: 8,
team: { id: 5 },
description: 'Alt',
amount: 5,
type: { id: TransactionTypeEnum.levy },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: new Date('2026-01-02T00:00:00.000Z'),
});
writeRepository.save.mockImplementation(async (value) => value);
await expect(
service.updateRecurringTransaction(
8,
{
description: ' Neu ',
amount: 12,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
},
42,
),
).resolves.toMatchObject({
id: 8,
description: 'Neu',
amount: 12,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
nextRunDate: '2026-09-01T00:00:00.000Z',
});
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_update',
details: 'teamId=5 recurringTransactionId=8 action=update',
userId: 42,
},
manager,
);
});
it('deletes an entry permanently after manager authorization and audits it', async () => {
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
const entry = { id: 8, team: { id: 5 } };
writeRepository.findOne.mockResolvedValue(entry);
await service.deleteRecurringTransaction(8, 42);
expect(writeRepository.remove).toHaveBeenCalledWith(entry);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_delete',
details: 'teamId=5 recurringTransactionId=8 action=delete',
userId: 42,
},
manager,
);
});
it('returns not found when a mutation target does not exist', async () => {
readRepository.findOne.mockResolvedValue(null);
await expect(
service.deleteRecurringTransaction(999, 42),
).rejects.toBeInstanceOf(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,202 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { Team } from 'src/teams/entities/team.entity';
import { TeamAccessService } from 'src/teams/team-access.service';
import { TransactionType } from 'src/transactions/entitites/transaction-type.entity';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
import { RecurringTransactionResponseDTO } from './dto/recurring-transaction-response.dto';
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
import { RecurringTransaction } from './entities/recurring-transaction.entity';
@Injectable()
export class RecurringTransactionsService {
constructor(
@InjectRepository(RecurringTransaction)
private readonly repository: Repository<RecurringTransaction>,
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly dataSource: DataSource,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async getTeamRecurringTransactions(
userId: number,
teamId: number,
): Promise<RecurringTransactionResponseDTO[]> {
const team = await this.teamRepository.findOne({ where: { id: teamId } });
if (!team) throw new NotFoundException('Team nicht gefunden.');
await this.access.assertMember(userId, teamId);
const entries = await this.repository.find({
where: { team: { id: teamId } },
order: { description: 'ASC' },
});
return entries.map((entry) => this.toResponse(entry));
}
createRecurringTransaction(
dto: CreateRecurringTransactionDTO,
userId: number,
): Promise<RecurringTransactionResponseDTO> {
return this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, dto.teamId);
await this.access.assertAtLeast(
userId,
team.id,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
const type = await this.findType(manager, dto.type);
const repository = manager.getRepository(RecurringTransaction);
const saved = await repository.save(
repository.create({
team,
description: dto.description.trim(),
amount: dto.amount,
type,
interval: dto.interval,
nextRunDate: dto.startDate,
active: true,
}),
);
await this.logger.info(
{
event: 'recurring_transaction_create',
details: `teamId=${team.id} recurringTransactionId=${saved.id} action=create`,
userId,
},
manager,
);
return this.toResponse(saved);
});
}
async updateRecurringTransaction(
id: number,
dto: UpdateRecurringTransactionDTO,
userId: number,
): Promise<RecurringTransactionResponseDTO> {
const owner = await this.findOwner(id);
return this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, owner.team.id);
await this.access.assertAtLeast(
userId,
team.id,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
const type = await this.findType(manager, dto.type);
const repository = manager.getRepository(RecurringTransaction);
const entry = await this.findTransactionalEntry(repository, id, team.id);
entry.description = dto.description.trim();
entry.amount = dto.amount;
entry.type = type;
entry.interval = dto.interval;
entry.active = dto.active;
const saved = await repository.save(entry);
await this.logger.info(
{
event: 'recurring_transaction_update',
details: `teamId=${team.id} recurringTransactionId=${id} action=update`,
userId,
},
manager,
);
return this.toResponse(saved);
});
}
async deleteRecurringTransaction(id: number, userId: number): Promise<void> {
const owner = await this.findOwner(id);
await this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, owner.team.id);
await this.access.assertAtLeast(
userId,
team.id,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
const repository = manager.getRepository(RecurringTransaction);
const entry = await this.findTransactionalEntry(repository, id, team.id);
await repository.remove(entry);
await this.logger.info(
{
event: 'recurring_transaction_delete',
details: `teamId=${team.id} recurringTransactionId=${id} action=delete`,
userId,
},
manager,
);
});
}
private async findOwner(id: number): Promise<RecurringTransaction> {
const entry = await this.repository.findOne({
where: { id },
relations: ['team'],
});
if (!entry?.team)
throw new NotFoundException('Wiederkehrende Buchung nicht gefunden.');
return entry;
}
private async lockTeam(
manager: EntityManager,
teamId: number,
): Promise<Team> {
const team = await manager
.getRepository(Team)
.createQueryBuilder('team')
.where('team.id = :teamId', { teamId })
.setLock('pessimistic_write')
.getOne();
if (!team) throw new NotFoundException('Team nicht gefunden.');
return team;
}
private async findType(
manager: EntityManager,
typeId: number,
): Promise<TransactionType> {
const type = await manager
.getRepository(TransactionType)
.findOne({ where: { id: typeId } });
if (!type) throw new NotFoundException('Buchungstyp nicht gefunden.');
return type;
}
private async findTransactionalEntry(
repository: Repository<RecurringTransaction>,
id: number,
teamId: number,
): Promise<RecurringTransaction> {
const entry = await repository.findOne({
where: { id, team: { id: teamId } },
relations: ['team'],
});
if (!entry)
throw new NotFoundException('Wiederkehrende Buchung nicht gefunden.');
return entry;
}
private toResponse(
entry: RecurringTransaction,
): RecurringTransactionResponseDTO {
return {
id: entry.id,
description: entry.description,
amount: Number(entry.amount),
type: entry.type.id,
interval: entry.interval,
nextRunDate: entry.nextRunDate,
active: entry.active,
createdAt: entry.createdAt,
};
}
}

View File

@@ -13,6 +13,8 @@ describe('PublicTeamAccessService', () => {
const penaltyRepository = { find: jest.fn() };
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
let service: PublicTeamAccessService;
let logger: any;
let eventEmitter: any;
const managedTeam = {
id: 7,
@@ -25,12 +27,16 @@ describe('PublicTeamAccessService', () => {
beforeEach(() => {
jest.resetAllMocks();
teamRepository.save.mockImplementation(async (team) => team);
logger = { info: jest.fn() };
eventEmitter = { emit: jest.fn() };
service = new PublicTeamAccessService(
teamRepository as any,
playerRepository as any,
transactionRepository as any,
penaltyRepository as any,
access as any,
logger as any,
eventEmitter as any,
);
});
@@ -82,6 +88,47 @@ describe('PublicTeamAccessService', () => {
expect(status.token).not.toBe('a'.repeat(64));
});
it('logs and emits when public access is enabled', async () => {
mockManagedTeam();
await service.setEnabled(4, 7, true);
expect(logger.info).toHaveBeenCalledWith({
event: 'public_access_enabled',
details: 'teamId=7',
userId: 4,
});
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.public_access.enabled',
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
);
});
it('does not log or emit when public access is disabled', async () => {
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
await service.setEnabled(4, 7, false);
expect(logger.info).not.toHaveBeenCalled();
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('logs and emits when the token is rotated', async () => {
mockManagedTeam({ ...managedTeam, publicAccessEnabled: true, publicAccessToken: 'a'.repeat(64) });
await service.rotate(4, 7);
expect(logger.info).toHaveBeenCalledWith({
event: 'public_access_rotated',
details: 'teamId=7',
userId: 4,
});
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.public_access.rotated',
expect.objectContaining({ teamId: 7, actorUserId: 4 }),
);
});
it('returns only whitelisted public team fields and active players', async () => {
teamRepository.findOne.mockResolvedValue({
id: 7,

View File

@@ -1,6 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { randomBytes } from 'crypto';
import { LoggingService } from '../database/logging/logging.service';
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
import {
PublicAccessEnabledEvent,
PublicAccessRotatedEvent,
} from '../notifications/events/public-access-changed.event';
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
import { Player } from '../players/entities/player.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
@@ -28,6 +35,8 @@ export class PublicTeamAccessService {
@InjectRepository(PenaltyEntity)
private readonly penaltyRepository: Repository<PenaltyEntity>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
private readonly eventEmitter: EventEmitter2,
) {}
async getStatus(
@@ -55,6 +64,19 @@ export class PublicTeamAccessService {
}
team.publicAccessEnabled = enabled;
await this.teamRepository.save(team);
if (enabled) {
await this.logger.info({
event: 'public_access_enabled',
details: `teamId=${teamId}`,
userId,
});
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.publicAccessEnabled,
new PublicAccessEnabledEvent(teamId, userId),
);
}
return this.toStatus(team);
}
@@ -68,6 +90,17 @@ export class PublicTeamAccessService {
const team = await this.loadManagedTeam(teamId);
team.publicAccessToken = this.createToken();
await this.teamRepository.save(team);
await this.logger.info({
event: 'public_access_rotated',
details: `teamId=${teamId}`,
userId,
});
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.publicAccessRotated,
new PublicAccessRotatedEvent(teamId, userId),
);
return this.toStatus(team);
}

View File

@@ -18,6 +18,7 @@ describe('TeamMembersService', () => {
let dataSource: any;
let logger: any;
let access: any;
let eventEmitter: any;
let service: TeamMembersService;
beforeEach(() => {
@@ -44,7 +45,8 @@ describe('TeamMembersService', () => {
dataSource = { transaction: jest.fn((work) => work(manager)) };
logger = { info: jest.fn() };
access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
service = new TeamMembersService(dataSource, logger, access as any);
eventEmitter = { emit: jest.fn() };
service = new TeamMembersService(dataSource, logger, access as any, eventEmitter as any);
});
it('checks the team-manager permission before touching the database', async () => {
@@ -188,6 +190,53 @@ describe('TeamMembersService', () => {
).rejects.toBeInstanceOf(NotFoundException);
});
it('emits a player-active-changed event after a real deactivation', async () => {
player.balance = 42;
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
await service.setActive(5, teamId, player.id, false);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.active_changed',
expect.objectContaining({
teamId,
actorUserId: 5,
playerId: player.id,
playerName: 'Pat Player',
active: false,
}),
);
});
it('does not emit when the active state is unchanged (idempotent)', async () => {
player = makePlayer(101, true, TeamRolesEnum.player, 0);
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
);
await service.setActive(5, teamId, player.id, true);
expect(eventEmitter.emit).not.toHaveBeenCalled();
});
it('emits a player-role-changed event after a real role change', async () => {
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
await service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.role_changed',
expect.objectContaining({
teamId,
actorUserId: 5,
playerId: player.id,
playerName: 'Pat Player',
teamRoleId: TeamRolesEnum.captain,
}),
);
});
function makePlayer(
id: number,
active: boolean,

View File

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

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 { TeamsController } from './teams.controller';
describe('TeamsController', () => {
const service = { createNewTeam: jest.fn() };
const publicAccess = {};
const teamMembers = {};
const teamPermissions = {};
const controller = new TeamsController(
service as any,
publicAccess as any,
teamMembers as any,
teamPermissions as any,
);
beforeEach(() => jest.clearAllMocks());
it('allows any logged-in user (not just admins) to create a team', () => {
expect(Reflect.getMetadata('roles', TeamsController.prototype.create)).toEqual([
RoleEnum.user,
RoleEnum.admin,
]);
expect(
Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.create),
).toContain(RolesGuard);
});
it('passes the authenticated user and the DTO to the service', () => {
const req = { user: { id: 42 } };
const dto = { name: '1. Herren' };
void controller.create(req as any, dto as any);
expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42);
});
});

View File

@@ -9,16 +9,18 @@ import {
Put,
Patch,
ParseIntPipe,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { TransactionsQueryDto } from 'src/transactions/dto/transactions-query.dto';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/roles/roles.decorator';
import { RoleEnum } from 'src/roles/roles.enum';
import { RolesGuard } from 'src/roles/roles.guard';
import { CreateTeamDTO } from './dto/create-team.dto';
import { TeamsService } from './teams.service';
import { TeamActivityRow, TeamsService } from './teams.service';
import { PublicTeamAccessService } from './public-team-access.service';
import { UpdatePublicAccessDto } from './dto/public-access.dto';
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
@@ -142,6 +144,25 @@ export class TeamsController {
return this.service.getTeamTransactions(id, userId);
}
@ApiOperation({
summary: 'Paginiertes Kassenjournal für ein Team',
description:
'Gibt Transaktionen (Spieler und Teamwallet) seitenweise zurück, mit serverseitiger Sortierung, Typ-Filter und Freitextsuche - für das AG-Grid-Journal.',
})
@ApiBearerAuth()
@Roles([RoleEnum.user, RoleEnum.admin])
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Get(':id/transactions/journal')
@HttpCode(HttpStatus.OK)
getTransactionsJournal(
@Req() req,
@Param('id') id: string,
@Query() query: TransactionsQueryDto,
): Promise<{ data: TeamActivityRow[]; total: number }> {
const userId = req.user?.id;
return this.service.getTeamTransactionsJournal(id, userId, query);
}
@ApiOperation({
summary: 'Neuen Spieler anlegen',
description:
@@ -223,7 +244,7 @@ export class TeamsController {
'Erstellt ein neues Team mit gegebenem Namen und gibt es zurück.',
})
@ApiBearerAuth()
@Roles([RoleEnum.admin])
@Roles([RoleEnum.user, RoleEnum.admin])
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Post()
@HttpCode(HttpStatus.CREATED)

View File

@@ -0,0 +1,516 @@
import { TeamsService } from './teams.service';
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
function monthKey(monthsAgo: number): string {
const now = new Date();
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function isoDate(monthsAgo: number, day: number): string {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
}
describe('TeamsService#getOverviewStats theoretical balance', () => {
const repository = { findOneOrFail: jest.fn() };
const access = { assertMember: jest.fn() };
let service: TeamsService;
beforeEach(() => {
jest.resetAllMocks();
access.assertMember.mockResolvedValue(undefined);
service = new TeamsService(
repository as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any,
{} as any,
{} as any,
{ emit: jest.fn() } as any,
);
});
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 100,
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: -30,
transactions: [
{
date: isoDate(2, 10),
amount: 30,
type: { id: 11, name: 'fine' },
note: 'Zu spät zum Training',
},
],
},
],
});
const result = await service.getOverviewStats(9, 42);
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
expect(beforeFine?.balance).toBe(100);
expect(beforeFine?.theoreticalBalance).toBe(100);
expect(now?.balance).toBe(100);
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
expect(now?.theoreticalBalance).toBe(130);
});
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 50,
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: -20,
transactions: [
{
date: isoDate(6, 5),
amount: 999,
type: { id: 1, name: 'credit' },
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
},
{
date: isoDate(1, 10),
amount: 20,
type: { id: 11, name: 'fine' },
note: 'Zu spät',
},
],
},
],
});
const result = await service.getOverviewStats(9, 42);
// Older than the adjustment's own month (6 months ago) — the only kind of checkpoint
// where wrongly including the €999 adjustment would still show up as "already happened".
const beforeAdjustment = result.balanceHistory.find((p) => p.month === monthKey(9));
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
// Without exclusion this would read 1049 (50 + the wrongly-included €999 adjustment);
// with correct exclusion only the (not-yet-existing-at-month-9) fine is irrelevant here too,
// so it stays at the team's flat 50.
expect(beforeAdjustment?.theoreticalBalance).toBe(50);
expect(now?.theoreticalBalance).toBe(70);
});
it("excludes an inactive player's outstanding debt from the theoretical balance", async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 100,
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: 0,
transactions: [],
},
{
id: 2,
firstName: 'Bea',
lastName: 'Beispiel',
active: false,
balance: -40,
transactions: [
{
date: isoDate(2, 10),
amount: 40,
type: { id: 11, name: 'fine' },
note: 'Zu spät',
},
],
},
],
});
const result = await service.getOverviewStats(9, 42);
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
// The inactive player's unpaid fine is real debt, but no longer counted at all —
// the theoretical line must match the actual cash balance exactly.
expect(now?.balance).toBe(100);
expect(now?.theoreticalBalance).toBe(100);
});
it('lowers the theoretical balance when a player has prepaid credit not yet reflected as spent', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 100,
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: 30,
transactions: [
{
date: isoDate(2, 10),
amount: 30,
type: { id: 1, name: 'credit' },
note: 'Vorauszahlung',
},
],
},
],
});
const result = await service.getOverviewStats(9, 42);
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
// Prepaid credit is not yet "spent" in the reconstruction, so the theoretical
// line dips below the actual cash balance at this checkpoint.
expect(now?.balance).toBe(100);
expect(now?.theoreticalBalance).toBe(70);
expect(now!.theoreticalBalance).toBeLessThan(now!.balance);
});
it('sums fine/levy/fee bookings per month into monthlyFlow.penalties, excluded from income/expense', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 100,
transactions: [{ date: isoDate(0, 1), amount: 100, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: -45,
transactions: [
{ date: isoDate(0, 5), amount: 10, type: { id: 11, name: 'fine' }, note: 'Zu spät' },
{ date: isoDate(0, 6), amount: 15, type: { id: 12, name: 'levy' }, note: 'Umlage' },
{ date: isoDate(0, 7), amount: 5, type: { id: 13, name: 'fee' }, note: 'Gebühr' },
{ date: isoDate(1, 8), amount: 20, type: { id: 11, name: 'fine' }, note: 'Vormonat' },
{ date: isoDate(0, 9), amount: 12, type: { id: 0, name: 'payment' }, note: 'Beitrag' },
],
},
],
});
const result = await service.getOverviewStats(9, 42);
const now = result.monthlyFlow.find((p) => p.month === monthKey(0));
const lastMonth = result.monthlyFlow.find((p) => p.month === monthKey(1));
expect(now?.penalties).toBe(30);
expect(lastMonth?.penalties).toBe(20);
// Payment still counts as income, fine/levy/fee never do.
expect(now?.income).toBe(12);
expect(now?.expense).toBe(0);
});
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 0,
transactions: [],
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
});
const result = await service.getOverviewStats(9, 42);
expect(result.balanceHistory).toEqual([]);
});
});
describe('TeamsService#getTeamTransactionsJournal', () => {
const repository = { findOneOrFail: jest.fn() };
const access = { assertMember: jest.fn() };
let service: TeamsService;
const team = {
id: 9,
transactions: [
{ id: 101, date: '2026-06-01', amount: 50, type: { name: 'credit' }, note: 'Sponsoring' },
{ id: 102, date: '2026-06-15', amount: 20, type: { name: 'expense' }, note: 'Bälle' },
],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
transactions: [
{ id: 1, date: '2026-06-10', amount: 12, type: { name: 'payment' }, note: 'Beitrag' },
{ id: 2, date: '2026-06-20', amount: 5, type: { name: 'fine' }, note: 'Zu spät' },
],
},
{
id: 2,
firstName: 'Bea',
lastName: 'Beispiel',
transactions: [
{ id: 3, date: '2026-06-05', amount: 30, type: { name: 'levy' }, note: 'Turnier-Umlage' },
],
},
],
};
beforeEach(() => {
jest.resetAllMocks();
access.assertMember.mockResolvedValue(undefined);
repository.findOneOrFail.mockResolvedValue(team);
service = new TeamsService(
repository as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any,
{} as any,
{} as any,
{ emit: jest.fn() } as any,
);
});
function query(overrides: Partial<Record<string, unknown>> = {}) {
return {
page: 1,
limit: 25,
sortBy: 'date',
sortDir: 'desc',
...overrides,
} as any;
}
it('checks membership before returning any data', async () => {
await service.getTeamTransactionsJournal(9, 42, query());
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
});
it('returns the total count of all 5 bookings across both sources, unpaginated', async () => {
const result = await service.getTeamTransactionsJournal(9, 42, query());
expect(result.total).toBe(5);
});
it('paginates using page/limit and slices from the already-sorted result', async () => {
const page1 = await service.getTeamTransactionsJournal(9, 42, query({ page: 1, limit: 2 }));
const page2 = await service.getTeamTransactionsJournal(9, 42, query({ page: 2, limit: 2 }));
expect(page1.data).toHaveLength(2);
expect(page2.data).toHaveLength(2);
expect(page1.total).toBe(5);
expect(page2.total).toBe(5);
// Newest first by default (date desc) - no overlap between the two pages.
expect(page1.data.map((row) => row.id)).toEqual([2, 102]);
expect(page2.data.map((row) => row.id)).toEqual([1, 3]);
});
it('sorts by amount in both directions', async () => {
const asc = await service.getTeamTransactionsJournal(
9,
42,
query({ sortBy: 'amount', sortDir: 'asc', limit: 100 }),
);
const desc = await service.getTeamTransactionsJournal(
9,
42,
query({ sortBy: 'amount', sortDir: 'desc', limit: 100 }),
);
expect(asc.data.map((row) => row.amount)).toEqual([5, 12, 20, 30, 50]);
expect(desc.data.map((row) => row.amount)).toEqual([50, 30, 20, 12, 5]);
});
it('filters by exact booking type', async () => {
const result = await service.getTeamTransactionsJournal(9, 42, query({ type: 'fine' }));
expect(result.total).toBe(1);
expect(result.data[0].note).toBe('Zu spät');
});
it('filters by a case-insensitive search across player name and note', async () => {
const byName = await service.getTeamTransactionsJournal(9, 42, query({ search: 'bea' }));
const byNote = await service.getTeamTransactionsJournal(9, 42, query({ search: 'BÄLLE' }));
expect(byName.total).toBe(1);
expect(byName.data[0].playerName).toBe('Bea Beispiel');
expect(byNote.total).toBe(1);
expect(byNote.data[0].note).toBe('Bälle');
});
it('rejects when the actor is not a team member', async () => {
access.assertMember.mockRejectedValue(new Error('forbidden'));
await expect(service.getTeamTransactionsJournal(9, 42, query())).rejects.toThrow('forbidden');
expect(repository.findOneOrFail).not.toHaveBeenCalled();
});
});
describe('TeamsService#createNewTeam', () => {
const repository = { create: jest.fn(), save: jest.fn() };
const playerRepository = { create: jest.fn(), save: jest.fn() };
const rolesRepository = { findOneBy: jest.fn() };
const settingsRepository = { create: jest.fn(), save: jest.fn() };
const usersRepository = { findOneBy: jest.fn() };
const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
let manager: any;
let dataSource: any;
let service: TeamsService;
beforeEach(() => {
jest.resetAllMocks();
manager = {
getRepository: jest.fn((entity: { name: string }) => {
switch (entity.name) {
case 'Team':
return repository;
case 'Player':
return playerRepository;
case 'TeamRole':
return rolesRepository;
case 'TeamSetting':
return settingsRepository;
case 'User':
return usersRepository;
default:
throw new Error(`unexpected entity ${entity.name}`);
}
}),
};
dataSource = { transaction: jest.fn((work: any) => work(manager)) };
service = new TeamsService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
logger as any,
{} as any,
{} as any,
dataSource as any,
{ emit: jest.fn() } as any,
);
});
it('creates the team, its default settings, and a captain membership for the creator, all inside one transaction', async () => {
const savedTeam = { id: 7, name: '1. Herren' };
repository.create.mockReturnValue({ name: '1. Herren' });
repository.save.mockResolvedValue(savedTeam);
settingsRepository.create.mockImplementation((s: unknown) => s);
settingsRepository.save.mockResolvedValue([]);
usersRepository.findOneBy.mockResolvedValue({
id: 42,
firstName: 'Alex',
lastName: 'Muster',
});
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
playerRepository.create.mockImplementation((p: unknown) => p);
playerRepository.save.mockResolvedValue({});
const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(result).toBe(savedTeam);
expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
expect(playerRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
firstName: 'Alex',
lastName: 'Muster',
team: savedTeam,
teamRole: { id: 3, name: 'captain' },
user: { id: 42, firstName: 'Alex', lastName: 'Muster' },
}),
);
expect(playerRepository.save).toHaveBeenCalled();
});
it('does not create the team at all if the captain-membership write fails', async () => {
const savedTeam = { id: 7, name: '1. Herren' };
repository.create.mockReturnValue({ name: '1. Herren' });
repository.save.mockResolvedValue(savedTeam);
settingsRepository.create.mockImplementation((s: unknown) => s);
settingsRepository.save.mockResolvedValue([]);
usersRepository.findOneBy.mockResolvedValue({ id: 42, firstName: 'Alex', lastName: 'Muster' });
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
playerRepository.create.mockImplementation((p: unknown) => p);
playerRepository.save.mockRejectedValue(new Error('db down'));
await expect(
service.createNewTeam({ name: '1. Herren' } as any, '42'),
).rejects.toThrow('db down');
expect(logger.info).not.toHaveBeenCalled();
});
});
describe('TeamsService.createNewPlayer', () => {
const repository = { findOneBy: jest.fn() };
const playerRepository = { create: jest.fn((value) => value), save: jest.fn() };
const rolesRepository = { findOneBy: jest.fn() };
const logger = { info: jest.fn() };
const access = { assertManager: jest.fn() };
const eventEmitter = { emit: jest.fn() };
let service: TeamsService;
beforeEach(() => {
jest.resetAllMocks();
access.assertManager.mockResolvedValue(undefined);
rolesRepository.findOneBy.mockResolvedValue({ id: 1, name: 'player' });
repository.findOneBy.mockResolvedValue({ id: 10, name: 'Team A' });
playerRepository.save.mockImplementation((value) =>
Promise.resolve({ ...value, id: 55 }),
);
service = new TeamsService(
repository as any,
playerRepository as any,
{} as any,
rolesRepository as any,
{} as any,
{} as any,
logger as any,
access as any,
{} as any,
{} as any,
eventEmitter as any,
);
});
it('emits a player-created event with the new player id and name', async () => {
await service.createNewPlayer('10', { firstName: 'Ada', lastName: 'Lovelace', teamRole: undefined }, '5');
expect(eventEmitter.emit).toHaveBeenCalledWith(
'notifications.player.created',
expect.objectContaining({
teamId: 10,
actorUserId: 5,
playerId: 55,
playerName: 'Ada Lovelace',
}),
);
});
});

View File

@@ -1,6 +1,9 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { LoggingService } from 'src/database/logging/logging.service';
import { NOTIFICATION_EVENT_NAME } from '../notifications/events/notification-event-names';
import { PlayerCreatedEvent } from '../notifications/events/player-created.event';
import { Player } from 'src/players/entities/player.entity';
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
@@ -8,11 +11,28 @@ import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
import { TEAM_SETTING_DEFAULTS } from 'src/team-settings/team-setting-defaults';
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { Repository } from 'typeorm';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { User } from 'src/users/entities/user.entity';
import { DataSource, EntityManager, Repository } from 'typeorm';
import {
TransactionsQueryDto,
TransactionsSortableField,
} from 'src/transactions/dto/transactions-query.dto';
import { CreateTeamDTO } from './dto/create-team.dto';
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
import { Team } from './entities/team.entity';
import { TeamAccessService } from './team-access.service';
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
export interface TeamActivityRow {
id: number;
date: string;
amount: number;
type: string;
note: string | null;
playerName?: string;
isTeamWalletTransaction: boolean;
}
@Injectable()
export class TeamsService {
@@ -31,6 +51,10 @@ export class TeamsService {
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
private logger: LoggingService,
private access: TeamAccessService,
@InjectRepository(User)
private usersRepository: Repository<User>,
private dataSource: DataSource,
private eventEmitter: EventEmitter2,
) {}
async getOverview(teamId: string, actorUserId: string) {
@@ -145,14 +169,25 @@ export class TeamsService {
details: `Spieler ${playerSaved.id}, ${p.firstName} ${p.lastName} erstellt`,
userId: Number(id),
});
this.eventEmitter.emit(
NOTIFICATION_EVENT_NAME.playerCreated,
new PlayerCreatedEvent(Number(id), Number(actorUserId), playerSaved.id, `${p.firstName} ${p.lastName}`),
);
return playerSaved;
}
async createNewTeam(teamdto: CreateTeamDTO, userId: string) {
const createTeam = this.repository.create(teamdto);
const team = await this.dataSource.transaction(async (manager) => {
const teamRepository = manager.getRepository(Team);
const createTeam = teamRepository.create(teamdto);
const savedTeam = await teamRepository.save(createTeam);
const team = await this.repository.save(createTeam);
await this.generateBasicTeamSettings(team);
await this.generateBasicTeamSettings(manager, savedTeam);
await this.addCreatorAsCaptain(manager, savedTeam, userId);
return savedTeam;
});
await this.logger.info({
event: 'team_create',
@@ -162,7 +197,33 @@ export class TeamsService {
return team;
}
private async generateBasicTeamSettings(team: Team): Promise<void> {
private async addCreatorAsCaptain(
manager: EntityManager,
team: Team,
userId: string,
): Promise<void> {
const [user, captainRole] = await Promise.all([
manager.getRepository(User).findOneBy({ id: Number(userId) }),
manager.getRepository(TeamRole).findOneBy({ id: TeamRolesEnum.captain }),
]);
const playerRepository = manager.getRepository(Player);
const player = playerRepository.create({
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
team,
teamRole: captainRole,
user: user ?? undefined,
});
await playerRepository.save(player);
}
private async generateBasicTeamSettings(
manager: EntityManager,
team: Team,
): Promise<void> {
const settingsRepository = manager.getRepository(TeamSetting);
const settings: CreateTeamSettingDTO[] = Object.entries(
TEAM_SETTING_DEFAULTS,
).map(([key, value]) => ({
@@ -171,8 +232,8 @@ export class TeamsService {
team,
}));
await this.settingsRepository.save(
settings.map((s) => this.settingsRepository.create(s)),
await settingsRepository.save(
settings.map((s) => settingsRepository.create(s)),
);
}
@@ -228,12 +289,98 @@ export class TeamsService {
return result;
}
async getTeamTransactionsJournal(
teamId: string | number,
userId: string | number,
query: TransactionsQueryDto,
): Promise<{ data: TeamActivityRow[]; total: number }> {
const id = Number(teamId);
await this.access.assertMember(Number(userId), id);
const team = await this.repository.findOneOrFail({
where: { id },
relations: ['players', 'players.transactions', 'transactions'],
});
const transactions: TeamActivityRow[] = [];
for (const t of team.transactions) {
transactions.push({
id: t.id,
date: t.date,
amount: Number(t.amount),
type: t.type.name,
note: t.note,
isTeamWalletTransaction: true,
});
}
for (const p of team.players) {
for (const t of p.transactions) {
transactions.push({
id: t.id,
date: t.date,
amount: Number(t.amount),
type: t.type.name,
note: t.note,
playerName: p.firstName + ' ' + p.lastName,
isTeamWalletTransaction: false,
});
}
}
const search = query.search?.trim().toLowerCase();
const filtered = transactions.filter((row) => {
if (query.type && row.type !== query.type) return false;
if (search) {
const haystack = `${row.playerName ?? 'Teamkasse'} ${row.note ?? ''}`.toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
const sortBy = query.sortBy ?? 'date';
const direction = query.sortDir === 'asc' ? 1 : -1;
const sorted = [...filtered].sort((a, b) => {
const aValue = this.transactionSortValue(a, sortBy);
const bValue = this.transactionSortValue(b, sortBy);
if (aValue < bValue) return -1 * direction;
if (aValue > bValue) return 1 * direction;
return 0;
});
const page = query.page ?? 1;
const limit = query.limit ?? 25;
const start = (page - 1) * limit;
const data = sorted.slice(start, start + limit);
return { data, total: filtered.length };
}
private transactionSortValue(
row: TeamActivityRow,
field: TransactionsSortableField,
): string | number {
switch (field) {
case 'amount':
return row.amount;
case 'playerName':
return (row.playerName ?? 'Teamkasse').toLowerCase();
case 'type':
return row.type;
case 'date':
default:
return row.date;
}
}
async getOverviewStats(
teamId: string | number,
actorUserId: string | number,
): Promise<{
balanceHistory: { month: string; balance: number }[];
monthlyFlow: { month: string; income: number; expense: number }[];
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
monthlyFlow: { month: string; income: number; expense: number; penalties: number }[];
topOutstanding: { playerId: number; playerName: string; balance: number }[];
}> {
const id = Number(teamId);
@@ -255,6 +402,10 @@ export class TeamsService {
// TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's
// debt but never move money, so they are excluded entirely.
const movements: { date: string; amount: number; type: string }[] = [];
// Fine/levy/fee (type.id > 10) raise a player's debt but never move real
// cash, so they're kept out of `movements` and tracked separately here
// purely to chart "how much was booked as penalties/levies this month".
const penaltyMovements: { date: string; amount: number }[] = [];
for (const t of teamTransactions) {
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
@@ -264,6 +415,8 @@ export class TeamsService {
for (const t of p.transactions ?? []) {
if (t.type.name === 'payment') {
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
} else if (t.type.id > 10) {
penaltyMovements.push({ date: t.date, amount: Number(t.amount) });
}
}
}
@@ -298,26 +451,16 @@ export class TeamsService {
// reconstruct earlier month-end balances — this guarantees the most
// recent point always equals team.balance by construction, regardless
// of undocumented history.
const descendingMovements = [...movements].sort((a, b) =>
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
);
const signedMovements = movements.map((m) => ({
date: m.date,
amount: this.signedFlowAmount(m),
}));
const currentBalance = Number(team.balance);
let futureSum = 0;
let movementIndex = 0;
const balanceHistory = [...months]
.reverse()
.map((month) => {
while (
movementIndex < descendingMovements.length &&
descendingMovements[movementIndex].date.slice(0, 7) > month
) {
futureSum += this.signedFlowAmount(descendingMovements[movementIndex]);
movementIndex++;
}
return { month, balance: this.round(currentBalance - futureSum) };
})
.reverse();
const rawBalances = this.reconstructBackward(months, currentBalance, signedMovements);
const balanceHistory = months.map((month, index) => ({
month,
balance: this.round(rawBalances[index]),
}));
const monthlyFlow = months.map((month) => {
const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month);
@@ -327,10 +470,24 @@ export class TeamsService {
const expense = monthMovements
.filter((m) => m.type === 'expense')
.reduce((sum, m) => sum + m.amount, 0);
return { month, income: this.round(income), expense: this.round(expense) };
const penalties = penaltyMovements
.filter((m) => m.date.slice(0, 7) === month)
.reduce((sum, m) => sum + m.amount, 0);
return {
month,
income: this.round(income),
expense: this.round(expense),
penalties: this.round(penalties),
};
});
return { balanceHistory, monthlyFlow, topOutstanding };
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
...point,
theoreticalBalance: this.round(point.balance + outstandingHistory[index]),
}));
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
}
private getLast12Months(): string[] {
@@ -347,6 +504,65 @@ export class TeamsService {
return movement.type === 'expense' ? -movement.amount : movement.amount;
}
private reconstructBackward(
months: string[],
currentValue: number,
signedMovements: { date: string; amount: number }[],
): number[] {
const descending = [...signedMovements].sort((a, b) =>
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
);
let futureSum = 0;
let index = 0;
return [...months]
.reverse()
.map((month) => {
while (index < descending.length && descending[index].date.slice(0, 7) > month) {
futureSum += descending[index].amount;
index++;
}
return currentValue - futureSum;
})
.reverse();
}
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
const activePlayers = players.filter((p) => p.active);
const totals = months.map(() => 0);
for (const player of activePlayers) {
const realTransactions = (player.transactions ?? []).filter(
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
);
const playerHistory = this.reconstructPlayerBalanceHistory(
months,
Number(player.balance),
realTransactions,
);
playerHistory.forEach((balance, index) => {
totals[index] += balance;
});
}
// House convention (see getOverview()'s team.outstanding = out * -1): "outstanding"
// is a positive number when players owe money, negative when they're in credit.
return totals.map((total) => total * -1);
}
private reconstructPlayerBalanceHistory(
months: string[],
currentBalance: number,
transactions: Transaction[],
): number[] {
const signedMovements = transactions.map((t) => {
const rawAmount = Number(t.amount);
const amount = t.type && t.type.id > 10 && rawAmount > 0 ? -rawAmount : rawAmount;
return { date: t.date, amount };
});
return this.reconstructBackward(months, currentBalance, signedMovements);
}
private round(value: number): number {
return Math.round(value * 100) / 100;
}

View File

@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export const TRANSACTIONS_SORTABLE_FIELDS = ['date', 'amount', 'playerName', 'type'] as const;
export type TransactionsSortableField = (typeof TRANSACTIONS_SORTABLE_FIELDS)[number];
export class TransactionsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 25;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsString()
type?: string;
@IsOptional()
@IsIn(TRANSACTIONS_SORTABLE_FIELDS)
sortBy: TransactionsSortableField = 'date';
@IsOptional()
@IsIn(['asc', 'desc'])
sortDir: 'asc' | 'desc' = 'desc';
}

View File

@@ -17,6 +17,7 @@
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"ag-grid-angular": "^36.0.2",
"chart.js": "^4.5.1",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
@@ -4236,6 +4237,42 @@
"node": ">= 0.6"
}
},
"node_modules/ag-charts-types": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.2.tgz",
"integrity": "sha512-F7ZG0g8Y+iKhJi50AfZRwEyUM/TBsNyh2IoXB0JaDN97lnbemIK8GE5kF1eBtXtN4mcC+lPXK9oZUeVXwO9EWA==",
"license": "MIT"
},
"node_modules/ag-grid-angular": {
"version": "36.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-36.0.2.tgz",
"integrity": "sha512-qBEvOmkcmioJTLZOozoJMYFMSu0/+I6fmeYJ7xrzxv3X88Vr/MoytdiR1j9GbLuhG8tusePfNiwuDnAJxEzbBw==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "36.0.2",
"tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/common": ">= 20.0.0",
"@angular/core": ">= 20.0.0"
}
},
"node_modules/ag-grid-community": {
"version": "36.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.0.2.tgz",
"integrity": "sha512-TINZfuFvMY2nc3JfQHiUWT7dNIxI89ZxS5XkXIPi/rYICoNupRqpaM41KVzGPPfSkM0AwhuzTFxAiF08zEkV1Q==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "14.0.2",
"ag-stack": "36.0.2"
}
},
"node_modules/ag-stack": {
"version": "36.0.2",
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.0.2.tgz",
"integrity": "sha512-YuhQExQw5YsWK0wxrksRyYBAqOU0v08lJH5uxRsKx+49ko5vkDgnJuhX4yF995BBVdLY1LKlXukLEub+olKyuA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",

View File

@@ -21,6 +21,7 @@
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"ag-grid-angular": "^36.0.2",
"chart.js": "^4.5.1",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",

View File

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

View File

@@ -48,6 +48,11 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('./features/users/users').then((m) => m.Users),
},
{
path: 'logs',
canActivate: [authGuard],
loadComponent: () => import('./features/logs/logs').then((m) => m.Logs),
},
{
path: 't/:token/:playerId',
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
@@ -88,6 +93,13 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/team/more/penalties/penalties').then((m) => m.Penalties),
},
{
path: 'more/recurring-transactions',
loadComponent: () =>
import('./features/team/more/recurring-transactions/recurring-transactions').then(
(m) => m.RecurringTransactions,
),
},
{
path: 'more/invite',
loadComponent: () => import('./features/team/more/invite/invite').then((m) => m.Invite),
@@ -111,6 +123,11 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/team/more/guide/guide').then((m) => m.Guide),
},
{
path: 'notifications',
loadComponent: () =>
import('./features/team/notifications/notifications').then((m) => m.Notifications),
},
],
},
{

View File

@@ -5,8 +5,10 @@ import { of } from 'rxjs';
import { App } from './app';
import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store';
import { environment } from '../environments/environment';
describe('App', () => {
const originalProduction = environment.production;
const token = signal<string | null>(null);
const updateUser = vi.fn();
const meResponse = signal<Record<string, unknown>>({
@@ -34,6 +36,10 @@ describe('App', () => {
}).compileComponents();
});
afterEach(() => {
environment.production = originalProduction;
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
expect(fixture.componentInstance).toBeTruthy();
@@ -73,4 +79,22 @@ describe('App', () => {
});
expect(updateUser).not.toHaveBeenCalled();
});
it('sets --env-banner-height to 0px and renders no banner in production', () => {
environment.production = true;
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('0px');
expect(fixture.nativeElement.querySelector('.env-banner')).toBeNull();
});
it('sets --env-banner-height to 28px and renders the banner outside production', () => {
environment.production = false;
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('28px');
expect(fixture.nativeElement.querySelector('.env-banner')).not.toBeNull();
});
});

View File

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

View File

@@ -12,6 +12,46 @@
} @else {
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
}
<span class="shell-header-spacer"></span>
<button
mat-icon-button
class="shell-notification-bell"
[matMenuTriggerFor]="notificationMenu"
(menuOpened)="onNotificationsMenuOpened()"
[matBadge]="unreadCount()"
[matBadgeHidden]="unreadCount() === 0"
matBadgeSize="small"
matBadgeColor="warn"
aria-label="Benachrichtigungen"
>
<mat-icon>notifications</mat-icon>
</button>
<mat-menu #notificationMenu="matMenu" class="shell-notification-menu">
<div class="shell-notification-menu__header">
<span>Benachrichtigungen</span>
<button mat-button (click)="onMarkAllRead()">Alle als gelesen markieren</button>
</div>
@if (notifications().length === 0) {
<div class="shell-notification-menu__empty">Keine Benachrichtigungen</div>
} @else {
@for (item of notifications(); track item.id) {
<button
mat-menu-item
class="shell-notification-menu__item"
[class.shell-notification-menu__item--unread]="!item.read"
(click)="onNotificationClick(item)"
>
<mat-icon>{{ notificationIcon(item) }}</mat-icon>
<span>{{ notificationLabel(item) }}</span>
</button>
}
@if (currentTeamId(); as teamId) {
<a mat-menu-item [routerLink]="['/team', teamId, 'notifications']">Alle anzeigen</a>
}
}
</mat-menu>
</mat-toolbar>
<main class="shell-content">

View File

@@ -1,7 +1,9 @@
:host {
display: flex;
flex-direction: column;
height: 100dvh;
height: calc(100dvh - var(--env-banner-height, 0px));
overflow: hidden;
position: relative;
}
.shell-header {
@@ -16,13 +18,26 @@
.shell-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
main {
padding-bottom: 48px;
}
.shell-bottom-nav {
position: absolute;
bottom: 0;
display: flex;
width: calc(100% - 48px);
align-self: center;
border-top-right-radius: 16px;
border-top-left-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
border-top: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
z-index: 2;
&__item {
flex: 1;
@@ -40,3 +55,37 @@
}
}
}
.shell-header-spacer {
flex: 1;
}
.shell-notification-bell {
color: var(--mat-sys-on-surface);
}
.shell-notification-menu {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
&__empty {
padding: 1rem;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
}
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
&--unread {
font-weight: 600;
}
}
}

View File

@@ -1,27 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Shell } from './shell';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../auth/auth-store';
import { Player } from '../../../models/player.model';
import { NotificationsStore } from '../../notifications/notifications-store';
describe('Shell', () => {
let httpMock: HttpTestingController;
let authStore: AuthStore;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let notificationsStore: {
unreadCount: ReturnType<typeof signal<number>>;
notifications: ReturnType<typeof signal<any[]>>;
startPolling: ReturnType<typeof vi.fn>;
loadRecent: ReturnType<typeof vi.fn>;
markRead: ReturnType<typeof vi.fn>;
markAllRead: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
localStorage.clear();
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
notificationsStore = {
unreadCount: signal(3),
notifications: signal([
{
id: 1,
event: 'player_creation',
actorUserId: 9,
payload: { playerId: 21, playerName: 'Ada Lovelace' },
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
},
]),
startPolling: vi.fn(),
loadRecent: vi.fn(),
markRead: vi.fn(),
markAllRead: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [Shell],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: NotificationsStore, useValue: notificationsStore },
{
provide: ActivatedRoute,
useValue: { paramMap: routeParams.asObservable() },
@@ -153,4 +181,58 @@ describe('Shell', () => {
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
});
it('starts polling notifications for the routed team id', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
expect(notificationsStore.startPolling).toHaveBeenCalledWith(5);
});
it('exposes the unread count from the notifications store', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
expect((fixture.componentInstance as any).unreadCount()).toBe(3);
});
it('loads recent notifications when the bell menu is opened', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
(fixture.componentInstance as any).onNotificationsMenuOpened();
expect(notificationsStore.loadRecent).toHaveBeenCalledWith(5);
});
it('marks a clicked notification as read and navigates to its target', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
const item = notificationsStore.notifications()[0];
(fixture.componentInstance as any).onNotificationClick(item);
expect(notificationsStore.markRead).toHaveBeenCalledWith(5, 1);
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
});
it('marks all notifications as read', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
(fixture.componentInstance as any).onMarkAllRead();
expect(notificationsStore.markAllRead).toHaveBeenCalledWith(5);
});
});

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 {
ActivatedRoute,
@@ -7,6 +7,7 @@ import {
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store';
import { NotificationsStore } from '../../notifications/notifications-store';
import {
notificationIcon,
notificationLabel,
notificationTarget,
} from '../../notifications/notification-presentation';
import { UserTeamReference } from '../../../models/user-directory.model';
import { NotificationItem } from '../../../models/notification.model';
@Component({
selector: 'app-shell',
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
MatIconModule,
MatMenuModule,
MatButtonModule,
MatBadgeModule,
],
templateUrl: './shell.html',
styleUrl: './shell.scss',
@@ -36,8 +45,12 @@ export class Shell {
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly teamStore = inject(TeamStore);
private readonly notificationsStore = inject(NotificationsStore);
protected readonly currentTeam = this.teamStore.team;
protected readonly currentTeamId = signal<number | null>(null);
protected readonly unreadCount = this.notificationsStore.unreadCount;
protected readonly notifications = this.notificationsStore.notifications;
protected readonly myTeams = computed(() => {
const seen = new Set<number>();
@@ -57,17 +70,13 @@ export class Shell {
this.myTeamsStore.ensureLoaded(userId);
}
// A direct subscription (not `effect()` + `toSignal()`) so the initial
// team load happens synchronously during construction, exactly like
// `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
// current value synchronously to a new subscriber. This keeps the
// component's behavior deterministic and trivial to test: no signal
// effect scheduling to wait for.
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0) {
this.teamStore.loadTeam(id);
this.currentTeamId.set(id);
this.notificationsStore.startPolling(id);
}
});
}
@@ -75,4 +84,33 @@ export class Shell {
protected switchTeam(teamId: number): void {
void this.router.navigate(['/team', teamId, 'overview']);
}
protected notificationLabel(item: NotificationItem): string {
return notificationLabel(item);
}
protected notificationIcon(item: NotificationItem): string {
return notificationIcon(item.event);
}
protected onNotificationsMenuOpened(): void {
const teamId = this.currentTeamId();
if (teamId !== null) {
this.notificationsStore.loadRecent(teamId);
}
}
protected onNotificationClick(item: NotificationItem): void {
const teamId = this.currentTeamId();
if (teamId === null) return;
this.notificationsStore.markRead(teamId, item.id);
void this.router.navigate(notificationTarget(item, teamId));
}
protected onMarkAllRead(): void {
const teamId = this.currentTeamId();
if (teamId !== null) {
this.notificationsStore.markAllRead(teamId);
}
}
}

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

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