Compare commits

..

3 Commits

Author SHA1 Message Date
Bastian Wagner
09da67b8ef Fix Mobile Layout 2026-08-03 15:25:37 +02:00
Bastian Wagner
145ebb924a docs: add implementation plan for team creation via UI
Task-by-task plan covering the loosened create-team role guard,
auto-captain membership on creation, the new TeamsApi/MyTeamsStore
methods, the CreateTeamDialog component, and wiring it into
team-select.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 15:03:59 +02:00
3 changed files with 996 additions and 0 deletions

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100dvh; height: 100dvh;
position: relative;
} }
.shell-header { .shell-header {