Files
teamwallet/docs/superpowers/plans/2026-08-03-team-erstellen.md
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

31 KiB

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:

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:

  @Roles([RoleEnum.admin])

to:

  @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
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):

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:

    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:

    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):

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):

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

  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
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):

  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:

export interface CreateTeamRequest {
  name: string;
}

Add the method to the TeamsApi class (alongside createPlayer):

  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
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:

  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:

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
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:

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:

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
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:

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:

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

  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:

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:

<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:

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