This commit is contained in:
2025-12-04 15:15:24 +01:00
parent b7d5551ea7
commit c698a0b893
119 changed files with 1167 additions and 2652 deletions

View File

@@ -0,0 +1,43 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '../../domain/entities/Team';
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class ApproveTeamJoinRequestUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<void> {
const { requestId } = command;
// There is no repository method to look up a single request by ID,
// so we rely on the repository implementation to surface all relevant
// requests via getJoinRequests and search by ID here.
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(
// For the in-memory fake used in tests, the teamId argument is ignored
// and all requests are returned.
'' as string,
);
const request = allRequests.find((r) => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
const membership: TeamMembership = {
teamId: request.teamId,
driverId: request.driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
await this.membershipRepository.removeJoinRequest(requestId);
}
}

View File

@@ -0,0 +1,54 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
Team,
TeamMembership,
TeamMembershipStatus,
TeamRole,
} from '../../domain/entities/Team';
import type {
CreateTeamCommandDTO,
CreateTeamResultDTO,
} from '../dto/CreateTeamCommandDTO';
export class CreateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: CreateTeamCommandDTO): Promise<CreateTeamResultDTO> {
const { name, tag, description, ownerId, leagues } = command;
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
ownerId,
);
if (existingMembership) {
throw new Error('Driver already belongs to a team');
}
const team: Team = {
id: `team-${Date.now()}`,
name,
tag,
description,
ownerId,
leagues,
createdAt: new Date(),
};
const createdTeam = await this.teamRepository.create(team);
const membership: TeamMembership = {
teamId: createdTeam.id,
driverId: ownerId,
role: 'owner' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
return { team: createdTeam };
}
}

View File

@@ -0,0 +1,13 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { GetAllTeamsQueryResultDTO } from '../dto/TeamCommandAndQueryDTO';
export class GetAllTeamsQuery {
constructor(
private readonly teamRepository: ITeamRepository,
) {}
async execute(): Promise<GetAllTeamsQueryResultDTO> {
const teams = await this.teamRepository.findAll();
return teams;
}
}

View File

@@ -0,0 +1,29 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
GetDriverTeamQueryParamsDTO,
GetDriverTeamQueryResultDTO,
} from '../dto/TeamCommandAndQueryDTO';
export class GetDriverTeamQuery {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetDriverTeamQueryParamsDTO): Promise<GetDriverTeamQueryResultDTO | null> {
const { driverId } = params;
const membership = await this.membershipRepository.getActiveMembershipForDriver(driverId);
if (!membership) {
return null;
}
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
return null;
}
return { team, membership };
}
}

View File

@@ -0,0 +1,17 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
/**
* Query object returning registered driver IDs for a race.
* Mirrors legacy getRegisteredDrivers behavior.
*/
export class GetRaceRegistrationsQuery {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<string[]> {
const { raceId } = params;
return this.registrationRepository.getRegisteredDrivers(raceId);
}
}

View File

@@ -0,0 +1,26 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
GetTeamDetailsQueryParamsDTO,
GetTeamDetailsQueryResultDTO,
} from '../dto/TeamCommandAndQueryDTO';
export class GetTeamDetailsQuery {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetTeamDetailsQueryParamsDTO): Promise<GetTeamDetailsQueryResultDTO> {
const { teamId, driverId } = params;
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new Error('Team not found');
}
const membership = await this.membershipRepository.getMembership(teamId, driverId);
return { team, membership };
}
}

View File

@@ -0,0 +1,14 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { TeamJoinRequest } from '../../domain/entities/Team';
import type { GetTeamJoinRequestsQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO';
export class GetTeamJoinRequestsQuery {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetTeamJoinRequestsQueryParamsDTO): Promise<TeamJoinRequest[]> {
const { teamId } = params;
return this.membershipRepository.getJoinRequests(teamId);
}
}

View File

