336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { Driver } from '@core/racing/domain/entities/Driver';
|
|
import { League } from '@core/racing/domain/entities/League';
|
|
import { JoinRequest } from '@core/racing/domain/entities/JoinRequest';
|
|
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
|
import { Race } from '@core/racing/domain/entities/Race';
|
|
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
|
|
import { seedId } from './SeedIdHelper';
|
|
|
|
export class RacingMembershipFactory {
|
|
constructor(
|
|
private readonly baseDate: Date,
|
|
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
|
|
) {}
|
|
|
|
createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] {
|
|
const memberships: LeagueMembership[] = [];
|
|
const leagueById = new Map(leagues.map(l => [l.id.toString(), l]));
|
|
|
|
const add = (props: {
|
|
leagueId: string;
|
|
driverId: string;
|
|
role: 'owner' | 'admin' | 'steward' | 'member';
|
|
status: 'active' | 'inactive' | 'pending';
|
|
joinedDaysAgo: number;
|
|
id?: string;
|
|
}): void => {
|
|
memberships.push(
|
|
LeagueMembership.create({
|
|
leagueId: props.leagueId,
|
|
driverId: props.driverId,
|
|
role: props.role,
|
|
status: props.status,
|
|
joinedAt: this.addDays(this.baseDate, -props.joinedDaysAgo),
|
|
...(props.id !== undefined ? { id: props.id } : {}),
|
|
}),
|
|
);
|
|
};
|
|
|
|
// Empty league: intentionally no memberships.
|
|
// (Keep `league-2` empty if it exists.)
|
|
// Widen the type to avoid TS2367 "no overlap" comparisons in some build modes.
|
|
const emptyLeagueId: string | undefined = leagueById.has(seedId('league-2', this.persistence)) ? (seedId('league-2', this.persistence) as string) : undefined;
|
|
|
|
// Demo league: "full" + overbooked with pending/inactive members.
|
|
const demoLeague = leagueById.get(seedId('league-5', this.persistence));
|
|
if (demoLeague) {
|
|
const maxDrivers = demoLeague.settings.maxDrivers ?? 32;
|
|
const activeDrivers = drivers.slice(0, Math.min(maxDrivers, drivers.length));
|
|
|
|
activeDrivers.forEach((driver, idx) => {
|
|
const driverId = driver.id.toString();
|
|
const expectedDriverId = seedId('driver-1', this.persistence);
|
|
const role =
|
|
driverId === expectedDriverId
|
|
? 'owner'
|
|
: idx === 1 || idx === 2
|
|
? 'admin'
|
|
: idx === 3 || idx === 4
|
|
? 'steward'
|
|
: 'member';
|
|
|
|
add({ leagueId: demoLeague.id.toString(), driverId, role, status: 'active', joinedDaysAgo: 60 - idx });
|
|
});
|
|
|
|
// Over-cap edge cases (membership exists but not active / pending)
|
|
const overbooked = drivers.slice(activeDrivers.length, activeDrivers.length + 4);
|
|
overbooked.forEach((driver, idx) => {
|
|
add({
|
|
leagueId: demoLeague.id.toString(),
|
|
driverId: driver.id.toString(),
|
|
role: 'member',
|
|
status: idx % 2 === 0 ? 'pending' : 'inactive',
|
|
joinedDaysAgo: 10 + idx,
|
|
});
|
|
});
|
|
}
|
|
|
|
// League with mixed statuses and roles (but not full).
|
|
const league1 = leagueById.get(seedId('league-1', this.persistence));
|
|
if (league1) {
|
|
const pick = drivers.slice(15, 25);
|
|
pick.forEach((driver, idx) => {
|
|
add({
|
|
leagueId: league1.id.toString(),
|
|
driverId: driver.id.toString(),
|
|
role: idx === 0 ? 'owner' : idx === 1 ? 'steward' : 'member',
|
|
status: idx % 5 === 0 ? 'pending' : idx % 7 === 0 ? 'inactive' : 'active',
|
|
joinedDaysAgo: 30 + idx,
|
|
});
|
|
});
|
|
}
|
|
|
|
// League with only pending memberships (tests "pending list" UX).
|
|
const league4 = leagueById.get(seedId('league-4', this.persistence));
|
|
if (league4) {
|
|
drivers.slice(40, 48).forEach((driver, idx) => {
|
|
add({
|
|
leagueId: league4.id.toString(),
|
|
driverId: driver.id.toString(),
|
|
role: idx === 0 ? 'owner' : 'member',
|
|
status: 'pending',
|
|
joinedDaysAgo: 3 + idx,
|
|
});
|
|
});
|
|
}
|
|
|
|
// Spread remaining drivers across remaining leagues to create realistic overlap.
|
|
for (const driver of drivers) {
|
|
const driverId = driver.id.toString();
|
|
const driverNumber = Number(driverId.split('-')[1]);
|
|
|
|
for (const league of leagues) {
|
|
const leagueId = league.id.toString();
|
|
if (leagueId === seedId('league-5', this.persistence)) continue;
|
|
if (emptyLeagueId && leagueId === emptyLeagueId) continue;
|
|
|
|
if (driverNumber % 11 === 0 && leagueId === seedId('league-3', this.persistence)) {
|
|
add({
|
|
leagueId,
|
|
driverId,
|
|
role: 'member',
|
|
status: 'inactive',
|
|
joinedDaysAgo: 120,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Sparse membership distribution (not every driver in every league)
|
|
if ((driverNumber + Number(leagueId.split('-')[1] ?? 0)) % 9 === 0) {
|
|
add({
|
|
leagueId,
|
|
driverId,
|
|
role: 'member',
|
|
status: 'active',
|
|
joinedDaysAgo: 45,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return memberships;
|
|
}
|
|
|
|
createLeagueJoinRequests(
|
|
drivers: Driver[],
|
|
leagues: League[],
|
|
leagueMemberships: LeagueMembership[],
|
|
): JoinRequest[] {
|
|
const membershipIds = new Set<string>(leagueMemberships.map(m => m.id.toString()));
|
|
const requests: JoinRequest[] = [];
|
|
|
|
const addRequest = (input: { leagueId: string; driverId: string; id?: string; message?: string; requestedAt?: Date }) => {
|
|
requests.push(
|
|
JoinRequest.create({
|
|
leagueId: input.leagueId,
|
|
driverId: input.driverId,
|
|
...(input.id !== undefined && { id: input.id }),
|
|
...(input.message !== undefined && { message: input.message }),
|
|
...(input.requestedAt !== undefined && { requestedAt: input.requestedAt }),
|
|
}),
|
|
);
|
|
};
|
|
|
|
// League with lots of requests + membership/request conflicts (everyone is a member of league-5 already).
|
|
const demoLeagueId = seedId('league-5', this.persistence);
|
|
const demoDrivers = drivers.slice(10, 35);
|
|
demoDrivers.forEach((driver, idx) => {
|
|
const message =
|
|
idx % 4 === 0
|
|
? 'Interested in consistent stewarding and clean racing.'
|
|
: idx % 4 === 1
|
|
? undefined
|
|
: idx % 4 === 2
|
|
? ''
|
|
: 'Can I join mid-season and still be eligible for points?';
|
|
|
|
addRequest({
|
|
leagueId: demoLeagueId,
|
|
driverId: driver.id.toString(),
|
|
requestedAt: this.addDays(this.baseDate, -(7 + idx)),
|
|
...(message !== undefined && { message }),
|
|
});
|
|
});
|
|
|
|
// League with a few "normal" requests (only drivers who are NOT members already).
|
|
const targetLeagueId = seedId('league-1', this.persistence);
|
|
const nonMembers = drivers
|
|
.filter(driver => !membershipIds.has(`${targetLeagueId}:${driver.id.toString()}`))
|
|
.slice(0, 6);
|
|
|
|
nonMembers.forEach((driver, idx) => {
|
|
addRequest({
|
|
leagueId: targetLeagueId,
|
|
driverId: driver.id.toString(),
|
|
requestedAt: this.addDays(this.baseDate, -(3 + idx)),
|
|
...(idx % 2 === 0 && { message: 'Looking for regular endurance rounds and stable race times.' }),
|
|
});
|
|
});
|
|
|
|
// Single request with no message (explicit id).
|
|
const league3Exists = leagues.some(l => l.id.toString() === seedId('league-3', this.persistence));
|
|
if (league3Exists && drivers[0]) {
|
|
addRequest({
|
|
id: seedId('league-3-join-req-1', this.persistence),
|
|
leagueId: seedId('league-3', this.persistence),
|
|
driverId: drivers[0].id.toString(),
|
|
requestedAt: this.addDays(this.baseDate, -9),
|
|
});
|
|
}
|
|
|
|
// Duplicate id edge case (last write wins in in-memory repo).
|
|
if (drivers[1]) {
|
|
addRequest({
|
|
id: seedId('dup-league-join-req-1', this.persistence),
|
|
leagueId: seedId('league-7', this.persistence),
|
|
driverId: drivers[1].id.toString(),
|
|
requestedAt: this.addDays(this.baseDate, -2),
|
|
message: 'First request message (will be overwritten).',
|
|
});
|
|
|
|
addRequest({
|
|
id: seedId('dup-league-join-req-1', this.persistence),
|
|
leagueId: seedId('league-7', this.persistence),
|
|
driverId: drivers[1].id.toString(),
|
|
requestedAt: this.addDays(this.baseDate, -1),
|
|
message: 'Updated request message (duplicate id).',
|
|
});
|
|
}
|
|
|
|
// Explicit conflict: join request exists even though membership exists.
|
|
const expectedDriverId = seedId('driver-1', this.persistence);
|
|
const driver1 = drivers.find(d => d.id.toString() === expectedDriverId);
|
|
if (driver1) {
|
|
addRequest({
|
|
id: seedId('conflict-req-league-5-driver-1', this.persistence),
|
|
leagueId: demoLeagueId,
|
|
driverId: driver1.id.toString(),
|
|
requestedAt: this.addDays(this.baseDate, -15),
|
|
message: 'Testing UI edge case: request exists for an existing member.',
|
|
});
|
|
}
|
|
|
|
return requests;
|
|
}
|
|
|
|
createRaceRegistrations(
|
|
races: Race[],
|
|
drivers: Driver[],
|
|
leagueMemberships: LeagueMembership[],
|
|
): RaceRegistration[] {
|
|
const registrations: RaceRegistration[] = [];
|
|
const activeMembershipKey = new Set(
|
|
leagueMemberships
|
|
.filter(m => m.status.toString() === 'active')
|
|
.map(m => `${m.leagueId.toString()}:${m.driverId.toString()}`),
|
|
);
|
|
|
|
const scheduled = races.filter((r) => r.status.toString() === 'scheduled');
|
|
|
|
for (const race of scheduled) {
|
|
const leagueId = race.leagueId.toString();
|
|
const targetCount = (race as unknown as { registeredCount?: number }).registeredCount ?? 0;
|
|
|
|
// 25%: intentionally no registrations
|
|
if (Number(race.id.toString().split('-')[1] ?? 0) % 4 === 0) {
|
|
continue;
|
|
}
|
|
|
|
const eligibleDrivers = drivers
|
|
.map(d => d.id.toString())
|
|
.filter(driverId => activeMembershipKey.has(`${leagueId}:${driverId}`));
|
|
|
|
const desired = Math.min(
|
|
eligibleDrivers.length,
|
|
Math.max(1, targetCount > 0 ? targetCount : 3),
|
|
);
|
|
|
|
const start = Number(race.id.toString().split('-')[1] ?? 0);
|
|
for (let i = 0; i < desired; i++) {
|
|
const driverId = eligibleDrivers[(start + i) % eligibleDrivers.length];
|
|
if (!driverId) continue;
|
|
|
|
registrations.push(
|
|
RaceRegistration.create({
|
|
raceId: race.id,
|
|
driverId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Edge case: one "outsider" registration (driver not active in league)
|
|
if (eligibleDrivers.length > 0 && drivers.length > eligibleDrivers.length) {
|
|
const outsider = drivers
|
|
.map(d => d.id.toString())
|
|
.find(driverId => !activeMembershipKey.has(`${leagueId}:${driverId}`));
|
|
|
|
if (outsider && start % 7 === 0) {
|
|
registrations.push(
|
|
RaceRegistration.create({
|
|
raceId: race.id,
|
|
driverId: outsider,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Edge case: duplicate registration (should be ignored by repo if unique constrained)
|
|
if (start % 9 === 0 && registrations.length > 0) {
|
|
const last = registrations[registrations.length - 1]!;
|
|
registrations.push(
|
|
RaceRegistration.create({
|
|
raceId: last.raceId.toString(),
|
|
driverId: last.driverId.toString(),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Keep a tiny curated "happy path" for the demo league as well
|
|
const upcomingDemoLeague = races.filter((r) => r.status.toString() === 'scheduled' && r.leagueId === seedId('league-5', this.persistence)).slice(0, 3);
|
|
for (const race of upcomingDemoLeague) {
|
|
registrations.push(
|
|
RaceRegistration.create({
|
|
raceId: race.id,
|
|
driverId: seedId('driver-1', this.persistence),
|
|
}),
|
|
);
|
|
}
|
|
|
|
return registrations;
|
|
}
|
|
|
|
private addDays(date: Date, days: number): Date {
|
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
}
|
|
} |