Compare commits
3 Commits
84bab04523
...
09da67b8ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09da67b8ef | ||
|
|
145ebb924a | ||
|
|
560fcfc11a |
882
docs/superpowers/plans/2026-08-03-team-erstellen.md
Normal file
882
docs/superpowers/plans/2026-08-03-team-erstellen.md
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
# Team erstellen über die UI Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Any logged-in user can create a new team via the Angular UI and automatically becomes its captain.
|
||||||
|
|
||||||
|
**Architecture:** Loosen the existing `POST /teams` backend endpoint from admin-only to any authenticated user, and extend `TeamsService.createNewTeam` to also create a `Player` membership row (role = captain) for the calling user. On the frontend, add a `createTeam` API method, a small `MatDialog` form component, and wire a persistent "Team erstellen" button into `team-select` that opens the dialog, refreshes the user's team list, and navigates into the new team.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS + TypeORM + Jest (backend, `myteamwallet_backend`); Angular 21 standalone components + Angular Material + signals + Vitest (frontend, `myteamwallet_frontend_modern`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Spec: `docs/superpowers/specs/2026-08-03-team-erstellen-design.md`.
|
||||||
|
- Creator's team role is always `captain` (`TeamRolesEnum.captain` = `3`, `myteamwallet_backend/src/team-roles/team-roles.enum.ts`) — no role choice in the UI.
|
||||||
|
- `CreateTeamDTO` stays `{ name: string }` — no other fields are added to the create-team form.
|
||||||
|
- Backend: any new constructor dependency on `TeamsService` must be appended as the **last** parameter — `teams.service.spec.ts` constructs `TeamsService` positionally in two places, and reordering breaks those mocks.
|
||||||
|
- Backend tests use Jest (`npm run test` from `myteamwallet_backend/`); frontend tests use Vitest via the Angular CLI (`npm test` from `myteamwallet_frontend_modern/`).
|
||||||
|
- All new user-facing copy is German, matching existing screens (e.g. "Team erstellen", "Teamname", "Erstellen", "Abbrechen").
|
||||||
|
- Out of scope (per spec): team deletion/archiving, custom alias entry, choosing the creator's role.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Loosen the `POST /teams` role guard to any logged-in user
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_backend/src/teams/teams.controller.ts:247`
|
||||||
|
- Test: `myteamwallet_backend/src/teams/teams.controller.spec.ts` (new file)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing new.
|
||||||
|
- Produces: nothing new — this task only changes an authorization decorator and adds a regression test for it. `TeamsController.create(req, teamDto)` keeps calling `this.service.createNewTeam(teamDto, userId)` unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `myteamwallet_backend/src/teams/teams.controller.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||||
|
import { RoleEnum } from '../roles/roles.enum';
|
||||||
|
import { RolesGuard } from '../roles/roles.guard';
|
||||||
|
import { TeamsController } from './teams.controller';
|
||||||
|
|
||||||
|
describe('TeamsController', () => {
|
||||||
|
const service = { createNewTeam: jest.fn() };
|
||||||
|
const publicAccess = {};
|
||||||
|
const teamMembers = {};
|
||||||
|
const teamPermissions = {};
|
||||||
|
const controller = new TeamsController(
|
||||||
|
service as any,
|
||||||
|
publicAccess as any,
|
||||||
|
teamMembers as any,
|
||||||
|
teamPermissions as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('allows any logged-in user (not just admins) to create a team', () => {
|
||||||
|
expect(Reflect.getMetadata('roles', TeamsController.prototype.create)).toEqual([
|
||||||
|
RoleEnum.user,
|
||||||
|
RoleEnum.admin,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.create),
|
||||||
|
).toContain(RolesGuard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the authenticated user and the DTO to the service', () => {
|
||||||
|
const req = { user: { id: 42 } };
|
||||||
|
const dto = { name: '1. Herren' };
|
||||||
|
|
||||||
|
controller.create(req as any, dto as any);
|
||||||
|
|
||||||
|
expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run (from `myteamwallet_backend/`): `npx jest teams.controller.spec.ts`
|
||||||
|
Expected: FAIL on the first test — `Reflect.getMetadata('roles', ...)` currently equals `[RoleEnum.admin]`, not `[RoleEnum.user, RoleEnum.admin]`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Loosen the guard**
|
||||||
|
|
||||||
|
In `myteamwallet_backend/src/teams/teams.controller.ts`, change line 247 from:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Roles([RoleEnum.admin])
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||||
|
```
|
||||||
|
|
||||||
|
(This is the only `@Roles([RoleEnum.admin])` occurrence in the file — it decorates the `create()` handler at lines 241-254.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx jest teams.controller.spec.ts`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_backend/src/teams/teams.controller.ts myteamwallet_backend/src/teams/teams.controller.spec.ts
|
||||||
|
git commit -m "feat: allow any logged-in user to create a team"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Make the creator a captain of the new team
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_backend/src/teams/teams.service.ts`
|
||||||
|
- Modify: `myteamwallet_backend/src/teams/teams.service.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `TeamRolesEnum.captain` (`myteamwallet_backend/src/team-roles/team-roles.enum.ts`, value `3`); `User` entity (`myteamwallet_backend/src/users/entities/user.entity.ts`, has `firstName: string | null`, `lastName: string | null`); `Player` entity (`myteamwallet_backend/src/players/entities/player.entity.ts`).
|
||||||
|
- Produces: `TeamsService.createNewTeam(teamdto: CreateTeamDTO, userId: string): Promise<Team>` now also creates a `Player` row linking the calling user to the new team with `teamRole.id === 3`. No signature change.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `myteamwallet_backend/src/teams/teams.service.spec.ts` (new `describe` block, e.g. after the existing two blocks):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('TeamsService#createNewTeam', () => {
|
||||||
|
const repository = { create: jest.fn(), save: jest.fn() };
|
||||||
|
const playerRepository = { create: jest.fn(), save: jest.fn() };
|
||||||
|
const rolesRepository = { findOneBy: jest.fn() };
|
||||||
|
const settingsRepository = { create: jest.fn(), save: jest.fn() };
|
||||||
|
const usersRepository = { findOneBy: jest.fn() };
|
||||||
|
const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
|
||||||
|
let service: TeamsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
service = new TeamsService(
|
||||||
|
repository as any,
|
||||||
|
playerRepository as any,
|
||||||
|
{} as any,
|
||||||
|
rolesRepository as any,
|
||||||
|
settingsRepository as any,
|
||||||
|
{} as any,
|
||||||
|
logger as any,
|
||||||
|
{} as any,
|
||||||
|
usersRepository as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the team, its default settings, and a captain membership for the creator', async () => {
|
||||||
|
const savedTeam = { id: 7, name: '1. Herren' };
|
||||||
|
repository.create.mockReturnValue({ name: '1. Herren' });
|
||||||
|
repository.save.mockResolvedValue(savedTeam);
|
||||||
|
settingsRepository.create.mockImplementation((s: unknown) => s);
|
||||||
|
settingsRepository.save.mockResolvedValue([]);
|
||||||
|
usersRepository.findOneBy.mockResolvedValue({
|
||||||
|
id: 42,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
});
|
||||||
|
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
|
||||||
|
playerRepository.create.mockImplementation((p: unknown) => p);
|
||||||
|
playerRepository.save.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');
|
||||||
|
|
||||||
|
expect(result).toBe(savedTeam);
|
||||||
|
expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
|
||||||
|
expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
|
||||||
|
expect(playerRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
team: savedTeam,
|
||||||
|
teamRole: { id: 3, name: 'captain' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(playerRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the **two existing** `new TeamsService(...)` constructions in this file (they currently pass 8 positional args; the constructor will have 9 after Step 3). Both blocks are byte-for-byte identical:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
service = new TeamsService(
|
||||||
|
repository as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||||
|
access as any,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace **both** occurrences (find-and-replace-all) with a 9th argument appended:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
service = new TeamsService(
|
||||||
|
repository as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||||
|
access as any,
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run (from `myteamwallet_backend/`): `npx jest teams.service.spec.ts`
|
||||||
|
Expected: FAIL — TypeScript compile error (`Expected 9 arguments, but got 8`) on the two pre-existing constructions until Step 1's replace-all is applied, and then a runtime FAIL on the new test (`playerRepository.create` not called) until Step 3 is done.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
In `myteamwallet_backend/src/teams/teams.service.ts`, add two imports (near the existing entity imports):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||||
|
import { User } from 'src/users/entities/user.entity';
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend the constructor by appending a new injected repository as the **last** parameter (do not reorder the existing ones — `teams.service.spec.ts` relies on their positions):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Injectable()
|
||||||
|
export class TeamsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Team)
|
||||||
|
private repository: Repository<Team>,
|
||||||
|
@InjectRepository(Player)
|
||||||
|
private playerRepository: Repository<Player>,
|
||||||
|
@InjectRepository(Transaction)
|
||||||
|
private transactionsRepository: Repository<Transaction>,
|
||||||
|
@InjectRepository(TeamRole)
|
||||||
|
private rolesRepository: Repository<TeamRole>,
|
||||||
|
@InjectRepository(TeamSetting)
|
||||||
|
private settingsRepository: Repository<TeamSetting>,
|
||||||
|
@InjectRepository(TeamWalletTransaction)
|
||||||
|
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
|
||||||
|
private logger: LoggingService,
|
||||||
|
private access: TeamAccessService,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private usersRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`User` is already registered in `TeamsModule`'s `TypeOrmModule.forFeature([...])` — see `myteamwallet_backend/src/teams/teams.module.ts:31` — so no module change is needed.)
|
||||||
|
|
||||||
|
Replace `createNewTeam` and add a new private helper right after it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async createNewTeam(teamdto: CreateTeamDTO, userId: string) {
|
||||||
|
const createTeam = this.repository.create(teamdto);
|
||||||
|
|
||||||
|
const team = await this.repository.save(createTeam);
|
||||||
|
await this.generateBasicTeamSettings(team);
|
||||||
|
await this.addCreatorAsCaptain(team, userId);
|
||||||
|
|
||||||
|
await this.logger.info({
|
||||||
|
event: 'team_create',
|
||||||
|
details: `created team id: ${team.id}`,
|
||||||
|
userId: Number(userId),
|
||||||
|
});
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addCreatorAsCaptain(team: Team, userId: string): Promise<void> {
|
||||||
|
const [user, captainRole] = await Promise.all([
|
||||||
|
this.usersRepository.findOneBy({ id: Number(userId) }),
|
||||||
|
this.rolesRepository.findOneBy({ id: TeamRolesEnum.captain }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const player = this.playerRepository.create({
|
||||||
|
firstName: user?.firstName ?? '',
|
||||||
|
lastName: user?.lastName ?? '',
|
||||||
|
team,
|
||||||
|
teamRole: captainRole,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.playerRepository.save(player);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`generateBasicTeamSettings` stays exactly as-is, right below.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx jest teams.service.spec.ts`
|
||||||
|
Expected: PASS (all existing tests plus the new `createNewTeam` test).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_backend/src/teams/teams.service.ts myteamwallet_backend/src/teams/teams.service.spec.ts
|
||||||
|
git commit -m "feat: make team creator a captain of the new team"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `TeamsApi.createTeam` (frontend HTTP call)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Team` model (`myteamwallet_frontend_modern/src/app/models/team.model.ts`: `{ id, name, alias, balance, outstanding?, players?, settings? }`).
|
||||||
|
- Produces: `export interface CreateTeamRequest { name: string }` and `TeamsApi.createTeam(request: CreateTeamRequest): Observable<Team>` — used by Task 5's dialog component.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts` (alongside the existing per-method tests, following the same `httpMock.expectOne(...).flush(...)` pattern already in that file):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('creates a team', () => {
|
||||||
|
const request = { name: '1. Herren' };
|
||||||
|
service.createTeam(request).subscribe();
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}teams`);
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual(request);
|
||||||
|
req.flush({ id: 7, name: '1. Herren', alias: 'a', balance: 0 });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||||
|
Expected: FAIL — `service.createTeam is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
In `myteamwallet_frontend_modern/src/app/core/team/teams-api.ts`, add the request interface next to `CreatePlayerRequest`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface CreateTeamRequest {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the method to the `TeamsApi` class (alongside `createPlayer`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createTeam(request: CreateTeamRequest): Observable<Team> {
|
||||||
|
return this.http.post<Team>(`${environment.apiUrl}teams`, request);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_frontend_modern/src/app/core/team/teams-api.ts myteamwallet_frontend_modern/src/app/core/team/teams-api.spec.ts
|
||||||
|
git commit -m "feat: add TeamsApi.createTeam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `MyTeamsStore.refresh` (force-reload after creating a team)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts`
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `TeamsApi.loadMyTeams(userId: number): Observable<UserTeamMembership[]>` (existing).
|
||||||
|
- Produces: `MyTeamsStore.refresh(userId: number): void` — always refetches, unlike `ensureLoaded` which is a no-op once loaded for that user. Used by Task 6.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('refresh always reloads, even for a user id already loaded', () => {
|
||||||
|
store.ensureLoaded(42);
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player]);
|
||||||
|
|
||||||
|
store.refresh(42);
|
||||||
|
expect(store.loading()).toBe(true);
|
||||||
|
|
||||||
|
const secondPlayer = { ...player, id: 2, team: { id: 6, name: 'Team B' } };
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player, secondPlayer]);
|
||||||
|
|
||||||
|
expect(store.loading()).toBe(false);
|
||||||
|
expect(store.players()).toEqual([player, secondPlayer]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||||
|
Expected: FAIL — `store.refresh is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
Replace the body of `myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { UserTeamMembership } from '../../models/user-directory.model';
|
||||||
|
import { TeamsApi } from './teams-api';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MyTeamsStore {
|
||||||
|
private readonly teamsApi = inject(TeamsApi);
|
||||||
|
|
||||||
|
private readonly playersSignal = signal<UserTeamMembership[]>([]);
|
||||||
|
private readonly loadingSignal = signal(false);
|
||||||
|
private readonly loadedForUserId = signal<number | null>(null);
|
||||||
|
|
||||||
|
readonly players = this.playersSignal.asReadonly();
|
||||||
|
readonly loading = this.loadingSignal.asReadonly();
|
||||||
|
|
||||||
|
ensureLoaded(userId: number): void {
|
||||||
|
if (this.loadedForUserId() === userId || this.loadingSignal()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fetch(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(userId: number): void {
|
||||||
|
this.fetch(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetch(userId: number): void {
|
||||||
|
this.loadingSignal.set(true);
|
||||||
|
this.teamsApi.loadMyTeams(userId).subscribe({
|
||||||
|
next: (players) => {
|
||||||
|
this.playersSignal.set(players);
|
||||||
|
this.loadedForUserId.set(userId);
|
||||||
|
this.loadingSignal.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loadingSignal.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS (all existing `MyTeamsStore` tests plus the new one).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_frontend_modern/src/app/core/team/my-teams-store.ts myteamwallet_frontend_modern/src/app/core/team/my-teams-store.spec.ts
|
||||||
|
git commit -m "feat: add MyTeamsStore.refresh for forced reloads"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `CreateTeamDialog` component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`
|
||||||
|
- Test: `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `TeamsApi.createTeam` (Task 3); `MatDialogRef<CreateTeamDialog, Team | undefined>` (Angular Material).
|
||||||
|
- Produces: `CreateTeamDialog` standalone component. Opening it via `MatDialog.open(CreateTeamDialog)` and subscribing to `afterClosed()` yields the created `Team` on success, or `undefined` if cancelled/dismissed. Used by Task 6.
|
||||||
|
|
||||||
|
This introduces a new pattern for this codebase: the only existing `MatDialogRef`/`MAT_DIALOG_DATA`-injecting component is `shared/confirm-dialog/confirm-dialog.ts` (a plain confirm/cancel dialog, no form). This component follows the same injection style but uses a `ReactiveFormsModule` form, matching how `members.ts`/`penalties.ts` build their (inline, non-dialog) create forms.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { CreateTeamDialog } from './create-team-dialog';
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
|
describe('CreateTeamDialog', () => {
|
||||||
|
let dialogRef: { close: ReturnType<typeof vi.fn> };
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dialogRef = { close: vi.fn() };
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CreateTeamDialog],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: MatDialogRef, useValue: dialogRef },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the submit button disabled until a team name is entered', () => {
|
||||||
|
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance['form'].invalid).toBe(true);
|
||||||
|
|
||||||
|
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||||
|
expect(fixture.componentInstance['form'].invalid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the team and closes the dialog with the created team', () => {
|
||||||
|
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||||
|
fixture.componentInstance['submit']();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(`${environment.apiUrl}teams`);
|
||||||
|
expect(request.request.body).toEqual({ name: '1. Herren' });
|
||||||
|
request.flush({ id: 9, name: '1. Herren', alias: 'a', balance: 0 });
|
||||||
|
|
||||||
|
expect(dialogRef.close).toHaveBeenCalledWith({
|
||||||
|
id: 9,
|
||||||
|
name: '1. Herren',
|
||||||
|
alias: 'a',
|
||||||
|
balance: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-enables the form and keeps the dialog open when the request fails', () => {
|
||||||
|
const fixture = TestBed.createComponent(CreateTeamDialog);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.componentInstance['form'].controls.name.setValue('1. Herren');
|
||||||
|
fixture.componentInstance['submit']();
|
||||||
|
|
||||||
|
httpMock
|
||||||
|
.expectOne(`${environment.apiUrl}teams`)
|
||||||
|
.flush('error', { status: 500, statusText: 'Server Error' });
|
||||||
|
|
||||||
|
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.componentInstance['saving']()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||||
|
Expected: FAIL — `create-team-dialog` module not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
Create `myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/create-team-dialog.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { Team } from '../../../models/team.model';
|
||||||
|
import { TeamsApi } from '../../../core/team/teams-api';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-create-team-dialog',
|
||||||
|
imports: [ReactiveFormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatInputModule],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title>Team erstellen</h2>
|
||||||
|
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||||
|
<mat-dialog-content>
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Teamname</mat-label>
|
||||||
|
<input matInput formControlName="name" />
|
||||||
|
</mat-form-field>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
|
||||||
|
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||||
|
Erstellen
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class CreateTeamDialog {
|
||||||
|
protected readonly dialogRef = inject(MatDialogRef<CreateTeamDialog, Team | undefined>);
|
||||||
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly teamsApi = inject(TeamsApi);
|
||||||
|
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly form = this.formBuilder.nonNullable.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
});
|
||||||
|
|
||||||
|
protected submit(): void {
|
||||||
|
if (this.form.invalid || this.saving()) return;
|
||||||
|
this.saving.set(true);
|
||||||
|
this.teamsApi.createTeam(this.form.getRawValue()).subscribe({
|
||||||
|
next: (team) => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.dialogRef.close(team);
|
||||||
|
},
|
||||||
|
error: () => this.saving.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS (3 new tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_frontend_modern/src/app/features/team-select/create-team-dialog/
|
||||||
|
git commit -m "feat: add CreateTeamDialog component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire "Team erstellen" into `team-select`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts`
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html`
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`
|
||||||
|
- Modify: `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `CreateTeamDialog` (Task 5); `MyTeamsStore.refresh(userId)` (Task 4); `MatDialog` (Angular Material).
|
||||||
|
- Produces: nothing consumed by later tasks — this is the last task.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.spec.ts`. First, add these imports at the top of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { Team } from '../../models/team.model';
|
||||||
|
import { MyTeamsStore } from '../../core/team/my-teams-store';
|
||||||
|
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the `describe('TeamSelect', ...)` setup (`let` declarations through the closing of `beforeEach`/`afterEach`) with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('TeamSelect', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let router: Router;
|
||||||
|
let authStore: AuthStore;
|
||||||
|
let myTeamsStore: MyTeamsStore;
|
||||||
|
let dialogClosed: Subject<Team | undefined>;
|
||||||
|
let dialog: { open: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
dialogClosed = new Subject<Team | undefined>();
|
||||||
|
dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TeamSelect],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: MatDialog, useValue: dialog },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
authStore = TestBed.inject(AuthStore);
|
||||||
|
myTeamsStore = TestBed.inject(MyTeamsStore);
|
||||||
|
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
(The existing three `it(...)` blocks stay unchanged below this.) Then add two new tests at the end of the `describe` block, before its closing `});`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('opens the create-team dialog and navigates into the newly created team on success', async () => {
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||||
|
const refreshSpy = vi.spyOn(myTeamsStore, 'refresh');
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TeamSelect);
|
||||||
|
fixture.detectChanges();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.componentInstance['createTeam']();
|
||||||
|
expect(dialog.open).toHaveBeenCalledWith(CreateTeamDialog);
|
||||||
|
|
||||||
|
const created: Team = { id: 9, name: '1. Herren', alias: 'a', balance: 0 };
|
||||||
|
dialogClosed.next(created);
|
||||||
|
|
||||||
|
expect(refreshSpy).toHaveBeenCalledWith(42);
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['/team', 9, 'overview']);
|
||||||
|
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not navigate when the create-team dialog is dismissed without a team', async () => {
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TeamSelect);
|
||||||
|
fixture.detectChanges();
|
||||||
|
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.componentInstance['createTeam']();
|
||||||
|
dialogClosed.next(undefined);
|
||||||
|
|
||||||
|
expect(navigateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run (from `myteamwallet_frontend_modern/`): `npm test`
|
||||||
|
Expected: FAIL — `createTeam` does not exist on `TeamSelect`, and `MatDialog` is unused/component doesn't inject it yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.ts` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Component, effect, inject } from '@angular/core';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { AuthStore } from '../../core/auth/auth-store';
|
||||||
|
import { MyTeamsStore } from '../../core/team/my-teams-store';
|
||||||
|
import { Team } from '../../models/team.model';
|
||||||
|
import { CreateTeamDialog } from './create-team-dialog/create-team-dialog';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-team-select',
|
||||||
|
imports: [RouterLink, MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
|
||||||
|
templateUrl: './team-select.html',
|
||||||
|
styleUrl: './team-select.scss',
|
||||||
|
})
|
||||||
|
export class TeamSelect {
|
||||||
|
private readonly authStore = inject(AuthStore);
|
||||||
|
private readonly myTeamsStore = inject(MyTeamsStore);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
protected readonly players = this.myTeamsStore.players;
|
||||||
|
protected readonly loading = this.myTeamsStore.loading;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const userId = this.authStore.currentUser()?.id;
|
||||||
|
if (userId) {
|
||||||
|
this.myTeamsStore.ensureLoaded(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const players = this.myTeamsStore.players();
|
||||||
|
if (!this.myTeamsStore.loading() && players.length === 1 && players[0].team) {
|
||||||
|
void this.router.navigate(['/team', players[0].team.id, 'overview'], {
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createTeam(): void {
|
||||||
|
this.dialog
|
||||||
|
.open(CreateTeamDialog)
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe((team: Team | undefined) => {
|
||||||
|
if (!team) return;
|
||||||
|
|
||||||
|
const userId = this.authStore.currentUser()?.id;
|
||||||
|
if (userId) {
|
||||||
|
this.myTeamsStore.refresh(userId);
|
||||||
|
}
|
||||||
|
void this.router.navigate(['/team', team.id, 'overview']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `myteamwallet_frontend_modern/src/app/features/team-select/team-select.html` with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="team-select-toolbar">
|
||||||
|
<button mat-stroked-button type="button" (click)="createTeam()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Team erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="team-select-loading">
|
||||||
|
<mat-spinner diameter="32" />
|
||||||
|
</div>
|
||||||
|
} @else if (players().length === 0) {
|
||||||
|
<div class="team-select-empty">
|
||||||
|
<p>Du bist noch keinem Team zugeordnet.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="team-select-page">
|
||||||
|
<h1>Team auswählen</h1>
|
||||||
|
<mat-nav-list>
|
||||||
|
@for (player of players(); track player.id) {
|
||||||
|
<a mat-list-item [routerLink]="['/team', player.team.id, 'overview']">
|
||||||
|
<span matListItemTitle>{{ player.team.name }}</span>
|
||||||
|
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</mat-nav-list>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `myteamwallet_frontend_modern/src/app/features/team-select/team-select.scss`:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.team-select-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1rem 1rem 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS (all `TeamSelect` tests, including the two new ones).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add myteamwallet_frontend_modern/src/app/features/team-select/
|
||||||
|
git commit -m "feat: wire create-team dialog into team-select"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual End-to-End Verification
|
||||||
|
|
||||||
|
After all tasks are complete:
|
||||||
|
|
||||||
|
1. Start the backend (`npm run start:dev` in `myteamwallet_backend/`) and frontend (`npm start` in `myteamwallet_frontend_modern/`).
|
||||||
|
2. Log in as a regular (non-admin) user.
|
||||||
|
3. Go to the team selection screen, click "Team erstellen", enter a name, submit.
|
||||||
|
4. Confirm: the dialog closes, the app navigates to `/team/<newId>/overview`, and the user appears under "Mitglieder" with role "Kapitän".
|
||||||
|
5. Go back to team selection (or log in as a user with 2+ teams) and confirm the "Team erstellen" button is visible even when teams already exist.
|
||||||
|
6. Optionally, call `POST /api/v1/teams` directly via Swagger as a non-admin user's JWT to confirm the 403 is gone.
|
||||||
113
docs/superpowers/specs/2026-08-03-team-erstellen-design.md
Normal file
113
docs/superpowers/specs/2026-08-03-team-erstellen-design.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Team erstellen über die UI
|
||||||
|
|
||||||
|
Status: approved
|
||||||
|
Datum: 2026-08-03
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Teams können in TeamWallet heute nur über einen bestehenden Backend-Endpoint
|
||||||
|
(`POST /api/v1/teams`, `teams.controller.ts`) angelegt werden, der ausschließlich globalen Admins
|
||||||
|
vorbehalten ist (`@Roles([RoleEnum.admin])`). Im modernen Angular-Frontend
|
||||||
|
(`myteamwallet_frontend_modern`) existiert dafür keine UI: `core/team/teams-api.ts` hat keine
|
||||||
|
`createTeam`-Methode, und `features/team-select/team-select.html` zeigt einem User ohne Teams nur
|
||||||
|
den Hinweistext „Du bist noch keinem Team zugeordnet." ohne jede Handlungsoption.
|
||||||
|
|
||||||
|
Ziel: jeder eingeloggte User soll selbstständig ein neues Team gründen können und wird dabei
|
||||||
|
automatisch dessen Kapitän. Das schließt eine der offensichtlichsten Lücken im Produkt
|
||||||
|
(Selbstregistrierung eines Teams) und folgt demselben Muster, das an anderer Stelle im Code bereits
|
||||||
|
als fehlend dokumentiert ist (`docs/plans/admin-user-management.md` merkt an, dass Admins auch noch
|
||||||
|
keine User anlegen können — ein verwandtes, aber bewusst getrenntes Folge-Thema).
|
||||||
|
|
||||||
|
## Entscheidungen aus dem Brainstorming
|
||||||
|
|
||||||
|
- **Berechtigung**: jeder eingeloggte User (`RoleEnum.user`) darf ein Team erstellen, nicht nur
|
||||||
|
Admins. Der Guard auf `POST /teams` wird entsprechend gelockert.
|
||||||
|
- **Automatische Mitgliedschaft**: der Ersteller wird automatisch **Kapitän** (`captain`,
|
||||||
|
Rollen-ID 3) des neuen Teams — die niedrigste Rollen-ID, die `TeamAccessService.assertManager`
|
||||||
|
bereits als „Manager" behandelt (`id >= 3`). Es gibt kein explizites „Owner"-Konzept, `captain`
|
||||||
|
ist die naheliegende Top-Rolle für den Ersteller.
|
||||||
|
- **Formularumfang**: nur der Teamname wird abgefragt (`CreateTeamDTO` bleibt `{ name: string }`).
|
||||||
|
Alias wird weiterhin automatisch aus dem Timestamp generiert, alles Weitere ist später über die
|
||||||
|
Team-Einstellungen anpassbar.
|
||||||
|
- **UI-Einstiegspunkt**: Button „Team erstellen" ist in `team-select` dauerhaft sichtbar — auch wenn
|
||||||
|
der User bereits Teams hat (nicht nur im Leerzustand), um auch das Anlegen weiterer Teams (z. B.
|
||||||
|
zweite Mannschaft) zu ermöglichen.
|
||||||
|
- **UI-Pattern**: Dialog/Modal statt eigener Route, passend zum bestehenden `MatDialog`-Muster im
|
||||||
|
Repo (z. B. `features/team/more/penalties/penalties.ts`) und angemessen für ein einzelnes
|
||||||
|
Eingabefeld.
|
||||||
|
- **Out of Scope**: Team löschen/archivieren ist ein eigenständiges Folge-Feature (andere
|
||||||
|
Berechtigungen/Risiken, z. B. Umgang mit bestehenden Spielern/Transaktionen) und wird separat
|
||||||
|
geplant.
|
||||||
|
|
||||||
|
## Architektur / Komponenten
|
||||||
|
|
||||||
|
### 1. Backend: `teams.controller.ts`
|
||||||
|
|
||||||
|
Guard auf `POST /teams` von `@Roles([RoleEnum.admin])` auf `[RoleEnum.user, RoleEnum.admin]`
|
||||||
|
erweitern — Muster wie an anderen Stellen desselben Controllers (z. B. Zeilen 113-114, 122-123).
|
||||||
|
Der aufrufende User wird wie überall im Controller über `@Req() req` → `req.user.id` an den Service
|
||||||
|
durchgereicht.
|
||||||
|
|
||||||
|
### 2. Backend: `teams.service.ts#createNewTeam`
|
||||||
|
|
||||||
|
Aktuell (Zeile 166-178) wird nur `Team` + Default-`TeamSetting`s (`generateBasicTeamSettings`)
|
||||||
|
angelegt; es entsteht kein `Player`-Datensatz, der User bleibt kein Mitglied des neuen Teams.
|
||||||
|
|
||||||
|
Erweiterung: nach dem Speichern von Team und Settings zusätzlich einen `Player` erzeugen —
|
||||||
|
verknüpft mit dem aufrufenden `User` (`userId`) und `teamRole = captain` (per
|
||||||
|
`rolesRepository.findOneBy({ id: 3 })`). Vorgehen spiegelt die bestehende Join-Erzeugung in
|
||||||
|
`createNewPlayer()` (Zeilen 122-164), dort wird bereits `Player` inkl. `TeamRole`-Verknüpfung für
|
||||||
|
andere Spieler angelegt.
|
||||||
|
|
||||||
|
Audit-Logging (`this.logger.info({ event: 'team_create', ... })`) bleibt erhalten.
|
||||||
|
|
||||||
|
### 3. Backend: `dto/create-team.dto.ts`
|
||||||
|
|
||||||
|
Unverändert (`{ name: string }`).
|
||||||
|
|
||||||
|
### 4. Frontend: `core/team/teams-api.ts`
|
||||||
|
|
||||||
|
Neue Methode `createTeam(name: string)` → `POST /teams`, analog zu den bestehenden Methoden wie
|
||||||
|
`createPlayer`.
|
||||||
|
|
||||||
|
### 5. Frontend: neue Dialog-Komponente
|
||||||
|
|
||||||
|
Kleine Standalone-Komponente mit Reactive Form (`FormBuilder`/`ReactiveFormsModule`,
|
||||||
|
`MatFormFieldModule`), ein Pflichtfeld „Teamname" — nach dem Muster von
|
||||||
|
`features/team/members/members.ts` (Form-Aufbau) kombiniert mit dem `MatDialog`-Öffnungsmuster aus
|
||||||
|
`features/team/more/penalties/penalties.ts`.
|
||||||
|
|
||||||
|
### 6. Frontend: `features/team-select/team-select.ts` / `.html`
|
||||||
|
|
||||||
|
Button „Team erstellen" dauerhaft im Template ergänzen (nicht nur im Leerzustand-Block). Klick öffnet
|
||||||
|
den Dialog über `MatDialog`; bei erfolgreichem Abschluss `MyTeamsStore` neu laden und per Router
|
||||||
|
direkt in das neu erstellte Team navigieren.
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
- Serverseitige Validierungsfehler (z. B. leerer/zu langer Name) werden als Formularfehler im Dialog
|
||||||
|
angezeigt, der Dialog bleibt offen.
|
||||||
|
- Netzwerk-/Serverfehler laufen über den bestehenden Snackbar/Toast-Mechanismus des Repos, wie bei
|
||||||
|
anderen Create-Flows (z. B. `createPlayer`).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Backend (`teams.service.spec.ts`): `createNewTeam()` legt zusätzlich zu Team und Settings einen
|
||||||
|
`Player` mit `teamRole = captain` und Verknüpfung zum aufrufenden User an.
|
||||||
|
- Backend (`teams.controller.spec.ts` bzw. e2e): `POST /teams` ist für `RoleEnum.user` erlaubt (nicht
|
||||||
|
mehr nur für `RoleEnum.admin`).
|
||||||
|
- Frontend: Komponententest für den neuen Dialog (analog `penalties.spec.ts`) — Formularvalidierung,
|
||||||
|
Aufruf von `teamsApi.createTeam`.
|
||||||
|
- Frontend: Test, dass `team-select` nach erfolgreicher Erstellung `MyTeamsStore` neu lädt und in das
|
||||||
|
neue Team navigiert.
|
||||||
|
- Manuelle Verifikation: als normaler User (nicht Admin) über `team-select` ein Team anlegen —
|
||||||
|
Aufruf sollte gelingen, User landet automatisch als Kapitän im neuen Team; Swagger-Aufruf von
|
||||||
|
`POST /teams` als normaler User bestätigt den gelockerten Guard.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Team löschen/archivieren (eigenes Folge-Feature).
|
||||||
|
- Auswahl der initialen Rolle des Erstellers (immer `captain`, keine Wahlmöglichkeit).
|
||||||
|
- Eigene Alias-Vergabe durch den User (weiterhin automatisch generiert).
|
||||||
|
- Selbstständiges Anlegen von Usern durch Admins (verwandte, aber separate Lücke, siehe
|
||||||
|
`docs/plans/admin-user-management.md`).
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-header {
|
.shell-header {
|
||||||
|
|||||||
Reference in New Issue
Block a user