359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import type { League } from '@gridpilot/racing/domain/entities/League';
|
|
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
|
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
|
import type { LeagueConfigFormViewModel } from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter';
|
|
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
|
|
import type { MembershipRole } from '@/lib/leagueMembership';
|
|
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
|
import {
|
|
getLeagueMembershipRepository,
|
|
getDriverRepository,
|
|
getGetLeagueFullConfigUseCase,
|
|
getRaceRepository,
|
|
getProtestRepository,
|
|
getDriverStats,
|
|
getAllDriverRankings,
|
|
} from '@/lib/di-container';
|
|
|
|
export interface LeagueJoinRequestViewModel {
|
|
id: string;
|
|
leagueId: string;
|
|
driverId: string;
|
|
requestedAt: Date;
|
|
message?: string;
|
|
driver?: DriverDTO;
|
|
}
|
|
|
|
export interface ProtestDriverSummary {
|
|
[driverId: string]: DriverDTO;
|
|
}
|
|
|
|
export interface ProtestRaceSummary {
|
|
[raceId: string]: Race;
|
|
}
|
|
|
|
export interface LeagueOwnerSummaryViewModel {
|
|
driver: DriverDTO;
|
|
rating: number | null;
|
|
rank: number | null;
|
|
}
|
|
|
|
export interface LeagueSummaryViewModel {
|
|
id: string;
|
|
ownerId: string;
|
|
settings: {
|
|
pointsSystem: string;
|
|
};
|
|
}
|
|
|
|
export interface LeagueAdminProtestsViewModel {
|
|
protests: Protest[];
|
|
racesById: ProtestRaceSummary;
|
|
driversById: ProtestDriverSummary;
|
|
}
|
|
|
|
export interface LeagueAdminConfigViewModel {
|
|
form: LeagueConfigFormModel | null;
|
|
}
|
|
|
|
export interface LeagueAdminPermissionsViewModel {
|
|
canRemoveMember: boolean;
|
|
canUpdateRoles: boolean;
|
|
}
|
|
|
|
export interface LeagueAdminViewModel {
|
|
joinRequests: LeagueJoinRequestViewModel[];
|
|
ownerSummary: LeagueOwnerSummaryViewModel | null;
|
|
config: LeagueAdminConfigViewModel;
|
|
protests: LeagueAdminProtestsViewModel;
|
|
}
|
|
|
|
/**
|
|
* Load join requests plus requester driver DTOs for a league.
|
|
*/
|
|
export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
|
|
const membershipRepo = getLeagueMembershipRepository();
|
|
const requests = await membershipRepo.getJoinRequests(leagueId);
|
|
|
|
const driverRepo = getDriverRepository();
|
|
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
|
|
const driverEntities = await Promise.all(uniqueDriverIds.map((id) => driverRepo.findById(id)));
|
|
const driverDtos = driverEntities
|
|
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
|
.filter((dto): dto is DriverDTO => dto !== null);
|
|
|
|
const driversById: Record<string, DriverDTO> = {};
|
|
for (const dto of driverDtos) {
|
|
driversById[dto.id] = dto;
|
|
}
|
|
|
|
return requests.map((request) => {
|
|
const base: LeagueJoinRequestViewModel = {
|
|
id: request.id,
|
|
leagueId: request.leagueId,
|
|
driverId: request.driverId,
|
|
requestedAt: request.requestedAt,
|
|
};
|
|
|
|
const message = request.message;
|
|
const driver = driversById[request.driverId];
|
|
|
|
return {
|
|
...base,
|
|
...(typeof message === 'string' && message.length > 0 ? { message } : {}),
|
|
...(driver ? { driver } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Approve a league join request and return updated join requests.
|
|
*/
|
|
export async function approveLeagueJoinRequest(
|
|
leagueId: string,
|
|
requestId: string
|
|
): Promise<LeagueJoinRequestViewModel[]> {
|
|
const membershipRepo = getLeagueMembershipRepository();
|
|
const requests = await membershipRepo.getJoinRequests(leagueId);
|
|
const request = requests.find((r) => r.id === requestId);
|
|
if (!request) {
|
|
throw new Error('Join request not found');
|
|
}
|
|
|
|
await membershipRepo.saveMembership({
|
|
id: request.id,
|
|
leagueId: request.leagueId,
|
|
driverId: request.driverId,
|
|
role: 'member',
|
|
status: 'active',
|
|
joinedAt: new Date(),
|
|
});
|
|
await membershipRepo.removeJoinRequest(requestId);
|
|
|
|
return loadLeagueJoinRequests(leagueId);
|
|
}
|
|
|
|
/**
|
|
* Reject a league join request (alpha: just remove).
|
|
*/
|
|
export async function rejectLeagueJoinRequest(
|
|
leagueId: string,
|
|
requestId: string
|
|
): Promise<LeagueJoinRequestViewModel[]> {
|
|
const membershipRepo = getLeagueMembershipRepository();
|
|
await membershipRepo.removeJoinRequest(requestId);
|
|
return loadLeagueJoinRequests(leagueId);
|
|
}
|
|
|
|
/**
|
|
* Compute permissions for a performer on league membership actions.
|
|
*/
|
|
export async function getLeagueAdminPermissions(
|
|
leagueId: string,
|
|
performerDriverId: string
|
|
): Promise<LeagueAdminPermissionsViewModel> {
|
|
const membershipRepo = getLeagueMembershipRepository();
|
|
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
|
|
|
const isOwner = performer?.role === 'owner';
|
|
const isAdmin = performer?.role === 'admin';
|
|
|
|
return {
|
|
canRemoveMember: Boolean(isOwner || isAdmin),
|
|
canUpdateRoles: Boolean(isOwner),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove a member from the league, enforcing simple role rules.
|
|
*/
|
|
export async function removeLeagueMember(
|
|
leagueId: string,
|
|
performerDriverId: string,
|
|
targetDriverId: string
|
|
): Promise<void> {
|
|
const membershipRepo = getLeagueMembershipRepository();
|
|
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
|
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
|
throw new Error('Only owners or admins can remove members');
|
|
}
|
|
|
|
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
|
if (!membership) {
|
|
throw new Error('Member not found');
|
|
}
|
|
if (membership.role === 'owner') {
|
|
throw new Error('Cannot remove the league owner');
|
|
}
|
|
|
|
await membershipRepo.removeMembership(leagueId, targetDriverId);
|
|
}
|
|
|
|
/**
|
|
* Update a member's role, enforcing simple owner-only rules.
|
|
*/
|
|
export async function updateLeagueMemberRole(
|
|
leagueId: string,
|
|
performerDriverId: string,
|
|
targetDriverId: string,
|
|
newRole: MembershipRole
|
|
): Promise<void> {
|
|
const membershipRepo = getLeagueMembershipRepository();
|
|
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
|
if (!performer || performer.role !== 'owner') {
|
|
throw new Error('Only the league owner can update roles');
|
|
}
|
|
|
|
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
|
if (!membership) {
|
|
throw new Error('Member not found');
|
|
}
|
|
if (membership.role === 'owner') {
|
|
throw new Error('Cannot change the owner role');
|
|
}
|
|
|
|
await membershipRepo.saveMembership({
|
|
...membership,
|
|
role: newRole,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load owner summary (DTO + rating/rank) for a league.
|
|
*/
|
|
export async function loadLeagueOwnerSummary(params: {
|
|
ownerId: string;
|
|
}): Promise<LeagueOwnerSummaryViewModel | null> {
|
|
const driverRepo = getDriverRepository();
|
|
const entity = await driverRepo.findById(params.ownerId);
|
|
if (!entity) return null;
|
|
|
|
const ownerDriver = EntityMappers.toDriverDTO(entity);
|
|
if (!ownerDriver) {
|
|
return null;
|
|
}
|
|
const stats = getDriverStats(ownerDriver.id);
|
|
const allRankings = getAllDriverRankings();
|
|
|
|
let rating: number | null = stats?.rating ?? null;
|
|
let rank: number | null = null;
|
|
|
|
if (stats) {
|
|
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
|
|
rank = stats.overallRank;
|
|
} else {
|
|
const indexInGlobal = allRankings.findIndex((stat) => stat.driverId === stats.driverId);
|
|
if (indexInGlobal !== -1) {
|
|
rank = indexInGlobal + 1;
|
|
}
|
|
}
|
|
|
|
if (rating === null) {
|
|
const globalEntry = allRankings.find((stat) => stat.driverId === stats.driverId);
|
|
if (globalEntry) {
|
|
rating = globalEntry.rating;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
driver: ownerDriver,
|
|
rating,
|
|
rank,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load league full config form.
|
|
*/
|
|
export async function loadLeagueConfig(
|
|
leagueId: string,
|
|
): Promise<LeagueAdminConfigViewModel> {
|
|
const useCase = getGetLeagueFullConfigUseCase();
|
|
const presenter = new LeagueFullConfigPresenter();
|
|
|
|
await useCase.execute({ leagueId }, presenter);
|
|
const fullConfig = presenter.getViewModel();
|
|
|
|
if (!fullConfig) {
|
|
return { form: null };
|
|
}
|
|
|
|
const formModel: LeagueConfigFormModel = {
|
|
leagueId: fullConfig.leagueId,
|
|
basics: {
|
|
...fullConfig.basics,
|
|
visibility: fullConfig.basics.visibility as LeagueConfigFormModel['basics']['visibility'],
|
|
},
|
|
structure: {
|
|
...fullConfig.structure,
|
|
mode: fullConfig.structure.mode as LeagueConfigFormModel['structure']['mode'],
|
|
},
|
|
championships: fullConfig.championships,
|
|
scoring: fullConfig.scoring,
|
|
dropPolicy: {
|
|
strategy: fullConfig.dropPolicy.strategy as LeagueConfigFormModel['dropPolicy']['strategy'],
|
|
...(fullConfig.dropPolicy.n !== undefined ? { n: fullConfig.dropPolicy.n } : {}),
|
|
},
|
|
timings: fullConfig.timings,
|
|
stewarding: {
|
|
decisionMode: fullConfig.stewarding.decisionMode as LeagueConfigFormModel['stewarding']['decisionMode'],
|
|
...(fullConfig.stewarding.requiredVotes !== undefined
|
|
? { requiredVotes: fullConfig.stewarding.requiredVotes }
|
|
: {}),
|
|
requireDefense: fullConfig.stewarding.requireDefense,
|
|
defenseTimeLimit: fullConfig.stewarding.defenseTimeLimit,
|
|
voteTimeLimit: fullConfig.stewarding.voteTimeLimit,
|
|
protestDeadlineHours: fullConfig.stewarding.protestDeadlineHours,
|
|
stewardingClosesHours: fullConfig.stewarding.stewardingClosesHours,
|
|
notifyAccusedOnProtest: fullConfig.stewarding.notifyAccusedOnProtest,
|
|
notifyOnVoteRequired: fullConfig.stewarding.notifyOnVoteRequired,
|
|
},
|
|
};
|
|
|
|
return { form: formModel };
|
|
}
|
|
|
|
/**
|
|
* Load protests, related races and driver DTOs for a league.
|
|
*/
|
|
export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminProtestsViewModel> {
|
|
const raceRepo = getRaceRepository();
|
|
const protestRepo = getProtestRepository();
|
|
const driverRepo = getDriverRepository();
|
|
|
|
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
|
|
|
const allProtests: Protest[] = [];
|
|
const racesById: Record<string, Race> = {};
|
|
|
|
for (const race of leagueRaces) {
|
|
racesById[race.id] = race;
|
|
const raceProtests = await protestRepo.findByRaceId(race.id);
|
|
allProtests.push(...raceProtests);
|
|
}
|
|
|
|
const driverIds = new Set<string>();
|
|
allProtests.forEach((p) => {
|
|
driverIds.add(p.protestingDriverId);
|
|
driverIds.add(p.accusedDriverId);
|
|
});
|
|
|
|
const driverEntities = await Promise.all(Array.from(driverIds).map((id) => driverRepo.findById(id)));
|
|
const driverDtos = driverEntities
|
|
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
|
.filter((dto): dto is DriverDTO => dto !== null);
|
|
|
|
const driversById: Record<string, DriverDTO> = {};
|
|
for (const dto of driverDtos) {
|
|
driversById[dto.id] = dto;
|
|
}
|
|
|
|
return {
|
|
protests: allProtests,
|
|
racesById,
|
|
driversById,
|
|
};
|
|
} |