Files
gridpilot.gg/apps/website/lib/presenters/LeagueAdminPresenter.ts
2025-12-11 21:06:25 +01:00

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,
};
}