From 145ebb924a2b68148bafb0337ca3b36c0ad4efb0 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 15:14:47 +0200 Subject: [PATCH] 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 --- .../plans/2026-08-03-team-erstellen.md | 882 ++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-team-erstellen.md diff --git a/docs/superpowers/plans/2026-08-03-team-erstellen.md b/docs/superpowers/plans/2026-08-03-team-erstellen.md new file mode 100644 index 0000000..1d6bb99 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-team-erstellen.md @@ -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` 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, + @InjectRepository(Player) + private playerRepository: Repository, + @InjectRepository(Transaction) + private transactionsRepository: Repository, + @InjectRepository(TeamRole) + private rolesRepository: Repository, + @InjectRepository(TeamSetting) + private settingsRepository: Repository, + @InjectRepository(TeamWalletTransaction) + private teamWalletTransactionRepository: Repository, + private logger: LoggingService, + private access: TeamAccessService, + @InjectRepository(User) + private usersRepository: Repository, + ) {} +``` + +(`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 { + 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` — 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 { + return this.http.post(`${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` (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([]); + private readonly loadingSignal = signal(false); + private readonly loadedForUserId = signal(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` (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 }; + 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: ` +

Team erstellen

+
+ + + Teamname + + + + + + + +
+ `, + styles: [ + ` + .full-width { + width: 100%; + } + `, + ], +}) +export class CreateTeamDialog { + protected readonly dialogRef = inject(MatDialogRef); + 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; + let dialog: { open: ReturnType }; + + beforeEach(async () => { + localStorage.clear(); + dialogClosed = new Subject(); + 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 +
+ +
+ +@if (loading()) { +
+ +
+} @else if (players().length === 0) { +
+

Du bist noch keinem Team zugeordnet.

+
+} @else { +
+

Team auswählen

+ + @for (player of players(); track player.id) { + + {{ player.team.name }} + {{ player.firstName }} {{ player.lastName }} + + } + +
+} +``` + +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//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.