Compare commits
145 Commits
e5ba292815
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caae4d955d | ||
|
|
1738a109a7 | ||
|
|
91808a63f5 | ||
|
|
c441140872 | ||
|
|
fc0c1ed522 | ||
|
|
95a667cb98 | ||
|
|
77ed71cfb6 | ||
|
|
ed365db283 | ||
|
|
bd421954c5 | ||
|
|
8405f797d7 | ||
|
|
7bfb3d07fc | ||
|
|
35e6c055c0 | ||
|
|
fe523bdce1 | ||
|
|
b40af02e2f | ||
|
|
8ace676abf | ||
|
|
6b3a9d69cc | ||
|
|
a7b087050c | ||
|
|
eb1173c706 | ||
|
|
812061fc6c | ||
|
|
017e6445fa | ||
|
|
d6733eff0d | ||
|
|
639ca651d8 | ||
|
|
8de4c11e24 | ||
|
|
451b5c4e42 | ||
|
|
05f4c2ddf0 | ||
|
|
273c25eccb | ||
|
|
97b0a5c19a | ||
|
|
7410672630 | ||
|
|
ecfa847d2a | ||
|
|
6bda24ec9f | ||
|
|
df634e7601 | ||
|
|
1fe2892ca4 | ||
|
|
020b390953 | ||
|
|
f1b4f7e5b4 | ||
|
|
24c509c0d5 | ||
|
|
4185efb83a | ||
|
|
cd9d7b165f | ||
|
|
6fceee5a07 | ||
|
|
02a4d2e59d | ||
|
|
7b499b361f | ||
|
|
0bad154971 | ||
|
|
da5998487a | ||
|
|
ce0b500d7a | ||
|
|
1c214c5f06 | ||
|
|
148269da11 | ||
|
|
db061a7ef7 | ||
|
|
11b70b9337 | ||
|
|
5c3ff52689 | ||
|
|
a65c7b35b3 | ||
|
|
72fa1d4331 | ||
|
|
d628d5e4d7 | ||
|
|
57869d5fc1 | ||
|
|
eed8266da2 | ||
|
|
5779483b21 | ||
|
|
196898b993 | ||
|
|
42e2bbc4ff | ||
|
|
a11eb73ce9 | ||
|
|
009aae1f2b | ||
|
|
18df224386 | ||
|
|
396dc29cf5 | ||
|
|
f8857f71e1 | ||
|
|
0b408f7d73 | ||
|
|
c6df6a6d38 | ||
|
|
cab04c5869 | ||
|
|
cf7c3efb0f | ||
|
|
a9df62a249 | ||
|
|
d8883d4687 | ||
|
|
15d5f1d4e4 | ||
|
|
b8bcf329c5 | ||
|
|
9e55c0549b | ||
|
|
6531f2553f | ||
|
|
b03198baa6 | ||
|
|
bb6b045a2a | ||
|
|
a05ffd0c7f | ||
|
|
886e4e6941 | ||
|
|
c1478f07f1 | ||
|
|
92eacbc5bb | ||
|
|
09da67b8ef | ||
|
|
145ebb924a | ||
|
|
560fcfc11a | ||
|
|
84bab04523 | ||
|
|
b21641f37a | ||
|
|
71d76725a4 | ||
|
|
862a1bea8d | ||
|
|
3cb8cd9a4a | ||
|
|
3d774a3455 | ||
|
|
ebfaac7590 | ||
|
|
d8f13d674d | ||
|
|
1b6ce57fbf | ||
|
|
5f619d649c | ||
|
|
fd850f372f | ||
|
|
27ab505aba | ||
|
|
6eeaa3a624 | ||
|
|
26391b5b2c | ||
|
|
367533f1f5 | ||
|
|
0e8ac74116 | ||
|
|
a94caed85f | ||
|
|
e2f271fd35 | ||
|
|
1058d641e7 | ||
|
|
f2e6704943 | ||
|
|
41a557f2c9 | ||
|
|
435b8d5c53 | ||
|
|
239b157015 | ||
|
|
4e6b945eec | ||
|
|
ecb6fd4394 | ||
|
|
6eab6251d5 | ||
|
|
bf1cef961a | ||
|
|
369d556a8b | ||
|
|
727ff0b1af | ||
|
|
c1238929ef | ||
|
|
b6f311b11b | ||
|
|
5dae4362b2 | ||
|
|
9664187049 | ||
|
|
3551641a85 | ||
|
|
2424a8c025 | ||
|
|
c7dfdd7498 | ||
|
|
fa05e55d43 | ||
|
|
17228a52db | ||
|
|
4c1bd49405 | ||
|
|
45658e42fa | ||
|
|
e3bff40181 | ||
|
|
945369796e | ||
|
|
431eba86e7 | ||
|
|
d962200b79 | ||
|
|
9eec6268d0 | ||
|
|
9dc9dc3dcf | ||
|
|
3ae0fd2000 | ||
|
|
1288f5fc60 | ||
|
|
d0ec000bff | ||
|
|
484c7473fb | ||
|
|
78761b570b | ||
|
|
bec1826bfa | ||
|
|
e6acfdcac7 | ||
|
|
8e318df94f | ||
|
|
c80f78594e | ||
|
|
0a0990c6f4 | ||
|
|
ae9de39ac4 | ||
|
|
4105460400 | ||
|
|
81040efd8b | ||
|
|
247aeefd4c | ||
|
|
d738b49cbf | ||
|
|
e722d9e26b | ||
|
|
fb3ed10b41 | ||
|
|
c382234746 | ||
|
|
bcb4207233 |
20
.env.example
20
.env.example
@@ -2,8 +2,6 @@ NODE_ENV=production
|
||||
APP_PORT=3999
|
||||
APP_NAME="NestJS API"
|
||||
API_PREFIX=api
|
||||
APP_FALLBACK_LANGUAGE=en
|
||||
APP_HEADER_LANGUAGE=x-custom-lang
|
||||
FRONTEND_DOMAIN=http://localhost:3999
|
||||
BACKEND_DOMAIN=http://localhost:3999
|
||||
|
||||
@@ -22,13 +20,6 @@ DATABASE_CA=
|
||||
DATABASE_KEY=
|
||||
DATABASE_CERT=
|
||||
|
||||
# Support "local", "s3"
|
||||
FILE_DRIVER=local
|
||||
ACCESS_KEY_ID=
|
||||
SECRET_ACCESS_KEY=
|
||||
AWS_S3_REGION=
|
||||
AWS_DEFAULT_S3_BUCKET=
|
||||
|
||||
MAIL_HOST=change-me
|
||||
MAIL_PORT=465
|
||||
MAIL_USER=change-me
|
||||
@@ -43,15 +34,4 @@ MAIL_CLIENT_PORT=1080
|
||||
AUTH_JWT_SECRET=change-me
|
||||
AUTH_JWT_TOKEN_EXPIRES_IN=100d
|
||||
|
||||
FACEBOOK_APP_ID=
|
||||
FACEBOOK_APP_SECRET=
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
APPLE_APP_AUDIENCE=[]
|
||||
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
|
||||
WORKER_HOST=redis://redis:6379/1
|
||||
|
||||
56
docs/plans/admin-user-management.md
Normal file
56
docs/plans/admin-user-management.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Admin User Management Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add a shared user directory and a secure global-admin management surface spanning the NestJS backend and the modern Angular frontend. The legacy frontend remains untouched.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Reuse the existing `User.status`, `User.role`, and `Player.user` relations; do not add a new assignment table.
|
||||
- Only global `RoleEnum.admin` users may mutate users or player assignments. Team roles are read-only.
|
||||
- Non-admin directory responses contain only users sharing at least one team with the requester, and only assignments from those shared teams. They never contain email or unrelated-team information.
|
||||
- Admin directory responses may contain email, global role, status, and all assignments, but never password, hash, social ID, or authentication secrets.
|
||||
- Admins may edit first name, last name, global role, status, and player assignments. They may not edit email/password, create users, delete users, or edit legacy frontend code.
|
||||
- Deactivation is immediate for login and already-issued JWTs, preserves assignments, and must not allow self-deactivation or loss of the last active admin. The same last-admin and self-protection applies to role demotion.
|
||||
- Admin mutation endpoints use narrow DTOs and server-side authorization. The existing generic user PATCH is not used by the modern frontend.
|
||||
- Follow strict TDD: add focused failing tests first, confirm the expected failure, then add minimal production code and refactor only while green.
|
||||
|
||||
## Task 1: Backend directory contract and query
|
||||
|
||||
- Add explicit directory/admin summary DTOs and pagination/search inputs.
|
||||
- Implement a user-directory service query that deduplicates users, scopes non-admin results to shared teams, filters their visible assignments to those teams, and returns all users/assignments plus email and role for admins.
|
||||
- Add focused service tests covering cross-team isolation, email/secret redaction, inactive visibility, admin visibility, deduplication, search, and pagination.
|
||||
- Keep response mapping explicit rather than serializing entities.
|
||||
|
||||
## Task 2: Backend admin mutations and authentication enforcement
|
||||
|
||||
- Add global-admin-only endpoints for profile changes, role changes, status changes, player search, assignment, reassignment, and unlinking.
|
||||
- Use transactions/locking for last-admin protection and assignment changes; reject self-deactivation/self-demotion and loss of the last active admin.
|
||||
- Ensure password/social login rejects inactive users and JWT validation reloads the current user, rejecting inactive/deleted users and returning the current database role.
|
||||
- Extend audit event types and record admin actor, target, and action without secrets.
|
||||
- Add a migration/index metadata for the player foreign keys used by directory queries.
|
||||
- Add focused controller/service/auth tests for authorization, narrow DTO behavior, status/session enforcement, role safeguards, and assignment conflict behavior.
|
||||
|
||||
## Task 3: Modern frontend API, models, routing, and authorization state
|
||||
|
||||
- Add typed directory/admin API clients matching the backend contract and never call the generic user PATCH.
|
||||
- Add typed view models for safe directory records, admin details, assignments, filters, and mutation requests.
|
||||
- Add the protected `/users` route and a visible entry in the existing More screen for every authenticated role.
|
||||
- Expose the current global role through existing auth state and treat it only as a presentation hint; backend authorization remains authoritative.
|
||||
- Add focused tests for API URLs/payloads, route protection, role derivation, and navigation visibility.
|
||||
|
||||
## Task 4: Modern frontend user directory and admin interactions
|
||||
|
||||
- Implement a responsive user-directory page in the existing modern frontend design system, using its spacing, typography, colors, controls, and list patterns as the accepted visual reference.
|
||||
- Provide search, pagination, status, and team/player assignment display with loading, empty, and error states.
|
||||
- Hide email, role, unrelated-team information, and every mutation control from non-admins.
|
||||
- For admins, add profile/role editing, activation/deactivation confirmation, player search, assignment, unlink confirmation, and explicit reassignment confirmation naming the current and target users.
|
||||
- Do not optimistically update security-sensitive state; reload affected data after successful mutations and surface `403`/safeguard errors clearly.
|
||||
- Add component tests for non-admin/admin rendering, confirmations, successful refresh, error behavior, search, and pagination.
|
||||
|
||||
## Task 5: Integration verification and documentation
|
||||
|
||||
- Run all focused backend tests and the backend build; document unrelated pre-existing full-suite failures separately.
|
||||
- Run the complete modern frontend test suite and a production/container build that does not require external font inlining.
|
||||
- Run the backend/frontend locally and verify the directory and primary admin workflow at desktop and mobile widths using the available browser tooling or Playwright fallback.
|
||||
- Confirm the legacy frontend has no changes and review the complete branch diff for data leakage, authorization bypasses, concurrency errors, and visual regressions.
|
||||
664
docs/superpowers/plans/2026-07-31-mail-versand.md
Normal file
664
docs/superpowers/plans/2026-07-31-mail-versand.md
Normal file
@@ -0,0 +1,664 @@
|
||||
# Mailversand reparieren + Templates neu gestalten — 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:** Mailversand für Registrierung und Passwort-vergessen im NestJS-Backend
|
||||
(`myteamwallet_backend`) wieder funktionsfähig machen, das mail-only `nestjs-i18n` Setup
|
||||
entfernen (Texte direkt auf Deutsch), und die zwei Handlebars-Templates in einem zu
|
||||
"TeamWallet" (Grün `#2e7d32`) passenden, hübschen Design neu bauen.
|
||||
|
||||
**Architecture:** `MailService` (`@nestjs-modules/mailer` + Nodemailer + Handlebars) bleibt die
|
||||
zentrale Versandstelle. Der Bug ist ein totes `return;` vor `sendMail(...)` in beiden Methoden —
|
||||
Fix ist ein reiner Code-Fix, keine Config-Änderung nötig (`.env` ist bereits lokal korrekt
|
||||
befüllt). `nestjs-i18n` wird komplett entfernt, deutsche Texte wandern direkt in die
|
||||
`.hbs`-Templates bzw. als String-Literale in `mail.service.ts`. Die zwei Templates teilen sich
|
||||
ein gemeinsames Handlebars-Block-Partial (`partials/layout.hbs`) für Header/Footer, um
|
||||
Duplikation zu vermeiden.
|
||||
|
||||
**Tech Stack:** NestJS 9, `@nestjs-modules/mailer` 1.8.1 (Nodemailer 6.8.0), Handlebars 4.7.7,
|
||||
Jest 29 (Unit-Tests), TypeScript 4.8.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Nur Deutsch — keine mehrsprachige i18n-Infrastruktur für Mails, keine Sprachdateien.
|
||||
- `nestjs-i18n` wird vollständig aus dem Backend entfernt (Modul, Dependency, `src/i18n/`).
|
||||
- Branding: Akzentfarbe `#2e7d32` (Grün), Textwordmark „TeamWallet" (kein Bild-Logo), Roboto mit
|
||||
Fallback-Stack `Roboto, Helvetica, Arial, sans-serif`, abgerundete Card-Optik (~12px Radius),
|
||||
`max-width: 600px`, Inline-CSS-safe (die `HandlebarsAdapter` inlined `<style>`-CSS automatisch
|
||||
via `inline-css`, `inlineCssEnabled` ist standardmäßig `true`).
|
||||
- Kein MJML, kein Build-Pipeline-Zusatz für Templates.
|
||||
- `.env` (in `myteamwallet_backend/.env`) wird **nicht** verändert — existiert bereits mit
|
||||
gültigen Werten.
|
||||
- Kein neuer e2e-/MailDev-Aufbau — Verifikation über Unit-Tests plus einen manuellen echten
|
||||
Testversand (siehe Task 4).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bugfix `MailService` + Vornamen-Personalisierung
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/mail/mail.service.ts`
|
||||
- Modify: `myteamwallet_backend/src/auth/auth.service.ts:191-196` (register) und `:246-251`
|
||||
(forgotPassword)
|
||||
- Test: `myteamwallet_backend/src/mail/mail.service.spec.ts` (neu)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `MailService.userSignUp(mailData: MailData<{ hash: string; firstName?: string | null }>): Promise<void>`
|
||||
- Produces: `MailService.forgotPassword(mailData: MailData<{ hash: string; firstName?: string | null }>): Promise<void>`
|
||||
- Beide senden über `this.mailerService.sendMail({ to, subject, text, template, context })` mit
|
||||
`context` immer inkl. der Keys `title`, `year`, `firstName`, `url`, `actionTitle` (Task 3
|
||||
Templates lesen genau diese Keys).
|
||||
|
||||
- [ ] **Step 1: Failing Test schreiben**
|
||||
|
||||
Datei `myteamwallet_backend/src/mail/mail.service.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
describe('MailService', () => {
|
||||
let service: MailService;
|
||||
let sendMail: jest.Mock;
|
||||
let configGet: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendMail = jest.fn().mockResolvedValue(undefined);
|
||||
configGet = jest.fn().mockReturnValue('https://app.example.com');
|
||||
|
||||
service = new MailService(
|
||||
{ sendMail } as unknown as MailerService,
|
||||
{ get: configGet } as unknown as ConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the activation mail with the confirm-email link', async () => {
|
||||
await service.userSignUp({
|
||||
to: 'user@example.com',
|
||||
data: { hash: 'abc123', firstName: 'Max' },
|
||||
});
|
||||
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.to).toBe('user@example.com');
|
||||
expect(call.template).toBe('activation');
|
||||
expect(call.context.url).toBe(
|
||||
'https://app.example.com/confirm-email/abc123',
|
||||
);
|
||||
expect(call.context.firstName).toBe('Max');
|
||||
});
|
||||
|
||||
it('sends the reset-password mail with the password-change link', async () => {
|
||||
await service.forgotPassword({
|
||||
to: 'user@example.com',
|
||||
data: { hash: 'xyz789', firstName: 'Erika' },
|
||||
});
|
||||
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.to).toBe('user@example.com');
|
||||
expect(call.template).toBe('reset-password');
|
||||
expect(call.context.url).toBe(
|
||||
'https://app.example.com/password-change/xyz789',
|
||||
);
|
||||
expect(call.context.firstName).toBe('Erika');
|
||||
});
|
||||
|
||||
it('works without a firstName (optional personalization)', async () => {
|
||||
await service.userSignUp({
|
||||
to: 'user@example.com',
|
||||
data: { hash: 'abc123' },
|
||||
});
|
||||
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.context.firstName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, erwartetes Scheitern bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest mail.service.spec.ts`
|
||||
Expected: FAIL — `MailService` erwartet aktuell drei Constructor-Parameter (`I18n`,
|
||||
`MailerService`, `ConfigService`), der Test übergibt nur zwei, und selbst bei passendem Aufruf
|
||||
würde `sendMail` wegen des toten `return;` nie aufgerufen. Der Test schlägt fehl (z. B.
|
||||
`expect(sendMail).toHaveBeenCalledTimes(1)` erhält `0`, oder ein `TypeError` beim Zugriff auf
|
||||
`this.i18n.t`).
|
||||
|
||||
- [ ] **Step 3: `mail.service.ts` neu implementieren**
|
||||
|
||||
Datei `myteamwallet_backend/src/mail/mail.service.ts` komplett ersetzen:
|
||||
|
||||
```typescript
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailData } from './interfaces/mail-data.interface';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private mailerService: MailerService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async userSignUp(
|
||||
mailData: MailData<{ hash: string; firstName?: string | null }>,
|
||||
) {
|
||||
const actionTitle = 'E-Mail bestätigen';
|
||||
const url = `${this.configService.get('app.frontendDomain')}/confirm-email/${
|
||||
mailData.data.hash
|
||||
}`;
|
||||
|
||||
await this.mailerService.sendMail({
|
||||
to: mailData.to,
|
||||
subject: 'Bestätige deine E-Mail-Adresse',
|
||||
text: `${url} ${actionTitle}`,
|
||||
template: 'activation',
|
||||
context: {
|
||||
title: 'Bestätige deine E-Mail-Adresse',
|
||||
year: new Date().getFullYear(),
|
||||
firstName: mailData.data.firstName,
|
||||
url,
|
||||
actionTitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async forgotPassword(
|
||||
mailData: MailData<{ hash: string; firstName?: string | null }>,
|
||||
) {
|
||||
const actionTitle = 'Passwort zurücksetzen';
|
||||
const url = `${this.configService.get('app.frontendDomain')}/password-change/${
|
||||
mailData.data.hash
|
||||
}`;
|
||||
|
||||
await this.mailerService.sendMail({
|
||||
to: mailData.to,
|
||||
subject: actionTitle,
|
||||
text: `${url} ${actionTitle}`,
|
||||
template: 'reset-password',
|
||||
context: {
|
||||
title: actionTitle,
|
||||
year: new Date().getFullYear(),
|
||||
firstName: mailData.data.firstName,
|
||||
url,
|
||||
actionTitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest mail.service.spec.ts`
|
||||
Expected: PASS — alle 3 Tests grün.
|
||||
|
||||
- [ ] **Step 5: `auth.service.ts` — Vornamen mitgeben**
|
||||
|
||||
In `myteamwallet_backend/src/auth/auth.service.ts`, die zwei bestehenden Aufrufstellen anpassen
|
||||
(nur die `data`-Objekte erweitern, sonst nichts ändern):
|
||||
|
||||
Zeilen 191-196 (in `register()`, `user` ist zu diesem Zeitpunkt bereits erstellt):
|
||||
|
||||
```typescript
|
||||
await this.mailService.userSignUp({
|
||||
to: user.email,
|
||||
data: {
|
||||
hash,
|
||||
firstName: user.firstName,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Zeilen 246-251 (in `forgotPassword()`, im `else`-Zweig nach `user` lookup):
|
||||
|
||||
```typescript
|
||||
await this.mailService.forgotPassword({
|
||||
to: email,
|
||||
data: {
|
||||
hash,
|
||||
firstName: user.firstName,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: TypeScript-Build prüfen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npm run build`
|
||||
Expected: Build erfolgreich, keine Type-Fehler (insbesondere keine Fehler zu `firstName` an den
|
||||
beiden Aufrufstellen).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_backend/src/mail/mail.service.ts myteamwallet_backend/src/mail/mail.service.spec.ts myteamwallet_backend/src/auth/auth.service.ts
|
||||
git commit -m "fix(mail): remove dead return before sendMail, add firstName personalization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `nestjs-i18n` vollständig entfernen
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/app.module.ts`
|
||||
- Modify: `myteamwallet_backend/src/config/app.config.ts`
|
||||
- Delete: `myteamwallet_backend/src/i18n/` (kompletter Ordner: `en/common.json`,
|
||||
`en/confirm-email.json`, `en/reset-password.json`)
|
||||
- Modify: `myteamwallet_backend/package.json`, `myteamwallet_backend/package-lock.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nichts aus Task 1.
|
||||
- Produces: keine neuen Symbole — reine Entfernung. Spätere Tasks verlassen sich nicht auf
|
||||
`nestjs-i18n`.
|
||||
|
||||
- [ ] **Step 1: `src/i18n/` Ordner löschen**
|
||||
|
||||
Run: `cd myteamwallet_backend && rm -rf src/i18n`
|
||||
|
||||
- [ ] **Step 2: `app.module.ts` bereinigen**
|
||||
|
||||
In `myteamwallet_backend/src/app.module.ts`:
|
||||
|
||||
Import-Block (Zeilen 1-30) — `import * as path from 'path';` (Zeile 9), `import { I18nModule } from 'nestjs-i18n/dist/i18n.module';` (Zeile 13) und `import { HeaderResolver } from 'nestjs-i18n';` (Zeile 14) entfernen; `import { ConfigModule, ConfigService } from '@nestjs/config';` (Zeile 11) zu `import { ConfigModule } from '@nestjs/config';` ändern (kein anderer Verbraucher von `ConfigService` mehr in dieser Datei). Ergebnis:
|
||||
|
||||
```typescript
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import databaseConfig from './config/database.config';
|
||||
import authConfig from './config/auth.config';
|
||||
import appConfig from './config/app.config';
|
||||
import mailConfig from './config/mail.config';
|
||||
import fileConfig from './config/file.config';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TypeOrmConfigService } from './database/typeorm-config.service';
|
||||
import { MailConfigService } from './mail/mail-config.service';
|
||||
import { ForgotModule } from './forgot/forgot.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { TeamsModule } from './teams/teams.module';
|
||||
import { TransactionsModule } from './transactions/transactions.module';
|
||||
import { TeamSettingsModule } from './team-settings/team-settings.module';
|
||||
import { TeamWalletTransactionsModule } from './team-wallet-transactions/team-wallet-transactions.module';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
import { LoggingModule } from './database/logging/logging.module';
|
||||
import { TranslateModule } from './translate/translate.module';
|
||||
import { PenaltyModule } from './penalty/penalty.module';
|
||||
```
|
||||
|
||||
Im `@Module({ imports: [...] })` Array den kompletten `I18nModule.forRootAsync({...})` Block
|
||||
(bisher direkt nach `MailerModule.forRootAsync({...})`) entfernen, sodass `MailerModule` direkt
|
||||
von `ServeStaticModule` gefolgt wird:
|
||||
|
||||
```typescript
|
||||
MailerModule.forRootAsync({
|
||||
useClass: MailConfigService,
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '../client'),
|
||||
exclude: ['*/api*'],
|
||||
}),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `app.config.ts` bereinigen**
|
||||
|
||||
`myteamwallet_backend/src/config/app.config.ts` — Zeilen `fallbackLanguage` und
|
||||
`headerLanguage` entfernen (waren ausschließlich für `I18nModule` gedacht, kein anderer
|
||||
Konsument im Code):
|
||||
|
||||
```typescript
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
name: process.env.APP_NAME,
|
||||
workingDirectory: process.env.PWD || process.cwd(),
|
||||
frontendDomain: process.env.FRONTEND_DOMAIN,
|
||||
backendDomain: process.env.BACKEND_DOMAIN,
|
||||
port: parseInt(process.env.APP_PORT || process.env.PORT, 10) || 3000,
|
||||
apiPrefix: process.env.API_PREFIX || 'api',
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Dependency entfernen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npm uninstall nestjs-i18n`
|
||||
Expected: `package.json` und `package-lock.json` werden automatisch aktualisiert (Eintrag
|
||||
`"nestjs-i18n": "9.2.2"` verschwindet aus `dependencies`).
|
||||
|
||||
- [ ] **Step 5: Keine verbleibenden Referenzen prüfen**
|
||||
|
||||
Run: `cd myteamwallet_backend && grep -rn "nestjs-i18n\|I18nModule\|HeaderResolver\|I18nRequestScopeService" src/`
|
||||
Expected: keine Treffer (leere Ausgabe).
|
||||
|
||||
- [ ] **Step 6: Build prüfen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npm run build`
|
||||
Expected: Build erfolgreich, keine Fehler zu fehlenden `nestjs-i18n`-Imports oder unbenutzten
|
||||
Imports.
|
||||
|
||||
- [ ] **Step 7: Bestehende Unit-Tests laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npm test`
|
||||
Expected: alle Tests grün, inkl. der in Task 1 hinzugefügten `mail.service.spec.ts`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_backend/src/app.module.ts myteamwallet_backend/src/config/app.config.ts myteamwallet_backend/package.json myteamwallet_backend/package-lock.json
|
||||
git status
|
||||
git add myteamwallet_backend/src/i18n
|
||||
git commit -m "chore(mail): remove nestjs-i18n, only ever used for the two mail templates"
|
||||
```
|
||||
|
||||
(Der zweite `git add` erfasst die Löschung von `src/i18n/*` — je nach Git-Version reicht auch
|
||||
ein einzelnes `git add -A myteamwallet_backend/src/i18n myteamwallet_backend/src/app.module.ts myteamwallet_backend/src/config/app.config.ts myteamwallet_backend/package.json myteamwallet_backend/package-lock.json`.)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: E-Mail-Templates neu gestalten
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/mail/mail-config.service.ts`
|
||||
- Create: `myteamwallet_backend/src/mail/mail-templates/partials/layout.hbs`
|
||||
- Modify: `myteamwallet_backend/src/mail/mail-templates/activation.hbs`
|
||||
- Modify: `myteamwallet_backend/src/mail/mail-templates/reset-password.hbs`
|
||||
- Test: `myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts` (neu)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Context-Keys aus Task 1 — `title`, `year`, `firstName`, `url`, `actionTitle`.
|
||||
- Produces: registriertes Handlebars-Partial `layout` (Name = Dateiname ohne Endung, da es
|
||||
direkt in `partials/` liegt), eingebunden via `{{#> layout}} ... {{/layout}}` in beiden
|
||||
Content-Templates.
|
||||
|
||||
- [ ] **Step 1: Failing Test schreiben**
|
||||
|
||||
Datei `myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
|
||||
describe('mail templates rendering', () => {
|
||||
const templatesDir = __dirname;
|
||||
|
||||
beforeAll(() => {
|
||||
const layoutSource = fs.readFileSync(
|
||||
path.join(templatesDir, 'partials', 'layout.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
Handlebars.registerPartial('layout', layoutSource);
|
||||
});
|
||||
|
||||
const baseContext = {
|
||||
title: 'Test-Betreff',
|
||||
year: 2026,
|
||||
firstName: 'Max',
|
||||
url: 'https://app.example.com/confirm-email/abc123',
|
||||
actionTitle: 'Jetzt bestätigen',
|
||||
};
|
||||
|
||||
it('renders activation.hbs with greeting, link and button text', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(templatesDir, 'activation.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
const html = Handlebars.compile(source, { strict: true })(baseContext);
|
||||
|
||||
expect(html).toContain('TeamWallet');
|
||||
expect(html).toContain('Hallo Max,');
|
||||
expect(html).toContain(baseContext.url);
|
||||
expect(html).toContain(baseContext.actionTitle);
|
||||
});
|
||||
|
||||
it('renders reset-password.hbs with greeting, link and button text', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(templatesDir, 'reset-password.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
const html = Handlebars.compile(source, { strict: true })(baseContext);
|
||||
|
||||
expect(html).toContain('TeamWallet');
|
||||
expect(html).toContain('Hallo Max,');
|
||||
expect(html).toContain(baseContext.url);
|
||||
expect(html).toContain(baseContext.actionTitle);
|
||||
});
|
||||
|
||||
it('falls back to a generic greeting when firstName is missing', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(templatesDir, 'activation.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
const html = Handlebars.compile(source, { strict: true })({
|
||||
...baseContext,
|
||||
firstName: undefined,
|
||||
});
|
||||
|
||||
expect(html).toContain('Hallo,');
|
||||
expect(html).not.toContain('Hallo Max,');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, erwartetes Scheitern bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest mail-templates.spec.ts`
|
||||
Expected: FAIL — `partials/layout.hbs` existiert noch nicht (`ENOENT`), bzw. die bestehenden
|
||||
`activation.hbs`/`reset-password.hbs` enthalten weder „TeamWallet" noch „Hallo Max,".
|
||||
|
||||
- [ ] **Step 3: Layout-Partial erstellen**
|
||||
|
||||
Datei `myteamwallet_backend/src/mail/mail-templates/partials/layout.hbs`:
|
||||
|
||||
```handlebars
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; background: #f4f6f4; font-family: Roboto, Helvetica, Arial, sans-serif; }
|
||||
.tw-container { max-width: 600px; margin: 0 auto; padding: 32px 16px; }
|
||||
.tw-card { background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); }
|
||||
.tw-header { background: #2e7d32; padding: 28px 32px; text-align: center; }
|
||||
.tw-wordmark { color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: 0.5px; }
|
||||
.tw-body { padding: 32px; color: #20251f; font-size: 15px; line-height: 1.6; }
|
||||
.tw-body p { margin: 0 0 16px; }
|
||||
.tw-button-row { text-align: center; padding: 8px 0 24px; }
|
||||
.tw-button { display: inline-block; background: #2e7d32; color: #ffffff !important; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 15px; }
|
||||
.tw-footer { text-align: center; padding: 20px 16px 0; color: #8a938a; font-size: 12px; line-height: 1.6; }
|
||||
.tw-footer a { color: #8a938a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tw-container">
|
||||
<div class="tw-card">
|
||||
<div class="tw-header">
|
||||
<span class="tw-wordmark">TeamWallet</span>
|
||||
</div>
|
||||
<div class="tw-body">
|
||||
{{> @partial-block }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-footer">
|
||||
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt.<br>© {{year}} TeamWallet</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `activation.hbs` neu gestalten**
|
||||
|
||||
Datei `myteamwallet_backend/src/mail/mail-templates/activation.hbs` komplett ersetzen:
|
||||
|
||||
```handlebars
|
||||
{{#> layout}}
|
||||
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||
<p>schön, dass du bei TeamWallet dabei bist! Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.</p>
|
||||
<div class="tw-button-row">
|
||||
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||
</div>
|
||||
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||
{{/layout}}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `reset-password.hbs` neu gestalten**
|
||||
|
||||
Datei `myteamwallet_backend/src/mail/mail-templates/reset-password.hbs` komplett ersetzen:
|
||||
|
||||
```handlebars
|
||||
{{#> layout}}
|
||||
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||
<p>du hast angefragt, dein TeamWallet-Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort zu vergeben.</p>
|
||||
<div class="tw-button-row">
|
||||
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||
</div>
|
||||
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren — es wird nichts verändert.</p>
|
||||
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||
{{/layout}}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Partials-Verzeichnis in `mail-config.service.ts` registrieren**
|
||||
|
||||
In `myteamwallet_backend/src/mail/mail-config.service.ts` den `template.options` Block
|
||||
erweitern (`partials.dir` zeigt auf den neuen Ordner, damit `HandlebarsAdapter` die `.hbs`
|
||||
Dateien darin beim Versand automatisch als Partials lädt):
|
||||
|
||||
```typescript
|
||||
createMailerOptions(): MailerOptions {
|
||||
return {
|
||||
transport: {
|
||||
host: this.configService.get('mail.host'),
|
||||
port: this.configService.get('mail.port'),
|
||||
ignoreTLS: this.configService.get('mail.ignoreTLS'),
|
||||
secure: this.configService.get('mail.secure'),
|
||||
requireTLS: this.configService.get('mail.requireTLS'),
|
||||
auth: {
|
||||
user: this.configService.get('mail.user'),
|
||||
pass: this.configService.get('mail.password'),
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
from: `"${this.configService.get(
|
||||
'mail.defaultName',
|
||||
)}" <${this.configService.get('mail.defaultEmail')}>`,
|
||||
},
|
||||
template: {
|
||||
dir: path.join(
|
||||
this.configService.get('app.workingDirectory'),
|
||||
'src',
|
||||
'mail',
|
||||
'mail-templates',
|
||||
),
|
||||
adapter: new HandlebarsAdapter(),
|
||||
options: {
|
||||
strict: true,
|
||||
partials: {
|
||||
dir: path.join(
|
||||
this.configService.get('app.workingDirectory'),
|
||||
'src',
|
||||
'mail',
|
||||
'mail-templates',
|
||||
'partials',
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MailerOptions;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Test laufen lassen, Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest mail-templates.spec.ts`
|
||||
Expected: PASS — alle 3 Tests grün.
|
||||
|
||||
- [ ] **Step 8: Alle Unit-Tests + Build**
|
||||
|
||||
Run: `cd myteamwallet_backend && npm test && npm run build`
|
||||
Expected: alle Tests grün, Build erfolgreich.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_backend/src/mail/mail-config.service.ts myteamwallet_backend/src/mail/mail-templates
|
||||
git commit -m "feat(mail): redesign email templates with TeamWallet branding and shared layout partial"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Manuelle Verifikation mit echtem Versand
|
||||
|
||||
Kein Code-Task — Nachweis, dass Registrierung und Passwort-vergessen tatsächlich E-Mails
|
||||
verschicken (Strato-SMTP, kein lokaler MailDev vorhanden, siehe Spec).
|
||||
|
||||
**Files:** keine.
|
||||
|
||||
- [ ] **Step 1: Backend lokal starten**
|
||||
|
||||
Voraussetzung: lokale MySQL-Instanz läuft (Container `brave_einstein`, Port 3306, bereits aktiv
|
||||
laut `docker ps`), `myteamwallet_backend/.env` unverändert vorhanden.
|
||||
|
||||
Run: `cd myteamwallet_backend && npm run start:dev`
|
||||
Expected: Server startet ohne Fehler auf Port `3999` (kein Absturz durch die entfernten
|
||||
`nestjs-i18n`-Imports, keine `MailerModule`-Config-Fehler).
|
||||
|
||||
- [ ] **Step 2: Registrierung auslösen (Aktivierungsmail)**
|
||||
|
||||
In einem zweiten Terminal, mit einer echten, von dir kontrollierten Test-Adresse:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3999/api/v1/auth/email/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"DEINE-TEST-ADRESSE@example.com","password":"Test1234!","firstName":"Max","lastName":"Mustermann"}'
|
||||
```
|
||||
|
||||
Expected: HTTP 201, und innerhalb kurzer Zeit trifft eine E-Mail „Bestätige deine
|
||||
E-Mail-Adresse" mit grünem TeamWallet-Header, Begrüßung „Hallo Max," und funktionierendem
|
||||
Bestätigungs-Button in der Test-Mailbox ein.
|
||||
|
||||
- [ ] **Step 3: Passwort-vergessen auslösen**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3999/api/v1/auth/forgot/password \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"DEINE-TEST-ADRESSE@example.com"}'
|
||||
```
|
||||
|
||||
Expected: HTTP 204/200 (je nach Response des Endpoints), und eine E-Mail „Passwort
|
||||
zurücksetzen" mit gleichem Layout trifft ein.
|
||||
|
||||
- [ ] **Step 4: Server stoppen**
|
||||
|
||||
`npm run start:dev` Prozess beenden (Ctrl+C).
|
||||
|
||||
- [ ] **Step 5: Ergebnis festhalten**
|
||||
|
||||
Kein Commit nötig — dies ist ein manueller Verifikationsschritt. Falls eine der beiden Mails
|
||||
nicht ankommt, zurück zu systematic-debugging (SMTP-Verbindung, Firewall, Spam-Ordner prüfen)
|
||||
bevor der Task als abgeschlossen gilt.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** Bugfix (Task 1), i18n-Entfernung (Task 2), Template-Redesign inkl.
|
||||
Branding/Partial (Task 3), Personalisierung (Task 1), Testing-Strategie laut korrigiertem Spec
|
||||
— Unit-Tests statt e2e/MailDev (Task 1 + 3), manueller Realversand (Task 4) — alles abgedeckt.
|
||||
`.env` explizit als "keine Aktion" markiert (Global Constraints), passend zum korrigierten
|
||||
Spec.
|
||||
- **Placeholder-Scan:** keine TBD/TODO, jeder Step enthält vollständigen Code.
|
||||
- **Typ-Konsistenz:** `MailData<{ hash: string; firstName?: string | null }>` konsistent in
|
||||
`mail.service.ts` (Task 1) und den Aufrufstellen in `auth.service.ts` (Task 1) verwendet;
|
||||
Context-Keys `title`/`year`/`firstName`/`url`/`actionTitle` konsistent zwischen `mail.service.ts`
|
||||
(Task 1) und den Templates (Task 3).
|
||||
67
docs/superpowers/plans/2026-08-01-kasse-kpi-charts.md
Normal file
67
docs/superpowers/plans/2026-08-01-kasse-kpi-charts.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Kasse-KPI-Charts 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:** Add three KPI charts (Kassenstand-Verlauf, Einnahmen/Ausgaben pro Monat, Top-10 offene Beiträge) to the existing team `Übersicht` page.
|
||||
|
||||
**Architecture:** A new read-only backend aggregation endpoint (`GET /teams/:id/overview/stats`) derives all three datasets at request time from existing `Transaction`/`TeamWalletTransaction` data — no new tables. The frontend renders them with a small reusable `ChartCanvas` component wrapping `chart.js` directly (no Angular chart wrapper), following the existing `switchMap`/`catchError` loading pattern already used for `activities` in `overview.ts`.
|
||||
|
||||
**Tech Stack:** NestJS 9, TypeORM 0.3, Jest (backend); Angular 21, Angular Material, signals/RxJS, chart.js (new dependency), Vitest (frontend).
|
||||
|
||||
Design spec: `docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Default/only time window: last 12 months, no picker.
|
||||
- "Ist-Kasse" rule: only `Transaction` type `payment` + all `TeamWalletTransaction` (`credit`, `expense`) count. `fine`/`levy`/`fee` are excluded from every chart in this feature.
|
||||
- Amounts are stored positive in the DB; sign convention when summing must match `setBalance()`: `expense` (`type.id` 14) subtracts, `payment`/`credit` adds.
|
||||
- `balanceHistory`'s last entry must equal `team.balance` (sanity check, cover in a test).
|
||||
- Chart library is `chart.js` only — do not add `ng2-charts`/`ngx-charts` (Angular 21 peer-dependency risk).
|
||||
- `topOutstanding` returns at most 10 active players (`active === true`, `balance < 0`), sorted by debt descending, with a link to the existing `members` route for the full list — do not render all players in the chart.
|
||||
- No new endpoints/UI outside the `Übersicht` page (no new route/tab).
|
||||
- No dark-mode-specific chart theming (app is light-only today).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend stats endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.controller.ts`
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.service.ts`
|
||||
- Test: `myteamwallet_backend/src/teams/teams.service.spec.ts`
|
||||
- Test: `myteamwallet_backend/src/teams/teams.controller.spec.ts`
|
||||
|
||||
- [ ] Write failing tests for a new `getOverviewStats(teamId)` service method covering: `balanceHistory` monthly bucketing over 12 months with carry-forward for months without movement and correct sign handling (expense subtracted, payment/credit added, matching `setBalance()`); `monthlyFlow` grouping where `payment`+`credit` sum into `income` and `expense` sums into `expense`, with `fine`/`levy`/`fee` excluded entirely; `topOutstanding` limited to 10 active players with `balance < 0`, sorted by debt descending; and the sanity check `balanceHistory.at(-1).balance === team.balance`.
|
||||
- [ ] Write a failing controller test for `GET :id/overview/stats` asserting it carries the same `@UseGuards(AuthGuard('jwt'), RolesGuard)` / `@Roles([RoleEnum.user, RoleEnum.admin])` as the existing `:id/overview` route and delegates to `service.getOverviewStats(id)`.
|
||||
- [ ] Run the focused backend tests and confirm they fail for the expected reason (method/route missing).
|
||||
- [ ] Implement `getOverviewStats` in `teams.service.ts`: load the team with `relations: ['players', 'players.transactions', 'transactions']` (same pattern as `getTeamTransactions`), build one date-sorted list from `team.transactions` (TeamWalletTransaction) plus each player's `transactions` filtered to `type.name === 'payment'`, derive `balanceHistory` (12 monthly points, cumulative sum with the sign rule above, carry-forward on empty months), `monthlyFlow` (same 12 months, `payment`/`credit` → `income`, `expense` → `expense`), and `topOutstanding` (active players, `balance < 0`, sorted, sliced to 10, mapped to `{ playerId, playerName: firstName + ' ' + lastName, balance: outstanding as positive number }`). Add the `GET ':id/overview/stats'` route to `teams.controller.ts` next to `:id/overview`, delegating to the new service method.
|
||||
- [ ] Run the focused backend tests plus `npm run build` in `myteamwallet_backend`; commit the backend slice.
|
||||
|
||||
### Task 2: Frontend chart infrastructure and overview integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/package.json` (add `chart.js`)
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts` (+ `.html`/`.scss`/`.spec.ts`)
|
||||
- Create: `myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts` (+ `.spec.ts`)
|
||||
- Create: `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.html`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`
|
||||
|
||||
- [ ] Install `chart.js` in `myteamwallet_frontend_modern` (no Angular wrapper package).
|
||||
- [ ] Write failing tests for `TeamStatsApi.loadStats(teamId)` (GET `teams/:id/overview/stats`, mirrors `TransactionsApi.loadTeamTransactions`) and the `TeamOverviewStats` model shape.
|
||||
- [ ] Write failing tests for the `ChartCanvas` shared component: it creates a `Chart.js` instance from `type`/`data`/`options` inputs, updates the instance when those inputs change, and destroys it on `ngOnDestroy`.
|
||||
- [ ] Write failing tests in `overview.spec.ts` for the three new chart cards: spinner while `loadingStats()` is true, empty-state per card when its dataset is an empty array, data reaching `ChartCanvas` once `TeamStatsApi.loadStats` resolves, silent empty-state (no thrown error) when the request errors, and a working `routerLink` from the Top-10 card to the team's `members` route.
|
||||
- [ ] Run the focused frontend tests and confirm they fail for the expected reason.
|
||||
- [ ] Implement `TeamOverviewStats` model and `TeamStatsApi` service.
|
||||
- [ ] Implement `ChartCanvas`: a `<canvas>`-backed component with `type`/`data`/`options` inputs that manages the `Chart` instance lifecycle via `effect()` and `ngOnDestroy`.
|
||||
- [ ] Implement the `Overview` changes: a `stats`/`loadingStats` signal pair fed by `teamStatsApi.loadStats(id)` through the same route-param `switchMap` + `catchError(() => of(null))` pattern already used for `activities`; three new `mat-card` sections between the balance-grid and the activity list (Kassenstand-Verlauf line chart, Einnahmen/Ausgaben grouped bar chart, Top-10-Schuldner horizontal bar chart with a "Alle Spieler ansehen" link to `members`), each with its own loading/empty state.
|
||||
- [ ] Run the focused frontend tests, the full frontend test suite, and the TypeScript checks; commit the frontend slice.
|
||||
|
||||
### Task 3: Integration and review
|
||||
|
||||
- [ ] Run the full backend test suite and build, the full frontend test suite and typecheck, and `git diff --check`.
|
||||
- [ ] Manually verify against a running instance: a team with transaction history renders all three charts with correct values; a team with no financial movements shows empty-states, not errors or blank canvases.
|
||||
- [ ] Request a read-only code review of the full diff range; fix Critical/Important findings and re-verify.
|
||||
- [ ] Run the branch-finishing workflow and preserve the worktree until the user chooses integration.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Penalty Catalog Management 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:** Extend the existing team penalty catalog with secure create, inline update, and confirmed delete management in the modern frontend.
|
||||
|
||||
**Architecture:** Keep the versioned `/penalty` API compatible, reuse `TeamAccessService` for team-scoped authorization, and serialize writes by locking the owning team in a transaction. The Angular feature remains at `/team/:id/more/penalties` and treats backend authorization as authoritative.
|
||||
|
||||
**Tech Stack:** NestJS 9, TypeORM 0.3, class-validator, Jest, Angular 21, Angular Material, signals/RxJS, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Only active captains, treasurers, coaches (`teamRole.id >= 3`) and global admins may mutate a team's catalog.
|
||||
- Active team members and global admins may read a team's catalog; cross-team reads are forbidden.
|
||||
- Description is trimmed and 1–120 characters; amount is EUR `0.01..10000.00` with at most two decimals.
|
||||
- Normalized duplicate descriptions within one team return `409 Conflict`.
|
||||
- Deletes are permanent and do not alter historical transactions.
|
||||
- Mutations and audit entries share one transaction; logs contain IDs and action, not catalog content.
|
||||
- Do not modify `myteamwallet_frontend` or integrate penalties into transaction booking.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Secure backend catalog contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/penalty/**`
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.module.ts`
|
||||
- Modify: `myteamwallet_backend/src/database/logging/model/logging-event.type.ts`
|
||||
- Test: `myteamwallet_backend/src/penalty/*.spec.ts`
|
||||
|
||||
- [ ] Write failing DTO, service, and HTTP-boundary tests for safe mapping, team membership, manager roles, validation, duplicate conflicts, locking, audit rollback, update, and delete.
|
||||
- [ ] Run focused tests and confirm failures are caused by missing behavior.
|
||||
- [ ] Implement explicit DTOs, class-level authentication, `TeamAccessService` reuse, transactional create/update/delete, normalized duplicate checks, and audit events.
|
||||
- [ ] Run focused tests and backend build; commit the backend slice.
|
||||
|
||||
### Task 2: Modern frontend management
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/penalty-api.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/models/penalty.model.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team/more/penalties/**`
|
||||
|
||||
- [ ] Write failing API and component tests for reader/manager views, inline edit/cancel, delete confirmation, pessimistic refresh, errors, retry, search retention, and accessible controls.
|
||||
- [ ] Run focused tests and confirm failures are caused by missing behavior.
|
||||
- [ ] Implement typed update/delete calls and the responsive inline management UI using existing Material patterns.
|
||||
- [ ] Run focused tests, the full modern frontend suite, and TypeScript checks; commit the frontend slice.
|
||||
|
||||
### Task 3: Integration and review
|
||||
|
||||
- [ ] Run focused backend tests, backend build, full frontend tests, frontend TypeScript checks, and `git diff --check`.
|
||||
- [ ] Confirm the legacy frontend has no feature-range diff and document the eight pre-existing backend placeholder failures separately.
|
||||
- [ ] Request a read-only full-range code review; fix Critical/Important findings and re-verify.
|
||||
- [ ] Run the branch-finishing workflow and preserve the worktree until the user chooses integration.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Team Cash Flow Presentation 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:** Present every modern-frontend transaction history from the team wallet's cash-flow perspective.
|
||||
|
||||
**Architecture:** Replace the debit-only helper with a context-aware pure presenter and render it through one shared standalone Angular component. All four histories consume the component, while backend contracts and financial calculations remain untouched.
|
||||
|
||||
**Tech Stack:** Angular 21, standalone components, signals, Angular Material, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Modify only `myteamwallet_frontend_modern` plus this feature's documentation.
|
||||
- Do not modify the backend, database, legacy frontend, balance calculations, forms, or transaction APIs.
|
||||
- Inflows are green and visibly prefixed with `+`; outflows are red and prefixed with `−`; non-cash entries are grey and unsigned.
|
||||
- Player payment is an inflow unless its stored amount is negative, in which case it is an outflow reversal.
|
||||
- Team credit is an inflow; team expense is an outflow.
|
||||
- Player credit, fine, levy, fee, and unknown types are neutral.
|
||||
- Use Material theme tokens and expose a German accessible direction label; color must not be the only signal.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Central cash-flow semantics and amount component
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/models/transaction-amount.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/models/transaction-amount.spec.ts`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.ts`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.scss`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produce `CashFlowDirection = 'inflow' | 'outflow' | 'neutral'`.
|
||||
- Produce `CashFlowContext = 'player' | 'team'`.
|
||||
- Produce `CashFlowPresentation { direction; amount; sign }`, where amount is absolute and sign is `'+' | '−' | ''`.
|
||||
- Produce `presentCashFlow(amount, type, context): CashFlowPresentation`.
|
||||
- Produce standalone `TransactionAmount` with required `amount`, `type`, and `context` inputs.
|
||||
|
||||
- [ ] Write table-driven helper tests with hand-derived expectations for numeric, string, and object types, payment reversal, and unknown type.
|
||||
- [ ] Write component tests proving visible signs, semantic classes, currency output, and German accessible labels.
|
||||
- [ ] Run the two focused specs and confirm they fail because the presenter and component do not exist.
|
||||
- [ ] Implement the minimal pure presenter and standalone component using `CurrencyPipe`, `LOCALE_ID: de-DE`, `var(--mat-sys-primary)`, `var(--mat-sys-error)`, and `var(--mat-sys-on-surface-variant)`.
|
||||
- [ ] Run the focused specs until green, format only touched files, and commit the task.
|
||||
|
||||
### Task 2: Adopt the shared presentation in every history
|
||||
|
||||
**Files:**
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.*`
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.*`
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/team/members/player-detail.*`
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/public-team/public-player.*`
|
||||
|
||||
**Interfaces:**
|
||||
- Consume `TransactionAmount` from Task 1.
|
||||
- Combined `TeamActivity` rows pass `context = activity.isTeamWalletTransaction ? 'team' : 'player'`.
|
||||
- Private and public player rows pass `context = 'player'`.
|
||||
|
||||
- [ ] Extend the four view specs so the old signed-number rendering fails for inflow, outflow, and neutral entries.
|
||||
- [ ] Run the focused view specs and confirm expected failures.
|
||||
- [ ] Import and render `TransactionAmount` in all four standalone components; remove obsolete `displayAmount` methods, helper imports, and local positive/negative amount styling.
|
||||
- [ ] Run the focused view specs until green and format only touched files.
|
||||
- [ ] Run the complete modern-frontend suite, TypeScript check, Angular build, and `git diff --check`.
|
||||
- [ ] Confirm the feature range contains no backend or legacy-frontend paths, then commit the task.
|
||||
|
||||
### Task 3: Review and finish
|
||||
|
||||
- [ ] Request task-level and full-range read-only reviews; fix Critical/Important findings and re-run covering tests.
|
||||
- [ ] Re-run the full modern-frontend suite, TypeScript check, build, and scope/diff checks on final HEAD.
|
||||
- [ ] Use the branch-finishing workflow and preserve the worktree until the user chooses integration.
|
||||
2496
docs/superpowers/plans/2026-08-03-cashbox-export.md
Normal file
2496
docs/superpowers/plans/2026-08-03-cashbox-export.md
Normal file
File diff suppressed because it is too large
Load Diff
882
docs/superpowers/plans/2026-08-03-team-erstellen.md
Normal file
882
docs/superpowers/plans/2026-08-03-team-erstellen.md
Normal file
@@ -0,0 +1,882 @@
|
||||
# Team erstellen über die UI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Any logged-in user can create a new team via the Angular UI and automatically becomes its captain.
|
||||
|
||||
**Architecture:** Loosen the existing `POST /teams` backend endpoint from admin-only to any authenticated user, and extend `TeamsService.createNewTeam` to also create a `Player` membership row (role = captain) for the calling user. On the frontend, add a `createTeam` API method, a small `MatDialog` form component, and wire a persistent "Team erstellen" button into `team-select` that opens the dialog, refreshes the user's team list, and navigates into the new team.
|
||||
|
||||
**Tech Stack:** NestJS + TypeORM + Jest (backend, `myteamwallet_backend`); Angular 21 standalone components + Angular Material + signals + Vitest (frontend, `myteamwallet_frontend_modern`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-08-03-team-erstellen-design.md`.
|
||||
- Creator's team role is always `captain` (`TeamRolesEnum.captain` = `3`, `myteamwallet_backend/src/team-roles/team-roles.enum.ts`) — no role choice in the UI.
|
||||
- `CreateTeamDTO` stays `{ name: string }` — no other fields are added to the create-team form.
|
||||
- Backend: any new constructor dependency on `TeamsService` must be appended as the **last** parameter — `teams.service.spec.ts` constructs `TeamsService` positionally in two places, and reordering breaks those mocks.
|
||||
- Backend tests use Jest (`npm run test` from `myteamwallet_backend/`); frontend tests use Vitest via the Angular CLI (`npm test` from `myteamwallet_frontend_modern/`).
|
||||
- All new user-facing copy is German, matching existing screens (e.g. "Team erstellen", "Teamname", "Erstellen", "Abbrechen").
|
||||
- Out of scope (per spec): team deletion/archiving, custom alias entry, choosing the creator's role.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Loosen the `POST /teams` role guard to any logged-in user
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.controller.ts:247`
|
||||
- Test: `myteamwallet_backend/src/teams/teams.controller.spec.ts` (new file)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
- Produces: nothing new — this task only changes an authorization decorator and adds a regression test for it. `TeamsController.create(req, teamDto)` keeps calling `this.service.createNewTeam(teamDto, userId)` unchanged.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `myteamwallet_backend/src/teams/teams.controller.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { TeamsController } from './teams.controller';
|
||||
|
||||
describe('TeamsController', () => {
|
||||
const service = { createNewTeam: jest.fn() };
|
||||
const publicAccess = {};
|
||||
const teamMembers = {};
|
||||
const teamPermissions = {};
|
||||
const controller = new TeamsController(
|
||||
service as any,
|
||||
publicAccess as any,
|
||||
teamMembers as any,
|
||||
teamPermissions as any,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('allows any logged-in user (not just admins) to create a team', () => {
|
||||
expect(Reflect.getMetadata('roles', TeamsController.prototype.create)).toEqual([
|
||||
RoleEnum.user,
|
||||
RoleEnum.admin,
|
||||
]);
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.create),
|
||||
).toContain(RolesGuard);
|
||||
});
|
||||
|
||||
it('passes the authenticated user and the DTO to the service', () => {
|
||||
const req = { user: { id: 42 } };
|
||||
const dto = { name: '1. Herren' };
|
||||
|
||||
controller.create(req as any, dto as any);
|
||||
|
||||
expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_backend/`): `npx jest teams.controller.spec.ts`
|
||||
Expected: FAIL on the first test — `Reflect.getMetadata('roles', ...)` currently equals `[RoleEnum.admin]`, not `[RoleEnum.user, RoleEnum.admin]`.
|
||||
|
||||
- [ ] **Step 3: Loosen the guard**
|
||||
|
||||
In `myteamwallet_backend/src/teams/teams.controller.ts`, change line 247 from:
|
||||
|
||||
```ts
|
||||
@Roles([RoleEnum.admin])
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
```
|
||||
|
||||
(This is the only `@Roles([RoleEnum.admin])` occurrence in the file — it decorates the `create()` handler at lines 241-254.)
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx jest teams.controller.spec.ts`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_backend/src/teams/teams.controller.ts myteamwallet_backend/src/teams/teams.controller.spec.ts
|
||||
git commit -m "feat: allow any logged-in user to create a team"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Make the creator a captain of the new team
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.service.ts`
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.service.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TeamRolesEnum.captain` (`myteamwallet_backend/src/team-roles/team-roles.enum.ts`, value `3`); `User` entity (`myteamwallet_backend/src/users/entities/user.entity.ts`, has `firstName: string | null`, `lastName: string | null`); `Player` entity (`myteamwallet_backend/src/players/entities/player.entity.ts`).
|
||||
- Produces: `TeamsService.createNewTeam(teamdto: CreateTeamDTO, userId: string): Promise<Team>` now also creates a `Player` row linking the calling user to the new team with `teamRole.id === 3`. No signature change.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_backend/src/teams/teams.service.spec.ts` (new `describe` block, e.g. after the existing two blocks):
|
||||
|
||||
```ts
|
||||
describe('TeamsService#createNewTeam', () => {
|
||||
const repository = { create: jest.fn(), save: jest.fn() };
|
||||
const playerRepository = { create: jest.fn(), save: jest.fn() };
|
||||
const rolesRepository = { findOneBy: jest.fn() };
|
||||
const settingsRepository = { create: jest.fn(), save: jest.fn() };
|
||||
const usersRepository = { findOneBy: jest.fn() };
|
||||
const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
playerRepository as any,
|
||||
{} as any,
|
||||
rolesRepository as any,
|
||||
settingsRepository as any,
|
||||
{} as any,
|
||||
logger as any,
|
||||
{} as any,
|
||||
usersRepository as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the team, its default settings, and a captain membership for the creator', async () => {
|
||||
const savedTeam = { id: 7, name: '1. Herren' };
|
||||
repository.create.mockReturnValue({ name: '1. Herren' });
|
||||
repository.save.mockResolvedValue(savedTeam);
|
||||
settingsRepository.create.mockImplementation((s: unknown) => s);
|
||||
settingsRepository.save.mockResolvedValue([]);
|
||||
usersRepository.findOneBy.mockResolvedValue({
|
||||
id: 42,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
});
|
||||
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
|
||||
playerRepository.create.mockImplementation((p: unknown) => p);
|
||||
playerRepository.save.mockResolvedValue({});
|
||||
|
||||
const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');
|
||||
|
||||
expect(result).toBe(savedTeam);
|
||||
expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
|
||||
expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
|
||||
expect(playerRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
team: savedTeam,
|
||||
teamRole: { id: 3, name: 'captain' },
|
||||
}),
|
||||
);
|
||||
expect(playerRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Also update the **two existing** `new TeamsService(...)` constructions in this file (they currently pass 8 positional args; the constructor will have 9 after Step 3). Both blocks are byte-for-byte identical:
|
||||
|
||||
```ts
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
);
|
||||
```
|
||||
|
||||
Replace **both** occurrences (find-and-replace-all) with a 9th argument appended:
|
||||
|
||||
```ts
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
{} as any,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_backend/`): `npx jest teams.service.spec.ts`
|
||||
Expected: FAIL — TypeScript compile error (`Expected 9 arguments, but got 8`) on the two pre-existing constructions until Step 1's replace-all is applied, and then a runtime FAIL on the new test (`playerRepository.create` not called) until Step 3 is done.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `myteamwallet_backend/src/teams/teams.service.ts`, add two imports (near the existing entity imports):
|
||||
|
||||
```ts
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
```
|
||||
|
||||
Extend the constructor by appending a new injected repository as the **last** parameter (do not reorder the existing ones — `teams.service.spec.ts` relies on their positions):
|
||||
|
||||
```ts
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
constructor(
|
||||
@InjectRepository(Team)
|
||||
private repository: Repository<Team>,
|
||||
@InjectRepository(Player)
|
||||
private playerRepository: Repository<Player>,
|
||||
@InjectRepository(Transaction)
|
||||
private transactionsRepository: Repository<Transaction>,
|
||||
@InjectRepository(TeamRole)
|
||||
private rolesRepository: Repository<TeamRole>,
|
||||
@InjectRepository(TeamSetting)
|
||||
private settingsRepository: Repository<TeamSetting>,
|
||||
@InjectRepository(TeamWalletTransaction)
|
||||
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
|
||||
private logger: LoggingService,
|
||||
private access: TeamAccessService,
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
) {}
|
||||
```
|
||||
|
||||
(`User` is already registered in `TeamsModule`'s `TypeOrmModule.forFeature([...])` — see `myteamwallet_backend/src/teams/teams.module.ts:31` — so no module change is needed.)
|
||||
|
||||
Replace `createNewTeam` and add a new private helper right after it:
|
||||
|
||||
```ts
|
||||
async createNewTeam(teamdto: CreateTeamDTO, userId: string) {
|
||||
const createTeam = this.repository.create(teamdto);
|
||||
|
||||
const team = await this.repository.save(createTeam);
|
||||
await this.generateBasicTeamSettings(team);
|
||||
await this.addCreatorAsCaptain(team, userId);
|
||||
|
||||
await this.logger.info({
|
||||
event: 'team_create',
|
||||
details: `created team id: ${team.id}`,
|
||||
userId: Number(userId),
|
||||
});
|
||||
return team;
|
||||
}
|
||||
|
||||
private async addCreatorAsCaptain(team: Team, userId: string): Promise<void> {
|
||||
const [user, captainRole] = await Promise.all([
|
||||
this.usersRepository.findOneBy({ id: Number(userId) }),
|
||||
this.rolesRepository.findOneBy({ id: TeamRolesEnum.captain }),
|
||||
]);
|
||||
|
||||
const player = this.playerRepository.create({
|
||||
firstName: user?.firstName ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
team,
|
||||
teamRole: captainRole,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
|
||||
await this.playerRepository.save(player);
|
||||
}
|
||||
```
|
||||
|
||||
(`generateBasicTeamSettings` stays exactly as-is, right below.)
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx jest teams.service.spec.ts`
|
||||
Expected: PASS (all existing tests plus the new `createNewTeam` test).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_backend/src/teams/teams.service.ts myteamwallet_backend/src/teams/teams.service.spec.ts
|
||||
git commit -m "feat: make team creator a captain of the new team"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `TeamsApi.createTeam` (frontend HTTP call)
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Team` model (`myteamwallet_frontend_modern/src/app/models/team.model.ts`: `{ id, name, alias, balance, outstanding?, players?, settings? }`).
|
||||
- Produces: `export interface CreateTeamRequest { name: string }` and `TeamsApi.createTeam(request: CreateTeamRequest): Observable<Team>` — used by Task 5's dialog component.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts` (alongside the existing per-method tests, following the same `httpMock.expectOne(...).flush(...)` pattern already in that file):
|
||||
|
||||
```ts
|
||||
it('creates a team', () => {
|
||||
const request = { name: '1. Herren' };
|
||||
service.createTeam(request).subscribe();
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}teams`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(request);
|
||||
req.flush({ id: 7, name: '1. Herren', alias: 'a', balance: 0 });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `service.createTeam is not a function`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`, add the request interface next to `CreatePlayerRequest`:
|
||||
|
||||
```ts
|
||||
export interface CreateTeamRequest {
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add the method to the `TeamsApi` class (alongside `createPlayer`):
|
||||
|
||||
```ts
|
||||
createTeam(request: CreateTeamRequest): Observable<Team> {
|
||||
return this.http.post<Team>(`${environment.apiUrl}teams`, request);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/core/team/teams-api.ts myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts
|
||||
git commit -m "feat: add TeamsApi.createTeam"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `MyTeamsStore.refresh` (force-reload after creating a team)
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TeamsApi.loadMyTeams(userId: number): Observable<UserTeamMembership[]>` (existing).
|
||||
- Produces: `MyTeamsStore.refresh(userId: number): void` — always refetches, unlike `ensureLoaded` which is a no-op once loaded for that user. Used by Task 6.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`:
|
||||
|
||||
```ts
|
||||
it('refresh always reloads, even for a user id already loaded', () => {
|
||||
store.ensureLoaded(42);
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player]);
|
||||
|
||||
store.refresh(42);
|
||||
expect(store.loading()).toBe(true);
|
||||
|
||||
const secondPlayer = { ...player, id: 2, team: { id: 6, name: 'Team B' } };
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player, secondPlayer]);
|
||||
|
||||
expect(store.loading()).toBe(false);
|
||||
expect(store.players()).toEqual([player, secondPlayer]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `store.refresh is not a function`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace the body of `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts` with:
|
||||
|
||||
```ts
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { UserTeamMembership } from '../../models/user-directory.model';
|
||||
import { TeamsApi } from './teams-api';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MyTeamsStore {
|
||||
private readonly teamsApi = inject(TeamsApi);
|
||||
|
||||
private readonly playersSignal = signal<UserTeamMembership[]>([]);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly loadedForUserId = signal<number | null>(null);
|
||||
|
||||
readonly players = this.playersSignal.asReadonly();
|
||||
readonly loading = this.loadingSignal.asReadonly();
|
||||
|
||||
ensureLoaded(userId: number): void {
|
||||
if (this.loadedForUserId() === userId || this.loadingSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetch(userId);
|
||||
}
|
||||
|
||||
refresh(userId: number): void {
|
||||
this.fetch(userId);
|
||||
}
|
||||
|
||||
private fetch(userId: number): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.teamsApi.loadMyTeams(userId).subscribe({
|
||||
next: (players) => {
|
||||
this.playersSignal.set(players);
|
||||
this.loadedForUserId.set(userId);
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingSignal.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS (all existing `MyTeamsStore` tests plus the new one).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts
|
||||
git commit -m "feat: add MyTeamsStore.refresh for forced reloads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `CreateTeamDialog` component
|
||||
|
||||
**Files:**
|
||||
- Create: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`
|
||||
- Test: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TeamsApi.createTeam` (Task 3); `MatDialogRef<CreateTeamDialog, Team | undefined>` (Angular Material).
|
||||
- Produces: `CreateTeamDialog` standalone component. Opening it via `MatDialog.open(CreateTeamDialog)` and subscribing to `afterClosed()` yields the created `Team` on success, or `undefined` if cancelled/dismissed. Used by Task 6.
|
||||
|
||||
This introduces a new pattern for this codebase: the only existing `MatDialogRef`/`MAT_DIALOG_DATA`-injecting component is `shared/confirm-dialog/confirm-dialog.ts` (a plain confirm/cancel dialog, no form). This component follows the same injection style but uses a `ReactiveFormsModule` form, matching how `members.ts`/`penalties.ts` build their (inline, non-dialog) create forms.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { CreateTeamDialog } from './create-team-dialog';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
describe('CreateTeamDialog', () => {
|
||||
let dialogRef: { close: ReturnType<typeof vi.fn> };
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogRef = { close: vi.fn() };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CreateTeamDialog],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
}).compileComponents();
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('keeps the submit button disabled until a team name is entered', () => {
|
||||
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance['form'].invalid).toBe(true);
|
||||
|
||||
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||
expect(fixture.componentInstance['form'].invalid).toBe(false);
|
||||
});
|
||||
|
||||
it('creates the team and closes the dialog with the created team', () => {
|
||||
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||
fixture.componentInstance['submit']();
|
||||
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}teams`);
|
||||
expect(request.request.body).toEqual({ name: '1. Herren' });
|
||||
request.flush({ id: 9, name: '1. Herren', alias: 'a', balance: 0 });
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith({
|
||||
id: 9,
|
||||
name: '1. Herren',
|
||||
alias: 'a',
|
||||
balance: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('re-enables the form and keeps the dialog open when the request fails', () => {
|
||||
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||
fixture.componentInstance['submit']();
|
||||
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}teams`)
|
||||
.flush('error', { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.componentInstance['saving']()).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `create-team-dialog` module not found.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`:
|
||||
|
||||
```ts
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { Team } from '../../../models/team.model';
|
||||
import { TeamsApi } from '../../../core/team/teams-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-team-dialog',
|
||||
imports: [ReactiveFormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatInputModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Team erstellen</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Teamname</mat-label>
|
||||
<input matInput formControlName="name" />
|
||||
</mat-form-field>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
Erstellen
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CreateTeamDialog {
|
||||
protected readonly dialogRef = inject(MatDialogRef<CreateTeamDialog, Team | undefined>);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly teamsApi = inject(TeamsApi);
|
||||
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly form = this.formBuilder.nonNullable.group({
|
||||
name: ['', Validators.required],
|
||||
});
|
||||
|
||||
protected submit(): void {
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.teamsApi.createTeam(this.form.getRawValue()).subscribe({
|
||||
next: (team) => {
|
||||
this.saving.set(false);
|
||||
this.dialogRef.close(team);
|
||||
},
|
||||
error: () => this.saving.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS (3 new tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/
|
||||
git commit -m "feat: add CreateTeamDialog component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire "Team erstellen" into `team-select`
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CreateTeamDialog` (Task 5); `MyTeamsStore.refresh(userId)` (Task 4); `MatDialog` (Angular Material).
|
||||
- Produces: nothing consumed by later tasks — this is the last task.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`. First, add these imports at the top of the file:
|
||||
|
||||
```ts
|
||||
import { Subject } from 'rxjs';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Team } from '../../models/team.model';
|
||||
import { MyTeamsStore } from '../../core/team/my-teams-store';
|
||||
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
|
||||
```
|
||||
|
||||
Then replace the `describe('TeamSelect', ...)` setup (`let` declarations through the closing of `beforeEach`/`afterEach`) with:
|
||||
|
||||
```ts
|
||||
describe('TeamSelect', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let router: Router;
|
||||
let authStore: AuthStore;
|
||||
let myTeamsStore: MyTeamsStore;
|
||||
let dialogClosed: Subject<Team | undefined>;
|
||||
let dialog: { open: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
dialogClosed = new Subject<Team | undefined>();
|
||||
dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TeamSelect],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
router = TestBed.inject(Router);
|
||||
authStore = TestBed.inject(AuthStore);
|
||||
myTeamsStore = TestBed.inject(MyTeamsStore);
|
||||
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
```
|
||||
|
||||
(The existing three `it(...)` blocks stay unchanged below this.) Then add two new tests at the end of the `describe` block, before its closing `});`:
|
||||
|
||||
```ts
|
||||
it('opens the create-team dialog and navigates into the newly created team on success', async () => {
|
||||
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||
const refreshSpy = vi.spyOn(myTeamsStore, 'refresh');
|
||||
|
||||
const fixture = TestBed.createComponent(TeamSelect);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['createTeam']();
|
||||
expect(dialog.open).toHaveBeenCalledWith(CreateTeamDialog);
|
||||
|
||||
const created: Team = { id: 9, name: '1. Herren', alias: 'a', balance: 0 };
|
||||
dialogClosed.next(created);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(42);
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/team', 9, 'overview']);
|
||||
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
});
|
||||
|
||||
it('does not navigate when the create-team dialog is dismissed without a team', async () => {
|
||||
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||
|
||||
const fixture = TestBed.createComponent(TeamSelect);
|
||||
fixture.detectChanges();
|
||||
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance['createTeam']();
|
||||
dialogClosed.next(undefined);
|
||||
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||
Expected: FAIL — `createTeam` does not exist on `TeamSelect`, and `MatDialog` is unused/component doesn't inject it yet.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts` with:
|
||||
|
||||
```ts
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AuthStore } from '../../core/auth/auth-store';
|
||||
import { MyTeamsStore } from '../../core/team/my-teams-store';
|
||||
import { Team } from '../../models/team.model';
|
||||
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-team-select',
|
||||
imports: [RouterLink, MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
|
||||
templateUrl: './team-select.html',
|
||||
styleUrl: './team-select.scss',
|
||||
})
|
||||
export class TeamSelect {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly myTeamsStore = inject(MyTeamsStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
protected readonly players = this.myTeamsStore.players;
|
||||
protected readonly loading = this.myTeamsStore.loading;
|
||||
|
||||
constructor() {
|
||||
const userId = this.authStore.currentUser()?.id;
|
||||
if (userId) {
|
||||
this.myTeamsStore.ensureLoaded(userId);
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const players = this.myTeamsStore.players();
|
||||
if (!this.myTeamsStore.loading() && players.length === 1 && players[0].team) {
|
||||
void this.router.navigate(['/team', players[0].team.id, 'overview'], {
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected createTeam(): void {
|
||||
this.dialog
|
||||
.open(CreateTeamDialog)
|
||||
.afterClosed()
|
||||
.subscribe((team: Team | undefined) => {
|
||||
if (!team) return;
|
||||
|
||||
const userId = this.authStore.currentUser()?.id;
|
||||
if (userId) {
|
||||
this.myTeamsStore.refresh(userId);
|
||||
}
|
||||
void this.router.navigate(['/team', team.id, 'overview']);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html` with:
|
||||
|
||||
```html
|
||||
<div class="team-select-toolbar">
|
||||
<button mat-stroked-button type="button" (click)="createTeam()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Team erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="team-select-loading">
|
||||
<mat-spinner diameter="32" />
|
||||
</div>
|
||||
} @else if (players().length === 0) {
|
||||
<div class="team-select-empty">
|
||||
<p>Du bist noch keinem Team zugeordnet.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="team-select-page">
|
||||
<h1>Team auswählen</h1>
|
||||
<mat-nav-list>
|
||||
@for (player of players(); track player.id) {
|
||||
<a mat-list-item [routerLink]="['/team', player.team.id, 'overview']">
|
||||
<span matListItemTitle>{{ player.team.name }}</span>
|
||||
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
|
||||
</a>
|
||||
}
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`:
|
||||
|
||||
```scss
|
||||
.team-select-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS (all `TeamSelect` tests, including the two new ones).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add myteamwallet_frontend_modern/src/app/features/team-select/
|
||||
git commit -m "feat: wire create-team dialog into team-select"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual End-to-End Verification
|
||||
|
||||
After all tasks are complete:
|
||||
|
||||
1. Start the backend (`npm run start:dev` in `myteamwallet_backend/`) and frontend (`npm start` in `myteamwallet_frontend_modern/`).
|
||||
2. Log in as a regular (non-admin) user.
|
||||
3. Go to the team selection screen, click "Team erstellen", enter a name, submit.
|
||||
4. Confirm: the dialog closes, the app navigates to `/team/<newId>/overview`, and the user appears under "Mitglieder" with role "Kapitän".
|
||||
5. Go back to team selection (or log in as a user with 2+ teams) and confirm the "Team erstellen" button is visible even when teams already exist.
|
||||
6. Optionally, call `POST /api/v1/teams` directly via Swagger as a non-admin user's JWT to confirm the 403 is gone.
|
||||
431
docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md
Normal file
431
docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# Theoretischer Kassenstand Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eine zweite, gestrichelte Linie im bestehenden "Kassenstand-Verlauf"-Chart auf der Team-Übersicht zeigt pro Monat den theoretischen Kassenstand (Ist-Kassenstand + zu diesem Zeitpunkt offene, noch unbezahlte Beiträge).
|
||||
|
||||
**Architecture:** Backend (`teams.service.ts#getOverviewStats`) rekonstruiert zusätzlich zur bestehenden Team-Kassenstand-Historie eine Spieler-Saldo-Historie pro aktivem Spieler (gleiches Rückwärts-Reconstruction-Muster wie beim Team-Kassenstand, nur mit allen Buchungstypen statt nur `payment`), summiert sie pro Monat zu "offene Beiträge" und addiert das Ergebnis als `theoreticalBalance`-Feld in jeden bestehenden `balanceHistory`-Punkt. Frontend übernimmt das neue Feld unverändert strukturell als zweite Chart.js-Datenserie im bestehenden Liniendiagramm.
|
||||
|
||||
**Tech Stack:** NestJS/TypeORM (Backend, `myteamwallet_backend`), Angular 21 mit Chart.js (Frontend, `myteamwallet_frontend_modern`), Jest (Backend-Tests), Vitest (Frontend-Tests).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Referenz-Spec: `docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md` (und Basis-Feature `docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`).
|
||||
- `theoreticalBalance` wird als zusätzliches Feld in die bestehenden `balanceHistory`-Punkte eingebettet, kein separates Array.
|
||||
- Historische Rekonstruktion nutzt die *heutige* Menge aktiver Spieler (kein historisches Mitgliedschafts-Tracking) — bewusste Näherung, identisch zur bestehenden `balanceHistory`-Logik.
|
||||
- Buchungen mit Notiz-Präfix `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (aus `team-members.service.ts`) werden aus der Rekonstruktion ausgeschlossen.
|
||||
- Vorzeichen-Regel für Spieler-Buchungen: `type.id > 10` (Strafe/Umlage/Gebühr) mindert den Saldo, alle anderen Typen erhöhen ihn — exakt wie in `TeamMembersService.recomputeBalance` und `Transaction.setBalance()`.
|
||||
- Zweite Chart-Linie: gestrichelt (`borderDash: [6, 4]`), Farbe `#1d70b8`, Label „Theoretisch (inkl. offene Beiträge)". Bestehende Ist-Linie bleibt `#4f8f46`, durchgezogen.
|
||||
- `balanceChartOptions.plugins.legend.display` wechselt von `false` auf sichtbar (`position: 'bottom'`, wie beim bestehenden Flow-Chart).
|
||||
- Gating unverändert: hat das Team gar keine Kassenbewegung (`movements.length === 0`), bleibt `balanceHistory: []` (kein Chart, wie heute).
|
||||
- Bestehende Felder `monthlyFlow` und `topOutstanding` bleiben strukturell unverändert.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — `theoreticalBalance` in `getOverviewStats` berechnen
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_backend/src/teams/teams.service.ts:1-15` (Import), `:231-334` (`getOverviewStats` + neue private Hilfsmethoden)
|
||||
- Create: `myteamwallet_backend/src/teams/teams.service.spec.ts` (existiert noch nicht)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (exportiert aus `myteamwallet_backend/src/teams/team-members.service.ts`), `Transaction`-Entity (bereits importiert in `teams.service.ts`), `Player`-Entity (bereits importiert).
|
||||
- Produces: `getOverviewStats(...)` liefert `balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]` — dieses Feld konsumiert Task 2 im Frontend (`BalanceHistoryPoint.theoreticalBalance`).
|
||||
|
||||
- [ ] **Step 1: Neue Testdatei mit fehlschlagenden Tests schreiben**
|
||||
|
||||
Erstelle `myteamwallet_backend/src/teams/teams.service.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TeamsService } from './teams.service';
|
||||
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||
|
||||
function monthKey(monthsAgo: number): string {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isoDate(monthsAgo: number, day: number): string {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
|
||||
}
|
||||
|
||||
describe('TeamsService#getOverviewStats theoretical balance', () => {
|
||||
const repository = { findOneOrFail: jest.fn() };
|
||||
const access = { assertMember: jest.fn() };
|
||||
let service: TeamsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service = new TeamsService(
|
||||
repository as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||
access as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 100,
|
||||
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
|
||||
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
|
||||
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: -30,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(2, 10),
|
||||
amount: 30,
|
||||
type: { id: 11, name: 'fine' },
|
||||
note: 'Zu spät zum Training',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
||||
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
expect(beforeFine?.balance).toBe(100);
|
||||
expect(beforeFine?.theoreticalBalance).toBe(100);
|
||||
expect(now?.balance).toBe(100);
|
||||
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
|
||||
expect(now?.theoreticalBalance).toBe(130);
|
||||
});
|
||||
|
||||
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 50,
|
||||
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
active: true,
|
||||
balance: -20,
|
||||
transactions: [
|
||||
{
|
||||
date: isoDate(6, 5),
|
||||
amount: 999,
|
||||
type: { id: 1, name: 'credit' },
|
||||
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
|
||||
},
|
||||
{
|
||||
date: isoDate(1, 10),
|
||||
amount: 20,
|
||||
type: { id: 11, name: 'fine' },
|
||||
note: 'Zu spät',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||
|
||||
// Wäre die Ausgleichsbuchung (999) nicht ausgeschlossen, würde sie hier bereits
|
||||
// durchschlagen (beforeFine liegt chronologisch nach ihrem Datum) — tut sie aber nicht.
|
||||
expect(beforeFine?.theoreticalBalance).toBe(50);
|
||||
expect(now?.theoreticalBalance).toBe(70);
|
||||
});
|
||||
|
||||
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||
repository.findOneOrFail.mockResolvedValue({
|
||||
id: 9,
|
||||
balance: 0,
|
||||
transactions: [],
|
||||
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
|
||||
});
|
||||
|
||||
const result = await service.getOverviewStats(9, 42);
|
||||
|
||||
expect(result.balanceHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tests ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
|
||||
Expected: FAIL — `result.balanceHistory` Punkte haben noch kein `theoreticalBalance`-Feld (`undefined` statt der erwarteten Zahlen), erste zwei Tests schlagen fehl. Dritter Test (Leerfall) sollte bereits PASS sein (unverändertes Verhalten) — das bestätigt, dass der Testaufbau korrekt gegen die bisherige Implementierung läuft.
|
||||
|
||||
- [ ] **Step 3: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX`-Import ergänzen**
|
||||
|
||||
In `myteamwallet_backend/src/teams/teams.service.ts`, nach der bestehenden Import-Zeile für `TeamAccessService` (Zeile ~15) ergänzen:
|
||||
|
||||
```ts
|
||||
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rückgabetyp von `getOverviewStats` erweitern**
|
||||
|
||||
In `teams.service.ts`, die Signatur von `getOverviewStats` (aktuell Zeile 231-238) ändern:
|
||||
|
||||
```ts
|
||||
async getOverviewStats(
|
||||
teamId: string | number,
|
||||
actorUserId: string | number,
|
||||
): Promise<{
|
||||
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
|
||||
monthlyFlow: { month: string; income: number; expense: number }[];
|
||||
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||
}> {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Berechnung von `theoreticalBalance` einfügen**
|
||||
|
||||
In `teams.service.ts`, direkt vor dem finalen `return { balanceHistory, monthlyFlow, topOutstanding };` am Ende von `getOverviewStats` (aktuell Zeile 333) einfügen:
|
||||
|
||||
```ts
|
||||
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
||||
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
||||
...point,
|
||||
theoreticalBalance: this.round(point.balance - outstandingHistory[index]),
|
||||
}));
|
||||
|
||||
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
||||
}
|
||||
```
|
||||
|
||||
und die alte letzte Zeile ` return { balanceHistory, monthlyFlow, topOutstanding };` entfernen (sie wird durch den obigen Block ersetzt).
|
||||
|
||||
- [ ] **Step 6: Private Hilfsmethoden ergänzen**
|
||||
|
||||
In `teams.service.ts`, nach der bestehenden privaten Methode `signedFlowAmount` (aktuell Zeile 346-348) einfügen:
|
||||
|
||||
```ts
|
||||
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
|
||||
const activePlayers = players.filter((p) => p.active);
|
||||
const totals = months.map(() => 0);
|
||||
|
||||
for (const player of activePlayers) {
|
||||
const realTransactions = (player.transactions ?? []).filter(
|
||||
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
|
||||
);
|
||||
const playerHistory = this.reconstructPlayerBalanceHistory(
|
||||
months,
|
||||
Number(player.balance),
|
||||
realTransactions,
|
||||
);
|
||||
playerHistory.forEach((balance, index) => {
|
||||
totals[index] += balance;
|
||||
});
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
private reconstructPlayerBalanceHistory(
|
||||
months: string[],
|
||||
currentBalance: number,
|
||||
transactions: Transaction[],
|
||||
): number[] {
|
||||
const descendingMovements = transactions
|
||||
.map((t) => ({
|
||||
date: t.date,
|
||||
amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount),
|
||||
}))
|
||||
.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0));
|
||||
|
||||
let futureSum = 0;
|
||||
let movementIndex = 0;
|
||||
return [...months]
|
||||
.reverse()
|
||||
.map((month) => {
|
||||
while (
|
||||
movementIndex < descendingMovements.length &&
|
||||
descendingMovements[movementIndex].date.slice(0, 7) > month
|
||||
) {
|
||||
futureSum += descendingMovements[movementIndex].amount;
|
||||
movementIndex++;
|
||||
}
|
||||
return currentBalance - futureSum;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
```
|
||||
|
||||
`Transaction` und `Player` sind in `teams.service.ts` bereits importiert (Zeile 4 bzw. 10 im bestehenden Import-Block) — keine weiteren Imports nötig.
|
||||
|
||||
- [ ] **Step 7: Tests ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
|
||||
Expected: PASS — alle drei Tests grün.
|
||||
|
||||
- [ ] **Step 8: Vollständige Backend-Suite und Build laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_backend && npm test -- --silent && npm run build`
|
||||
Expected: alle bestehenden Tests weiterhin PASS (insbesondere keine Regression in anderen `teams`-Tests), Build ohne TypeScript-Fehler.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_backend
|
||||
git add src/teams/teams.service.ts src/teams/teams.service.spec.ts
|
||||
git commit -m "feat: add theoretical balance history to team overview stats
|
||||
|
||||
Reconstructs each active player's balance per month (same backward
|
||||
technique as the existing cash-balance history) so the overview stats
|
||||
endpoint can report what the team balance would be if all currently
|
||||
open dues had already been paid."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — zweite Chart-Linie im Kassenstand-Verlauf
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `theoreticalBalance` Feld aus Task 1 (`BalanceHistoryPoint.theoreticalBalance: number`, via `GET /teams/:id/overview/stats`).
|
||||
- Produces: keine neuen öffentlichen Interfaces — reine Chart-Darstellungs-Änderung innerhalb `Overview`.
|
||||
|
||||
- [ ] **Step 1: Modell erweitern**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`, `BalanceHistoryPoint` ändern:
|
||||
|
||||
```ts
|
||||
export interface BalanceHistoryPoint {
|
||||
month: string;
|
||||
balance: number;
|
||||
theoreticalBalance: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fehlschlagenden Test schreiben**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`, `sampleStats` (aktuell Zeile 22-32) um `theoreticalBalance` ergänzen:
|
||||
|
||||
```ts
|
||||
const sampleStats: TeamOverviewStats = {
|
||||
balanceHistory: [
|
||||
{ month: '2026-06', balance: 100, theoreticalBalance: 100 },
|
||||
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
|
||||
],
|
||||
monthlyFlow: [
|
||||
{ month: '2026-06', income: 50, expense: 10 },
|
||||
{ month: '2026-07', income: 40, expense: 15 },
|
||||
],
|
||||
topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }],
|
||||
};
|
||||
```
|
||||
|
||||
Im Test `'passes the loaded stats to each ChartCanvas once the request resolves'` (aktuell Zeile 181-200), nach der bestehenden Assertion `expect(balanceChart.data.labels).toHaveLength(2);` ergänzen:
|
||||
|
||||
```ts
|
||||
expect(balanceChart.data.datasets).toHaveLength(2);
|
||||
expect(balanceChart.data.datasets[0].data).toEqual([100, 125]);
|
||||
expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)');
|
||||
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
|
||||
Expected: FAIL — `balanceChart.data.datasets` hat noch Länge 1, die neuen Assertions schlagen fehl (bzw. TypeScript-Compile-Fehler, weil `sampleStats` noch nicht zum erweiterten `BalanceHistoryPoint`-Typ passt, falls Step 1 vor Step 2 gemacht wurde — in diesem Fall zunächst nur diesen Test isoliert betrachten).
|
||||
|
||||
- [ ] **Step 4: Zweite Datenserie und Legende in `overview.ts` ergänzen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`, die Farbkonstanten (aktuell Zeile 23-25) um die neue Farbe ergänzen:
|
||||
|
||||
```ts
|
||||
const BALANCE_COLOR = '#4f8f46';
|
||||
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
|
||||
const INCOME_COLOR = '#4f8f46';
|
||||
const EXPENSE_COLOR = '#c1121f';
|
||||
```
|
||||
|
||||
`balanceChartData` (aktuell Zeile 65-80) um die zweite Datenserie erweitern:
|
||||
|
||||
```ts
|
||||
protected readonly balanceChartData = computed<ChartData>(() => {
|
||||
const points = this.balanceHistory();
|
||||
return {
|
||||
labels: points.map((point) => formatMonthLabel(point.month)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Kassenstand',
|
||||
data: points.map((point) => point.balance),
|
||||
borderColor: BALANCE_COLOR,
|
||||
backgroundColor: BALANCE_COLOR,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: 'Theoretisch (inkl. offene Beiträge)',
|
||||
data: points.map((point) => point.theoreticalBalance),
|
||||
borderColor: THEORETICAL_BALANCE_COLOR,
|
||||
backgroundColor: THEORETICAL_BALANCE_COLOR,
|
||||
borderDash: [6, 4],
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
`balanceChartOptions` (aktuell Zeile 115-119) die Legende einblenden:
|
||||
|
||||
```ts
|
||||
protected readonly balanceChartOptions: ChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
|
||||
Expected: PASS — alle Tests in `overview.spec.ts` grün, inklusive der neuen Dataset-Assertions.
|
||||
|
||||
- [ ] **Step 6: Vollständige Frontend-Suite laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false`
|
||||
Expected: alle Tests PASS (keine Regression in anderen Specs durch die geänderte `BalanceHistoryPoint`-Typform).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/models/team-stats.model.ts src/app/features/team/overview/overview.ts src/app/features/team/overview/overview.spec.ts
|
||||
git commit -m "feat: show theoretical balance line in the cash-balance chart
|
||||
|
||||
Adds a second, dashed line to the existing balance-history chart that
|
||||
includes currently open player dues, so managers can see at a glance
|
||||
how far the actual cash balance lags behind what has been pledged."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec-Abdeckung:** Backend-Berechnung (Task 1, Steps 4-6), Response-Form-Änderung (Task 1, Step 4), Frontend-Chart/Legende/Farben (Task 2, Step 4), Testing-Anforderungen aus der Spec (historische Rekonstruktion, Ausgleichsbuchungs-Ausschluss, Leerfall, Frontend-Dataset-Assertions) sind je in eigenen Test-Steps abgedeckt. Manuelle Verifikation aus der Spec ist bewusst nicht als Plan-Task modelliert — bei Bedarf nach Abschluss beider Tasks manuell im Browser gegen ein Team mit unbezahlter Strafe prüfen.
|
||||
- **Typkonsistenz:** `theoreticalBalance: number` konsistent in Backend-Rückgabetyp (Task 1, Step 4), Frontend-Modell (Task 2, Step 1) und allen Test-Fixtures verwendet.
|
||||
- **Scope:** Einzelne, in sich geschlossene Erweiterung eines bereits bestehenden Features — keine weitere Zerlegung nötig.
|
||||
3287
docs/superpowers/plans/2026-08-04-notification-center.md
Normal file
3287
docs/superpowers/plans/2026-08-04-notification-center.md
Normal file
File diff suppressed because it is too large
Load Diff
355
docs/superpowers/plans/2026-08-05-env-indicator.md
Normal file
355
docs/superpowers/plans/2026-08-05-env-indicator.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Umgebungs-Indikator (EnvBanner) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ein schmaler Banner-Streifen erscheint app-weit oben, sobald `environment.production === false` (aktuell nur der lokale `ng serve`-Build), damit man Entwicklungsumgebung und echte App nie verwechselt.
|
||||
|
||||
**Architecture:** Neue Standalone-Komponente `EnvBanner` wird einmalig in `app.html` vor `<router-outlet />` eingebunden (single source of truth für alle Routen). `App` (Root-Komponente) bindet zusätzlich eine CSS-Custom-Property `--env-banner-height` auf ihr eigenes Host-Element, damit die drei `height:100dvh`-Layouts (Shell, Public-Team, Public-Player) die Banner-Höhe kompensieren können, ohne den kürzlich behobenen Doppel-Scrollbar-Bug erneut einzuführen.
|
||||
|
||||
**Tech Stack:** Angular 21 (Standalone Components, Signals, neue Control-Flow-Syntax), SCSS, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Banner-Höhe ist eine feste Konstante `ENV_BANNER_HEIGHT_PX = 28` (Pixel), exportiert aus `env-banner.ts` und in `app.ts` wiederverwendet — an genau diesen zwei Stellen referenziert, nicht dupliziert.
|
||||
- Banner-Text ist exakt `⚠ Entwicklungsumgebung` — keine zusätzlichen technischen Details (API-URL, Build-Hash).
|
||||
- Banner-Farbe ist Amber/Orange (`#f4a300` Hintergrund, `#20251F` Text) — bewusst nicht das App-Grün (`--mat-sys-primary`).
|
||||
- Kein Dismiss/Schließen-Button.
|
||||
- Kein neues Feld in den drei Environment-Dateien — einzige Quelle ist das bereits vorhandene `environment.production` (siehe `docs/superpowers/specs/2026-08-05-env-indicator-design.md`, Abschnitt „Entscheidungen aus dem Brainstorming").
|
||||
- `EnvBanner` wird ausschließlich einmal in `app.html` eingebunden, nicht zusätzlich in Shell/Public-Seiten/Auth-Seiten.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `EnvBanner`-Komponente
|
||||
|
||||
**Files:**
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.scss`
|
||||
- Test: `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `environment` aus `myteamwallet_frontend_modern/src/environments/environment.ts` (Feld `production: boolean`, per Angular `fileReplacements` je Build-Konfiguration ausgetauscht — bereits vorhanden, keine Änderung nötig).
|
||||
- Produces: `export class EnvBanner` (Selector `app-env-banner`, keine Inputs) und `export const ENV_BANNER_HEIGHT_PX = 28;` — beide werden in Task 2 von `app.ts` importiert.
|
||||
|
||||
- [ ] **Step 1: Fehlschlagenden Test schreiben**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EnvBanner } from './env-banner';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('EnvBanner', () => {
|
||||
const originalProduction = environment.production;
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('shows the environment banner outside production', async () => {
|
||||
environment.production = false;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element?.textContent).toContain('Entwicklungsumgebung');
|
||||
});
|
||||
|
||||
it('renders nothing in production', async () => {
|
||||
environment.production = true;
|
||||
await TestBed.configureTestingModule({ imports: [EnvBanner] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(EnvBanner);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('.env-banner');
|
||||
expect(element).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/env-banner.spec.ts'`
|
||||
Expected: FAIL — `Cannot find module './env-banner'` (die Komponente existiert noch nicht).
|
||||
|
||||
- [ ] **Step 3: Komponente implementieren**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`:
|
||||
|
||||
```ts
|
||||
import { Component } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export const ENV_BANNER_HEIGHT_PX = 28;
|
||||
|
||||
@Component({
|
||||
selector: 'app-env-banner',
|
||||
templateUrl: './env-banner.html',
|
||||
styleUrl: './env-banner.scss',
|
||||
})
|
||||
export class EnvBanner {
|
||||
protected readonly showBanner = !environment.production;
|
||||
}
|
||||
```
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.html`:
|
||||
|
||||
```html
|
||||
@if (showBanner) {
|
||||
<div class="env-banner" role="status">⚠ Entwicklungsumgebung</div>
|
||||
}
|
||||
```
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.scss`:
|
||||
|
||||
```scss
|
||||
// Höhe muss mit ENV_BANNER_HEIGHT_PX in env-banner.ts übereinstimmen.
|
||||
.env-banner {
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f4a300;
|
||||
color: #20251f;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/env-banner.spec.ts'`
|
||||
Expected: PASS — beide Tests grün.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/shared/env-banner/
|
||||
git commit -m "feat: add EnvBanner component for non-production environments
|
||||
|
||||
Standalone component that renders a small banner whenever
|
||||
environment.production is false, so the local dev build is never
|
||||
mistaken for the real app."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Einbau in `App` (Root-Komponente) inkl. Höhen-Variable
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/app.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/app.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/app.spec.ts` (existiert noch nicht)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `EnvBanner`, `ENV_BANNER_HEIGHT_PX` aus Task 1 (`myteamwallet_frontend_modern/src/app/shared/env-banner/env-banner.ts`); `environment` aus `myteamwallet_frontend_modern/src/environments/environment.ts`.
|
||||
- Produces: `<app-root>` setzt die Inline-Style-Custom-Property `--env-banner-height` (Wert inkl. `px`-Einheit, z. B. `"28px"` oder `"0px"`) auf seinem eigenen Host-Element. Task 3 liest diese Property per `var(--env-banner-height, 0px)`.
|
||||
|
||||
- [ ] **Step 1: Fehlschlagenden Test schreiben**
|
||||
|
||||
Erstelle `myteamwallet_frontend_modern/src/app/app.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
describe('App', () => {
|
||||
const originalProduction = environment.production;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.production = originalProduction;
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 0px and renders no banner in production', async () => {
|
||||
environment.production = true;
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('0px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets --env-banner-height to 28px and renders the banner outside production', async () => {
|
||||
environment.production = false;
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('28px');
|
||||
expect(fixture.nativeElement.querySelector('.env-banner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test ausführen und Fehlschlag bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/app.spec.ts'`
|
||||
Expected: FAIL — `--env-banner-height` ist leer (`''`), kein `.env-banner`-Element vorhanden.
|
||||
|
||||
- [ ] **Step 3: `App` erweitern**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/app.ts`, den bestehenden Inhalt ersetzen durch:
|
||||
|
||||
```ts
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { AuthApi } from './core/auth/auth-api';
|
||||
import { AuthStore } from './core/auth/auth-store';
|
||||
import { environment } from '../environments/environment';
|
||||
import { ENV_BANNER_HEIGHT_PX, EnvBanner } from './shared/env-banner/env-banner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, EnvBanner],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
'[style.--env-banner-height]': 'bannerHeight',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
protected readonly bannerHeight = `${environment.production ? 0 : ENV_BANNER_HEIGHT_PX}px`;
|
||||
|
||||
constructor() {
|
||||
if (this.authStore.token()) {
|
||||
this.authApi.me().subscribe({
|
||||
next: (response) => {
|
||||
const { token, ...user } = response;
|
||||
if (token) {
|
||||
this.authStore.setSession(token, user);
|
||||
} else {
|
||||
this.authStore.updateUser(user);
|
||||
}
|
||||
},
|
||||
error: () => undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Nur `imports`, `host` und die neue `bannerHeight`-Property sind neu — Konstruktor-Logik unverändert übernommen.)
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/app.html`, den bestehenden Inhalt ersetzen durch:
|
||||
|
||||
```html
|
||||
<app-env-banner />
|
||||
<router-outlet />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test ausführen und Erfolg bestätigen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/app.spec.ts'`
|
||||
Expected: PASS — beide Tests grün.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/app.ts src/app/app.html src/app/app.spec.ts
|
||||
git commit -m "feat: mount EnvBanner app-wide and expose --env-banner-height
|
||||
|
||||
Renders the banner once at the app root so every route picks it up,
|
||||
and exposes its height as a CSS custom property so fixed-viewport
|
||||
layouts (Shell, public pages) can compensate for it."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Höhen-Kompensation in den `100dvh`-Layouts
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/public-team/public-team.scss`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/features/public-team/public-player.scss`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--env-banner-height` Custom Property aus Task 2, gelesen per `var(--env-banner-height, 0px)` — der Fallback `0px` ist notwendig, damit `shell.spec.ts`, `public-team.spec.ts` und `public-player.spec.ts` (die diese Komponenten isoliert ohne `<app-root>`-Ancestor rendern) unverändert weiter grün bleiben.
|
||||
|
||||
Diese drei Dateien nutzen aktuell `height: 100dvh;` als feste Zusage „genau ein Bildschirm hoch" (siehe `docs/superpowers/plans/die-public-seite-kann-async-shamir.md` vom selben Tag zum Doppel-Scrollbar-Fix). Ohne Anpassung würde der neue Banner die Shell/Public-Seite um seine Höhe über den sichtbaren Bereich hinausschieben — derselbe Bugtyp wie der dort behobene.
|
||||
|
||||
- [ ] **Step 1: `shell.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss`, im `:host`-Block:
|
||||
|
||||
```scss
|
||||
// vorher: height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `public-team.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/public-team/public-team.scss`, im `:host`-Block dieselbe Änderung:
|
||||
|
||||
```scss
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `public-player.scss` anpassen**
|
||||
|
||||
In `myteamwallet_frontend_modern/src/app/features/public-team/public-player.scss`, im `:host`-Block dieselbe Änderung:
|
||||
|
||||
```scss
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vollständige Test-Suite laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false`
|
||||
Expected: alle Tests weiterhin PASS — reine CSS-Wertänderung, `shell.spec.ts`/`public-team.spec.ts`/`public-player.spec.ts` prüfen kein Layout und bleiben unberührt.
|
||||
|
||||
- [ ] **Step 5: Build laufen lassen**
|
||||
|
||||
Run: `cd myteamwallet_frontend_modern && npx ng build`
|
||||
Expected: Build erfolgreich (nur die bereits bekannte, unveränderte Bundle-Budget-Warnung).
|
||||
|
||||
- [ ] **Step 6: Manuelle Verifikation (kein automatisierter CSS-Layout-Test im Projekt vorhanden)**
|
||||
|
||||
`npm start`, im Browser (z. B. via Chrome DevTools) auf einer Shell-Seite mit langer Liste (`/team/:id/overview`) sowie auf `/t/:token` mit Inhalt prüfen:
|
||||
- Banner ist sichtbar, Header darunter bleibt beim Scrollen fix, Bottom-Nav ist vollständig sichtbar (nicht abgeschnitten).
|
||||
- `document.body.scrollHeight === document.body.clientHeight` (kein zusätzlicher Scrollbar auf `body`, wie beim vorherigen Fix verifiziert).
|
||||
- `ng build` (ohne `--configuration`, also production) und `ng build --configuration=container` zeigen keinen Banner (visuell/Bundle prüfen), `ng serve` (development) zeigt ihn.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd myteamwallet_frontend_modern
|
||||
git add src/app/core/layout/shell/shell.scss src/app/features/public-team/public-team.scss src/app/features/public-team/public-player.scss
|
||||
git commit -m "fix: compensate 100dvh layouts for the env banner's height
|
||||
|
||||
Shell and the public pages commit to exactly one viewport tall; without
|
||||
this, the new dev-environment banner would push their bottom edge (and
|
||||
Shell's bottom nav) past the visible viewport, the same overflow-leak
|
||||
bug fixed earlier today."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec-Abdeckung:** Komponente + Sichtbarkeitslogik (Task 1), App-weite Einbindung + Höhen-Variable (Task 2), Höhen-Kompensation der drei betroffenen Layouts (Task 3) — alle Abschnitte der Spec sind abgedeckt. Kein neues Environment-Feld (bewusst, siehe Spec).
|
||||
- **Typkonsistenz:** `ENV_BANNER_HEIGHT_PX` einmal in `env-banner.ts` definiert, in `app.ts` importiert und verwendet — keine Duplikation des Zahlenwerts außer dem dokumentierten Kommentar in `env-banner.scss`. `bannerHeight` liefert einen fertigen `px`-String statt eine Zahl mit `[style.prop.px]`-Unit-Suffix, da Angulars Unit-Suffix-Syntax für CSS-Custom-Properties (`--foo`) nicht zuverlässig dokumentiert/getestet ist — sicherer, den fertigen String zu binden.
|
||||
- **Scope:** Einzelne, in sich geschlossene Erweiterung; keine weitere Zerlegung nötig.
|
||||
145
docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md
Normal file
145
docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Kasse-KPIs als Graphen auf der Team-Übersicht
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-01
|
||||
|
||||
## Kontext
|
||||
|
||||
Frontend: Angular 21 (`myteamwallet_frontend_modern`), Angular Material als Design-System,
|
||||
`LOCALE_ID: 'de-DE'`. Backend: NestJS (`myteamwallet_backend`, basierend auf
|
||||
`nestjs-boilerplate`) mit TypeORM-Entities.
|
||||
|
||||
Die bestehende Team-Übersicht (`features/team/overview/overview.ts` + `.html`) zeigt aktuell nur
|
||||
zwei Kennzahlen-Kacheln (Teamkasse-Saldo, offene Beiträge, aus `GET /teams/:id/overview` bzw.
|
||||
`teams.service.ts#getOverview`) sowie eine Liste der letzten 10 Aktivitäten
|
||||
(`TransactionsApi.loadTeamTransactions`). Es gibt weder eine Chart-Bibliothek im Frontend noch
|
||||
einen Backend-Endpoint, der Transaktionen zeitlich oder kategorisch aggregiert — `Transaction`
|
||||
und `TeamWalletTransaction` liefern nur flache Listen; `team.balance`/`player.balance` sind reine
|
||||
Laufsummen ohne historische Zwischenstände.
|
||||
|
||||
Datenmodell (relevant für Aggregation):
|
||||
|
||||
- `Transaction` (Spieler-Ebene, `transaction-type.enum.ts`): `payment` (id 0), `credit` (id 1),
|
||||
`fine` (11), `levy` (12), `fee` (13). Nur `payment` verändert laut
|
||||
`transaction.entity.ts#setBalance()` zusätzlich `team.balance` — Strafen/Beiträge (`fine`,
|
||||
`levy`, `fee`) erhöhen nur die Schuld des Spielers (`player.balance`), bis sie bezahlt werden.
|
||||
- `TeamWalletTransaction` (Team-Ebene, `team-wallet-transaction.enum.ts`): `credit` (1),
|
||||
`expense` (14) — verändern `team.balance` direkt.
|
||||
|
||||
Ziel: auf der Übersicht drei KPI-Graphen ergänzen, damit Trainer/Kassenwarte den Kassenverlauf
|
||||
auf einen Blick erfassen, ohne die volle Aktivitätsliste durchsuchen zu müssen.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **KPIs**: Kassenstand-Verlauf über Zeit, Einnahmen vs. Ausgaben pro Monat, offene Beiträge je
|
||||
Spieler (Top 10). Keine Kategorie-Verteilung (Strafen/Beiträge/Ausgaben-Anteile) in diesem Zug.
|
||||
- **Platzierung**: direkt auf der bestehenden Übersicht-Seite, kein neuer Tab/Bereich.
|
||||
- **Zeitraum**: feste laufende Saison, letzte 12 Monate — kein Zeitraum-Umschalter in diesem Zug.
|
||||
- **Chart-Bibliothek**: `chart.js` direkt (kein `ng2-charts`/`ngx-charts`-Wrapper), um
|
||||
Peer-Dependency-Risiken mit dem sehr neuen Angular 21 zu vermeiden — Chart.js hat keine
|
||||
Angular-Abhängigkeit.
|
||||
- **Einnahmen-Logik**: „Ist-Kasse" — nur tatsächliche Zahlungsbewegungen zählen als
|
||||
Einnahme/Ausgabe (Spieler-`payment` + Team-Wallet-`credit`/`expense`). Verhängte, aber noch
|
||||
nicht bezahlte `fine`/`levy`/`fee` zählen **nicht** mit — konsistent mit dem
|
||||
Kassenstand-Verlauf, der denselben Datenausschnitt nutzt.
|
||||
- **Offene-Beiträge-Chart**: nur Top 10 Schuldner (höchste negative `player.balance`, nur aktive
|
||||
Spieler), mit Link zur bestehenden Mitgliederverwaltung (`team/:id/members`) für die
|
||||
vollständige Liste.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: neuer Aggregations-Endpoint
|
||||
|
||||
Neue Route `GET /teams/:id/overview/stats` in `teams.controller.ts`, Logik in
|
||||
`teams.service.ts` (neue Methode `getOverviewStats(teamId)`, analog zu `getOverview`).
|
||||
Antwortform:
|
||||
|
||||
```ts
|
||||
interface TeamOverviewStats {
|
||||
balanceHistory: { month: string /* 'YYYY-MM' */; balance: number }[]; // 12 Einträge
|
||||
monthlyFlow: { month: string; income: number; expense: number }[]; // 12 Einträge
|
||||
topOutstanding: { playerId: number; playerName: string; balance: number }[]; // max. 10
|
||||
}
|
||||
```
|
||||
|
||||
Berechnung:
|
||||
|
||||
- Relevante Rohdaten: alle `Transaction` vom Typ `payment` des Teams + alle
|
||||
`TeamWalletTransaction` des Teams, jeweils mit `date` und `amount`, aufsteigend sortiert.
|
||||
(Wiederverwendung der bestehenden Relationen `team.players.transactions` /
|
||||
`team.transactions`, wie in `getTeamTransactions` bereits geladen — Filterung auf `payment`
|
||||
ergänzen.)
|
||||
- `balanceHistory`: kumulative Summe der Rohdaten bilden, pro Kalendermonat der letzten 12 Monate
|
||||
den Stand am Monatsende übernehmen; Monate ohne Bewegung übernehmen den letzten bekannten
|
||||
Stand. Vorzeichen wie in den bestehenden `setBalance()`-Methoden: `amount` ist in der DB stets
|
||||
positiv gespeichert, `expense` (`TeamWalletTransaction`, `type.id` 14) wird beim Aufsummieren
|
||||
abgezogen, `payment`/`credit` addiert. Der letzte Wert der Reihe muss `team.balance`
|
||||
entsprechen (Sanity-Check im Unit-Test).
|
||||
- `monthlyFlow`: dieselben Rohdaten nach Monat gruppieren; `payment` und `credit` (positiver
|
||||
Betrag) fließen in `income`, `expense` in `expense` (als positive Summe ausgewiesen, nicht
|
||||
negativ).
|
||||
- `topOutstanding`: aktive Spieler (`player.active`) mit `balance < 0` laden (gleiche
|
||||
Player-Relation wie `getOverview`), nach `balance` aufsteigend (= höchste Schuld zuerst)
|
||||
sortieren, auf 10 begrenzen, `balance` als positiver `outstanding`-Betrag ausgeben.
|
||||
|
||||
Kein neues TypeORM-Entity, keine neue Tabelle — reine Ableitung aus bestehenden Daten zur
|
||||
Laufzeit (Datenvolumen pro Team ist klein genug, keine Materialisierung nötig).
|
||||
|
||||
### 2. Frontend: Chart-Integration
|
||||
|
||||
- Neue Dependency: `chart.js` (`npm install chart.js`, kein zusätzlicher Angular-Wrapper).
|
||||
- Neue wiederverwendbare Komponente `shared/chart-canvas/chart-canvas.ts` (+ `.html`/`.scss`):
|
||||
kapselt ein `<canvas>`-Element und den Chart.js-Instanz-Lifecycle. Inputs: `type` (`'line'` |
|
||||
`'bar'`), `data`, `options` (Chart.js-native Typen). Erstellt die `Chart`-Instanz in
|
||||
`afterNextRender`/`ngAfterViewInit`, aktualisiert sie über `effect()` bei Input-Änderungen,
|
||||
zerstört sie in `ngOnDestroy`. Wird von allen drei KPI-Charts mit unterschiedlicher Config
|
||||
genutzt — kein chart-spezifischer Code dupliziert sich.
|
||||
- Neuer `TeamStatsApi`-Service (`core/team/team-stats-api.ts`, analog zu
|
||||
`core/team/transactions-api.ts`) mit `loadStats(teamId): Observable<TeamOverviewStats>`, neues
|
||||
Model `TeamOverviewStats` in `models/`.
|
||||
|
||||
### 3. UI: `overview.ts` / `overview.html`
|
||||
|
||||
- `Overview`-Component bekommt ein zusätzliches `stats`-Signal + `loadingStats`-Signal, gefüllt
|
||||
über denselben `switchMap`-auf-Route-Param-Pattern wie `activities`
|
||||
(`teamStatsApi.loadStats(id).pipe(catchError(() => of(null)))`).
|
||||
- Neue Sektion zwischen Balance-Kacheln und Aktivitätsliste, drei `mat-card`s:
|
||||
1. Liniendiagramm „Kassenstand-Verlauf" (`balanceHistory`).
|
||||
2. Gruppiertes Balkendiagramm „Einnahmen & Ausgaben" (`monthlyFlow`, zwei Serien).
|
||||
3. Horizontales Balkendiagramm „Offene Beiträge (Top 10)" (`topOutstanding`), darunter ein
|
||||
Link/Button „Alle Spieler ansehen" → `routerLink` zu `members` innerhalb des Team-Kontexts.
|
||||
- Jede Chart-Karte hat einen eigenen Ladezustand (`mat-spinner`, wie bei der Aktivitätsliste) und
|
||||
einen Empty-State bei leeren Arrays (z. B. neues Team ohne Bewegungen) statt eines leeren
|
||||
Canvas.
|
||||
- Chart-Farben orientieren sich an der bestehenden `balance-card`/Material-Palette (Grün für
|
||||
positiv/Einnahmen, Rot-Ton für negativ/Ausgaben) — App hat aktuell nur ein Light-Theme
|
||||
(`color-scheme: light` in `styles.scss`), kein Dark-Mode-Handling nötig.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Fehler beim Laden der Stats führen zu einem stillen Empty-State pro Chart-Karte (kein globaler
|
||||
Fehlerblock, keine Snackbar) — konsistent mit dem bestehenden Umgang bei `activities`
|
||||
(`catchError(() => of([]))`). Der Rest der Übersicht-Seite (Balance-Kacheln, Aktivitätsliste)
|
||||
bleibt unabhängig vom Erfolg des Stats-Requests voll funktionsfähig.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: neuer Jest-Unit-Test-Block für `getOverviewStats` in `teams.service.spec.ts` —
|
||||
prüft Monatsgruppierung, Ist-Kasse-Filterung (fine/levy/fee werden ignoriert), Top-10-Sortierung
|
||||
und den Sanity-Check `balanceHistory.at(-1).balance === team.balance`.
|
||||
- Backend: Controller-Test für die neue Route (Auth-Guard greift, Response-Form) in
|
||||
`teams.controller.spec.ts`, analog zu bestehenden Tests für `/overview`.
|
||||
- Frontend: Erweiterung von `overview.spec.ts` um Fälle mit gemocktem `TeamStatsApi`
|
||||
(Loading-, Empty- und Daten-Zustand pro Chart-Karte).
|
||||
- Manuelle Verifikation: Team mit realistischer Transaktionshistorie lokal aufrufen, alle drei
|
||||
Charts visuell prüfen (inkl. Team ohne jegliche Bewegungen → Empty-States statt Fehler).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Zeitraum-Umschalter / freie Datumsauswahl für die Charts.
|
||||
- Kategorie-Verteilungs-Chart (Anteile Strafen/Beiträge/Ausgaben).
|
||||
- Dark-Mode-spezifisches Chart-Theming (App hat aktuell kein Dark-Theme).
|
||||
- Anzeige aller Spieler im Offene-Beiträge-Chart (nur Top 10 + Link auf bestehende
|
||||
Mitgliederverwaltung).
|
||||
- Persistierung/Materialisierung historischer Kassenstände (Berechnung erfolgt zur Laufzeit aus
|
||||
bestehenden Transaktionsdaten).
|
||||
@@ -0,0 +1,23 @@
|
||||
# Team Cash Flow Presentation Design
|
||||
|
||||
## Goal
|
||||
|
||||
Display transaction amounts consistently from the team wallet's perspective in every modern-frontend transaction history.
|
||||
|
||||
## Semantics
|
||||
|
||||
- A player payment and a team-wallet credit are real inflows: green with an explicit plus sign.
|
||||
- A team-wallet expense and a negative player-payment reversal are real outflows: red with a mathematical minus sign.
|
||||
- Player fines, levies, fees, and player credits do not move team-wallet cash: grey and unsigned.
|
||||
- Unknown transaction types are neutral to avoid claiming a cash movement that the application cannot prove.
|
||||
- Player payouts remain general team-wallet expenses with the player's name in the note; no new transaction type is introduced.
|
||||
|
||||
## Design
|
||||
|
||||
Replace the existing debit-only amount helper with a context-aware cash-flow presenter. A shared standalone Angular component owns formatting, semantic color, signs, and accessible labels. Cashbox and overview activities pass `team` or `player` based on `isTeamWalletTransaction`; private and public player histories always pass `player`.
|
||||
|
||||
No backend API, database, balance calculation, booking form, balance card, or legacy-frontend behavior changes.
|
||||
|
||||
## Accessibility and Testing
|
||||
|
||||
The visible amount uses `+`, `−`, or no sign and Material semantic color tokens. The amount exposes an accessible German label identifying Einzahlung, Auszahlung, or keine Kassenbewegung. Unit tests cover numeric, string, and object transaction types plus reversals and unknown types; component and view tests prove consistent rendering in all four histories.
|
||||
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Kassenbuch-Export (manuell + automatischer PDF-Versand)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-03
|
||||
|
||||
## Kontext
|
||||
|
||||
TeamWallet bietet mit dem Kassenjournal (`teams/:id/transactions/journal`, Cashbox-Seite im
|
||||
Frontend) bereits eine paginierte, filterbare Ansicht aller Buchungen. Es gibt aber keine
|
||||
Möglichkeit, diese Daten für die Vereinsbuchhaltung oder eine Kassenprüfung zu exportieren — weder
|
||||
manuell (CSV/PDF-Download) noch automatisiert (regelmäßiger Versand an Vorstand/Kassenprüfer). Beide
|
||||
Fähigkeiten fehlen komplett (kein Export-Code, `mail`-Modul aktuell nur für Login/Passwort-Reset
|
||||
genutzt).
|
||||
|
||||
Ziel: Ein Kassenwart/Captain/Coach kann (a) für einen frei wählbaren Zeitraum einen Kassenbuch-Export
|
||||
als CSV und/oder PDF herunterladen, und (b) optional einen wiederkehrenden automatischen PDF-Versand
|
||||
an beliebige E-Mail-Adressen einrichten (z.B. monatlich an den Vereinsvorstand).
|
||||
|
||||
Das Feature wurde im Brainstorming aus mehreren Optionen ausgewählt (Alternativen: Saldo-
|
||||
Erinnerungen, server-seitige Journal-Filterung, Belege-Anhang, Vier-Augen-Prinzip — diese sind nicht
|
||||
Teil dieses Plans). Ausdrücklich nicht Teil dieses oder eines zukünftigen Plans: Team
|
||||
verlassen/löschen.
|
||||
|
||||
## Fachliche Einordnung
|
||||
|
||||
Ein "Kassenbuch" bildet nur **echte Kassenbewegungen** ab — Buchungen, die laut `setBalance()`
|
||||
(`Transaction`- und `TeamWalletTransaction`-Entity) tatsächlich `team.balance` verändern:
|
||||
|
||||
- `Transaction` mit `type.id === 0` (`payment`) — Spieler zahlt echtes Geld ein.
|
||||
- **Alle** `TeamWalletTransaction`-Einträge (`credit` und `expense`) — direkte Kassenbewegungen ohne
|
||||
Spielerbezug.
|
||||
|
||||
Spieler-Fälligkeiten (`fee`/`levy`/`fine`, `type.id > 10`) verändern nur die Spielerschuld, nie den
|
||||
Kassenbestand, und werden **bewusst ausgeschlossen** (entspricht der gewählten Option "Nur
|
||||
Kassenjournal (Team-Saldo)").
|
||||
|
||||
Der Export zeigt einen **Periodensaldo** (laufende Summe ab 0, beginnend am gewählten Startdatum),
|
||||
keinen historischen Kontostand — eine Rekonstruktion des absoluten Kontostands zu einem beliebigen
|
||||
Vergangenheitszeitpunkt wäre für den ersten Wurf YAGNI.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Format**: CSV und PDF, beide.
|
||||
- **Zeitraum (manueller Export)**: frei wählbares Von/Bis-Datum.
|
||||
- **Inhalt**: nur Kassenjournal (Team-Saldo), keine Spieler-Fälligkeiten.
|
||||
- **Berechtigung**: wie Buchungen anlegen (`transaction_create_min_role`) — sowohl für den manuellen
|
||||
Export als auch für die Konfiguration des automatischen Versands.
|
||||
- **Automatischer Versand — Intervalle**: monatlich, quartalsweise, jährlich (identisch zu den
|
||||
wiederkehrenden Buchungen).
|
||||
- **Automatischer Versand — Zeitraum**: immer der jeweils **abgelaufene volle Zeitraum** (z.B. bei
|
||||
monatlichem Versand am 1. des Monats immer genau der komplette Vormonat), nicht "seit letztem
|
||||
Versand" (das wäre bei verpassten Läufen mehrdeutig).
|
||||
- **Automatischer Versand — Umfang**: **eine** Konfiguration pro Team (eine Empfängerliste, ein
|
||||
Intervall, pausierbar) statt mehrerer unabhängiger Abos.
|
||||
- **Out of Scope**: Team verlassen/löschen (bereits an anderer Stelle ausgeschlossen).
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: neues Modul `cashbox-export/`
|
||||
|
||||
Struktur analog zu `recurring-transactions/` (eigenständiges Modul statt Erweiterung von
|
||||
`teams.service.ts`, das bereits die Journal- und Statistik-Logik trägt).
|
||||
|
||||
**`cashbox-export.service.ts`**:
|
||||
- `buildRows(team: Team, from: string, to: string): CashboxExportRow[]` — reine Funktion auf einer
|
||||
bereits geladenen `Team`-Entity (inkl. `players.transactions.type`, `transactions.type`). Filtert
|
||||
auf echte Kassenbewegungen (s.o.), grenzt auf `[from, to]` ein (Ende inklusiv, Tagesende), sortiert
|
||||
chronologisch aufsteigend, berechnet laufenden `runningTotal`. Wird sowohl vom HTTP-Pfad als auch
|
||||
vom Scheduler verwendet — keine Duplikation der Filterlogik.
|
||||
- `getExportRowsForUser(teamId, userId, from, to)` — HTTP-Pfad: prüft `transaction_create_min_role`
|
||||
via `TeamAccessService.assertAtLeast`, lädt das Team, ruft `buildRows` auf.
|
||||
- `buildCsv(team, rows, from, to): string` — Semikolon-getrennt, deutsches Komma als
|
||||
Dezimaltrennzeichen (Excel-DE-Standard), RFC4180-Escaping für Notizen mit Semikolon/Anführungszeichen/
|
||||
Zeilenumbruch. Spalten: Datum, Typ, Wer (Spielername oder "Teamkasse"), Notiz, Betrag, Periodensaldo.
|
||||
Bei leerem Zeitraum: nur Kopfzeile + Hinweiszeile "Keine Buchungen im gewählten Zeitraum".
|
||||
- `buildPdf(team, rows, from, to): Buffer` — einfache Tabellen-PDF via neuer Abhängigkeit **`pdfkit`**
|
||||
(kein Chromium/Puppeteer nötig): Kopf mit Teamname + Zeitraum + Erstellungsdatum, Tabelle, Fußzeile
|
||||
mit Periodensaldo. Gleiche Leerzeitraum-Behandlung wie CSV.
|
||||
|
||||
**`cashbox-export.controller.ts`** (Pfad `cashbox-export`, `version: '1'`, nur `AuthGuard('jwt')`,
|
||||
Berechtigung im Service):
|
||||
- `GET cashbox-export/:teamId?from=&to=&format=csv|pdf` — liefert Datei über `@Res({passthrough:
|
||||
false})` mit manuell gesetzten Headern (`Content-Type`, `Content-Disposition: attachment;
|
||||
filename="kassenbuch_<teamAlias>_<from>_<to>.<ext>"`), kein globaler Response-Interceptor im
|
||||
Projekt vorhanden, der das stören würde.
|
||||
- `GET cashbox-export/:teamId/subscription` — aktuelle Versand-Konfiguration (oder Default:
|
||||
`{ recipients: [], interval: 'monthly', active: false }`).
|
||||
- `PUT cashbox-export/:teamId/subscription` — Upsert (Empfänger/Intervall/Aktiv-Status).
|
||||
|
||||
**Neue Entity `entities/cashbox-export-subscription.entity.ts`**: `id`, `team` (ManyToOne, in der
|
||||
Praxis 1:1 durch Anwendungslogik im Service erzwungen — nur eine Subscription pro Team wird gepflegt/
|
||||
aktualisiert statt neu angelegt), `recipients` (`simple-array`-Spalte, Liste von E-Mail-Strings),
|
||||
`interval` (`RecurringTransactionIntervalEnum`, wiederverwendet aus dem `recurring-transactions`-
|
||||
Modul — fachlich identisches Konzept), `active` (default `false`), `nextRunDate` (string, ISO-Datum),
|
||||
`createdAt`.
|
||||
|
||||
**DTO `UpsertCashboxExportSubscriptionDto`**: `recipients: string[]` (`@IsEmail({}, {each:true})`),
|
||||
`interval`, `active`. Validierung: `active === true` mit leerer `recipients`-Liste wird mit 400
|
||||
abgelehnt (ergibt keinen Sinn, nichts zu versenden aber "aktiv").
|
||||
|
||||
**`cashbox-export.scheduler.ts`** (`@Cron`, zeitlich versetzt zum bestehenden
|
||||
Recurring-Transactions-Job, z.B. `04:00 Uhr` statt `03:00 Uhr`, um DB-Last zu entzerren):
|
||||
1. Lädt alle `active: true`-Subscriptions mit `nextRunDate <= heute` (inkl. `team`).
|
||||
2. Pro fälliger Subscription: bestimmt den **abgelaufenen** Zeitraum passend zum `interval`
|
||||
ausgehend von `nextRunDate` (z.B. `nextRunDate = 2026-09-01`, `interval = monthly` → Zeitraum
|
||||
`2026-08-01`–`2026-08-31`), lädt das Team (inkl. Relationen), ruft `buildRows` + `buildPdf` auf
|
||||
(Wiederverwendung derselben Logik wie der manuelle Export), verschickt das PDF per
|
||||
`MailService`/`MailerService`-Attachment an alle `recipients` (neues Template
|
||||
`mail-templates/cashbox-export.hbs`, analog Aufbau zu `reset-password.hbs`), rückt `nextRunDate`
|
||||
um das Intervall vor (gleiche `setUTCMonth`-Arithmetik wie im Recurring-Transactions-Scheduler:
|
||||
`+1`/`+3`/`+12` Monate) und speichert.
|
||||
3. Ein verpasster Tag (Server-Downtime) wird beim nächsten Lauf automatisch nachgeholt (rein
|
||||
datumsbasierter Check wie beim Recurring-Transactions-Scheduler).
|
||||
|
||||
**Registrierung**: `CashboxExportModule` in `src/app.module.ts` ergänzen (analog `PenaltyModule`/
|
||||
`RecurringTransactionsModule`); `MailModule` importieren für den Versand.
|
||||
|
||||
**Logging-Events**: `cashbox_export_download`, `cashbox_export_subscription_update`,
|
||||
`cashbox_export_subscription_run` in `logging-event.type.ts` ergänzen.
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
**Cashbox-Toolbar**: neuer "Export"-Button (sichtbar nur mit `canDo(team(), 'transactionCreate')`,
|
||||
kein neuer Permission-Key) öffnet einen Dialog mit Von/Bis-Datumsfeldern und Format-Auswahl
|
||||
(CSV/PDF), löst über `CashboxExportApi.exportCashbox(teamId, from, to, format)`
|
||||
(`responseType: 'blob'`) den Download aus. Ein kleiner `FileDownloadService.save(blob, filename)`
|
||||
kapselt den Anchor-Click-Mechanismus, damit die Dialog-Komponente ohne echte DOM-Downloads getestet
|
||||
werden kann (Service wird im Test gemockt).
|
||||
|
||||
Im selben Export-Bereich zusätzlich ein Zahnrad/Link "Automatischen Versand einrichten" → eigener
|
||||
Dialog: Chip-Liste für E-Mail-Adressen (hinzufügen/entfernen, clientseitige Format-Validierung vor
|
||||
dem Speichern), Intervall-Dropdown, Aktiv/Pausiert-Toggle, Speichern-Button. Neue Methoden
|
||||
`CashboxExportApi.getSubscription(teamId)` / `updateSubscription(teamId, dto)`.
|
||||
|
||||
Neues Model `models/cashbox-export.model.ts` (`CashboxExportFormat`, `CashboxExportSubscription`,
|
||||
`UpdateCashboxExportSubscription`).
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- `from > to` → 400 (Backend), Submit-Button im Dialog zusätzlich clientseitig deaktiviert.
|
||||
- Keine Buchungen im Zeitraum → Datei wird trotzdem erzeugt (Kopfzeile + Hinweistext), kein Fehler.
|
||||
- Ungültige E-Mail-Adresse in der Empfängerliste → 400 (DTO-Validierung), Inline-Fehler im Dialog.
|
||||
- `active: true` mit leerer Empfängerliste → 400.
|
||||
- Fehlende Berechtigung → bestehender `assertAtLeast`-Wurf (403), keine neue Behandlung nötig.
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend**:
|
||||
- `cashbox-export.service.spec.ts` — Filterlogik (Ausschluss fee/levy/fine, Einschluss payment +
|
||||
alle TeamWallet-Typen), Datumsgrenzen (inklusive Tagesende), laufender Saldo, leerer Zeitraum,
|
||||
Berechtigungsdurchsetzung.
|
||||
- `cashbox-export.http.spec.ts` — Auth erforderlich, korrekte Header/Content-Type je Format, CSV-
|
||||
Inhalt exakt geprüft (String-Vergleich), PDF nur auf `%PDF-`-Signatur + Non-Empty geprüft (kein
|
||||
Byte-Vergleich).
|
||||
- `cashbox-export-subscription.service.spec.ts` — Upsert, Validierung (aktiv + leere Liste),
|
||||
Berechtigung.
|
||||
- `cashbox-export.scheduler.spec.ts` — Perioden-Berechnung je Intervall (`it.each`), PDF+Mail-
|
||||
Dispatch mit gemocktem `MailerService` (Attachment vorhanden, korrekte Empfänger/Betreff),
|
||||
`nextRunDate`-Vorrücken, überspringt inaktive/nicht-fällige Subscriptions, Downtime-Nachholung.
|
||||
|
||||
**Frontend**:
|
||||
- `cashbox-export-api.spec.ts` — korrekte HTTP-Calls (Query-Params, `responseType: 'blob'`,
|
||||
Subscription-GET/PUT).
|
||||
- Export-Dialog-Spec — Formvalidierung (`from <= to`), Permission-Gating, ruft
|
||||
`FileDownloadService.save` mit korrekten Argumenten auf.
|
||||
- Subscription-Dialog-Spec — Laden/Speichern, Chip-Validierung, Permission-Gating.
|
||||
|
||||
## Bewusst nicht enthalten (YAGNI)
|
||||
|
||||
- Kein historischer Anfangssaldo (nur Periodensaldo ab 0 innerhalb des Exportzeitraums).
|
||||
- Kein Export der Spieler-Fälligkeiten (fee/levy/fine).
|
||||
- Kein Excel-(.xlsx)-Format, nur CSV+PDF.
|
||||
- Keine mehreren Versand-Konfigurationen pro Team.
|
||||
- Kein CSV im automatischen Versand, nur PDF.
|
||||
- Keine Empfänger-Verifizierung (Double-Opt-In) für frei eingetragene Adressen.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber.
|
||||
- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber.
|
||||
- **Manuell**: Backend lokal starten, über die neue UI einen CSV- und einen PDF-Export für einen
|
||||
Zeitraum mit bekannten Testbuchungen herunterladen und Inhalt/Saldo stichprobenartig prüfen; eine
|
||||
Subscription mit `nextRunDate` = heute anlegen, Scheduler-Methode einmalig manuell aufrufen, prüfen
|
||||
dass eine E-Mail mit PDF-Anhang an alle konfigurierten Adressen geht und `nextRunDate` korrekt
|
||||
vorrückt.
|
||||
113
docs/superpowers/specs/2026-08-03-team-erstellen-design.md
Normal file
113
docs/superpowers/specs/2026-08-03-team-erstellen-design.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Team erstellen über die UI
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-03
|
||||
|
||||
## Kontext
|
||||
|
||||
Teams können in TeamWallet heute nur über einen bestehenden Backend-Endpoint
|
||||
(`POST /api/v1/teams`, `teams.controller.ts`) angelegt werden, der ausschließlich globalen Admins
|
||||
vorbehalten ist (`@Roles([RoleEnum.admin])`). Im modernen Angular-Frontend
|
||||
(`myteamwallet_frontend_modern`) existiert dafür keine UI: `core/team/teams-api.ts` hat keine
|
||||
`createTeam`-Methode, und `features/team-select/team-select.html` zeigt einem User ohne Teams nur
|
||||
den Hinweistext „Du bist noch keinem Team zugeordnet." ohne jede Handlungsoption.
|
||||
|
||||
Ziel: jeder eingeloggte User soll selbstständig ein neues Team gründen können und wird dabei
|
||||
automatisch dessen Kapitän. Das schließt eine der offensichtlichsten Lücken im Produkt
|
||||
(Selbstregistrierung eines Teams) und folgt demselben Muster, das an anderer Stelle im Code bereits
|
||||
als fehlend dokumentiert ist (`docs/plans/admin-user-management.md` merkt an, dass Admins auch noch
|
||||
keine User anlegen können — ein verwandtes, aber bewusst getrenntes Folge-Thema).
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Berechtigung**: jeder eingeloggte User (`RoleEnum.user`) darf ein Team erstellen, nicht nur
|
||||
Admins. Der Guard auf `POST /teams` wird entsprechend gelockert.
|
||||
- **Automatische Mitgliedschaft**: der Ersteller wird automatisch **Kapitän** (`captain`,
|
||||
Rollen-ID 3) des neuen Teams — die niedrigste Rollen-ID, die `TeamAccessService.assertManager`
|
||||
bereits als „Manager" behandelt (`id >= 3`). Es gibt kein explizites „Owner"-Konzept, `captain`
|
||||
ist die naheliegende Top-Rolle für den Ersteller.
|
||||
- **Formularumfang**: nur der Teamname wird abgefragt (`CreateTeamDTO` bleibt `{ name: string }`).
|
||||
Alias wird weiterhin automatisch aus dem Timestamp generiert, alles Weitere ist später über die
|
||||
Team-Einstellungen anpassbar.
|
||||
- **UI-Einstiegspunkt**: Button „Team erstellen" ist in `team-select` dauerhaft sichtbar — auch wenn
|
||||
der User bereits Teams hat (nicht nur im Leerzustand), um auch das Anlegen weiterer Teams (z. B.
|
||||
zweite Mannschaft) zu ermöglichen.
|
||||
- **UI-Pattern**: Dialog/Modal statt eigener Route, passend zum bestehenden `MatDialog`-Muster im
|
||||
Repo (z. B. `features/team/more/penalties/penalties.ts`) und angemessen für ein einzelnes
|
||||
Eingabefeld.
|
||||
- **Out of Scope**: Team löschen/archivieren ist ein eigenständiges Folge-Feature (andere
|
||||
Berechtigungen/Risiken, z. B. Umgang mit bestehenden Spielern/Transaktionen) und wird separat
|
||||
geplant.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: `teams.controller.ts`
|
||||
|
||||
Guard auf `POST /teams` von `@Roles([RoleEnum.admin])` auf `[RoleEnum.user, RoleEnum.admin]`
|
||||
erweitern — Muster wie an anderen Stellen desselben Controllers (z. B. Zeilen 113-114, 122-123).
|
||||
Der aufrufende User wird wie überall im Controller über `@Req() req` → `req.user.id` an den Service
|
||||
durchgereicht.
|
||||
|
||||
### 2. Backend: `teams.service.ts#createNewTeam`
|
||||
|
||||
Aktuell (Zeile 166-178) wird nur `Team` + Default-`TeamSetting`s (`generateBasicTeamSettings`)
|
||||
angelegt; es entsteht kein `Player`-Datensatz, der User bleibt kein Mitglied des neuen Teams.
|
||||
|
||||
Erweiterung: nach dem Speichern von Team und Settings zusätzlich einen `Player` erzeugen —
|
||||
verknüpft mit dem aufrufenden `User` (`userId`) und `teamRole = captain` (per
|
||||
`rolesRepository.findOneBy({ id: 3 })`). Vorgehen spiegelt die bestehende Join-Erzeugung in
|
||||
`createNewPlayer()` (Zeilen 122-164), dort wird bereits `Player` inkl. `TeamRole`-Verknüpfung für
|
||||
andere Spieler angelegt.
|
||||
|
||||
Audit-Logging (`this.logger.info({ event: 'team_create', ... })`) bleibt erhalten.
|
||||
|
||||
### 3. Backend: `dto/create-team.dto.ts`
|
||||
|
||||
Unverändert (`{ name: string }`).
|
||||
|
||||
### 4. Frontend: `core/team/teams-api.ts`
|
||||
|
||||
Neue Methode `createTeam(name: string)` → `POST /teams`, analog zu den bestehenden Methoden wie
|
||||
`createPlayer`.
|
||||
|
||||
### 5. Frontend: neue Dialog-Komponente
|
||||
|
||||
Kleine Standalone-Komponente mit Reactive Form (`FormBuilder`/`ReactiveFormsModule`,
|
||||
`MatFormFieldModule`), ein Pflichtfeld „Teamname" — nach dem Muster von
|
||||
`features/team/members/members.ts` (Form-Aufbau) kombiniert mit dem `MatDialog`-Öffnungsmuster aus
|
||||
`features/team/more/penalties/penalties.ts`.
|
||||
|
||||
### 6. Frontend: `features/team-select/team-select.ts` / `.html`
|
||||
|
||||
Button „Team erstellen" dauerhaft im Template ergänzen (nicht nur im Leerzustand-Block). Klick öffnet
|
||||
den Dialog über `MatDialog`; bei erfolgreichem Abschluss `MyTeamsStore` neu laden und per Router
|
||||
direkt in das neu erstellte Team navigieren.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- Serverseitige Validierungsfehler (z. B. leerer/zu langer Name) werden als Formularfehler im Dialog
|
||||
angezeigt, der Dialog bleibt offen.
|
||||
- Netzwerk-/Serverfehler laufen über den bestehenden Snackbar/Toast-Mechanismus des Repos, wie bei
|
||||
anderen Create-Flows (z. B. `createPlayer`).
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend (`teams.service.spec.ts`): `createNewTeam()` legt zusätzlich zu Team und Settings einen
|
||||
`Player` mit `teamRole = captain` und Verknüpfung zum aufrufenden User an.
|
||||
- Backend (`teams.controller.spec.ts` bzw. e2e): `POST /teams` ist für `RoleEnum.user` erlaubt (nicht
|
||||
mehr nur für `RoleEnum.admin`).
|
||||
- Frontend: Komponententest für den neuen Dialog (analog `penalties.spec.ts`) — Formularvalidierung,
|
||||
Aufruf von `teamsApi.createTeam`.
|
||||
- Frontend: Test, dass `team-select` nach erfolgreicher Erstellung `MyTeamsStore` neu lädt und in das
|
||||
neue Team navigiert.
|
||||
- Manuelle Verifikation: als normaler User (nicht Admin) über `team-select` ein Team anlegen —
|
||||
Aufruf sollte gelingen, User landet automatisch als Kapitän im neuen Team; Swagger-Aufruf von
|
||||
`POST /teams` als normaler User bestätigt den gelockerten Guard.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Team löschen/archivieren (eigenes Folge-Feature).
|
||||
- Auswahl der initialen Rolle des Erstellers (immer `captain`, keine Wahlmöglichkeit).
|
||||
- Eigene Alias-Vergabe durch den User (weiterhin automatisch generiert).
|
||||
- Selbstständiges Anlegen von Usern durch Admins (verwandte, aber separate Lücke, siehe
|
||||
`docs/plans/admin-user-management.md`).
|
||||
@@ -0,0 +1,111 @@
|
||||
# Theoretischer Kassenstand (Ist + offene Beiträge) im Kassenstand-Verlauf
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-03
|
||||
|
||||
## Kontext
|
||||
|
||||
Ergänzung zum bestehenden Kassenstand-Verlauf-Chart aus
|
||||
`docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`. Der Chart auf der Team-Übersicht
|
||||
(`features/team/overview/overview.ts` + `.html`) zeigt aktuell eine Linie „Kassenstand" über die
|
||||
letzten 12 Monate, gespeist aus `GET /teams/:id/overview/stats` bzw.
|
||||
`teams.service.ts#getOverviewStats`.
|
||||
|
||||
Auf derselben Übersicht existiert bereits eine zweite Kennzahl „Offene Beiträge" (aus
|
||||
`teams.service.ts#getOverview`, Zeile 46-53): `-(Summe der `balance` aller aktiven Spieler)`. Sie
|
||||
zeigt nur den heutigen Wert, keinen Verlauf.
|
||||
|
||||
Ziel: eine zweite Linie im bestehenden Chart, die pro Monat den theoretischen Kassenstand zeigt —
|
||||
also „was wäre in der Kasse, wenn alle offenen Beiträge bereits bezahlt worden wären" —, um Trainer/
|
||||
Kassenwarte auf einen Blick erkennen zu lassen, wie stark der Ist-Stand vom Soll-Stand abweicht.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Historie statt Snapshot**: Die zweite Linie zeigt für jeden Monat die zu diesem Zeitpunkt
|
||||
tatsächlich offenen Beiträge, nicht den heutigen Wert konstant über alle 12 Punkte addiert.
|
||||
- **Näherung wie beim bestehenden Kassenstand-Verlauf**: Es wird mit der *heutigen* Menge aktiver
|
||||
Spieler gerechnet, kein historisches Tracking von Mitgliedschaft/Aktiv-Status. Das ist dieselbe
|
||||
Vereinfachung, die `balanceHistory` bereits für den Kassenstand selbst nutzt (siehe
|
||||
`getOverviewStats`-Kommentar zu `team.balance` als Anker).
|
||||
- **Datenform**: `theoreticalBalance` wird als zusätzliches Feld direkt in jeden bestehenden
|
||||
`balanceHistory`-Punkt eingebettet (`{ month, balance, theoreticalBalance }`), kein separates
|
||||
Array — additive, nicht-brechende Erweiterung der bestehenden Response.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: `teams.service.ts#getOverviewStats`
|
||||
|
||||
Neue private Hilfsberechnung, analog zur bestehenden Rückwärts-Rekonstruktion von `balanceHistory`
|
||||
(Zeile ~290-320), aber auf Spieler-Ebene statt Team-Ebene:
|
||||
|
||||
- Datenquelle: `team.players` (bereits geladen über `relations: ['players', 'players.transactions',
|
||||
'transactions']`), gefiltert auf `player.active` — dieselbe Teilmenge, die `getOverview` für die
|
||||
heutige „Offene Beiträge"-Kachel verwendet.
|
||||
- Für jeden aktiven Spieler: dessen `transactions` (bereits geladen), **ausgenommen** Zeilen mit
|
||||
`note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX)` (Import aus
|
||||
`team-members.service.ts`) — dieselbe Ausschlussregel wie in
|
||||
`TeamMembersService.recomputeBalance`, damit synthetische Ausgleichsbuchungen die Historie nicht
|
||||
verfälschen.
|
||||
- Vorzeichen je Buchung: `type.id > 10` (Strafe/Umlage/Gebühr, IDs 11-13) mindert den Spieler-Saldo,
|
||||
alle anderen Typen (`payment`, `credit`) erhöhen ihn — identische Regel wie in
|
||||
`TeamMembersService.recomputeBalance` (Zeile ~127-133) und `TransactionsService.reverse()`
|
||||
(`type.id > 10`-Check).
|
||||
- Rekonstruktion: ausgehend von `player.balance` (aktueller, autoritativer Wert) rückwärts durch die
|
||||
nach Datum absteigend sortierten Buchungen laufen und pro Monat der letzten 12 Monate den
|
||||
rekonstruierten Saldo am Monatsende ermitteln — strukturell identisch zum bestehenden
|
||||
`descendingMovements`/`futureSum`-Muster für `balanceHistory`, nur pro Spieler statt einmal fürs
|
||||
Team.
|
||||
- Pro Monat: `outstandingAtMonth = -Σ(rekonstruierter Saldo aktiver Spieler)`,
|
||||
`theoreticalBalance = balanceHistory[monat].balance + outstandingAtMonth`.
|
||||
- Rückgabeform ändert sich zu:
|
||||
```ts
|
||||
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]
|
||||
```
|
||||
`monthlyFlow` und `topOutstanding` bleiben unverändert.
|
||||
- Gating unverändert: Ist `movements.length === 0` (keine Kassenbewegung je), bleibt
|
||||
`balanceHistory: []` wie heute — ein Team mit ausschließlich unbezahlten Strafen, aber ganz ohne
|
||||
Zahlungsbewegung, zeigt weiterhin keinen Chart (Out of Scope, siehe unten).
|
||||
|
||||
### 2. Frontend: `overview.ts` / Chart-Konfiguration
|
||||
|
||||
- `models/team-stats.model.ts`: `BalanceHistoryPoint` um `theoreticalBalance: number` erweitern.
|
||||
- `balanceChartData` (computed) bekommt eine zweite Dataset-Eintrag:
|
||||
- Label: „Theoretisch (inkl. offene Beiträge)"
|
||||
- `data: points.map((p) => p.theoreticalBalance)`
|
||||
- Gestrichelt (`borderDash: [6, 4]`), eigene Farbe `#1d70b8` (Blauton, klar unterscheidbar vom
|
||||
Grün `#4f8f46` der Ist-Linie), `fill: false`.
|
||||
- `balanceChartOptions`: `plugins.legend.display` von `false` auf `true` (bzw. `position: 'bottom'`
|
||||
wie beim Flow-Chart), da jetzt zwei Linien unterschieden werden müssen.
|
||||
- Keine Änderung an `ChartCanvas` (shared component) nötig — reine Config-/Daten-Änderung.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Unverändert zum bestehenden Muster: Fehler beim Laden der Stats führen zum bestehenden stillen
|
||||
Empty-State der Chart-Karte. Kein neuer Fehlerfall durch diese Erweiterung.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend (`teams.service.spec.ts`, Erweiterung des bestehenden `getOverviewStats`-Testblocks):
|
||||
- Sanity-Check: `balanceHistory.at(-1).theoreticalBalance === team.balance + aktuelle Summe
|
||||
offener Beiträge` (heutiger Wert, wie von `getOverview` berechnet).
|
||||
- Historische Rekonstruktion: Testfall mit einer Strafe (`fine`) in einem früheren Monat, die erst
|
||||
im aktuellen Monat bezahlt wurde — `theoreticalBalance` im früheren Monat muss die damals
|
||||
offene Strafe enthalten, `balance` (Ist) nicht.
|
||||
- Deaktivierungs-Ausgleichsbuchungen werden aus der Rekonstruktion ausgeschlossen (Testfall mit
|
||||
einem zwischenzeitlich deaktivierten und wieder aktivierten Spieler).
|
||||
- Leerfall (`movements.length === 0`) liefert weiterhin `balanceHistory: []`.
|
||||
- Frontend (`overview.spec.ts`): Erweiterung des bestehenden Chart-Daten-Tests um Assertion, dass
|
||||
`balanceChartData()` zwei Datasets enthält und die zweite Serie aus `theoreticalBalance` gespeist
|
||||
wird.
|
||||
- Manuelle Verifikation: Team mit einer unbezahlten Strafe/Umlage lokal aufrufen, prüfen dass die
|
||||
theoretische Linie sichtbar über der Ist-Linie liegt und bei vollständiger Bezahlung beide Linien
|
||||
zusammenlaufen.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Historisches Tracking von Mitgliedschaft/Aktiv-Status (Näherung mit heutiger aktiver
|
||||
Spieler-Menge, siehe oben).
|
||||
- Teams mit ausschließlich unbezahlten Strafen/Umlagen/Gebühren, aber ganz ohne Kassenbewegung —
|
||||
zeigen weiterhin keinen Chart (bestehende Einschränkung aus dem Basis-Feature, nicht neu
|
||||
eingeführt).
|
||||
- Zeitraum-Umschalter (weiterhin feste letzte 12 Monate, wie im Basis-Feature festgelegt).
|
||||
210
docs/superpowers/specs/2026-08-04-notification-center-design.md
Normal file
210
docs/superpowers/specs/2026-08-04-notification-center-design.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Notification Center (Team-Benachrichtigungen)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-04
|
||||
|
||||
## Kontext
|
||||
|
||||
TeamWallet protokolliert bereits viele team-relevante Ereignisse (Spieler hinzugefügt/deaktiviert,
|
||||
Rollenänderung, Einladungslink erstellt/eingelöst) über den globalen `LoggingService` in `LogEntry`
|
||||
— aber dieses Log ist admin-only, global (kein Team-Bezug, kein `teamId`), und kennt keinen
|
||||
Lesestatus pro Nutzer. Ein normaler Spieler erfährt aktuell nicht, wenn in seinem Team etwas
|
||||
passiert (z.B. er selbst deaktiviert wurde oder der Freigabelink rotiert wurde), außer er merkt es
|
||||
zufällig.
|
||||
|
||||
Ziel: Ein Benachrichtigungscenter (Glocke oben rechts im Header mit Ungelesen-Badge und Dropdown),
|
||||
das aktiven Team-Mitgliedern mit Login relevante Team-Ereignisse anzeigt, mit Sprung zur
|
||||
betroffenen Stelle und einer Vollansicht-Seite für die Historie.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Abgedeckte Events (v1)**: Spieler hinzugefügt/deaktiviert/reaktiviert, Team-Rolle geändert,
|
||||
Freigabelink aktiviert/rotiert, Einladungslink erstellt. Das Einlösen eines Einladungslinks selbst
|
||||
löst **keine** eigene Benachrichtigung aus (der Aufruf ist unauthentifiziert, reine
|
||||
Token-Validierung, oft nur eine Vorschau ohne tatsächlichen Beitritt) — der tatsächliche Beitritt
|
||||
wird stattdessen bereits durch das Event "Spieler hinzugefügt" abgedeckt.
|
||||
- **Empfänger**: alle aktiven Player eines Teams mit verknüpftem User-Account (analog zur
|
||||
Mitgliedschaftsprüfung in `TeamAccessService`), abzüglich des Verursachers — wer eine Aktion selbst
|
||||
auslöst, bekommt dafür keine eigene Benachrichtigung.
|
||||
- **Zustellung**: kein Echtzeit-Push (keine WebSocket/SSE-Infrastruktur im Projekt vorhanden).
|
||||
Stattdessen Polling des Ungelesen-Zählers alle 30s, passend zum bestehenden HTTP+Signal-Store-Muster
|
||||
des Frontends.
|
||||
- **Datenmodell**: Fan-out beim Schreiben (`Notification` + eine `NotificationRecipient`-Zeile pro
|
||||
Empfänger mit eigenem Lesestatus) statt eines zentralen Events mit Read-Join-Tabelle oder einer
|
||||
Erweiterung von `LogEntry` — bei den hier üblichen kleinen Teamgrößen (typischerweise < 30 Spieler)
|
||||
ist der Schreib-Overhead irrelevant, die Leseabfragen (Ungelesen zählen, Liste je Nutzer, als
|
||||
gelesen markieren) bleiben dafür trivial.
|
||||
- **Entkopplung**: Domain-Services lösen Business-Logik weiterhin unverändert aus und feuern danach
|
||||
nur ein Domain-Event über `@nestjs/event-emitter` (`EventEmitter2`) — ein zentrales
|
||||
`NotificationsModule` lauscht auf diese Events und legt die Benachrichtigungen an. Domain-Services
|
||||
kennen `NotificationsService` nicht; neue Benachrichtigungstypen erfordern nur einen neuen Listener,
|
||||
keine Änderung an bestehenden Services.
|
||||
- **Klick-Verhalten**: Klick auf eine Benachrichtigung navigiert zur betroffenen Stelle (z.B.
|
||||
Mitgliederliste) und markiert sie als gelesen.
|
||||
- **Vollansicht**: eigene, team-gescopte Seite mit paginierter Historie zusätzlich zum Dropdown
|
||||
(letzte 20 Einträge).
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: neues Modul `notifications/`
|
||||
|
||||
**Neue Entities** (`notifications/entities/`):
|
||||
|
||||
- `Notification`: `id`, `team` (ManyToOne `Team`), `event` (`NOTIFICATION_EVENT`-String-Union, eigene
|
||||
Typdatei analog `logging-event.type.ts`), `actorUserId`, `payload` (`text`-Spalte, JSON-serialisiert
|
||||
— enthält je Event die Felder für Anzeigetext + Deep-Link, z.B. `{ playerId, playerName }`),
|
||||
`createdAt`.
|
||||
- `NotificationRecipient`: `id`, `notification` (ManyToOne `Notification`, `onDelete: 'CASCADE'`),
|
||||
`userId`, `read` (boolean, default `false`), `readAt` (nullable `Date`). Index auf
|
||||
`(userId, read, createdAt via notification)` bzw. praktisch auf `(userId, notificationId)` und
|
||||
zusätzlich ein Index auf `notification.team` + `userId` für die gefilterte Team-Ansicht.
|
||||
|
||||
**Domain-Events** (`notifications/events/`): reine Datenklassen, ein File pro Event-Familie —
|
||||
`player-active-changed.event.ts`, `player-role-changed.event.ts`, `player-created.event.ts`,
|
||||
`share-link-changed.event.ts`, `invite-link-created.event.ts`. Jede trägt mindestens `teamId`,
|
||||
`actorUserId`, event-spezifische IDs/Namen für Text und Deep-Link.
|
||||
|
||||
**Emit-Punkte** (jeweils ein zusätzlicher `this.eventEmitter.emit(...)`-Aufruf **nach** erfolgreichem
|
||||
Abschluss der bestehenden Logik, ohne deren Ablauf/Transaktion zu verändern):
|
||||
|
||||
- `team-members.service.ts` `setActive()` — nach `return this.dataSource.transaction(...)` erfolgreich
|
||||
resolved hat (Emit außerhalb des Transaktions-Callbacks, damit bei Rollback nie ein Event feuert).
|
||||
- `team-members.service.ts` `setTeamRole()` — analog.
|
||||
- `teams.service.ts` Player-Erstellung (Stelle, die aktuell `player_creation` loggt) — analog.
|
||||
- `public-team-access.service.ts` `setEnabled()` / `rotate()` — hier gibt es aktuell **keine**
|
||||
Transaktion (nur `repository.save()`), Emit direkt nach erfolgreichem `save()`. Zusätzlich werden
|
||||
hier neue `LOGEVENT`-Werte `public_access_enabled`, `public_access_rotated` ergänzt (bisher fehlt an
|
||||
dieser Stelle jegliches Logging) und ein `LoggingService.info()`-Aufruf ergänzt, analog zu den
|
||||
anderen Services.
|
||||
- `auth.service.ts` `createTeamInvite()` — nach dem bestehenden `logger.info(...)`-Aufruf, mit dem
|
||||
echten `actorUserId`-Parameter der Methode (nicht dem im bestehenden Log hart codierten `userId: 0`
|
||||
— dieser bestehende Log-Aufruf selbst bleibt unverändert, das Event nutzt aber den korrekten Actor).
|
||||
|
||||
**`NotificationsListener`** (`notifications/notifications.listener.ts`): ein `@OnEvent(...)`-Handler
|
||||
pro Event-Typ, baut Anzeigetext + Deep-Link-Payload und ruft `NotificationsService.create(...)` auf.
|
||||
Fehler im Handler werden abgefangen und via `LoggingService.error()` protokolliert statt propagiert —
|
||||
ein Fehler beim Anlegen der Benachrichtigung darf die bereits committete Business-Aktion nicht
|
||||
nachträglich als fehlgeschlagen erscheinen lassen.
|
||||
|
||||
**`NotificationsService`**:
|
||||
|
||||
- `create(teamId, event, actorUserId, payload)` — ermittelt Empfänger über dasselbe Query-Muster wie
|
||||
`TeamAccessService`/`PublicTeamAccessService` (aktive `Player` mit `user.id IS NOT NULL` für das
|
||||
Team, `actorUserId` ausgeschlossen), legt `Notification` + `NotificationRecipient`-Zeilen an.
|
||||
- `listForUser(userId, teamId, cursor, limit)` — für Dropdown und Vollansicht.
|
||||
- `getUnreadCount(userId, teamId)`.
|
||||
- `markRead(recipientId, userId)` — prüft Eigentümerschaft der Recipient-Zeile.
|
||||
- `markAllRead(userId, teamId)`.
|
||||
|
||||
**`NotificationsController`** (`version: '1'`, `AuthGuard('jwt')` + `TeamAccessService.assertMember`):
|
||||
|
||||
- `GET teams/:teamId/notifications?cursor=&limit=`
|
||||
- `GET teams/:teamId/notifications/unread-count`
|
||||
- `PATCH teams/:teamId/notifications/:id/read`
|
||||
- `PATCH teams/:teamId/notifications/read-all`
|
||||
|
||||
**Retention**: `NotificationRetentionScheduler`, `@Cron(CronExpression.EVERY_DAY_AT_5AM)` (zeitlich
|
||||
versetzt zu `LogRetentionScheduler` um 4 Uhr), löscht `Notification`-Zeilen älter als
|
||||
`app.logRetentionDays` (gleiche Config wiederverwendet, kein neuer Config-Wert nötig) —
|
||||
`NotificationRecipient` fällt per `onDelete: 'CASCADE'` automatisch mit weg. Gleiches
|
||||
Fehlerbehandlung-Muster wie `LogRetentionScheduler` (try/catch, `logger.info`/`logger.error` mit
|
||||
`log_retention_cleanup_run`-artigen neuen Events `notification_retention_cleanup_run`/`_fail`).
|
||||
|
||||
**Neue Dependency**: `@nestjs/event-emitter`, registriert via `EventEmitterModule.forRoot()` in
|
||||
`app.module.ts` (neben dem bestehenden `ScheduleModule.forRoot()`).
|
||||
|
||||
**Registrierung**: `NotificationsModule` in `src/app.module.ts` ergänzen (analog
|
||||
`CashboxExportModule`), exportiert `NotificationsService`/`EventEmitter2`-Nutzung für die
|
||||
Domain-Services (bzw. Domain-Services importieren direkt `EventEmitterModule`/`EventEmitter2` aus
|
||||
`@nestjs/event-emitter`, kein Import von `NotificationsModule` nötig — das ist der Kern der
|
||||
Entkopplung).
|
||||
|
||||
**Migration**: eine neue TypeORM-Migration in `src/database/migrations` für `notification` und
|
||||
`notification_recipient` inkl. der oben genannten Indizes.
|
||||
|
||||
**Neue `LOGEVENT`-Werte** in `logging-event.type.ts`: `public_access_enabled`,
|
||||
`public_access_rotated`, `notification_retention_cleanup_run`, `notification_retention_cleanup_run_fail`.
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
**Bell im Header** (`core/layout/shell/shell.html`/`shell.ts`): `mat-icon-button` mit
|
||||
`notifications`-Icon, `matBadge` für den Ungelesen-Zähler (ausgeblendet bei 0), positioniert links
|
||||
neben dem bestehenden Team-Switcher in der `shell-header`-Toolbar, `[matMenuTriggerFor]="notificationMenu"`
|
||||
— gleiches `MatMenuModule`-Pattern wie der bestehende Team-Switcher.
|
||||
|
||||
**Dropdown** (`mat-menu`): Liste der letzten 20 Benachrichtigungen (Icon je Event-Typ, Text, relative
|
||||
Zeit via Angular `DatePipe`/eigenes Pipe), "Alle als gelesen markieren"-Button oben, "Alle
|
||||
anzeigen"-Link unten zur Vollansicht-Seite. Klick auf einen Eintrag: `markRead()` + Router-Navigation
|
||||
zum Deep-Link (z.B. `/team/:teamId/members` mit Query-Param oder Fragment zum Hervorheben des
|
||||
betroffenen Spielers, je nach Event-Typ auch andere Zielrouten wie die Team-Einstellungen für
|
||||
Freigabelink-Events).
|
||||
|
||||
**Vollansicht-Seite** (`features/notifications/notifications.ts/html`, Route
|
||||
`/team/:teamId/notifications`): einfache paginierte Liste (kein ag-grid nötig, da kein
|
||||
Admin-Filterbedarf wie bei der Logs-Seite), gleiche Klick-Navigation wie im Dropdown.
|
||||
|
||||
**State**: neuer `NotificationsStore` (Signal-Service im Team-Kontext, analog `MyTeamsStore`) hält
|
||||
`notifications`- und `unreadCount`-Signals. Pollt `unread-count` alle 30s via `interval()` +
|
||||
`switchMap`, solange ein Team aktiv ist; die volle Liste wird nur bei Dropdown-Öffnen bzw.
|
||||
Seitenaufruf der Vollansicht geladen (kein Dauer-Polling der ganzen Liste).
|
||||
|
||||
**Neues Model** (`models/notification.model.ts`): `NotificationEvent`-Union (Frontend-seitiges
|
||||
Gegenstück zu `NOTIFICATION_EVENT`), `NotificationDto`, mit Mapping-Funktion Event-Typ → Icon/Text/
|
||||
Zielroute (zentral an einer Stelle, damit neue Event-Typen nicht über die Komponente verstreut
|
||||
behandelt werden müssen).
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- Notification-Erstellung schlägt fehl → wird im `NotificationsListener` abgefangen und geloggt,
|
||||
bricht die ursprüngliche (bereits erfolgreich abgeschlossene) Aktion nicht nachträglich ab.
|
||||
- `markRead`/`markAllRead` auf fremde bzw. nicht existente Recipient-Zeile → `NotFoundException`
|
||||
bzw. stiller No-Op bei `markAllRead` (nichts zu markieren ist kein Fehlerfall).
|
||||
- Polling-Request schlägt fehl (Netzwerk) → Store behält den letzten bekannten Zählerstand, kein
|
||||
Fehler-Toast (nicht kritisch genug für eine Nutzerunterbrechung).
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend**:
|
||||
|
||||
- `notifications.service.spec.ts` — Empfänger-Ermittlung (aktive Player mit User, Actor
|
||||
ausgeschlossen), Fan-out-Erstellung, `listForUser`/`getUnreadCount`-Filterung nach `teamId`+`userId`,
|
||||
`markRead`-Eigentümerprüfung, `markAllRead`.
|
||||
- `notifications.listener.spec.ts` — pro Event-Typ: korrekter Aufruf von
|
||||
`NotificationsService.create` mit erwartetem Payload; Fehler im Service wird abgefangen und geloggt,
|
||||
nicht weitergeworfen.
|
||||
- Bestehende Specs von `team-members.service.ts`, `public-team-access.service.ts`, `auth.service.ts`
|
||||
um Assertions ergänzt, dass das jeweilige Domain-Event nach erfolgreichem Abschluss emittiert wird
|
||||
(gemockter `EventEmitter2`), und bei Rollback/Fehler **nicht** emittiert wird.
|
||||
- `notification-retention.scheduler.spec.ts` — analog `log-retention.scheduler.spec.ts`.
|
||||
- `notifications.http.spec.ts` — Auth/Team-Membership erforderlich, Pagination, `read`/`read-all`.
|
||||
|
||||
**Frontend**:
|
||||
|
||||
- `notifications-store.spec.ts` — Polling-Intervall, Unread-Count-Update, Laden der Liste.
|
||||
- `notifications-api.spec.ts` — korrekte HTTP-Calls.
|
||||
- Bell/Dropdown-Komponenten-Spec — Badge-Anzeige bei >0, Klick markiert gelesen + navigiert,
|
||||
"Alle als gelesen"-Button.
|
||||
- Vollansicht-Seiten-Spec — Pagination, Klick-Navigation.
|
||||
|
||||
## Bewusst nicht enthalten (YAGNI)
|
||||
|
||||
- Kein Echtzeit-Push (WebSocket/SSE) — Polling reicht für den Anwendungsfall und vermeidet neue
|
||||
Infrastruktur.
|
||||
- Keine Benachrichtigung beim reinen Einlösen/Validieren eines Einladungslinks (unauthentifiziert,
|
||||
kein verlässlicher Actor, oft nur Vorschau ohne Beitritt).
|
||||
- Keine Benachrichtigungseinstellungen pro Nutzer (z.B. E-Mail-Digest, Stummschalten einzelner
|
||||
Event-Typen) — alle aktiven Mitglieder mit Login sehen alle abgedeckten Events.
|
||||
- Keine rollenbasierte Einschränkung der Empfänger (z.B. "nur Manager") — alle aktiven Mitglieder mit
|
||||
Login.
|
||||
- Keine Browser-Push-Benachrichtigungen (Service Worker/Web Push) außerhalb der App.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber.
|
||||
- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber.
|
||||
- **Manuell**: Backend + Frontend lokal starten, mit zwei Test-Usern im selben Team: User A
|
||||
deaktiviert einen Spieler, User B (nicht der deaktivierte Spieler selbst, aber Mitglied) sieht die
|
||||
Badge-Zahl nach kurzer Zeit (Polling) hochgehen, öffnet das Dropdown, sieht den Eintrag, klickt
|
||||
darauf → Navigation zur Mitgliederliste + Eintrag als gelesen markiert, Badge sinkt. Gleiches
|
||||
stichprobenartig für Rollenänderung, Freigabelink-Rotation und Einladungslink-Erstellung
|
||||
durchspielen. Vollansicht-Seite aufrufen und Pagination über mehrere erzeugte Einträge prüfen.
|
||||
113
docs/superpowers/specs/2026-08-05-env-indicator-design.md
Normal file
113
docs/superpowers/specs/2026-08-05-env-indicator-design.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Umgebungs-Indikator (Entwicklungsumgebung-Banner)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-05
|
||||
|
||||
## Kontext
|
||||
|
||||
Beim Arbeiten und Testen kann leicht unklar sein, ob man gerade in der lokalen
|
||||
Entwicklungsumgebung (`ng serve`, `environment.development.ts`) oder in der echten,
|
||||
produktiven App unterwegs ist — beide sehen optisch identisch aus. Ziel: ein visueller
|
||||
Indikator, der überall in der App sofort erkennbar macht, wenn man sich in der
|
||||
Entwicklungsumgebung befindet, damit man sich beim Testen nicht vertut.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Betroffene Umgebungen**: Es gibt drei Angular-Build-Konfigurationen
|
||||
(`myteamwallet_frontend_modern/angular.json`): `production` (Standard,
|
||||
`environment.ts`, echte API auf myteamwallet.de), `development`
|
||||
(`environment.development.ts`, nur lokal via `ng serve`, `localhost:3999`) und
|
||||
`container` (`environment.container.ts`, `npm run build:container`). Der `container`-Build
|
||||
ist der reguläre Deploy-Weg der echten Produktion (z. B. self-hosted per Docker) — **kein**
|
||||
Staging-System — und setzt bereits selbst `production: true`. Der Indikator muss also nur
|
||||
`environment.production === false` erkennen; kein neues Feld in den Environment-Dateien nötig.
|
||||
- **Darstellung**: dünner Banner-Streifen ganz oben über der gesamten App (nicht nur im
|
||||
Header), warme Warnfarbe (Amber/Orange, bewusst nicht das App-Grün), Text „⚠
|
||||
Entwicklungsumgebung", zentriert, klein, kein Dismiss-Button (der Zweck ist ja gerade, ihn
|
||||
nicht wegzuklicken und zu vergessen).
|
||||
- **Inhalt**: nur der Umgebungsname, keine zusätzlichen technischen Details (API-URL o. ä.).
|
||||
- **Platzierung im Code**: einmalig in `app.html` vor `<router-outlet />`, statt in jeder
|
||||
Seite einzeln — automatisch auf jeder Route (Shell, Public-Seiten, Login, Register, Users,
|
||||
Logs, …) sichtbar, single source of truth.
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Neue Komponente `EnvBanner`
|
||||
|
||||
**Ordner:** `myteamwallet_frontend_modern/src/app/shared/env-banner/`
|
||||
|
||||
Standalone-Komponente nach dem Muster bestehender Shared-Komponenten (`context-help`,
|
||||
`skeleton`) — kein Modul/Barrel, keine Inputs.
|
||||
|
||||
- `env-banner.ts`: importiert `environment` aus `../../../environments/environment` und
|
||||
exponiert `protected readonly showBanner = !environment.production;`. Exportiert außerdem
|
||||
die Konstante `export const ENV_BANNER_HEIGHT_PX = 28;` (wird von `App` für die
|
||||
Höhen-Kompensation wiederverwendet, siehe unten — ein einziger Ort für die Pixel-Zahl).
|
||||
- `env-banner.html`: `@if (showBanner) { <div class="env-banner" role="status">⚠
|
||||
Entwicklungsumgebung</div> }` — rendert in Produktion buchstäblich nichts (kein leeres
|
||||
DOM-Element).
|
||||
- `env-banner.scss`: `.env-banner { height: 28px; display:flex; align-items:center;
|
||||
justify-content:center; background: #f4a300; color:#20251F; font-weight:700; font-size:
|
||||
0.75rem; letter-spacing:0.04em; text-transform:uppercase; flex-shrink:0; }` (die `28px`
|
||||
müssen mit `ENV_BANNER_HEIGHT_PX` übereinstimmen — als Kommentar im SCSS vermerkt).
|
||||
- `env-banner.spec.ts`: rendert Text wenn `environment.production === false`, rendert nichts
|
||||
wenn `true` (Environment-Objekt im Test gemockt/überschrieben).
|
||||
|
||||
### 2. Einbau in `app.html` / `app.ts`
|
||||
|
||||
`app.html` bekommt vor `<router-outlet />` ein `<app-env-banner />`. `App` importiert
|
||||
`EnvBanner` in seine `imports`-Liste.
|
||||
|
||||
### 3. Höhen-Kompensation für `height: 100dvh`-Layouts
|
||||
|
||||
Der Banner nimmt echten Platz im normalen Fluss ein. Für Seiten, die nur `min-height:100dvh`
|
||||
nutzen und sich auf `body`s eigenen Scrollbar verlassen (Login, Register,
|
||||
Forgot/Reset-Password, Confirm-Email, Users, Logs), ist das unproblematisch — `body` gleicht
|
||||
das automatisch aus, kein Änderungsbedarf.
|
||||
|
||||
**Aber** `shell.scss`, `public-team.scss` und `public-player.scss` nutzen `height: 100dvh`
|
||||
als feste Zusage „genau ein Bildschirm hoch" (siehe
|
||||
`docs/superpowers/plans/`-Historie zum Doppel-Scrollbar-Fix vom selben Tag). Ohne Anpassung
|
||||
würde die Shell/Public-Seite exakt um die Banner-Höhe über den sichtbaren Bereich
|
||||
hinausragen (Bottom-Nav leicht abgeschnitten) — derselbe Bugtyp wie der kürzlich gefixte.
|
||||
|
||||
**Fix:** `App` (Root-Komponente) bindet eine CSS-Custom-Property auf ihr eigenes
|
||||
Host-Element. Host-Bindings werten Ausdrücke gegen die Komponenten-Instanz aus, daher als
|
||||
Instanz-Property vorhalten:
|
||||
|
||||
```ts
|
||||
host: {
|
||||
'[style.--env-banner-height.px]': 'bannerHeight',
|
||||
}
|
||||
// ...
|
||||
protected readonly bannerHeight = environment.production ? 0 : ENV_BANNER_HEIGHT_PX;
|
||||
```
|
||||
|
||||
Da `<app-root>` ein gemeinsamer Vorfahre von `EnvBanner` und allen Routen-Komponenten
|
||||
(Shell, Public-Seiten, …) ist, vererbt sich die Property automatisch nach unten. Die drei
|
||||
betroffenen SCSS-Dateien ändern:
|
||||
|
||||
```scss
|
||||
// vorher: height: 100dvh;
|
||||
height: calc(100dvh - var(--env-banner-height, 0px));
|
||||
```
|
||||
|
||||
In Produktion ist die Property `0px`, `calc(100dvh - 0px)` verhält sich identisch zu vorher
|
||||
— keine Verhaltensänderung außerhalb der Entwicklungsumgebung.
|
||||
|
||||
## Testing
|
||||
|
||||
- `env-banner.spec.ts` (neu): Sichtbarkeit abhängig von `environment.production`.
|
||||
- `app.spec.ts`: Erweiterung um Assertion, dass `--env-banner-height` korrekt `0px` bzw.
|
||||
`28px` auf dem Host gesetzt wird (je nach gemocktem `environment.production`).
|
||||
- Bestehende Tests (`shell.spec.ts`, `public-team.spec.ts`, `public-player.spec.ts`) bleiben
|
||||
unverändert grün — die `calc()`-Änderung ist rein visuell/CSS, keine Verhaltensänderung.
|
||||
- Manuelle Verifikation: `ng serve` (development) zeigt den Banner, `ng build` (production)
|
||||
und `ng build --configuration=container` zeigen ihn nicht; Shell/Public-Seiten scrollen mit
|
||||
Banner weiterhin korrekt ohne abgeschnittene Bottom-Nav (per Chrome DevTools nachprüfen).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Kein neues `environmentName`-Feld in den Environment-Dateien (nicht nötig, siehe oben).
|
||||
- Kein Dismiss/Ausblenden des Banners.
|
||||
- Keine Anzeige zusätzlicher technischer Details (API-URL, Build-Hash) im Banner-Text.
|
||||
10
env-example
10
env-example
@@ -2,10 +2,9 @@ NODE_ENV=development
|
||||
APP_PORT=3000
|
||||
APP_NAME="NestJS API"
|
||||
API_PREFIX=api
|
||||
APP_FALLBACK_LANGUAGE=en
|
||||
APP_HEADER_LANGUAGE=x-custom-lang
|
||||
FRONTEND_DOMAIN=http://localhost:3000
|
||||
BACKEND_DOMAIN=http://localhost:3000
|
||||
LOG_RETENTION_DAYS=365
|
||||
|
||||
DATABASE_TYPE=postgres
|
||||
DATABASE_HOST=postgres
|
||||
@@ -21,13 +20,6 @@ DATABASE_CA=
|
||||
DATABASE_KEY=
|
||||
DATABASE_CERT=
|
||||
|
||||
# Support "local", "s3"
|
||||
FILE_DRIVER=local
|
||||
ACCESS_KEY_ID=
|
||||
SECRET_ACCESS_KEY=
|
||||
AWS_S3_REGION=
|
||||
AWS_DEFAULT_S3_BUCKET=
|
||||
|
||||
MAIL_HOST=maildev
|
||||
MAIL_PORT=1025
|
||||
MAIL_USER=
|
||||
|
||||
@@ -26,7 +26,6 @@ Seeden: npm run seed:run
|
||||
- [x] Sign in and sign up via email.
|
||||
- [x] Social sign in (Apple, Facebook, Google, Twitter).
|
||||
- [x] Admin and User roles.
|
||||
- [x] I18N ([nestjs-i18n](https://www.npmjs.com/package/nestjs-i18n)).
|
||||
- [x] File uploads. Support local and Amazon S3 drivers.
|
||||
- [x] Swagger.
|
||||
- [x] E2E and units tests.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [{ "include": "i18n/**/*", "watchAssets": true }]
|
||||
"assets": [{ "include": "mail/mail-templates/**/*.hbs", "watchAssets": true }]
|
||||
}
|
||||
}
|
||||
|
||||
2230
myteamwallet_backend/package-lock.json
generated
2230
myteamwallet_backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,34 +33,30 @@
|
||||
"@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",
|
||||
"apple-signin-auth": "1.7.4",
|
||||
"bcryptjs": "2.4.3",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.13.2",
|
||||
"fb": "2.0.0",
|
||||
"google-auth-library": "8.7.0",
|
||||
"handlebars": "4.7.7",
|
||||
"multer": "1.4.4",
|
||||
"multer-s3": "2.10.0",
|
||||
"mysql2": "^2.3.3",
|
||||
"nestjs-i18n": "9.2.2",
|
||||
"nodemailer": "6.8.0",
|
||||
"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",
|
||||
"rxjs": "7.5.7",
|
||||
"source-map-support": "0.5.21",
|
||||
"swagger-ui-express": "4.5.0",
|
||||
"twitter": "1.7.1",
|
||||
"typeorm": "0.3.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,17 +66,14 @@
|
||||
"@nestjs/testing": "9.1.6",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/express": "4.17.14",
|
||||
"@types/facebook-js-sdk": "3.3.6",
|
||||
"@types/jest": "29.2.3",
|
||||
"@types/multer": "1.4.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",
|
||||
"@types/twitter": "1.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.43.0",
|
||||
"@typescript-eslint/parser": "5.43.0",
|
||||
"aws-sdk": "2.1243.0",
|
||||
"env-cmd": "10.1.0",
|
||||
"eslint": "8.27.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
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';
|
||||
import authConfig from './config/auth.config';
|
||||
import appConfig from './config/app.config';
|
||||
import mailConfig from './config/mail.config';
|
||||
import fileConfig from './config/file.config';
|
||||
import * as path from 'path';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { I18nModule } from 'nestjs-i18n/dist/i18n.module';
|
||||
import { HeaderResolver } from 'nestjs-i18n';
|
||||
import { TypeOrmConfigService } from './database/typeorm-config.service';
|
||||
import { MailConfigService } from './mail/mail-config.service';
|
||||
import { ForgotModule } from './forgot/forgot.module';
|
||||
@@ -20,19 +18,23 @@ import { DataSource } from 'typeorm';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { TeamsModule } from './teams/teams.module';
|
||||
import { TransactionsModule } from './transactions/transactions.module';
|
||||
import { TeamSettingsModule } from './team-settings/team-settings.module';
|
||||
import { TeamWalletTransactionsModule } from './team-wallet-transactions/team-wallet-transactions.module';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
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, fileConfig],
|
||||
load: [databaseConfig, authConfig, appConfig, mailConfig],
|
||||
envFilePath: ['.env'],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
@@ -45,23 +47,6 @@ import { PenaltyModule } from './penalty/penalty.module';
|
||||
MailerModule.forRootAsync({
|
||||
useClass: MailConfigService,
|
||||
}),
|
||||
I18nModule.forRootAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
fallbackLanguage: configService.get('app.fallbackLanguage'),
|
||||
loaderOptions: { path: path.join(__dirname, '/i18n/'), watch: true },
|
||||
}),
|
||||
resolvers: [
|
||||
{
|
||||
use: HeaderResolver,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return [configService.get('app.headerLanguage')];
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '../client'),
|
||||
exclude: ['*/api*'],
|
||||
@@ -73,11 +58,13 @@ import { PenaltyModule } from './penalty/penalty.module';
|
||||
PlayersModule,
|
||||
TeamsModule,
|
||||
TransactionsModule,
|
||||
TeamSettingsModule,
|
||||
TeamWalletTransactionsModule,
|
||||
LoggingModule,
|
||||
TranslateModule,
|
||||
PenaltyModule,
|
||||
RecurringTransactionsModule,
|
||||
CashboxExportModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export enum AuthProvidersEnum {
|
||||
email = 'email',
|
||||
facebook = 'facebook',
|
||||
google = 'google',
|
||||
twitter = 'twitter',
|
||||
apple = 'apple',
|
||||
}
|
||||
|
||||
30
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal file
30
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
|
||||
describe('AuthController session enforcement', () => {
|
||||
it('protects GET auth/me with JWT validation', () => {
|
||||
const guards = Reflect.getMetadata(
|
||||
GUARDS_METADATA,
|
||||
AuthController.prototype.me,
|
||||
);
|
||||
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not expose self-deletion that can race with an admin promotion', () => {
|
||||
expect(AuthController.prototype).not.toHaveProperty('delete');
|
||||
});
|
||||
|
||||
it('uses the narrow validated registration DTO instead of an untyped body', () => {
|
||||
const parameterTypes = Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
AuthController.prototype,
|
||||
'register',
|
||||
);
|
||||
|
||||
expect(parameterTypes[0]).toBe(AuthRegisterLoginDto);
|
||||
expect(AuthRegisterLoginDto.prototype).not.toHaveProperty('linkPlayerId');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
Patch,
|
||||
Delete,
|
||||
UseInterceptors,
|
||||
ClassSerializerInterceptor,
|
||||
SerializeOptions,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
ApiOkResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CreateInviteDTO } from './dto/create-invite.dto';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller({
|
||||
@@ -51,7 +51,7 @@ export class AuthController {
|
||||
|
||||
@Post('email/register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async register(@Body() createUserDto: any) {
|
||||
async register(@Body() createUserDto: AuthRegisterLoginDto) {
|
||||
return this.service.register(createUserDto);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export class AuthController {
|
||||
groups: ['exposeProvider'],
|
||||
})
|
||||
@Get('me')
|
||||
// @UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
public me(@Request() request: Request) {
|
||||
return this.service.me(request.headers['authorization']);
|
||||
@@ -95,14 +95,6 @@ export class AuthController {
|
||||
return this.service.update(request.user, userDto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Delete('me')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
public async delete(@Request() request) {
|
||||
return this.service.softDelete(request.user);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Erstellt Registrierungstoken',
|
||||
description:
|
||||
@@ -116,10 +108,11 @@ export class AuthController {
|
||||
@Post('invite')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public getInvite(
|
||||
@Request() request,
|
||||
@Body()
|
||||
invite: any,
|
||||
) {
|
||||
return this.service.createTeamInvite(invite);
|
||||
return this.service.createTeamInvite(invite, Number(request.user.id));
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MailModule } from 'src/mail/mail.module';
|
||||
import { IsExist } from 'src/utils/validators/is-exists.validator';
|
||||
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +20,7 @@ import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
ForgotModule,
|
||||
PassportModule,
|
||||
MailModule,
|
||||
TeamsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
187
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal file
187
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AuthProvidersEnum } from './auth-providers.enum';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { StatusEnum } from '../statuses/statuses.enum';
|
||||
|
||||
describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
let jwtService: any;
|
||||
let usersService: any;
|
||||
let logger: any;
|
||||
let dataSource: any;
|
||||
let confirmationUser: any;
|
||||
let lockedUserQuery: any;
|
||||
let userRepository: any;
|
||||
let service: AuthService;
|
||||
let mailService: any;
|
||||
let eventEmitter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jwtService = {
|
||||
sign: jest.fn(() => 'signed-token'),
|
||||
verify: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
};
|
||||
usersService = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
linkPlayerToUserId: jest.fn(),
|
||||
};
|
||||
logger = { info: jest.fn(), debug: jest.fn() };
|
||||
mailService = { userSignUp: jest.fn() };
|
||||
confirmationUser = user(StatusEnum.inactive);
|
||||
confirmationUser.hash = 'confirmation-hash';
|
||||
lockedUserQuery = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn(() => confirmationUser),
|
||||
};
|
||||
userRepository = {
|
||||
createQueryBuilder: jest.fn(() => lockedUserQuery),
|
||||
save: jest.fn((value) => Promise.resolve(value)),
|
||||
};
|
||||
const manager = { getRepository: jest.fn(() => userRepository) };
|
||||
dataSource = {
|
||||
transaction: jest.fn((work) => work(manager)),
|
||||
};
|
||||
eventEmitter = { emit: jest.fn() };
|
||||
service = new AuthService(
|
||||
jwtService,
|
||||
usersService,
|
||||
{} as any,
|
||||
mailService,
|
||||
logger,
|
||||
dataSource,
|
||||
{ assertAtLeast: jest.fn() } as any,
|
||||
eventEmitter as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects password login for an inactive user before issuing a token', async () => {
|
||||
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
|
||||
|
||||
await expect(
|
||||
service.validateLogin({
|
||||
email: 'inactive@example.com',
|
||||
password: 'password',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'user_login_fail',
|
||||
details: 'userId=2 reason=inactive',
|
||||
userId: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('never includes an email in an unknown-user login audit event', async () => {
|
||||
usersService.findOne.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.validateLogin({
|
||||
email: 'secret@example.com',
|
||||
password: 'secret-password',
|
||||
}),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'user_login_fail',
|
||||
details: 'reason=user_not_found',
|
||||
userId: -1,
|
||||
});
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
|
||||
'secret@example.com',
|
||||
);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
|
||||
'secret-password',
|
||||
);
|
||||
});
|
||||
|
||||
it('never includes a rejected invite token in logging details', async () => {
|
||||
jwtService.verify.mockImplementation(() => {
|
||||
throw new Error('invalid');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getTeamFromInvite('secret-token'),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'user_invite_link_validate_fail',
|
||||
details: 'invitation validation failed',
|
||||
userId: 0,
|
||||
});
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
|
||||
'secret-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not refresh an inactive user through the me endpoint flow', async () => {
|
||||
jwtService.verify.mockReturnValue({ id: 2 });
|
||||
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
|
||||
|
||||
await expect(service.me('Bearer existing-token')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes email confirmation on the user row and consumes the hash', async () => {
|
||||
await service.confirmEmail('confirmation-hash');
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockedUserQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(confirmationUser.status).toEqual({ id: StatusEnum.active });
|
||||
expect(confirmationUser.hash).toBeNull();
|
||||
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
|
||||
});
|
||||
|
||||
it('ignores a public registration player id and never mutates player ownership', async () => {
|
||||
usersService.create.mockResolvedValue({
|
||||
id: 8,
|
||||
email: 'new@example.com',
|
||||
});
|
||||
|
||||
await service.register({
|
||||
email: 'new@example.com',
|
||||
password: 'password',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
linkPlayerId: 101,
|
||||
} as any);
|
||||
|
||||
expect(usersService.create.mock.calls[0][0]).not.toHaveProperty(
|
||||
'linkPlayerId',
|
||||
);
|
||||
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,
|
||||
email: 'inactive@example.com',
|
||||
password: 'password-hash',
|
||||
provider: AuthProvidersEnum.email,
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: {
|
||||
id: statusId,
|
||||
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
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';
|
||||
@@ -12,13 +19,17 @@ import { plainToClass } from 'class-transformer';
|
||||
import { Status } from 'src/statuses/entities/status.entity';
|
||||
import { Role } from 'src/roles/entities/role.entity';
|
||||
import { AuthProvidersEnum } from './auth-providers.enum';
|
||||
import { SocialInterface } from 'src/social/interfaces/social.interface';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import { ForgotService } from 'src/forgot/forgot.service';
|
||||
import { MailService } from 'src/mail/mail.service';
|
||||
import { CreateInviteDTO } from './dto/create-invite.dto';
|
||||
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 {
|
||||
@@ -28,6 +39,9 @@ export class AuthService {
|
||||
private forgotService: ForgotService,
|
||||
private mailService: MailService,
|
||||
private logger: LoggingService,
|
||||
private dataSource: DataSource,
|
||||
private teamAccess: TeamAccessService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async validateLogin(
|
||||
@@ -40,7 +54,7 @@ export class AuthService {
|
||||
if (!user) {
|
||||
await this.logger.info({
|
||||
event: 'user_login_fail',
|
||||
details: `mail not found: ${loginDto.email}`,
|
||||
details: 'reason=user_not_found',
|
||||
userId: -1,
|
||||
});
|
||||
throw new HttpException(
|
||||
@@ -54,6 +68,8 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertActiveUser(user);
|
||||
|
||||
if (user.provider !== AuthProvidersEnum.email) {
|
||||
throw new HttpException(
|
||||
{
|
||||
@@ -79,7 +95,7 @@ export class AuthService {
|
||||
|
||||
await this.logger.info({
|
||||
event: 'user_login_success',
|
||||
details: `logged in: ${loginDto.email}`,
|
||||
details: `userId=${user.id}`,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -87,7 +103,7 @@ export class AuthService {
|
||||
} else {
|
||||
await this.logger.info({
|
||||
event: 'user_login_fail',
|
||||
details: `incorrect password for user: ${loginDto.email}`,
|
||||
details: `userId=${user.id} reason=incorrect_password`,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -103,63 +119,6 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async validateSocialLogin(
|
||||
authProvider: string,
|
||||
socialData: SocialInterface,
|
||||
): Promise<{ token: string; user: User }> {
|
||||
let user: User;
|
||||
const socialEmail = socialData.email?.toLowerCase();
|
||||
|
||||
const userByEmail = await this.usersService.findOne({
|
||||
email: socialEmail,
|
||||
});
|
||||
|
||||
user = await this.usersService.findOne({
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
if (socialEmail && !userByEmail) {
|
||||
user.email = socialEmail;
|
||||
}
|
||||
await this.usersService.update(user.id, user);
|
||||
} else if (userByEmail) {
|
||||
user = userByEmail;
|
||||
} else {
|
||||
const role = plainToClass(Role, {
|
||||
id: RoleEnum.user,
|
||||
});
|
||||
const status = plainToClass(Status, {
|
||||
id: StatusEnum.active,
|
||||
});
|
||||
|
||||
user = await this.usersService.create({
|
||||
email: socialEmail,
|
||||
firstName: socialData.firstName,
|
||||
lastName: socialData.lastName,
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
role,
|
||||
status,
|
||||
});
|
||||
|
||||
user = await this.usersService.findOne({
|
||||
id: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const jwtToken = await this.jwtService.sign({
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return {
|
||||
token: jwtToken,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async register(dto: AuthRegisterLoginDto): Promise<void> {
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
@@ -167,8 +126,10 @@ export class AuthService {
|
||||
.digest('hex');
|
||||
|
||||
const user = await this.usersService.create({
|
||||
...dto,
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
role: {
|
||||
id: RoleEnum.user,
|
||||
} as Role,
|
||||
@@ -178,13 +139,9 @@ export class AuthService {
|
||||
hash,
|
||||
});
|
||||
|
||||
if (user && dto.linkPlayerId != null) {
|
||||
await this.usersService.linkPlayerToUserId(user, dto.linkPlayerId);
|
||||
}
|
||||
|
||||
await this.logger.info({
|
||||
event: 'user_create',
|
||||
details: `user created with mail: ${dto.email}`,
|
||||
details: `userId=${user.id}`,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -192,30 +149,32 @@ export class AuthService {
|
||||
to: user.email,
|
||||
data: {
|
||||
hash,
|
||||
firstName: user.firstName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async confirmEmail(hash: string): Promise<void> {
|
||||
const user = await this.usersService.findOne({
|
||||
hash,
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(User);
|
||||
const user = await repository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.hash = :hash', { hash })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
error: `notFound`,
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
user.hash = null;
|
||||
user.status = plainToClass(Status, { id: StatusEnum.active });
|
||||
await repository.save(user);
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
error: `notFound`,
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
user.hash = null;
|
||||
user.status = plainToClass(Status, {
|
||||
id: StatusEnum.active,
|
||||
});
|
||||
await user.save();
|
||||
}
|
||||
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
@@ -247,6 +206,7 @@ export class AuthService {
|
||||
to: email,
|
||||
data: {
|
||||
hash,
|
||||
firstName: user.firstName,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -279,40 +239,31 @@ export class AuthService {
|
||||
|
||||
async me(token: string): Promise<User> {
|
||||
token = token.replace('Bearer ', '');
|
||||
let role: any;
|
||||
let payload: any;
|
||||
let refreshToken = false;
|
||||
try {
|
||||
role = this.jwtService.verify(token);
|
||||
|
||||
const u = await this.usersService.findOne({
|
||||
id: role.id,
|
||||
});
|
||||
|
||||
await this.logger.debug({
|
||||
event: 'user_token_verification_success',
|
||||
details: `Email: ${u.email}`,
|
||||
userId: u.id,
|
||||
});
|
||||
|
||||
return u;
|
||||
} catch (error) {
|
||||
const role = this.jwtService.decode(token);
|
||||
|
||||
const user = await this.usersService.findOne({
|
||||
id: (role as any).id,
|
||||
});
|
||||
payload = this.jwtService.verify(token);
|
||||
} catch {
|
||||
payload = this.jwtService.decode(token);
|
||||
refreshToken = true;
|
||||
}
|
||||
if (!payload?.id) throw new UnauthorizedException();
|
||||
const user = await this.usersService.findOne({ id: payload.id });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
await this.assertActiveUser(user);
|
||||
if (refreshToken) {
|
||||
const t = await this.jwtService.sign({
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
user['token'] = t;
|
||||
await this.logger.debug({
|
||||
event: 'user_token_verification_success',
|
||||
details: `Email: ${user.email}`,
|
||||
userId: user.id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
await this.logger.debug({
|
||||
event: 'user_token_verification_success',
|
||||
details: `userId=${user.id}`,
|
||||
userId: user.id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
|
||||
@@ -358,11 +309,14 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async softDelete(user: User): Promise<void> {
|
||||
await this.usersService.softDelete(user.id);
|
||||
}
|
||||
async createTeamInvite(object: CreateInviteDTO, actorUserId: number) {
|
||||
await this.teamAccess.assertAtLeast(
|
||||
actorUserId,
|
||||
object.teamId,
|
||||
'invite_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
);
|
||||
|
||||
async createTeamInvite(object: CreateInviteDTO) {
|
||||
const token = await this.jwtService.sign(object, {
|
||||
expiresIn: '30d',
|
||||
});
|
||||
@@ -373,6 +327,11 @@ export class AuthService {
|
||||
userId: 0,
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
NOTIFICATION_EVENT_NAME.inviteLinkCreated,
|
||||
new InviteLinkCreatedEvent(object.teamId, actorUserId, object.teamName),
|
||||
);
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
@@ -392,7 +351,7 @@ export class AuthService {
|
||||
} catch {
|
||||
await this.logger.info({
|
||||
event: 'user_invite_link_validate_fail',
|
||||
details: `validation failed for token ${token}`,
|
||||
details: 'invitation validation failed',
|
||||
userId: 0,
|
||||
});
|
||||
|
||||
@@ -402,4 +361,14 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertActiveUser(user: User): Promise<void> {
|
||||
if (user.status?.id === StatusEnum.active) return;
|
||||
await this.logger.info({
|
||||
event: 'user_login_fail',
|
||||
details: `userId=${user.id} reason=inactive`,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new ForbiddenException('User account is inactive');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,4 @@ export class AuthRegisterLoginDto {
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: 27 })
|
||||
linkPlayerId: number | null;
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Allow, IsNotEmpty } from 'class-validator';
|
||||
import { Tokens } from 'src/social/tokens';
|
||||
import { AuthProvidersEnum } from '../auth-providers.enum';
|
||||
|
||||
export class AuthSocialLoginDto {
|
||||
@Allow()
|
||||
@ApiProperty({ type: () => Tokens })
|
||||
tokens: Tokens;
|
||||
|
||||
@ApiProperty({ enum: AuthProvidersEnum })
|
||||
@IsNotEmpty()
|
||||
socialType: AuthProvidersEnum;
|
||||
|
||||
@Allow()
|
||||
@ApiProperty({ required: false })
|
||||
firstName?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiProperty({ required: false })
|
||||
lastName?: string;
|
||||
}
|
||||
24
myteamwallet_backend/src/auth/dto/auth-update.dto.spec.ts
Normal file
24
myteamwallet_backend/src/auth/dto/auth-update.dto.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { AuthUpdateDto } from './auth-update.dto';
|
||||
|
||||
describe('AuthUpdateDto help preference', () => {
|
||||
it('rejects a non-boolean helpTextsEnabled value', async () => {
|
||||
const dto = Object.assign(new AuthUpdateDto(), {
|
||||
helpTextsEnabled: 'yes',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors.some((error) => error.property === 'helpTextsEnabled')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a boolean helpTextsEnabled value without requiring profile fields', async () => {
|
||||
const dto = Object.assign(new AuthUpdateDto(), {
|
||||
helpTextsEnabled: false,
|
||||
});
|
||||
|
||||
await expect(validate(dto)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, MinLength, Validate } from 'class-validator';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, MinLength } from 'class-validator';
|
||||
import { IsExist } from '../../utils/validators/is-exists.validator';
|
||||
|
||||
export class AuthUpdateDto {
|
||||
@ApiProperty({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
helpTextsEnabled?: boolean;
|
||||
|
||||
@ApiProperty({ example: 'John' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { RoleEnum } from '../../roles/roles.enum';
|
||||
import { StatusEnum } from '../../statuses/statuses.enum';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
const jwtService = {} as any;
|
||||
const configService = { get: jest.fn(() => 'secret') } as any;
|
||||
let usersService: any;
|
||||
let strategy: JwtStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
usersService = { findOne: jest.fn() };
|
||||
strategy = new JwtStrategy(jwtService, configService, usersService);
|
||||
});
|
||||
|
||||
it('reloads the current database user and replaces a stale token role', async () => {
|
||||
usersService.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: { id: StatusEnum.active, name: 'Active' },
|
||||
password: 'must-not-be-exposed',
|
||||
});
|
||||
|
||||
const result = await strategy.validate({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.admin },
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
} as any);
|
||||
|
||||
expect(usersService.findOne).toHaveBeenCalledWith({ id: 2 });
|
||||
expect(result).toEqual({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: { id: StatusEnum.active, name: 'Active' },
|
||||
});
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('rejects a missing or soft-deleted database user', async () => {
|
||||
usersService.findOne.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects an inactive current database user', async () => {
|
||||
usersService.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
status: { id: StatusEnum.inactive },
|
||||
});
|
||||
|
||||
await expect(
|
||||
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects a payload without an id without querying the database', async () => {
|
||||
await expect(
|
||||
strategy.validate({ iat: 1, exp: 2 } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(usersService.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { StatusEnum } from '../../statuses/statuses.enum';
|
||||
|
||||
type JwtPayload = Pick<User, 'id' | 'role'> & { iat: number; exp: number };
|
||||
|
||||
@@ -12,6 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private usersService: UsersService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@@ -19,10 +22,18 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
public validate(payload: JwtPayload) {
|
||||
public async validate(payload: JwtPayload) {
|
||||
if (!payload.id) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return payload;
|
||||
const user = await this.usersService.findOne({ id: payload.id });
|
||||
if (!user || user.status?.id !== StatusEnum.active) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role ?? null,
|
||||
status: user.status ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
|
||||
describe('CashboxExportSubscriptionService', () => {
|
||||
const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) };
|
||||
const access = { assertAtLeast: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
let service: CashboxExportSubscriptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new CashboxExportSubscriptionService(repository as any, access as any, logger as any);
|
||||
});
|
||||
|
||||
it('returns a paused default when no subscription exists yet', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getSubscription(5, 42)).resolves.toEqual({
|
||||
recipients: [],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: false,
|
||||
nextRunDate: null,
|
||||
});
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
42,
|
||||
5,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the existing subscription', async () => {
|
||||
repository.findOne.mockResolvedValue({
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
active: true,
|
||||
nextRunDate: '2027-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await expect(service.getSubscription(5, 42)).resolves.toEqual({
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
active: true,
|
||||
nextRunDate: '2027-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects activating with an empty recipient list', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.upsertSubscription(5, 42, {
|
||||
recipients: [],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a new subscription and computes the next period boundary on first activation', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
|
||||
|
||||
const result = await service.upsertSubscription(5, 42, {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_subscription_update',
|
||||
details: 'teamId=5 active=true interval=monthly recipients=1',
|
||||
userId: 42,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the existing nextRunDate when editing recipients without changing interval or activation state', async () => {
|
||||
repository.findOne.mockResolvedValue({
|
||||
recipients: ['old@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await service.upsertSubscription(5, 42, {
|
||||
recipients: ['new@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('recomputes nextRunDate when the interval changes', async () => {
|
||||
repository.findOne.mockResolvedValue({
|
||||
recipients: ['a@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
|
||||
|
||||
const result = await service.upsertSubscription(5, 42, {
|
||||
recipients: ['a@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(result.nextRunDate).toBe('2027-08-01T00:00:00.000Z');
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { CashboxExportSubscriptionResponseDTO } from './dto/cashbox-export-subscription-response.dto';
|
||||
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
||||
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
||||
|
||||
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
|
||||
[RecurringTransactionIntervalEnum.monthly]: 1,
|
||||
[RecurringTransactionIntervalEnum.quarterly]: 3,
|
||||
[RecurringTransactionIntervalEnum.yearly]: 12,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashboxExportSubscriptionService {
|
||||
constructor(
|
||||
@InjectRepository(CashboxExportSubscription)
|
||||
private readonly repository: Repository<CashboxExportSubscription>,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async getSubscription(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
): Promise<CashboxExportSubscriptionResponseDTO> {
|
||||
await this.assertAccess(userId, teamId);
|
||||
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
|
||||
if (!existing) {
|
||||
return {
|
||||
recipients: [],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: false,
|
||||
nextRunDate: null,
|
||||
};
|
||||
}
|
||||
return this.toResponse(existing);
|
||||
}
|
||||
|
||||
async upsertSubscription(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
dto: UpsertCashboxExportSubscriptionDTO,
|
||||
): Promise<CashboxExportSubscriptionResponseDTO> {
|
||||
await this.assertAccess(userId, teamId);
|
||||
if (dto.active && dto.recipients.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'Ein aktivierter automatischer Versand benötigt mindestens eine Empfängeradresse.',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
|
||||
const needsNewSchedule =
|
||||
!existing || (dto.active && (!existing.active || existing.interval !== dto.interval));
|
||||
|
||||
const entity =
|
||||
existing ??
|
||||
this.repository.create({ team: { id: teamId } as any, nextRunDate: null, active: false });
|
||||
|
||||
entity.recipients = dto.recipients;
|
||||
entity.interval = dto.interval;
|
||||
entity.active = dto.active;
|
||||
if (needsNewSchedule) {
|
||||
entity.nextRunDate = this.nextBoundary(dto.interval);
|
||||
}
|
||||
|
||||
const saved = await this.repository.save(entity);
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_subscription_update',
|
||||
details: `teamId=${teamId} active=${dto.active} interval=${dto.interval} recipients=${dto.recipients.length}`,
|
||||
userId,
|
||||
});
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
private async assertAccess(userId: number, teamId: number): Promise<void> {
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
teamId,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
}
|
||||
|
||||
private nextBoundary(interval: RecurringTransactionIntervalEnum): string {
|
||||
const now = new Date();
|
||||
const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
entity: CashboxExportSubscription,
|
||||
): CashboxExportSubscriptionResponseDTO {
|
||||
return {
|
||||
recipients: entity.recipients,
|
||||
interval: entity.interval,
|
||||
active: entity.active,
|
||||
nextRunDate: entity.nextRunDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { CashboxExportController } from './cashbox-export.controller';
|
||||
|
||||
describe('CashboxExportController.runDueSubscriptionsNow', () => {
|
||||
const service = { exportForUser: jest.fn() };
|
||||
const subscriptionService = { getSubscription: jest.fn(), upsertSubscription: jest.fn() };
|
||||
const scheduler = { runDueSubscriptions: jest.fn() };
|
||||
const controller = new CashboxExportController(
|
||||
service as any,
|
||||
subscriptionService as any,
|
||||
scheduler as any,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('is guarded by the global admin role', () => {
|
||||
expect(
|
||||
Reflect.getMetadata('roles', CashboxExportController.prototype.runDueSubscriptionsNow),
|
||||
).toEqual([RoleEnum.admin]);
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, CashboxExportController.prototype.runDueSubscriptionsNow),
|
||||
).toContain(RolesGuard);
|
||||
});
|
||||
|
||||
it('delegates to the scheduler', async () => {
|
||||
await controller.runDueSubscriptionsNow();
|
||||
|
||||
expect(scheduler.runDueSubscriptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { Roles } from '../roles/roles.decorator';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
|
||||
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
|
||||
type AuthenticatedRequest = { user: { id: number } };
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller({ path: 'cashbox-export', version: '1' })
|
||||
export class CashboxExportController {
|
||||
constructor(
|
||||
private readonly service: CashboxExportService,
|
||||
private readonly subscriptionService: CashboxExportSubscriptionService,
|
||||
private readonly scheduler: CashboxExportScheduler,
|
||||
) {}
|
||||
|
||||
@Get(':teamId')
|
||||
async exportCashbox(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Query() query: CashboxExportQueryDto,
|
||||
@Res({ passthrough: false }) res: Response,
|
||||
): Promise<void> {
|
||||
const { buffer, contentType, filename } = await this.service.exportForUser(
|
||||
teamId,
|
||||
request.user.id,
|
||||
query.from,
|
||||
query.to,
|
||||
query.format,
|
||||
);
|
||||
res.set({
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
});
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':teamId/subscription')
|
||||
getSubscription(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
) {
|
||||
return this.subscriptionService.getSubscription(teamId, request.user.id);
|
||||
}
|
||||
|
||||
@Put(':teamId/subscription')
|
||||
upsertSubscription(
|
||||
@Request() request: AuthenticatedRequest,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Body() dto: UpsertCashboxExportSubscriptionDTO,
|
||||
) {
|
||||
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto);
|
||||
}
|
||||
|
||||
@Post('admin/run')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles([RoleEnum.admin])
|
||||
runDueSubscriptionsNow(): Promise<void> {
|
||||
return this.scheduler.runDueSubscriptions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
INestApplication,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
||||
import validationOptions from '../utils/validation-options';
|
||||
import { CashboxExportController } from './cashbox-export.controller';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
|
||||
describe('cashbox export HTTP boundary', () => {
|
||||
let app: INestApplication;
|
||||
const service = {
|
||||
exportForUser: jest.fn(),
|
||||
};
|
||||
const subscriptionService = {
|
||||
getSubscription: jest.fn(),
|
||||
upsertSubscription: jest.fn(),
|
||||
};
|
||||
const scheduler = { runDueSubscriptions: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [CashboxExportController],
|
||||
providers: [
|
||||
{ provide: CashboxExportService, useValue: service },
|
||||
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
|
||||
{ provide: CashboxExportScheduler, useValue: scheduler },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard('jwt'))
|
||||
.useValue({
|
||||
canActivate(context) {
|
||||
const httpRequest = context.switchToHttp().getRequest();
|
||||
if (httpRequest.headers.authorization !== 'Bearer user') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
httpRequest.user = { id: 42, role: { id: 2 } };
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(() => app.close());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('rejects an invalid format', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=xls')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(422);
|
||||
expect(service.exportForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a full ISO datetime instead of a plain YYYY-MM-DD date for from', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
'/api/v1/cashbox-export/5?from=2026-08-01T12:00:00Z&to=2026-08-31&format=csv',
|
||||
)
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(422);
|
||||
expect(service.exportForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a malformed date string for to', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=not-a-date&format=csv')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(422);
|
||||
expect(service.exportForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a CSV file with correct headers and content', async () => {
|
||||
const csvBuffer = Buffer.from('Datum;Typ;Wer;Notiz;Betrag;Periodensaldo', 'utf-8');
|
||||
service.exportForUser.mockResolvedValue({
|
||||
buffer: csvBuffer,
|
||||
contentType: 'text/csv; charset=utf-8',
|
||||
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toContain('text/csv');
|
||||
expect(response.headers['content-disposition']).toContain(
|
||||
'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
||||
);
|
||||
expect(response.text).toBe(csvBuffer.toString('utf-8'));
|
||||
expect(service.exportForUser).toHaveBeenCalledWith(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
});
|
||||
|
||||
it('returns a PDF file with correct headers and binary content', async () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4 fake content');
|
||||
service.exportForUser.mockResolvedValue({
|
||||
buffer: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=pdf')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toBe('application/pdf');
|
||||
expect(Buffer.from(response.body).equals(pdfBuffer)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the current subscription', async () => {
|
||||
const subscription = {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
};
|
||||
subscriptionService.getSubscription.mockResolvedValue(subscription);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/cashbox-export/5/subscription')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200)
|
||||
.expect(subscription);
|
||||
expect(subscriptionService.getSubscription).toHaveBeenCalledWith(5, 42);
|
||||
});
|
||||
|
||||
it('rejects an invalid recipient email on upsert', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.put('/api/v1/cashbox-export/5/subscription')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({ recipients: ['not-an-email'], interval: 'monthly', active: true })
|
||||
.expect(422);
|
||||
expect(subscriptionService.upsertSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a valid subscription upsert', async () => {
|
||||
const subscription = {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
active: true,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
};
|
||||
subscriptionService.upsertSubscription.mockResolvedValue(subscription);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.put('/api/v1/cashbox-export/5/subscription')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({ recipients: ['vorstand@example.com'], interval: 'monthly', active: true })
|
||||
.expect(200)
|
||||
.expect(subscription);
|
||||
expect(subscriptionService.upsertSubscription).toHaveBeenCalledWith(5, 42, {
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: 'monthly',
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { MailModule } from 'src/mail/mail.module';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
||||
import { CashboxExportController } from './cashbox-export.controller';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
|
||||
@Module({
|
||||
controllers: [CashboxExportController],
|
||||
providers: [CashboxExportService, CashboxExportSubscriptionService, CashboxExportScheduler],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Team, CashboxExportSubscription]),
|
||||
TeamsModule,
|
||||
MailModule,
|
||||
LoggingModule,
|
||||
],
|
||||
})
|
||||
export class CashboxExportModule {}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
||||
|
||||
describe('CashboxExportScheduler', () => {
|
||||
const subscriptionRepository = { find: jest.fn(), save: jest.fn((v) => v) };
|
||||
const teamRepository = { findOne: jest.fn() };
|
||||
const mailService = { cashboxExport: jest.fn() };
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
let scheduler: CashboxExportScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
scheduler = new CashboxExportScheduler(
|
||||
subscriptionRepository as any,
|
||||
teamRepository as any,
|
||||
mailService as any,
|
||||
logger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when no subscription is due', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([]);
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(teamRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(mailService.cashboxExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emails the elapsed monthly period and advances nextRunDate', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
team: { id: 5 },
|
||||
recipients: ['vorstand@example.com'],
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [
|
||||
{ date: '2026-08-15T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
|
||||
],
|
||||
players: [],
|
||||
});
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(teamRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 5 },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
expect(mailService.cashboxExport).toHaveBeenCalledTimes(1);
|
||||
const [mailData, attachment, filename] = mailService.cashboxExport.mock.calls[0];
|
||||
expect(mailData).toEqual({
|
||||
to: 'vorstand@example.com',
|
||||
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
|
||||
});
|
||||
expect(Buffer.isBuffer(attachment)).toBe(true);
|
||||
expect(filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
|
||||
expect(subscriptionRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nextRunDate: '2026-10-01T00:00:00.000Z' }),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_subscription_run',
|
||||
details: 'teamId=5 recipients=1 from=2026-08-01 to=2026-08-31',
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[RecurringTransactionIntervalEnum.monthly, '2026-09-01T00:00:00.000Z', '2026-08-01', '2026-08-31', '2026-10-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.quarterly, '2026-09-01T00:00:00.000Z', '2026-06-01', '2026-08-31', '2026-12-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.yearly, '2027-01-01T00:00:00.000Z', '2026-01-01', '2026-12-31', '2028-01-01T00:00:00.000Z'],
|
||||
])(
|
||||
'computes the elapsed period and next run date for %s',
|
||||
async (interval, nextRunDate, expectedFrom, expectedTo, expectedNext) => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval, nextRunDate, active: true },
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
});
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
const [mailData] = mailService.cashboxExport.mock.calls[0];
|
||||
expect(mailData.data.from).toBe(expectedFrom);
|
||||
expect(mailData.data.to).toBe(expectedTo);
|
||||
expect(subscriptionRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nextRunDate: expectedNext }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('processes multiple due subscriptions independently', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
});
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('continues processing when one subscription fails', async () => {
|
||||
subscriptionRepository.find.mockResolvedValue([
|
||||
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
||||
]);
|
||||
teamRepository.findOne.mockResolvedValue({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
});
|
||||
mailService.cashboxExport.mockRejectedValueOnce(new Error('smtp down'));
|
||||
|
||||
await scheduler.runDueSubscriptions();
|
||||
|
||||
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
|
||||
expect(subscriptionRepository.save).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
details: expect.stringContaining('subscriptionId=1 teamId=5'),
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { MailService } from 'src/mail/mail.service';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
import { buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
||||
|
||||
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
|
||||
[RecurringTransactionIntervalEnum.monthly]: 1,
|
||||
[RecurringTransactionIntervalEnum.quarterly]: 3,
|
||||
[RecurringTransactionIntervalEnum.yearly]: 12,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashboxExportScheduler {
|
||||
constructor(
|
||||
@InjectRepository(CashboxExportSubscription)
|
||||
private readonly subscriptionRepository: Repository<CashboxExportSubscription>,
|
||||
@InjectRepository(Team)
|
||||
private readonly teamRepository: Repository<Team>,
|
||||
private readonly mailService: MailService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async runDueSubscriptions(): Promise<void> {
|
||||
const today = new Date().toISOString();
|
||||
const due = await this.subscriptionRepository.find({
|
||||
where: { active: true, nextRunDate: LessThanOrEqual(today) },
|
||||
relations: ['team'],
|
||||
});
|
||||
|
||||
for (const subscription of due) {
|
||||
try {
|
||||
await this.runOne(subscription);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.logger.error({
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
details: `recurring subscription failed: subscriptionId=${subscription.id} teamId=${subscription.team.id}: ${errorMessage}`,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runOne(subscription: CashboxExportSubscription): Promise<void> {
|
||||
const team = await this.teamRepository.findOne({
|
||||
where: { id: subscription.team.id },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
if (!team) return;
|
||||
|
||||
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
|
||||
const rows = buildRows(team, from, to);
|
||||
const receivableRows = buildReceivableRows(team, from, to);
|
||||
const pdf = await buildPdf(team, rows, receivableRows, from, to);
|
||||
const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`;
|
||||
|
||||
await this.mailService.cashboxExport(
|
||||
{ to: subscription.recipients.join(', '), data: { teamName: team.name, from, to } },
|
||||
pdf,
|
||||
filename,
|
||||
);
|
||||
|
||||
subscription.nextRunDate = this.advance(subscription.nextRunDate, subscription.interval);
|
||||
await this.subscriptionRepository.save(subscription);
|
||||
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_subscription_run',
|
||||
details: `teamId=${team.id} recipients=${subscription.recipients.length} from=${from} to=${to}`,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
|
||||
private periodBounds(
|
||||
nextRunDate: string,
|
||||
interval: RecurringTransactionIntervalEnum,
|
||||
): { from: string; to: string } {
|
||||
const end = new Date(nextRunDate);
|
||||
end.setUTCDate(end.getUTCDate() - 1);
|
||||
const start = new Date(nextRunDate);
|
||||
start.setUTCMonth(start.getUTCMonth() - INTERVAL_MONTHS[interval]);
|
||||
return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
private advance(nextRunDate: string, interval: RecurringTransactionIntervalEnum): string {
|
||||
const date = new Date(nextRunDate);
|
||||
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { CashboxExportService } from './cashbox-export.service';
|
||||
|
||||
describe('CashboxExportService', () => {
|
||||
const teamRepository = { findOne: jest.fn() };
|
||||
const access = { assertAtLeast: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
let service: CashboxExportService;
|
||||
let callOrder: string[];
|
||||
|
||||
const team = {
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
|
||||
],
|
||||
players: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
callOrder = [];
|
||||
|
||||
access.assertAtLeast.mockImplementation(async () => {
|
||||
callOrder.push('assertAtLeast');
|
||||
});
|
||||
|
||||
teamRepository.findOne.mockImplementation(async () => {
|
||||
callOrder.push('findOne');
|
||||
return team;
|
||||
});
|
||||
|
||||
service = new CashboxExportService(teamRepository as any, access as any, logger as any);
|
||||
});
|
||||
|
||||
it('checks permission before loading data', async () => {
|
||||
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
42,
|
||||
5,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls assertAtLeast before findOne to enforce permission check ordering', async () => {
|
||||
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(callOrder).toEqual(['assertAtLeast', 'findOne']);
|
||||
});
|
||||
|
||||
it('skips team lookup when permission check rejects', async () => {
|
||||
access.assertAtLeast.mockRejectedValueOnce(new ForbiddenException('Insufficient permissions'));
|
||||
|
||||
await expect(
|
||||
service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(teamRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown team', async () => {
|
||||
teamRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
service.exportForUser(999, 42, '2026-08-01', '2026-08-31', 'csv'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects a range where from is after to without querying the team', async () => {
|
||||
await expect(
|
||||
service.exportForUser(5, 42, '2026-08-31', '2026-08-01', 'csv'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(access.assertAtLeast).not.toHaveBeenCalled();
|
||||
expect(teamRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds a CSV buffer with the correct content type and filename', async () => {
|
||||
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(result.contentType).toBe('text/csv; charset=utf-8');
|
||||
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.csv');
|
||||
expect(result.buffer.toString('utf-8')).toContain('Sponsoring');
|
||||
});
|
||||
|
||||
it('builds a PDF buffer with the correct content type and filename', async () => {
|
||||
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'pdf');
|
||||
|
||||
expect(result.contentType).toBe('application/pdf');
|
||||
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
|
||||
expect(result.buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('logs a cashbox_export_download event after a successful export', async () => {
|
||||
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'cashbox_export_download',
|
||||
details: 'teamId=5 format=csv from=2026-08-01 to=2026-08-31',
|
||||
userId: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CashboxExportService {
|
||||
constructor(
|
||||
@InjectRepository(Team)
|
||||
private readonly teamRepository: Repository<Team>,
|
||||
private readonly access: TeamAccessService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async exportForUser(
|
||||
teamId: number,
|
||||
userId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
format: 'csv' | 'pdf',
|
||||
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||
if (from > to) {
|
||||
throw new BadRequestException(
|
||||
'Der Startzeitraum darf nicht nach dem Endzeitraum liegen.',
|
||||
);
|
||||
}
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
teamId,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
const team = await this.teamRepository.findOne({
|
||||
where: { id: teamId },
|
||||
relations: ['players', 'players.transactions', 'transactions'],
|
||||
});
|
||||
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||
|
||||
const rows = buildRows(team, from, to);
|
||||
|
||||
if (format === 'csv') {
|
||||
const result = {
|
||||
buffer: Buffer.from(buildCsv(rows), 'utf-8'),
|
||||
contentType: 'text/csv; charset=utf-8',
|
||||
filename: `kassenbuch_${team.alias}_${from}_${to}.csv`,
|
||||
};
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_download',
|
||||
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
|
||||
userId,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
const result = {
|
||||
buffer: await buildPdf(team, rows, buildReceivableRows(team, from, to), from, to),
|
||||
contentType: 'application/pdf',
|
||||
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
|
||||
};
|
||||
await this.logger.info({
|
||||
event: 'cashbox_export_download',
|
||||
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
|
||||
userId,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||
|
||||
// Reads the page count directly out of the raw PDF bytes instead of pulling in
|
||||
// a parser dependency. Coupled to pdfkit's current /Pages dict serialization -
|
||||
// a pdfkit upgrade that reorders/reflows it could require adjusting this regex.
|
||||
function pdfPageCount(buffer: Buffer): number {
|
||||
const match = buffer.toString('latin1').match(/\/Type\s*\/Pages[\s\S]{0,80}?\/Count\s+(\d+)/);
|
||||
if (!match) throw new Error('Could not find page count in PDF buffer');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
describe('buildRows', () => {
|
||||
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
transactions: [],
|
||||
players: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('includes team-wallet credit rows as-is and negates expense rows so they reduce the budget', () => {
|
||||
// DB stores TeamWalletTransaction.amount as a positive number even for
|
||||
// expenses (see team-wallet-transaction.entity.ts setBalance()); buildRows
|
||||
// must negate expenses itself so they subtract from the running total.
|
||||
const rows = buildRows(
|
||||
team({
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Sponsoring', type: { name: 'credit' } },
|
||||
{ date: '2026-08-10T00:00:00.000Z', amount: 20, note: 'Bälle', type: { name: 'expense' } },
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Sponsoring', amount: 100, runningTotal: 100 },
|
||||
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20, runningTotal: 80 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes only "payment" player transactions, excluding fee/levy/fine', () => {
|
||||
const rows = buildRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
transactions: [
|
||||
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
|
||||
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-03T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
|
||||
const rows = buildRows(
|
||||
team({
|
||||
transactions: [
|
||||
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'credit' } },
|
||||
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'credit' } },
|
||||
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'credit' } },
|
||||
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'credit' } },
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
|
||||
});
|
||||
|
||||
it('returns an empty array when nothing falls in range', () => {
|
||||
const rows = buildRows(team() as any, '2026-08-01', '2026-08-31');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips transactions with null type and includes valid ones', () => {
|
||||
const rows = buildRows(
|
||||
team({
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Valid credit', type: { name: 'credit' } },
|
||||
{ date: '2026-08-06T00:00:00.000Z', amount: 50, note: 'Null type team wallet', type: null },
|
||||
],
|
||||
players: [
|
||||
{
|
||||
firstName: 'Bob',
|
||||
lastName: 'Smith',
|
||||
transactions: [
|
||||
{ date: '2026-08-07T00:00:00.000Z', amount: 20, note: 'Valid payment', type: { name: 'payment' } },
|
||||
{ date: '2026-08-08T00:00:00.000Z', amount: 30, note: 'Null type player', type: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Valid credit', amount: 100, runningTotal: 100 },
|
||||
{ date: '2026-08-07T00:00:00.000Z', type: 'payment', who: 'Bob Smith', note: 'Valid payment', amount: 20, runningTotal: 120 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReceivableRows', () => {
|
||||
const team = (overrides: Partial<{ players: any[] }> = {}) => ({
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'team-a',
|
||||
players: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('includes fine, levy and fee player transactions, excluding payment and credit', () => {
|
||||
const rows = buildReceivableRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
transactions: [
|
||||
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
|
||||
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 5, note: 'Zu spät', type: { name: 'fine' } },
|
||||
{ date: '2026-08-06T00:00:00.000Z', amount: 20, note: 'Umlage Trikots', type: { name: 'levy' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ date: '2026-08-04T00:00:00.000Z', type: 'fee', who: 'Alex Muster', note: 'Monatsbeitrag', amount: 15 },
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'fine', who: 'Alex Muster', note: 'Zu spät', amount: 5 },
|
||||
{ date: '2026-08-06T00:00:00.000Z', type: 'levy', who: 'Alex Muster', note: 'Umlage Trikots', amount: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
|
||||
const rows = buildReceivableRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Bob',
|
||||
lastName: 'Smith',
|
||||
transactions: [
|
||||
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'fine' } },
|
||||
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'fine' } },
|
||||
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'fee' } },
|
||||
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'levy' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
|
||||
});
|
||||
|
||||
it('skips transactions with null type and returns an empty array when nothing matches', () => {
|
||||
const rows = buildReceivableRows(
|
||||
team({
|
||||
players: [
|
||||
{
|
||||
firstName: 'Carla',
|
||||
lastName: 'Beispiel',
|
||||
transactions: [
|
||||
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Null type', type: null },
|
||||
{ date: '2026-08-06T00:00:00.000Z', amount: 10, note: 'Zahlung', type: { name: 'payment' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as any,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCsv', () => {
|
||||
it('renders the header and formatted rows with German decimals', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
||||
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20.5, runningTotal: -10.5 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Zahlung;Alex Muster;Bar bezahlt;10,00;10,00\r\n' +
|
||||
'2026-08-10;Ausgabe;Teamkasse;Bälle;-20,50;-10,50',
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes notes containing a semicolon and escapes embedded quotes', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Spende; "danke"', amount: 5, runningTotal: 5 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Guthaben;Teamkasse;"Spende; ""danke""";5,00;5,00',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a placeholder row when there are no bookings', () => {
|
||||
const csv = buildCsv([]);
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\nKeine Buchungen im gewählten Zeitraum',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats negative-zero as positive zero', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -0.001, runningTotal: 0 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Ausgabe;Teamkasse;Test;0,00;0,00',
|
||||
);
|
||||
});
|
||||
|
||||
it('rounds half-cent boundaries correctly', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Test', amount: 1.005, runningTotal: 1.005 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Zahlung;Alex Muster;Test;1,01;1,01',
|
||||
);
|
||||
});
|
||||
|
||||
it('rounds negative half-cent boundaries correctly', () => {
|
||||
const csv = buildCsv([
|
||||
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -1.005, runningTotal: -1.005 },
|
||||
]);
|
||||
|
||||
expect(csv).toBe(
|
||||
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
||||
'2026-08-05;Ausgabe;Teamkasse;Test;-1,01;-1,01',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPdf', () => {
|
||||
const cashRow = {
|
||||
date: '2026-08-05T00:00:00.000Z',
|
||||
type: 'payment',
|
||||
who: 'Alex Muster',
|
||||
note: 'Bar bezahlt',
|
||||
amount: 10,
|
||||
runningTotal: 10,
|
||||
};
|
||||
|
||||
const receivableRow = {
|
||||
date: '2026-08-06T00:00:00.000Z',
|
||||
type: 'fine',
|
||||
who: 'Bob Smith',
|
||||
note: 'Zu spät',
|
||||
amount: 5,
|
||||
};
|
||||
|
||||
it('produces a non-empty valid PDF buffer with cash rows only', async () => {
|
||||
const buffer = await buildPdf({ name: 'Team A' } as any, [cashRow], [], '2026-08-01', '2026-08-31');
|
||||
|
||||
expect(Buffer.isBuffer(buffer)).toBe(true);
|
||||
expect(buffer.length).toBeGreaterThan(100);
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('produces a valid PDF when there are only receivable rows', async () => {
|
||||
const buffer = await buildPdf({ name: 'Team A' } as any, [], [receivableRow], '2026-08-01', '2026-08-31');
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('produces a valid PDF with both cash and receivable rows', async () => {
|
||||
const buffer = await buildPdf(
|
||||
{ name: 'Team A' } as any,
|
||||
[cashRow],
|
||||
[receivableRow],
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('still produces a valid PDF when both sections are empty', async () => {
|
||||
const buffer = await buildPdf({ name: 'Team A' } as any, [], [], '2026-08-01', '2026-08-31');
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
expect(pdfPageCount(buffer)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not append blank trailing pages when content fits on a single page', async () => {
|
||||
const buffer = await buildPdf(
|
||||
{ name: 'Team A' } as any,
|
||||
[cashRow],
|
||||
[receivableRow],
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(pdfPageCount(buffer)).toBe(1);
|
||||
});
|
||||
|
||||
it('paginates correctly and stays a valid PDF for many rows', async () => {
|
||||
const manyRows = Array.from({ length: 60 }, (_, i) => ({
|
||||
...cashRow,
|
||||
date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
||||
note: `Buchung ${i}`,
|
||||
runningTotal: 10 * (i + 1),
|
||||
}));
|
||||
const manyReceivables = Array.from({ length: 60 }, (_, i) => ({
|
||||
...receivableRow,
|
||||
date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
||||
note: `Forderung ${i}`,
|
||||
}));
|
||||
|
||||
const buffer = await buildPdf(
|
||||
{ name: 'Team A' } as any,
|
||||
manyRows,
|
||||
manyReceivables,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
);
|
||||
|
||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
expect(buffer.length).toBeGreaterThan(2000);
|
||||
// Guards against the footer loop reintroducing blank trailing pages: with
|
||||
// the bug, this dataset produced 12 pages (3x the real content pages).
|
||||
expect(pdfPageCount(buffer)).toBe(4);
|
||||
});
|
||||
});
|
||||
479
myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts
Normal file
479
myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import PDFDocument = require('pdfkit');
|
||||
|
||||
export interface CashboxExportRow {
|
||||
date: string;
|
||||
type: string;
|
||||
who: string;
|
||||
note: string;
|
||||
amount: number;
|
||||
runningTotal: number;
|
||||
}
|
||||
|
||||
interface RawRow {
|
||||
date: string;
|
||||
type: string;
|
||||
who: string;
|
||||
note: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export function buildRows(team: Team, from: string, to: string): CashboxExportRow[] {
|
||||
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
||||
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
||||
|
||||
const raw: RawRow[] = [];
|
||||
|
||||
for (const transaction of team.transactions ?? []) {
|
||||
if (!transaction.type) continue;
|
||||
const amount = Number(transaction.amount);
|
||||
raw.push({
|
||||
date: transaction.date,
|
||||
type: transaction.type.name,
|
||||
who: 'Teamkasse',
|
||||
note: transaction.note,
|
||||
amount: transaction.type.name === 'expense' ? -amount : amount,
|
||||
});
|
||||
}
|
||||
|
||||
for (const player of team.players ?? []) {
|
||||
for (const transaction of player.transactions ?? []) {
|
||||
if (!transaction.type || transaction.type.name !== 'payment') continue;
|
||||
raw.push({
|
||||
date: transaction.date,
|
||||
type: transaction.type.name,
|
||||
who: `${player.firstName} ${player.lastName}`,
|
||||
note: transaction.note,
|
||||
amount: Number(transaction.amount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = raw
|
||||
.filter((row) => {
|
||||
const time = new Date(row.date).getTime();
|
||||
return time >= fromTime && time <= toTime;
|
||||
})
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
|
||||
let runningTotal = 0;
|
||||
return filtered.map((row) => {
|
||||
runningTotal += row.amount;
|
||||
return { ...row, runningTotal };
|
||||
});
|
||||
}
|
||||
|
||||
export interface CashboxReceivableRow {
|
||||
date: string;
|
||||
type: string;
|
||||
who: string;
|
||||
note: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const RECEIVABLE_TYPES = new Set(['fine', 'levy', 'fee']);
|
||||
|
||||
export function buildReceivableRows(team: Team, from: string, to: string): CashboxReceivableRow[] {
|
||||
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
||||
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
||||
|
||||
const raw: CashboxReceivableRow[] = [];
|
||||
for (const player of team.players ?? []) {
|
||||
for (const transaction of player.transactions ?? []) {
|
||||
if (!transaction.type || !RECEIVABLE_TYPES.has(transaction.type.name)) continue;
|
||||
raw.push({
|
||||
date: transaction.date,
|
||||
type: transaction.type.name,
|
||||
who: `${player.firstName} ${player.lastName}`,
|
||||
note: transaction.note,
|
||||
amount: Number(transaction.amount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return raw
|
||||
.filter((row) => {
|
||||
const time = new Date(row.date).getTime();
|
||||
return time >= fromTime && time <= toTime;
|
||||
})
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
payment: 'Zahlung',
|
||||
credit: 'Guthaben',
|
||||
expense: 'Ausgabe',
|
||||
fine: 'Strafe',
|
||||
levy: 'Umlage',
|
||||
fee: 'Gebühr',
|
||||
};
|
||||
|
||||
function formatGermanAmount(value: number): string {
|
||||
const rounded = Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * 100) / 100;
|
||||
const normalized = rounded === 0 ? 0 : rounded;
|
||||
return normalized.toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
function escapeCsvField(value: string): string {
|
||||
if (/[;"\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildCsv(rows: CashboxExportRow[]): string {
|
||||
const lines = ['Datum;Typ;Wer;Notiz;Betrag;Periodensaldo'];
|
||||
if (rows.length === 0) {
|
||||
lines.push('Keine Buchungen im gewählten Zeitraum');
|
||||
} else {
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
[
|
||||
row.date.slice(0, 10),
|
||||
TYPE_LABELS[row.type] ?? row.type,
|
||||
escapeCsvField(row.who),
|
||||
escapeCsvField(row.note),
|
||||
formatGermanAmount(row.amount),
|
||||
formatGermanAmount(row.runningTotal),
|
||||
].join(';'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 40;
|
||||
|
||||
const COLORS = {
|
||||
headerBand: '#4f8f46',
|
||||
headerText: '#ffffff',
|
||||
sectionTitle: '#3f7f3c',
|
||||
tableHeaderBg: '#e8f2e4',
|
||||
tableHeaderText: '#20251f',
|
||||
zebra: '#f7f8f2',
|
||||
border: '#dde3d8',
|
||||
text: '#20251f',
|
||||
muted: '#5b6357',
|
||||
positive: '#2e7d32',
|
||||
negative: '#c1121f',
|
||||
receivable: '#9e9e9e',
|
||||
footerText: '#8a9186',
|
||||
};
|
||||
|
||||
interface Column {
|
||||
label: string;
|
||||
width: number;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
const CASH_COLUMNS: Column[] = [
|
||||
{ label: 'Datum', width: 60 },
|
||||
{ label: 'Typ', width: 60 },
|
||||
{ label: 'Wer', width: 110 },
|
||||
{ label: 'Notiz', width: 150 },
|
||||
{ label: 'Betrag', width: 65, align: 'right' },
|
||||
{ label: 'Saldo', width: 65, align: 'right' },
|
||||
];
|
||||
|
||||
const RECEIVABLE_COLUMNS: Column[] = [
|
||||
{ label: 'Datum', width: 60 },
|
||||
{ label: 'Typ', width: 70 },
|
||||
{ label: 'Wer', width: 130 },
|
||||
{ label: 'Notiz', width: 190 },
|
||||
{ label: 'Betrag', width: 65, align: 'right' },
|
||||
];
|
||||
|
||||
const ROW_HEIGHT = 20;
|
||||
const HEADER_ROW_HEIGHT = 22;
|
||||
const CELL_PADDING = 5;
|
||||
|
||||
interface TableRow {
|
||||
cells: string[];
|
||||
cellColors?: (string | undefined)[];
|
||||
boldCells?: boolean[];
|
||||
}
|
||||
|
||||
function tableWidth(columns: Column[]): number {
|
||||
return columns.reduce((sum, col) => sum + col.width, 0);
|
||||
}
|
||||
|
||||
function formatAmount(value: number): string {
|
||||
return `${formatGermanAmount(value)} €`;
|
||||
}
|
||||
|
||||
// Character-count heuristic instead of doc.widthOfString: keeps row height
|
||||
// fixed at one line without coupling truncation to the exact font metrics
|
||||
// used at draw time.
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
function drawTableHeaderRow(doc: PDFKit.PDFDocument, columns: Column[], y: number): void {
|
||||
const width = tableWidth(columns);
|
||||
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).fill(COLORS.tableHeaderBg);
|
||||
let colX = PAGE_MARGIN;
|
||||
for (const column of columns) {
|
||||
doc
|
||||
.fillColor(COLORS.tableHeaderText)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(9)
|
||||
.text(column.label, colX + CELL_PADDING, y + 6, {
|
||||
width: column.width - CELL_PADDING * 2,
|
||||
align: column.align ?? 'left',
|
||||
lineBreak: false,
|
||||
});
|
||||
colX += column.width;
|
||||
}
|
||||
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).stroke(COLORS.border);
|
||||
}
|
||||
|
||||
function drawTable(
|
||||
doc: PDFKit.PDFDocument,
|
||||
columns: Column[],
|
||||
rows: TableRow[],
|
||||
startY: number,
|
||||
pageBottom: number,
|
||||
): number {
|
||||
const width = tableWidth(columns);
|
||||
let y = startY;
|
||||
drawTableHeaderRow(doc, columns, y);
|
||||
y += HEADER_ROW_HEIGHT;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
if (y + ROW_HEIGHT > pageBottom) {
|
||||
doc.addPage();
|
||||
y = PAGE_MARGIN;
|
||||
drawTableHeaderRow(doc, columns, y);
|
||||
y += HEADER_ROW_HEIGHT;
|
||||
}
|
||||
if (index % 2 === 1) {
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.zebra);
|
||||
}
|
||||
let colX = PAGE_MARGIN;
|
||||
row.cells.forEach((cellText, colIndex) => {
|
||||
const column = columns[colIndex];
|
||||
doc
|
||||
.fillColor(row.cellColors?.[colIndex] ?? COLORS.text)
|
||||
.font(row.boldCells?.[colIndex] ? 'Helvetica-Bold' : 'Helvetica')
|
||||
.fontSize(9)
|
||||
.text(cellText, colX + CELL_PADDING, y + 5, {
|
||||
width: column.width - CELL_PADDING * 2,
|
||||
align: column.align ?? 'left',
|
||||
lineBreak: false,
|
||||
});
|
||||
colX += column.width;
|
||||
});
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
|
||||
y += ROW_HEIGHT;
|
||||
});
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
function drawSummaryRow(
|
||||
doc: PDFKit.PDFDocument,
|
||||
columns: Column[],
|
||||
label: string,
|
||||
value: string,
|
||||
startY: number,
|
||||
pageBottom: number,
|
||||
valueColor: string,
|
||||
): number {
|
||||
let y = startY;
|
||||
if (y + ROW_HEIGHT > pageBottom) {
|
||||
doc.addPage();
|
||||
y = PAGE_MARGIN;
|
||||
}
|
||||
const width = tableWidth(columns);
|
||||
const valueColumnWidth = columns[columns.length - 1].width;
|
||||
const labelWidth = width - valueColumnWidth - CELL_PADDING * 2;
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.tableHeaderBg);
|
||||
doc
|
||||
.fillColor(COLORS.tableHeaderText)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(9)
|
||||
.text(label, PAGE_MARGIN + CELL_PADDING, y + 5, { width: labelWidth, lineBreak: false });
|
||||
doc
|
||||
.fillColor(valueColor)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(9)
|
||||
.text(value, PAGE_MARGIN + width - valueColumnWidth + CELL_PADDING, y + 5, {
|
||||
width: valueColumnWidth - CELL_PADDING * 2,
|
||||
align: 'right',
|
||||
lineBreak: false,
|
||||
});
|
||||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
|
||||
return y + ROW_HEIGHT;
|
||||
}
|
||||
|
||||
function addFooters(doc: PDFKit.PDFDocument, teamName: string): void {
|
||||
const range = doc.bufferedPageRange();
|
||||
const generatedAt = new Date().toLocaleDateString('de-DE');
|
||||
for (let i = range.start; i < range.start + range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
const footerY = doc.page.height - 25;
|
||||
// footerY sits inside the reserved bottom margin (below pdfkit's page
|
||||
// maxY()). Without an explicit `height`, pdfkit's LineWrapper measures
|
||||
// overflow against the full-page maxY() and calls addPage() here on every
|
||||
// iteration - silently appending blank trailing pages. Bounding the text
|
||||
// to its own small box (well over the 8pt single-line height needed)
|
||||
// keeps the overflow check local and stops that auto-pagination.
|
||||
doc
|
||||
.fontSize(8)
|
||||
.font('Helvetica')
|
||||
.fillColor(COLORS.footerText)
|
||||
.text(`${teamName} – Kassenbuch-Report, erstellt am ${generatedAt}`, PAGE_MARGIN, footerY, {
|
||||
width: doc.page.width - PAGE_MARGIN * 2 - 60,
|
||||
height: 20,
|
||||
lineBreak: false,
|
||||
});
|
||||
doc
|
||||
.fontSize(8)
|
||||
.fillColor(COLORS.footerText)
|
||||
.text(`Seite ${i - range.start + 1} von ${range.count}`, doc.page.width - PAGE_MARGIN - 60, footerY, {
|
||||
width: 60,
|
||||
height: 20,
|
||||
align: 'right',
|
||||
lineBreak: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPdf(
|
||||
team: Pick<Team, 'name'>,
|
||||
rows: CashboxExportRow[],
|
||||
receivableRows: CashboxReceivableRow[],
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ margin: PAGE_MARGIN, bufferPages: true, size: 'A4' });
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageBottom = doc.page.height - PAGE_MARGIN - 30;
|
||||
|
||||
doc.rect(0, 0, pageWidth, 90).fill(COLORS.headerBand);
|
||||
doc
|
||||
.fillColor(COLORS.headerText)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(20)
|
||||
.text(team.name, PAGE_MARGIN, 28, { width: pageWidth - PAGE_MARGIN * 2, lineBreak: false });
|
||||
doc.font('Helvetica').fontSize(11).text('Kassenbuch-Report', PAGE_MARGIN, 55);
|
||||
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`, PAGE_MARGIN, 70);
|
||||
|
||||
let y = 110;
|
||||
|
||||
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
|
||||
doc.text('Kassenbewegungen', PAGE_MARGIN, y);
|
||||
y += 20;
|
||||
doc
|
||||
.fontSize(9)
|
||||
.font('Helvetica')
|
||||
.fillColor(COLORS.muted)
|
||||
.text('Buchungen, die den tatsächlichen Kassenstand verändern.', PAGE_MARGIN, y);
|
||||
y += 18;
|
||||
|
||||
if (rows.length === 0) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Oblique')
|
||||
.fillColor(COLORS.muted)
|
||||
.text('Keine Buchungen im gewählten Zeitraum.', PAGE_MARGIN, y);
|
||||
y += 24;
|
||||
} else {
|
||||
const cashTableRows: TableRow[] = rows.map((row) => ({
|
||||
cells: [
|
||||
row.date.slice(0, 10),
|
||||
TYPE_LABELS[row.type] ?? row.type,
|
||||
truncate(row.who, 20),
|
||||
truncate(row.note, 26),
|
||||
formatAmount(row.amount),
|
||||
formatAmount(row.runningTotal),
|
||||
],
|
||||
cellColors: [
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
row.amount < 0 ? COLORS.negative : COLORS.positive,
|
||||
undefined,
|
||||
],
|
||||
boldCells: [false, false, false, false, false, true],
|
||||
}));
|
||||
y = drawTable(doc, CASH_COLUMNS, cashTableRows, y, pageBottom);
|
||||
|
||||
const endBalance = rows[rows.length - 1].runningTotal;
|
||||
y = drawSummaryRow(
|
||||
doc,
|
||||
CASH_COLUMNS,
|
||||
'Endsaldo Kassenbewegungen',
|
||||
formatAmount(endBalance),
|
||||
y,
|
||||
pageBottom,
|
||||
endBalance < 0 ? COLORS.negative : COLORS.positive,
|
||||
);
|
||||
y += 20;
|
||||
}
|
||||
|
||||
y += 10;
|
||||
if (y + 70 > pageBottom) {
|
||||
doc.addPage();
|
||||
y = PAGE_MARGIN;
|
||||
}
|
||||
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
|
||||
doc.text('Forderungen (Strafen, Beiträge, Umlagen)', PAGE_MARGIN, y);
|
||||
y += 20;
|
||||
doc
|
||||
.fontSize(9)
|
||||
.font('Helvetica')
|
||||
.fillColor(COLORS.muted)
|
||||
.text(
|
||||
'Im Zeitraum angelegte Forderungen gegen Mitglieder. Diese verändern den tatsächlichen Kassenstand nicht, solange sie nicht bezahlt wurden.',
|
||||
PAGE_MARGIN,
|
||||
y,
|
||||
{ width: tableWidth(RECEIVABLE_COLUMNS) },
|
||||
);
|
||||
y += 28;
|
||||
|
||||
if (receivableRows.length === 0) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Oblique')
|
||||
.fillColor(COLORS.muted)
|
||||
.text('Keine Forderungen im gewählten Zeitraum.', PAGE_MARGIN, y);
|
||||
y += 24;
|
||||
} else {
|
||||
const receivableTableRows: TableRow[] = receivableRows.map((row) => ({
|
||||
cells: [
|
||||
row.date.slice(0, 10),
|
||||
TYPE_LABELS[row.type] ?? row.type,
|
||||
truncate(row.who, 24),
|
||||
truncate(row.note, 34),
|
||||
formatAmount(row.amount),
|
||||
],
|
||||
cellColors: [undefined, undefined, undefined, undefined, COLORS.receivable],
|
||||
}));
|
||||
y = drawTable(doc, RECEIVABLE_COLUMNS, receivableTableRows, y, pageBottom);
|
||||
|
||||
const total = receivableRows.reduce((sum, row) => sum + row.amount, 0);
|
||||
y = drawSummaryRow(
|
||||
doc,
|
||||
RECEIVABLE_COLUMNS,
|
||||
'Summe Forderungen',
|
||||
formatAmount(total),
|
||||
y,
|
||||
pageBottom,
|
||||
COLORS.receivable,
|
||||
);
|
||||
}
|
||||
|
||||
addFooters(doc, team.name);
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsIn, Matches } from 'class-validator';
|
||||
|
||||
export class CashboxExportQueryDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
from: string;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
to: string;
|
||||
|
||||
@IsIn(['csv', 'pdf'])
|
||||
format: 'csv' | 'pdf';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
|
||||
export class CashboxExportSubscriptionResponseDTO {
|
||||
recipients: string[];
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
active: boolean;
|
||||
nextRunDate: string | null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsBoolean, IsEmail, IsIn } from 'class-validator';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
|
||||
export class UpsertCashboxExportSubscriptionDTO {
|
||||
@ApiProperty({ example: ['vorstand@example.com'] })
|
||||
@IsArray()
|
||||
@IsEmail({}, { each: true })
|
||||
recipients: string[];
|
||||
|
||||
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
|
||||
@IsIn(Object.values(RecurringTransactionIntervalEnum))
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
@IsBoolean()
|
||||
active: boolean;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
||||
|
||||
@Entity()
|
||||
export class CashboxExportSubscription extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@OneToOne(() => Team, { eager: false })
|
||||
@JoinColumn()
|
||||
team: Team;
|
||||
|
||||
@Column({ type: 'simple-array' })
|
||||
recipients: string[];
|
||||
|
||||
@Column()
|
||||
interval: RecurringTransactionIntervalEnum;
|
||||
|
||||
@Column({ default: false })
|
||||
active: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
nextRunDate: string | null;
|
||||
}
|
||||
@@ -8,6 +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',
|
||||
fallbackLanguage: process.env.APP_FALLBACK_LANGUAGE || 'en',
|
||||
headerLanguage: process.env.APP_HEADER_LANGUAGE || 'x-custom-lang',
|
||||
logRetentionDays: parseInt(process.env.LOG_RETENTION_DAYS, 10) || 365,
|
||||
}));
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('file', () => ({
|
||||
driver: process.env.FILE_DRIVER,
|
||||
accessKeyId: process.env.ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.SECRET_ACCESS_KEY,
|
||||
awsDefaultS3Bucket: process.env.AWS_DEFAULT_S3_BUCKET,
|
||||
awsDefaultS3Url: process.env.AWS_DEFAULT_S3_URL,
|
||||
awsS3Region: process.env.AWS_S3_REGION,
|
||||
maxFileSize: 5242880, // 5mb
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { LOGEVENT, LOGEVENT_VALUES, LOGLEVEL, LOGLEVEL_VALUES } from '../model/logging-event.type';
|
||||
|
||||
export class AdminLogQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(LOGLEVEL_VALUES)
|
||||
level?: LOGLEVEL;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(LOGEVENT_VALUES)
|
||||
event?: LOGEVENT;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit = 50;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { LessThan } from 'typeorm';
|
||||
import { LogRetentionScheduler } from './log-retention.scheduler';
|
||||
|
||||
describe('LogRetentionScheduler', () => {
|
||||
const repository = { delete: jest.fn() };
|
||||
const configService = { get: jest.fn() };
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
let scheduler: LogRetentionScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-04T12:00:00.000Z'));
|
||||
configService.get.mockReturnValue(365);
|
||||
repository.delete.mockResolvedValue({ affected: 3 });
|
||||
scheduler = new LogRetentionScheduler(repository as any, configService as any, logger as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('deletes log entries older than the configured retention window', async () => {
|
||||
await scheduler.cleanupOldLogs();
|
||||
|
||||
expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays');
|
||||
expect(repository.delete).toHaveBeenCalledWith({
|
||||
createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses whatever retention window the config reports', async () => {
|
||||
configService.get.mockReturnValue(30);
|
||||
|
||||
await scheduler.cleanupOldLogs();
|
||||
|
||||
expect(repository.delete).toHaveBeenCalledWith({
|
||||
createdAt: LessThan(new Date('2026-07-05T12:00:00.000Z')),
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the number of deleted entries', async () => {
|
||||
repository.delete.mockResolvedValue({ affected: 7 });
|
||||
|
||||
await scheduler.cleanupOldLogs();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'log_retention_cleanup_run',
|
||||
details: 'deletedCount=7 retentionDays=365',
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs and does not rethrow when the delete fails', async () => {
|
||||
repository.delete.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
await expect(scheduler.cleanupOldLogs()).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
event: 'log_retention_cleanup_run_fail',
|
||||
details: 'connection reset',
|
||||
userId: -1,
|
||||
});
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { LogEntry } from './entities/log-entry.entity';
|
||||
import { LoggingService } from './logging.service';
|
||||
|
||||
@Injectable()
|
||||
export class LogRetentionScheduler {
|
||||
constructor(
|
||||
@InjectRepository(LogEntry)
|
||||
private readonly repository: Repository<LogEntry>,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async cleanupOldLogs(): Promise<void> {
|
||||
const retentionDays = this.configService.get<number>('app.logRetentionDays');
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
|
||||
|
||||
try {
|
||||
const result = await this.repository.delete({ createdAt: LessThan(cutoff) });
|
||||
|
||||
await this.logger.info({
|
||||
event: 'log_retention_cleanup_run',
|
||||
details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`,
|
||||
userId: -1,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.logger.error({
|
||||
event: 'log_retention_cleanup_run_fail',
|
||||
details: errorMessage,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LogEntry } from './entities/log-entry.entity';
|
||||
import { LogRetentionScheduler } from './log-retention.scheduler';
|
||||
import { LoggingService } from './logging.service';
|
||||
import { LogsController } from './logs.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LogEntry])],
|
||||
providers: [LoggingService],
|
||||
controllers: [LogsController],
|
||||
providers: [LoggingService, LogRetentionScheduler],
|
||||
exports: [LoggingService],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
|
||||
@@ -1,18 +1,123 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LoggingService } from './logging.service';
|
||||
|
||||
describe('LoggingService', () => {
|
||||
let service: LoggingService;
|
||||
it('can persist an event through the caller transaction manager', async () => {
|
||||
const defaultRepository = { save: jest.fn() };
|
||||
const transactionRepository = { save: jest.fn() };
|
||||
const manager = {
|
||||
getRepository: jest.fn(() => transactionRepository),
|
||||
} as any;
|
||||
const service = new LoggingService(defaultRepository as any);
|
||||
const event = {
|
||||
event: 'admin_user_profile_update' as const,
|
||||
details: 'targetUserId=2',
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [LoggingService],
|
||||
}).compile();
|
||||
await service.info(event, manager);
|
||||
|
||||
service = module.get<LoggingService>(LoggingService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(transactionRepository.save).toHaveBeenCalledWith({
|
||||
...event,
|
||||
level: 'INFO',
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from '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 {
|
||||
@@ -23,22 +23,25 @@ export class LoggingService {
|
||||
});
|
||||
}
|
||||
|
||||
async info({
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
}: {
|
||||
event: LOGEVENT;
|
||||
details: string;
|
||||
userId: number;
|
||||
}) {
|
||||
async info(
|
||||
{
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
}: {
|
||||
event: LOGEVENT;
|
||||
details: string;
|
||||
userId: number;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
) {
|
||||
const e: CreateLogDTO = {
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
level: 'INFO',
|
||||
};
|
||||
await this.repository.save(e);
|
||||
await (manager?.getRepository(LogEntry) ?? this.repository).save(e);
|
||||
}
|
||||
|
||||
async error({
|
||||
@@ -92,4 +95,45 @@ export class LoggingService {
|
||||
};
|
||||
await this.repository.save(e);
|
||||
}
|
||||
|
||||
async findLogs(query: {
|
||||
page: number;
|
||||
limit: number;
|
||||
level?: LOGLEVEL;
|
||||
event?: LOGEVENT;
|
||||
from?: string;
|
||||
to?: string;
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
data: LogEntry[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}> {
|
||||
const builder = this.repository.createQueryBuilder('log');
|
||||
if (query.level) builder.andWhere('log.level = :level', { level: query.level });
|
||||
if (query.event) builder.andWhere('log.event = :event', { event: query.event });
|
||||
if (query.from) builder.andWhere('log.createdAt >= :from', { from: query.from });
|
||||
if (query.to) {
|
||||
builder.andWhere('log.createdAt <= :to', { to: new Date(`${query.to}T23:59:59.999Z`) });
|
||||
}
|
||||
const term = query.search?.trim().toLocaleLowerCase();
|
||||
if (term) {
|
||||
builder.andWhere('LOWER(log.details) LIKE :search', { search: `%${term}%` });
|
||||
}
|
||||
const total = await builder.getCount();
|
||||
const data = await builder
|
||||
.orderBy('log.createdAt', 'DESC')
|
||||
.offset((query.page - 1) * query.limit)
|
||||
.limit(query.limit)
|
||||
.getMany();
|
||||
return {
|
||||
data,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total,
|
||||
hasNextPage: query.page * query.limit < total,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { RoleEnum } from '../../roles/roles.enum';
|
||||
import { RolesGuard } from '../../roles/roles.guard';
|
||||
import { AdminLogQueryDto } from './dto/admin-log-query.dto';
|
||||
import { LogsController } from './logs.controller';
|
||||
|
||||
describe('LogsController', () => {
|
||||
const service = { findLogs: jest.fn() };
|
||||
const controller = new LogsController(service as any);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('uses a separate versioned admin/logs controller guarded by the global admin role', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, LogsController)).toBe('admin/logs');
|
||||
expect(Reflect.getMetadata('roles', LogsController)).toEqual([RoleEnum.admin]);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, LogsController)).toContain(RolesGuard);
|
||||
});
|
||||
|
||||
it('passes the query straight through to the service', async () => {
|
||||
const query = { page: 2, limit: 50, level: 'ERROR' as const };
|
||||
|
||||
await controller.findLogs(query as any);
|
||||
|
||||
expect(service.findLogs).toHaveBeenCalledWith(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminLogQueryDto', () => {
|
||||
it('defaults page and limit when omitted', async () => {
|
||||
const dto = plainToInstance(AdminLogQueryDto, {});
|
||||
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
expect(dto).toMatchObject({ page: 1, limit: 50 });
|
||||
});
|
||||
|
||||
it('accepts valid level, event, and date-range filters', async () => {
|
||||
const dto = plainToInstance(AdminLogQueryDto, {
|
||||
level: 'ERROR',
|
||||
event: 'cashbox_export_subscription_run_fail',
|
||||
from: '2026-01-01',
|
||||
to: '2026-01-31',
|
||||
search: 'teamId=5',
|
||||
page: '2',
|
||||
limit: '100',
|
||||
});
|
||||
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
expect(dto).toMatchObject({ page: 2, limit: 100 });
|
||||
});
|
||||
|
||||
it('rejects an unknown level or event value', async () => {
|
||||
const level = plainToInstance(AdminLogQueryDto, { level: 'NOPE' });
|
||||
const event = plainToInstance(AdminLogQueryDto, { event: 'not_a_real_event' });
|
||||
|
||||
expect(await validate(level)).not.toEqual([]);
|
||||
expect(await validate(event)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a limit above the maximum', async () => {
|
||||
const dto = plainToInstance(AdminLogQueryDto, { limit: 500 });
|
||||
|
||||
expect(await validate(dto)).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
21
myteamwallet_backend/src/database/logging/logs.controller.ts
Normal file
21
myteamwallet_backend/src/database/logging/logs.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Roles } from '../../roles/roles.decorator';
|
||||
import { RoleEnum } from '../../roles/roles.enum';
|
||||
import { RolesGuard } from '../../roles/roles.guard';
|
||||
import { AdminLogQueryDto } from './dto/admin-log-query.dto';
|
||||
import { LoggingService } from './logging.service';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles([RoleEnum.admin])
|
||||
@Controller({ path: 'admin/logs', version: '1' })
|
||||
export class LogsController {
|
||||
constructor(private readonly loggingService: LoggingService) {}
|
||||
|
||||
@Get()
|
||||
findLogs(@Query() query: AdminLogQueryDto) {
|
||||
return this.loggingService.findLogs(query);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,83 @@ export type LOGEVENT =
|
||||
| 'transaction_create_fail'
|
||||
| 'transaction_reverse'
|
||||
| 'player_creation'
|
||||
| 'team_create';
|
||||
| '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 const LOGEVENT_VALUES: LOGEVENT[] = [
|
||||
'user_create',
|
||||
'application_start',
|
||||
'transaction_create',
|
||||
'team_transaction_create',
|
||||
'team_transaction_get',
|
||||
'user_login_success',
|
||||
'user_login_fail',
|
||||
'user_token_verification_success',
|
||||
'user_token_verification_fail',
|
||||
'user_invite_link_create',
|
||||
'user_invite_link_validate',
|
||||
'user_invite_link_validate_fail',
|
||||
'transaction_create_fail',
|
||||
'transaction_reverse',
|
||||
'player_creation',
|
||||
'admin_user_profile_update',
|
||||
'admin_user_role_update',
|
||||
'admin_user_status_update',
|
||||
'admin_player_assign',
|
||||
'admin_player_unlink',
|
||||
'player_active_update',
|
||||
'player_team_role_update',
|
||||
'penalty_catalog_create',
|
||||
'penalty_catalog_update',
|
||||
'penalty_catalog_delete',
|
||||
'team_create',
|
||||
'team_permissions_update',
|
||||
'scheduled_recurring_transaction_check_start',
|
||||
'scheduled_recurring_transaction_check_finished',
|
||||
'recurring_transaction_create',
|
||||
'recurring_transaction_update',
|
||||
'recurring_transaction_delete',
|
||||
'recurring_transaction_run',
|
||||
'cashbox_export_download',
|
||||
'cashbox_export_subscription_update',
|
||||
'cashbox_export_subscription_run',
|
||||
'cashbox_export_subscription_run_fail',
|
||||
'log_retention_cleanup_run',
|
||||
'log_retention_cleanup_run_fail',
|
||||
'notification_create_fail',
|
||||
'notification_retention_cleanup_run',
|
||||
'notification_retention_cleanup_run_fail',
|
||||
'public_access_enabled',
|
||||
'public_access_rotated',
|
||||
];
|
||||
|
||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
export const LOGLEVEL_VALUES: LOGLEVEL[] = ['FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPlayerLookupIndexes1785517200000 implements MigrationInterface {
|
||||
name = 'AddPlayerLookupIndexes1785517200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX "IDX_player_user_id"');
|
||||
await queryRunner.query('DROP INDEX "IDX_player_team_id"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddHelpTextsEnabled1785520800000 implements MigrationInterface {
|
||||
name = 'AddHelpTextsEnabled1785520800000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "user" ADD "helpTextsEnabled" boolean NOT NULL DEFAULT true',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "user" DROP COLUMN "helpTextsEnabled"',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
const BACKFILLED_KEYS = [
|
||||
'invite_min_role',
|
||||
'member_manage_min_role',
|
||||
'penalty_manage_min_role',
|
||||
'public_access_manage_min_role',
|
||||
] as const;
|
||||
|
||||
export class AddTeamPermissionSettings1785524400000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddTeamPermissionSettings1785524400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const key of BACKFILLED_KEYS) {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "team_setting" ("teamId", "key", "value")
|
||||
SELECT "id", '${key}', '3' FROM "team"
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "team_setting" ts
|
||||
WHERE ts."teamId" = "team"."id" AND ts."key" = '${key}'
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "team_setting" ("teamId", "key", "value")
|
||||
SELECT "teamId", 'transaction_reverse_min_role', "value" FROM "team_setting"
|
||||
WHERE "key" = 'transaction_create_min_role'
|
||||
AND "teamId" NOT IN (
|
||||
SELECT "teamId" FROM "team_setting" WHERE "key" = 'transaction_reverse_min_role'
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "team_setting" WHERE "key" IN (
|
||||
'transaction_reverse_min_role',
|
||||
'invite_min_role',
|
||||
'member_manage_min_role',
|
||||
'penalty_manage_min_role',
|
||||
'public_access_manage_min_role'
|
||||
)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddNotificationTables1785600000000 implements MigrationInterface {
|
||||
name = 'AddNotificationTables1785600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "notification" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"teamId" integer NOT NULL,
|
||||
"event" character varying NOT NULL,
|
||||
"actorUserId" integer NOT NULL,
|
||||
"payload" text NOT NULL,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_notification_id" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_notification_team_id" ON "notification" ("teamId")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "notification"
|
||||
ADD CONSTRAINT "FK_notification_team"
|
||||
FOREIGN KEY ("teamId") REFERENCES "team"("id")
|
||||
ON DELETE CASCADE
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "notification_recipient" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"notificationId" integer NOT NULL,
|
||||
"userId" integer NOT NULL,
|
||||
"read" boolean NOT NULL DEFAULT false,
|
||||
"readAt" TIMESTAMP,
|
||||
CONSTRAINT "PK_notification_recipient_id" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_notification_recipient_notification_id" ON "notification_recipient" ("notificationId")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_notification_recipient_user_id" ON "notification_recipient" ("userId")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "notification_recipient"
|
||||
ADD CONSTRAINT "FK_notification_recipient_notification"
|
||||
FOREIGN KEY ("notificationId") REFERENCES "notification"("id")
|
||||
ON DELETE CASCADE
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "notification_recipient"`);
|
||||
await queryRunner.query(`DROP TABLE "notification"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
describe('AddHelpTextsEnabled1785520800000', () => {
|
||||
it('adds a reversible enabled-by-default user preference', async () => {
|
||||
const migrationModule = require('./1785520800000-AddHelpTextsEnabled');
|
||||
const migration = new migrationModule.AddHelpTextsEnabled1785520800000();
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
|
||||
await migration.up(queryRunner);
|
||||
expect(queryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "user" ADD "helpTextsEnabled" boolean NOT NULL DEFAULT true',
|
||||
);
|
||||
|
||||
queryRunner.query.mockClear();
|
||||
await migration.down(queryRunner);
|
||||
expect(queryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "user" DROP COLUMN "helpTextsEnabled"',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the user entity default aligned with the migration', () => {
|
||||
const column = getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === User &&
|
||||
candidate.propertyName === 'helpTextsEnabled',
|
||||
);
|
||||
|
||||
expect(column?.options).toMatchObject({ type: Boolean, default: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
describe('AddNotificationTables1785600000000', () => {
|
||||
it('creates the notification and notification_recipient tables with their indexes and foreign keys', async () => {
|
||||
const migrationModule = require('./1785600000000-AddNotificationTables');
|
||||
const migration = new migrationModule.AddNotificationTables1785600000000();
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
|
||||
await migration.up(queryRunner);
|
||||
|
||||
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls).toHaveLength(7);
|
||||
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification"'))).toBe(true);
|
||||
expect(calls.some((sql) => sql.includes('CREATE TABLE "notification_recipient"'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(calls.some((sql) => sql.includes('IDX_notification_team_id'))).toBe(true);
|
||||
expect(
|
||||
calls.some((sql) => sql.includes('IDX_notification_recipient_notification_id')),
|
||||
).toBe(true);
|
||||
expect(calls.some((sql) => sql.includes('IDX_notification_recipient_user_id'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(calls.some((sql) => sql.includes('FK_notification_team'))).toBe(true);
|
||||
expect(calls.some((sql) => sql.includes('FK_notification_recipient_notification'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('drops both tables on down, recipient first to respect the foreign key', async () => {
|
||||
const migrationModule = require('./1785600000000-AddNotificationTables');
|
||||
const migration = new migrationModule.AddNotificationTables1785600000000();
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
|
||||
await migration.down(queryRunner);
|
||||
|
||||
const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls).toEqual(['DROP TABLE "notification_recipient"', 'DROP TABLE "notification"']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { Player } from '../../players/entities/player.entity';
|
||||
import { AddPlayerLookupIndexes1785517200000 } from './1785517200000-AddPlayerLookupIndexes';
|
||||
|
||||
describe('AddPlayerLookupIndexes1785517200000', () => {
|
||||
it('adds reversible indexes for team and user foreign-key lookups', async () => {
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
const migration = new AddPlayerLookupIndexes1785517200000();
|
||||
|
||||
await migration.up(queryRunner);
|
||||
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
|
||||
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
|
||||
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
|
||||
]);
|
||||
|
||||
queryRunner.query.mockClear();
|
||||
await migration.down(queryRunner);
|
||||
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
|
||||
'DROP INDEX "IDX_player_user_id"',
|
||||
'DROP INDEX "IDX_player_team_id"',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps entity index metadata aligned with the migration', () => {
|
||||
const playerIndexes = getMetadataArgsStorage()
|
||||
.indices.filter((index) => index.target === Player)
|
||||
.map((index) => index.name);
|
||||
|
||||
expect(playerIndexes).toEqual(
|
||||
expect.arrayContaining(['IDX_player_team_id', 'IDX_player_user_id']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
describe('AddTeamPermissionSettings1785524400000', () => {
|
||||
it('backfills the new min-role settings for existing teams idempotently', async () => {
|
||||
const migrationModule = require('./1785524400000-AddTeamPermissionSettings');
|
||||
const migration =
|
||||
new migrationModule.AddTeamPermissionSettings1785524400000();
|
||||
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(5);
|
||||
for (const key of [
|
||||
'invite_min_role',
|
||||
'member_manage_min_role',
|
||||
'penalty_manage_min_role',
|
||||
'public_access_manage_min_role',
|
||||
]) {
|
||||
expect(
|
||||
calls.some(
|
||||
(sql) => sql.includes(`'${key}'`) && sql.includes('NOT EXISTS'),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
calls.some(
|
||||
(sql) =>
|
||||
sql.includes("'transaction_reverse_min_role'") &&
|
||||
sql.includes("'transaction_create_min_role'"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the new min-role settings again on down', async () => {
|
||||
const migrationModule = require('./1785524400000-AddTeamPermissionSettings');
|
||||
const migration =
|
||||
new migrationModule.AddTeamPermissionSettings1785524400000();
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
|
||||
await migration.down(queryRunner);
|
||||
|
||||
expect(queryRunner.query).toHaveBeenCalledTimes(1);
|
||||
const sql = queryRunner.query.mock.calls[0][0];
|
||||
expect(sql).toContain('DELETE FROM "team_setting"');
|
||||
expect(sql).toContain('transaction_reverse_min_role');
|
||||
expect(sql).toContain('invite_min_role');
|
||||
expect(sql).toContain('member_manage_min_role');
|
||||
expect(sql).toContain('penalty_manage_min_role');
|
||||
expect(sql).toContain('public_access_manage_min_role');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { HomeService } from './home.service';
|
||||
|
||||
@ApiTags('Home')
|
||||
@Controller()
|
||||
export class HomeController {
|
||||
constructor(private service: HomeService) {}
|
||||
|
||||
@Get()
|
||||
appInfo() {
|
||||
return this.service.appInfo();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HomeService } from './home.service';
|
||||
import { HomeController } from './home.controller';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [HomeController],
|
||||
providers: [HomeService],
|
||||
})
|
||||
export class HomeModule {}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class HomeService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
appInfo() {
|
||||
return { name: this.configService.get('app.name') };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"confirmEmail": "Confirm email",
|
||||
"resetPassword": "Reset password"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"text1": "Hey!",
|
||||
"text2": "You’re almost ready to start enjoying",
|
||||
"text3": "Simply click the big green button below to verify your email address."
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"text1": "Trouble signing in?",
|
||||
"text2": "Resetting your password is easy.",
|
||||
"text3": "Just press the button below and follow the instructions. We’ll have you up and running in no time.",
|
||||
"text4": "If you did not make this request then please ignore this email."
|
||||
}
|
||||
56
myteamwallet_backend/src/mail/mail-config.service.spec.ts
Normal file
56
myteamwallet_backend/src/mail/mail-config.service.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as path from 'path';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailConfigService } from './mail-config.service';
|
||||
|
||||
describe('MailConfigService integration', () => {
|
||||
it('produces mailer options whose adapter actually renders the shared layout partial', (done) => {
|
||||
const workingDirectory = path.join(__dirname, '..', '..');
|
||||
const configValues: Record<string, unknown> = {
|
||||
'app.workingDirectory': workingDirectory,
|
||||
'mail.host': 'localhost',
|
||||
'mail.port': 1025,
|
||||
'mail.ignoreTLS': true,
|
||||
'mail.secure': false,
|
||||
'mail.requireTLS': false,
|
||||
'mail.user': '',
|
||||
'mail.password': '',
|
||||
'mail.defaultName': 'TeamWallet',
|
||||
'mail.defaultEmail': 'test@example.com',
|
||||
};
|
||||
const configService = {
|
||||
get: (key: string) => configValues[key],
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const options = new MailConfigService(configService).createMailerOptions();
|
||||
|
||||
const mail: {
|
||||
data: {
|
||||
template: string;
|
||||
context: Record<string, unknown>;
|
||||
html?: string;
|
||||
};
|
||||
} = {
|
||||
data: {
|
||||
template: 'activation',
|
||||
context: {
|
||||
title: 'Test',
|
||||
year: 2026,
|
||||
firstName: 'Max',
|
||||
url: 'https://example.com/confirm-email/abc',
|
||||
actionTitle: 'Jetzt bestätigen',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
options.template.adapter.compile(
|
||||
mail,
|
||||
(err?: Error) => {
|
||||
expect(err).toBeUndefined();
|
||||
expect(mail.data.html).toContain('TeamWallet');
|
||||
expect(mail.data.html).toContain('tw-wordmark');
|
||||
done();
|
||||
},
|
||||
options,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as handlebars from 'handlebars';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailerOptions, MailerOptionsFactory } from '@nestjs-modules/mailer';
|
||||
@@ -9,6 +11,19 @@ export class MailConfigService implements MailerOptionsFactory {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
createMailerOptions(): MailerOptions {
|
||||
// __dirname resolves to src/mail in dev (ts-node) and dist/mail in the
|
||||
// built image, so this stays correct without depending on whether the
|
||||
// process was started from a source or compiled checkout.
|
||||
const templatesDir = path.join(__dirname, 'mail-templates');
|
||||
|
||||
handlebars.registerPartial(
|
||||
'layout',
|
||||
fs.readFileSync(
|
||||
path.join(templatesDir, 'partials', 'layout.hbs'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
transport: {
|
||||
host: this.configService.get('mail.host'),
|
||||
@@ -27,12 +42,7 @@ export class MailConfigService implements MailerOptionsFactory {
|
||||
)}" <${this.configService.get('mail.defaultEmail')}>`,
|
||||
},
|
||||
template: {
|
||||
dir: path.join(
|
||||
this.configService.get('app.workingDirectory'),
|
||||
'src',
|
||||
'mail',
|
||||
'mail-templates',
|
||||
),
|
||||
dir: templatesDir,
|
||||
adapter: new HandlebarsAdapter(),
|
||||
options: {
|
||||
strict: true,
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=">
|
||||
<title>{{title}}</title>
|
||||
</head>
|
||||
|
||||
<body style="margin:0;font-family:arial">
|
||||
<table style="border:0;width:100%">
|
||||
<tr style="background:#eeeeee">
|
||||
<td style="padding:20px;color:#808080;text-align:center;font-size:40px;font-weight:600">
|
||||
{{app_name}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px;color:#808080;font-size:16px;font-weight:100">
|
||||
{{text1}}<br>
|
||||
{{text2}} {{app_name}}.<br>
|
||||
{{text3}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">
|
||||
<a href="{{url}}"
|
||||
style="display:inline-block;padding:20px;background:#00838f;text-decoration:none;color:#ffffff">{{actionTitle}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{#> layout}}
|
||||
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||
<p>schön, dass du bei TeamWallet dabei bist! Bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.</p>
|
||||
<div class="tw-button-row">
|
||||
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||
</div>
|
||||
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||
{{/layout}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
|
||||
describe('mail templates rendering', () => {
|
||||
const templatesDir = __dirname;
|
||||
|
||||
beforeAll(() => {
|
||||
const layoutSource = fs.readFileSync(
|
||||
path.join(templatesDir, 'partials', 'layout.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
Handlebars.registerPartial('layout', layoutSource);
|
||||
});
|
||||
|
||||
const baseContext = {
|
||||
title: 'Test-Betreff',
|
||||
year: 2026,
|
||||
firstName: 'Max',
|
||||
url: 'https://app.example.com/confirm-email/abc123',
|
||||
actionTitle: 'Jetzt bestätigen',
|
||||
};
|
||||
|
||||
it('renders activation.hbs with greeting, link and button text', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(templatesDir, 'activation.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
const html = Handlebars.compile(source, { strict: true })(baseContext);
|
||||
|
||||
expect(html).toContain('TeamWallet');
|
||||
expect(html).toContain('Hallo Max,');
|
||||
expect(html).toContain(baseContext.url);
|
||||
expect(html).toContain(baseContext.actionTitle);
|
||||
});
|
||||
|
||||
it('renders reset-password.hbs with greeting, link and button text', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(templatesDir, 'reset-password.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
const html = Handlebars.compile(source, { strict: true })(baseContext);
|
||||
|
||||
expect(html).toContain('TeamWallet');
|
||||
expect(html).toContain('Hallo Max,');
|
||||
expect(html).toContain(baseContext.url);
|
||||
expect(html).toContain(baseContext.actionTitle);
|
||||
});
|
||||
|
||||
it('falls back to a generic greeting when firstName is missing', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(templatesDir, 'activation.hbs'),
|
||||
'utf-8',
|
||||
);
|
||||
const html = Handlebars.compile(source, { strict: true })({
|
||||
...baseContext,
|
||||
firstName: undefined,
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; background: #f4f6f4; font-family: Roboto, Helvetica, Arial, sans-serif; }
|
||||
.tw-container { max-width: 600px; margin: 0 auto; padding: 32px 16px; }
|
||||
.tw-card { background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); }
|
||||
.tw-header { background: #2e7d32; padding: 28px 32px; text-align: center; }
|
||||
.tw-wordmark { color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: 0.5px; }
|
||||
.tw-body { padding: 32px; color: #20251f; font-size: 15px; line-height: 1.6; }
|
||||
.tw-body p { margin: 0 0 16px; }
|
||||
.tw-button-row { text-align: center; padding: 8px 0 24px; }
|
||||
.tw-button { display: inline-block; background: #2e7d32; color: #ffffff !important; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 15px; }
|
||||
.tw-footer { text-align: center; padding: 20px 16px 0; color: #8a938a; font-size: 12px; line-height: 1.6; }
|
||||
.tw-footer a { color: #8a938a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tw-container">
|
||||
<div class="tw-card">
|
||||
<div class="tw-header">
|
||||
<span class="tw-wordmark">TeamWallet</span>
|
||||
</div>
|
||||
<div class="tw-body">
|
||||
{{> @partial-block }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-footer">
|
||||
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt.<br>© {{year}} TeamWallet</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,38 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=">
|
||||
<title>{{title}}</title>
|
||||
</head>
|
||||
|
||||
<body style="margin:0;font-family:arial">
|
||||
<table style="border:0;width:100%">
|
||||
<tr style="background:#eeeeee">
|
||||
<td style="padding:20px;color:#808080;text-align:center;font-size:40px;font-weight:600">
|
||||
{{app_name}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px;color:#808080;font-size:16px;font-weight:100">
|
||||
{{text1}}<br>
|
||||
{{text2}}<br>
|
||||
{{text3}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">
|
||||
<a href="{{url}}"
|
||||
style="display:inline-block;padding:20px;background:#00838f;text-decoration:none;color:#ffffff">{{actionTitle}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px;color:#808080;font-size:16px;font-weight:100">
|
||||
{{text4}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{#> layout}}
|
||||
<p>Hallo{{#if firstName}} {{firstName}}{{/if}},</p>
|
||||
<p>du hast angefragt, dein TeamWallet-Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort zu vergeben.</p>
|
||||
<div class="tw-button-row">
|
||||
<a class="tw-button" href="{{url}}">{{actionTitle}}</a>
|
||||
</div>
|
||||
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren — es wird nichts verändert.</p>
|
||||
<p>Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br><a href="{{url}}">{{url}}</a></p>
|
||||
{{/layout}}
|
||||
|
||||
85
myteamwallet_backend/src/mail/mail.service.spec.ts
Normal file
85
myteamwallet_backend/src/mail/mail.service.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
describe('MailService', () => {
|
||||
let service: MailService;
|
||||
let sendMail: jest.Mock;
|
||||
let configGet: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendMail = jest.fn().mockResolvedValue(undefined);
|
||||
configGet = jest.fn().mockReturnValue('https://app.example.com');
|
||||
|
||||
service = new MailService(
|
||||
{ sendMail } as unknown as MailerService,
|
||||
{ get: configGet } as unknown as ConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the activation mail with the confirm-email link', async () => {
|
||||
await service.userSignUp({
|
||||
to: 'user@example.com',
|
||||
data: { hash: 'abc123', firstName: 'Max' },
|
||||
});
|
||||
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.to).toBe('user@example.com');
|
||||
expect(call.template).toBe('activation');
|
||||
expect(call.context.url).toBe(
|
||||
'https://app.example.com/confirm-email/abc123',
|
||||
);
|
||||
expect(call.context.firstName).toBe('Max');
|
||||
});
|
||||
|
||||
it('sends the reset-password mail with the password-change link', async () => {
|
||||
await service.forgotPassword({
|
||||
to: 'user@example.com',
|
||||
data: { hash: 'xyz789', firstName: 'Erika' },
|
||||
});
|
||||
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.to).toBe('user@example.com');
|
||||
expect(call.template).toBe('reset-password');
|
||||
expect(call.context.url).toBe(
|
||||
'https://app.example.com/password-change/xyz789',
|
||||
);
|
||||
expect(call.context.firstName).toBe('Erika');
|
||||
});
|
||||
|
||||
it('works without a firstName (optional personalization)', async () => {
|
||||
await service.userSignUp({
|
||||
to: 'user@example.com',
|
||||
data: { hash: 'abc123' },
|
||||
});
|
||||
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +1,78 @@
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { I18n, I18nRequestScopeService } from 'nestjs-i18n';
|
||||
import { MailData } from './interfaces/mail-data.interface';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
@I18n()
|
||||
private i18n: I18nRequestScopeService,
|
||||
private mailerService: MailerService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async userSignUp(mailData: MailData<{ hash: string }>) {
|
||||
return;
|
||||
async userSignUp(
|
||||
mailData: MailData<{ hash: string; firstName?: string | null }>,
|
||||
) {
|
||||
const actionTitle = 'E-Mail bestätigen';
|
||||
const url = `${this.configService.get('app.frontendDomain')}/confirm-email/${
|
||||
mailData.data.hash
|
||||
}`;
|
||||
|
||||
await this.mailerService.sendMail({
|
||||
to: mailData.to,
|
||||
subject: await this.i18n.t('common.confirmEmail'),
|
||||
text: `${this.configService.get('app.frontendDomain')}/confirm-email/${
|
||||
mailData.data.hash
|
||||
} ${await this.i18n.t('common.confirmEmail')}`,
|
||||
subject: 'Bestätige deine E-Mail-Adresse',
|
||||
text: `${url} ${actionTitle}`,
|
||||
template: 'activation',
|
||||
context: {
|
||||
title: await this.i18n.t('common.confirmEmail'),
|
||||
url: `${this.configService.get('app.frontendDomain')}/confirm-email/${
|
||||
mailData.data.hash
|
||||
}`,
|
||||
actionTitle: await this.i18n.t('common.confirmEmail'),
|
||||
app_name: this.configService.get('app.name'),
|
||||
text1: await this.i18n.t('confirm-email.text1'),
|
||||
text2: await this.i18n.t('confirm-email.text2'),
|
||||
text3: await this.i18n.t('confirm-email.text3'),
|
||||
title: 'Bestätige deine E-Mail-Adresse',
|
||||
year: new Date().getFullYear(),
|
||||
firstName: mailData.data.firstName,
|
||||
url,
|
||||
actionTitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async forgotPassword(mailData: MailData<{ hash: string }>) {
|
||||
return;
|
||||
async forgotPassword(
|
||||
mailData: MailData<{ hash: string; firstName?: string | null }>,
|
||||
) {
|
||||
const actionTitle = 'Passwort zurücksetzen';
|
||||
const url = `${this.configService.get('app.frontendDomain')}/password-change/${
|
||||
mailData.data.hash
|
||||
}`;
|
||||
|
||||
await this.mailerService.sendMail({
|
||||
to: mailData.to,
|
||||
subject: await this.i18n.t('common.resetPassword'),
|
||||
text: `${this.configService.get('app.frontendDomain')}/password-change/${
|
||||
mailData.data.hash
|
||||
} ${await this.i18n.t('common.resetPassword')}`,
|
||||
subject: actionTitle,
|
||||
text: `${url} ${actionTitle}`,
|
||||
template: 'reset-password',
|
||||
context: {
|
||||
title: await this.i18n.t('common.resetPassword'),
|
||||
url: `${this.configService.get('app.frontendDomain')}/password-change/${
|
||||
mailData.data.hash
|
||||
}`,
|
||||
actionTitle: await this.i18n.t('common.resetPassword'),
|
||||
app_name: this.configService.get('app.name'),
|
||||
text1: await this.i18n.t('reset-password.text1'),
|
||||
text2: await this.i18n.t('reset-password.text2'),
|
||||
text3: await this.i18n.t('reset-password.text3'),
|
||||
text4: await this.i18n.t('reset-password.text4'),
|
||||
title: actionTitle,
|
||||
year: new Date().getFullYear(),
|
||||
firstName: mailData.data.firstName,
|
||||
url,
|
||||
actionTitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async cashboxExport(
|
||||
mailData: MailData<{ teamName: string; from: string; to: string }>,
|
||||
attachment: Buffer,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
await this.mailerService.sendMail({
|
||||
to: mailData.to,
|
||||
subject: `Kassenbuch-Export ${mailData.data.teamName}`,
|
||||
template: 'cashbox-export',
|
||||
context: {
|
||||
title: `Kassenbuch-Export ${mailData.data.teamName}`,
|
||||
year: new Date().getFullYear(),
|
||||
teamName: mailData.data.teamName,
|
||||
from: mailData.data.from,
|
||||
to: mailData.data.to,
|
||||
},
|
||||
attachments: [{ filename, content: attachment }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class NotificationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { Notification } from './notification.entity';
|
||||
|
||||
@Entity()
|
||||
export class NotificationRecipient extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index('IDX_notification_recipient_notification_id')
|
||||
@ManyToOne(() => Notification, { onDelete: 'CASCADE' })
|
||||
notification: Notification;
|
||||
|
||||
@Index('IDX_notification_recipient_user_id')
|
||||
@Column()
|
||||
userId: number;
|
||||
|
||||
@Column({ default: false })
|
||||
read: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
readAt: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { NOTIFICATION_EVENT } from '../model/notification-event.type';
|
||||
|
||||
@Entity()
|
||||
export class Notification extends EntityHelper {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index('IDX_notification_team_id')
|
||||
@ManyToOne(() => Team, { eager: false })
|
||||
team: Team;
|
||||
|
||||
@Column()
|
||||
event: NOTIFICATION_EVENT;
|
||||
|
||||
@Column()
|
||||
actorUserId: number;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
payload: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class InviteLinkCreatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly teamName: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const NOTIFICATION_EVENT_NAME = {
|
||||
playerActiveChanged: 'notifications.player.active_changed',
|
||||
playerRoleChanged: 'notifications.player.role_changed',
|
||||
playerCreated: 'notifications.player.created',
|
||||
publicAccessEnabled: 'notifications.public_access.enabled',
|
||||
publicAccessRotated: 'notifications.public_access.rotated',
|
||||
inviteLinkCreated: 'notifications.invite_link.created',
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
export class PlayerActiveChangedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly playerId: number,
|
||||
public readonly playerName: string,
|
||||
public readonly active: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class PlayerCreatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly playerId: number,
|
||||
public readonly playerName: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class PlayerRoleChangedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
public readonly playerId: number,
|
||||
public readonly playerName: string,
|
||||
public readonly teamRoleId: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class PublicAccessEnabledEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class PublicAccessRotatedEvent {
|
||||
constructor(
|
||||
public readonly teamId: number,
|
||||
public readonly actorUserId: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type NOTIFICATION_EVENT =
|
||||
| 'player_active_update'
|
||||
| 'player_team_role_update'
|
||||
| 'player_creation'
|
||||
| 'public_access_enabled'
|
||||
| 'public_access_rotated'
|
||||
| 'user_invite_link_create';
|
||||
|
||||
export const NOTIFICATION_EVENT_VALUES: NOTIFICATION_EVENT[] = [
|
||||
'player_active_update',
|
||||
'player_team_role_update',
|
||||
'player_creation',
|
||||
'public_access_enabled',
|
||||
'public_access_rotated',
|
||||
'user_invite_link_create',
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LessThan } from 'typeorm';
|
||||
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
|
||||
|
||||
describe('NotificationRetentionScheduler', () => {
|
||||
const repository = { delete: jest.fn() };
|
||||
const configService = { get: jest.fn() };
|
||||
const logger = { info: jest.fn(), error: jest.fn() };
|
||||
let scheduler: NotificationRetentionScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-08-04T12:00:00.000Z'));
|
||||
configService.get.mockReturnValue(365);
|
||||
repository.delete.mockResolvedValue({ affected: 3 });
|
||||
scheduler = new NotificationRetentionScheduler(repository as any, configService as any, logger as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('deletes notifications older than the configured retention window', async () => {
|
||||
await scheduler.cleanupOldNotifications();
|
||||
|
||||
expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays');
|
||||
expect(repository.delete).toHaveBeenCalledWith({
|
||||
createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')),
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the number of deleted notifications', async () => {
|
||||
repository.delete.mockResolvedValue({ affected: 7 });
|
||||
|
||||
await scheduler.cleanupOldNotifications();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'notification_retention_cleanup_run',
|
||||
details: 'deletedCount=7 retentionDays=365',
|
||||
userId: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs and does not rethrow when the delete fails', async () => {
|
||||
repository.delete.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
await expect(scheduler.cleanupOldNotifications()).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({
|
||||
event: 'notification_retention_cleanup_run_fail',
|
||||
details: 'connection reset',
|
||||
userId: -1,
|
||||
});
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRetentionScheduler {
|
||||
constructor(
|
||||
@InjectRepository(Notification)
|
||||
private readonly repository: Repository<Notification>,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_5AM)
|
||||
async cleanupOldNotifications(): Promise<void> {
|
||||
const retentionDays = this.configService.get<number>('app.logRetentionDays');
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
|
||||
|
||||
try {
|
||||
const result = await this.repository.delete({ createdAt: LessThan(cutoff) });
|
||||
|
||||
await this.logger.info({
|
||||
event: 'notification_retention_cleanup_run',
|
||||
details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`,
|
||||
userId: -1,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.logger.error({
|
||||
event: 'notification_retention_cleanup_run_fail',
|
||||
details: errorMessage,
|
||||
userId: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { TeamAccessService } from '../teams/team-access.service';
|
||||
import { NotificationQueryDto } from './dto/notification-query.dto';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller({ path: 'teams', version: '1' })
|
||||
export class NotificationsController {
|
||||
constructor(
|
||||
private readonly service: NotificationsService,
|
||||
private readonly access: TeamAccessService,
|
||||
) {}
|
||||
|
||||
@Get(':teamId/notifications')
|
||||
async list(
|
||||
@Req() req,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Query() query: NotificationQueryDto,
|
||||
) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
return this.service.listForUser(Number(req.user.id), teamId, query.page ?? 1, query.limit ?? 20);
|
||||
}
|
||||
|
||||
@Get(':teamId/notifications/unread-count')
|
||||
async unreadCount(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
return { count: await this.service.getUnreadCount(Number(req.user.id), teamId) };
|
||||
}
|
||||
|
||||
@Patch(':teamId/notifications/:id/read')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markRead(
|
||||
@Req() req,
|
||||
@Param('teamId', ParseIntPipe) teamId: number,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
await this.service.markRead(id, Number(req.user.id));
|
||||
}
|
||||
|
||||
@Patch(':teamId/notifications/read-all')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markAllRead(@Req() req, @Param('teamId', ParseIntPipe) teamId: number) {
|
||||
await this.access.assertMember(Number(req.user.id), teamId);
|
||||
await this.service.markAllRead(Number(req.user.id), teamId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
INestApplication,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import validationOptions from '../utils/validation-options';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { TeamAccessService } from '../teams/team-access.service';
|
||||
|
||||
describe('notifications HTTP boundary', () => {
|
||||
let app: INestApplication;
|
||||
const service = {
|
||||
listForUser: jest.fn(),
|
||||
getUnreadCount: jest.fn(),
|
||||
markRead: jest.fn(),
|
||||
markAllRead: jest.fn(),
|
||||
};
|
||||
const access = { assertMember: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
{ provide: NotificationsService, useValue: service },
|
||||
{ provide: TeamAccessService, useValue: access },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard('jwt'))
|
||||
.useValue({
|
||||
canActivate(context) {
|
||||
const httpRequest = context.switchToHttp().getRequest();
|
||||
if (httpRequest.headers.authorization !== 'Bearer user') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
httpRequest.user = { id: 42, role: { id: 2 } };
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(() => app.close());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/teams/10/notifications').expect(401);
|
||||
});
|
||||
|
||||
it('lists notifications for the authenticated user after checking membership', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.listForUser.mockResolvedValue({ data: [], page: 2, limit: 5, total: 0, hasNextPage: false });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/teams/10/notifications?page=2&limit=5')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(access.assertMember).toHaveBeenCalledWith(42, 10);
|
||||
expect(service.listForUser).toHaveBeenCalledWith(42, 10, 2, 5);
|
||||
});
|
||||
|
||||
it('defaults to page 1 and limit 20 when not provided', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.listForUser.mockResolvedValue({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/teams/10/notifications')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(service.listForUser).toHaveBeenCalledWith(42, 10, 1, 20);
|
||||
});
|
||||
|
||||
it('returns the unread count', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.getUnreadCount.mockResolvedValue(4);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/teams/10/notifications/unread-count')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ count: 4 });
|
||||
});
|
||||
|
||||
it('marks a single notification as read', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.markRead.mockResolvedValue(undefined);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/teams/10/notifications/7/read')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(service.markRead).toHaveBeenCalledWith(7, 42);
|
||||
});
|
||||
|
||||
it('marks all notifications as read', async () => {
|
||||
access.assertMember.mockResolvedValue(undefined);
|
||||
service.markAllRead.mockResolvedValue(undefined);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/teams/10/notifications/read-all')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(service.markAllRead).toHaveBeenCalledWith(42, 10);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user