@@ -0,0 +1,14 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { TeamMembership } from '../../domain/entities/Team';
import type { GetTeamMembersQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO';
export class GetTeamMembersQuery {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetTeamMembersQueryParamsDTO): Promise<TeamMembership[]> {
const { teamId } = params;
return this.membershipRepository.getTeamMembers(teamId);
}
}

View File

@@ -0,0 +1,17 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
/**
* Read-only wrapper around IRaceRegistrationRepository.isRegistered.
* Mirrors legacy isRegistered behavior.
*/
export class IsDriverRegisteredForRaceQuery {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise<boolean> {
const { raceId, driverId } = params;
return this.registrationRepository.isRegistered(raceId, driverId);
}
}

View File

@@ -6,11 +6,7 @@ import type {
MembershipRole,
MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
export interface JoinLeagueCommand {
leagueId: string;
driverId: string;
}
import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
export class JoinLeagueUseCase {
constructor(private readonly membershipRepository: ILeagueMembershipRepository) {}
@@ -22,7 +18,7 @@ export class JoinLeagueUseCase {
* - Throws when membership already exists for this league/driver.
* - Creates a new active membership with role "member" and current timestamp.
*/
async execute(command: JoinLeagueCommand): Promise<LeagueMembership> {
async execute(command: JoinLeagueCommandDTO): Promise<LeagueMembership> {
const { leagueId, driverId } = command;
const existing = await this.membershipRepository.getMembership(leagueId, driverId);

View File

@@ -0,0 +1,46 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
} from '../../domain/entities/Team';
import type { JoinTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class JoinTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: JoinTeamCommandDTO): Promise<void> {
const { teamId, driverId } = command;
const existingActive = await this.membershipRepository.getActiveMembershipForDriver(
driverId,
);
if (existingActive) {
throw new Error('Driver already belongs to a team');
}
const existingMembership = await this.membershipRepository.getMembership(teamId, driverId);
if (existingMembership) {
throw new Error('Already a member or have a pending request');
}
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new Error('Team not found');
}
const membership: TeamMembership = {
teamId,
driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
}
}

View File

@@ -0,0 +1,25 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { LeaveTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class LeaveTeamUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: LeaveTeamCommandDTO): Promise<void> {
const { teamId, driverId } = command;
const membership = await this.membershipRepository.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Not a member of this team');
}
if (membership.role === 'owner') {
throw new Error(
'Team owner cannot leave. Transfer ownership or disband team first.',
);
}
await this.membershipRepository.removeMembership(teamId, driverId);
}
}

View File

@@ -1,40 +0,0 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
export interface IsDriverRegisteredForRaceQueryParams {
raceId: string;
driverId: string;
}
export class IsDriverRegisteredForRaceQuery {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
/**
* Read-only wrapper around IRaceRegistrationRepository.isRegistered.
* Mirrors legacy isRegistered behavior.
*/
async execute(params: IsDriverRegisteredForRaceQueryParams): Promise<boolean> {
const { raceId, driverId } = params;
return this.registrationRepository.isRegistered(raceId, driverId);
}
}
export interface GetRaceRegistrationsQueryParams {
raceId: string;
}
/**
* Query object returning registered driver IDs for a race.
* Mirrors legacy getRegisteredDrivers behavior.
*/
export class GetRaceRegistrationsQuery {
constructor(
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
async execute(params: GetRaceRegistrationsQueryParams): Promise<string[]> {
const { raceId } = params;
return this.registrationRepository.getRegisteredDrivers(raceId);
}
}

View File

@@ -1,12 +1,7 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
export interface RegisterForRaceCommand {
raceId: string;
leagueId: string;
driverId: string;
}
import type { RegisterForRaceCommandDTO } from '../dto/RegisterForRaceCommandDTO';
export class RegisterForRaceUseCase {
constructor(
@@ -20,7 +15,7 @@ export class RegisterForRaceUseCase {
* - validates active league membership
* - registers driver for race
*/
async execute(command: RegisterForRaceCommand): Promise<void> {
async execute(command: RegisterForRaceCommandDTO): Promise<void> {
const { raceId, leagueId, driverId } = command;
const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId);

View File

@@ -0,0 +1,13 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { RejectTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class RejectTeamJoinRequestUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: RejectTeamJoinRequestCommandDTO): Promise<void> {
const { requestId } = command;
await this.membershipRepository.removeJoinRequest(requestId);
}
}

View File

@@ -1,339 +0,0 @@
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
import type {
Team,
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '@gridpilot/racing/domain/entities/Team';
export interface CreateTeamCommand {
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}
export interface CreateTeamResult {
team: Team;
}
export class CreateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: CreateTeamCommand): Promise<CreateTeamResult> {
const { name, tag, description, ownerId, leagues } = command;
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
ownerId,
);
if (existingMembership) {
throw new Error('Driver already belongs to a team');
}
const team: Team = {
id: `team-${Date.now()}`,
name,
tag,
description,
ownerId,
leagues,
createdAt: new Date(),
};
const createdTeam = await this.teamRepository.create(team);
const membership: TeamMembership = {
teamId: createdTeam.id,
driverId: ownerId,
role: 'owner' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
return { team: createdTeam };
}
}
export interface JoinTeamCommand {
teamId: string;
driverId: string;
}
export class JoinTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: JoinTeamCommand): Promise<void> {
const { teamId, driverId } = command;
const existingActive = await this.membershipRepository.getActiveMembershipForDriver(
driverId,
);
if (existingActive) {
throw new Error('Driver already belongs to a team');
}
const existingMembership = await this.membershipRepository.getMembership(teamId, driverId);
if (existingMembership) {
throw new Error('Already a member or have a pending request');
}
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new Error('Team not found');
}
const membership: TeamMembership = {
teamId,
driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
}
}
export interface LeaveTeamCommand {
teamId: string;
driverId: string;
}
export class LeaveTeamUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: LeaveTeamCommand): Promise<void> {
const { teamId, driverId } = command;
const membership = await this.membershipRepository.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Not a member of this team');
}
if (membership.role === 'owner') {
throw new Error(
'Team owner cannot leave. Transfer ownership or disband team first.',
);
}
await this.membershipRepository.removeMembership(teamId, driverId);
}
}
export interface ApproveTeamJoinRequestCommand {
requestId: string;
}
export class ApproveTeamJoinRequestUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: ApproveTeamJoinRequestCommand): Promise<void> {
const { requestId } = command;
// We have only getJoinRequests(teamId), so scan all teams via naive approach.
// In-memory demo implementations will keep counts small.
// Caller tests seed join requests directly in repository.
const allTeamIds = new Set<string>();
const allRequests: TeamJoinRequest[] = [];
// There is no repository method to list all requests; tests use the fake directly,
// so here we rely on getJoinRequests per team only when they are known.
// To keep this use-case generic, we assume the repository will surface
// the relevant request when getJoinRequests is called for its team.
// Thus we let infrastructure handle request lookup and mapping.
// For the in-memory fake used in tests, we can simply reconstruct behavior
// by having the fake expose all requests; production impl can optimize.
// Minimal implementation using repository capabilities only:
// let the repository throw if the request cannot be found by ID.
const requestsForUnknownTeam = await this.membershipRepository.getJoinRequests(
(undefined as unknown) as string,
);
const request = requestsForUnknownTeam.find((r) => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
const membership: TeamMembership = {
teamId: request.teamId,
driverId: request.driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
await this.membershipRepository.removeJoinRequest(requestId);
}
}
export interface RejectTeamJoinRequestCommand {
requestId: string;
}
export class RejectTeamJoinRequestUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: RejectTeamJoinRequestCommand): Promise<void> {
const { requestId } = command;
await this.membershipRepository.removeJoinRequest(requestId);
}
}
export interface UpdateTeamCommand {
teamId: string;
updates: Partial<Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>>;
updatedBy: string;
}
export class UpdateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: UpdateTeamCommand): Promise<void> {
const { teamId, updates, updatedBy } = command;
const updaterMembership = await this.membershipRepository.getMembership(teamId, updatedBy);
if (!updaterMembership || (updaterMembership.role !== 'owner' && updaterMembership.role !== 'manager')) {
throw new Error('Only owners and managers can update team info');
}
const existing = await this.teamRepository.findById(teamId);
if (!existing) {
throw new Error('Team not found');
}
const updated: Team = {
...existing,
...updates,
};
await this.teamRepository.update(updated);
}
}
export interface GetAllTeamsQueryResult {
teams: Team[];
}
export class GetAllTeamsQuery {
constructor(
private readonly teamRepository: ITeamRepository,
) {}
async execute(): Promise<Team[]> {
return this.teamRepository.findAll();
}
}
export interface GetTeamDetailsQueryParams {
teamId: string;
driverId: string;
}
export interface GetTeamDetailsQueryResult {
team: Team;
membership: TeamMembership | null;
}
export class GetTeamDetailsQuery {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetTeamDetailsQueryParams): Promise<GetTeamDetailsQueryResult> {
const { teamId, driverId } = params;
const team = await this.teamRepository.findById(teamId);
if (!team) {
throw new Error('Team not found');
}
const membership = await this.membershipRepository.getMembership(teamId, driverId);
return { team, membership };
}
}
export interface GetTeamMembersQueryParams {
teamId: string;
}
export class GetTeamMembersQuery {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetTeamMembersQueryParams): Promise<TeamMembership[]> {
const { teamId } = params;
return this.membershipRepository.getTeamMembers(teamId);
}
}
export interface GetTeamJoinRequestsQueryParams {
teamId: string;
}
export class GetTeamJoinRequestsQuery {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetTeamJoinRequestsQueryParams): Promise<TeamJoinRequest[]> {
const { teamId } = params;
return this.membershipRepository.getJoinRequests(teamId);
}
}
export interface GetDriverTeamQueryParams {
driverId: string;
}
export interface GetDriverTeamQueryResult {
team: Team;
membership: TeamMembership;
}
export class GetDriverTeamQuery {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(params: GetDriverTeamQueryParams): Promise<GetDriverTeamQueryResult | null> {
const { driverId } = params;
const membership = await this.membershipRepository.getActiveMembershipForDriver(driverId);
if (!membership) {
return null;
}
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
return null;
}
return { team, membership };
}
}

View File

@@ -0,0 +1,32 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Team } from '../../domain/entities/Team';
import type { UpdateTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
export class UpdateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: UpdateTeamCommandDTO): Promise<void> {
const { teamId, updates, updatedBy } = command;
const updaterMembership = await this.membershipRepository.getMembership(teamId, updatedBy);
if (!updaterMembership || (updaterMembership.role !== 'owner' && updaterMembership.role !== 'manager')) {
throw new Error('Only owners and managers can update team info');
}
const existing = await this.teamRepository.findById(teamId);
if (!existing) {
throw new Error('Team not found');
}
const updated: Team = {
...existing,
...updates,
};
await this.teamRepository.update(updated);
}
}

View File

@@ -1,9 +1,5 @@
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
export interface WithdrawFromRaceCommand {
raceId: string;
driverId: string;
}
import type { WithdrawFromRaceCommandDTO } from '../dto/WithdrawFromRaceCommandDTO';
/**
* Mirrors legacy withdrawFromRace behavior:
@@ -17,7 +13,7 @@ export class WithdrawFromRaceUseCase {
private readonly registrationRepository: IRaceRegistrationRepository,
) {}
async execute(command: WithdrawFromRaceCommand): Promise<void> {
async execute(command: WithdrawFromRaceCommandDTO): Promise<void> {
const { raceId, driverId } = command;
// Let repository enforce "not registered" error behavior to match legacy logic.