wip
This commit is contained in:
@@ -21,6 +21,7 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
getListLeagueScoringPresetsQuery,
|
||||
} from '@/lib/di-container';
|
||||
@@ -52,7 +53,7 @@ import { LeagueStewardingSection } from './LeagueStewardingSection';
|
||||
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
|
||||
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
|
||||
|
||||
function saveFormToStorage(form: LeagueConfigFormModel): void {
|
||||
function saveFormToStorage(form: LeagueWizardFormModel): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
@@ -60,11 +61,16 @@ function saveFormToStorage(form: LeagueConfigFormModel): void {
|
||||
}
|
||||
}
|
||||
|
||||
function loadFormFromStorage(): LeagueConfigFormModel | null {
|
||||
function loadFormFromStorage(): LeagueWizardFormModel | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as LeagueConfigFormModel;
|
||||
const parsed = JSON.parse(stored) as LeagueWizardFormModel;
|
||||
if (!parsed.seasonName) {
|
||||
const seasonStartDate = parsed.timings?.seasonStartDate;
|
||||
parsed.seasonName = getDefaultSeasonName(seasonStartDate);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
@@ -105,6 +111,10 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
|
||||
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
|
||||
|
||||
type LeagueWizardFormModel = LeagueConfigFormModel & {
|
||||
seasonName?: string;
|
||||
};
|
||||
|
||||
interface CreateLeagueWizardProps {
|
||||
stepName: StepName;
|
||||
onStepChange: (stepName: StepName) => void;
|
||||
@@ -160,8 +170,21 @@ function getDefaultSeasonStartDate(): string {
|
||||
return datePart ?? '';
|
||||
}
|
||||
|
||||
function createDefaultForm(): LeagueConfigFormModel {
|
||||
function getDefaultSeasonName(seasonStartDate?: string): string {
|
||||
if (seasonStartDate) {
|
||||
const parsed = new Date(seasonStartDate);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
const year = parsed.getFullYear();
|
||||
return `Season 1 (${year})`;
|
||||
}
|
||||
}
|
||||
const fallbackYear = new Date().getFullYear();
|
||||
return `Season 1 (${fallbackYear})`;
|
||||
}
|
||||
|
||||
function createDefaultForm(): LeagueWizardFormModel {
|
||||
const defaultPatternId = 'sprint-main-driver';
|
||||
const defaultSeasonStartDate = getDefaultSeasonStartDate();
|
||||
|
||||
return {
|
||||
basics: {
|
||||
@@ -201,7 +224,7 @@ function createDefaultForm(): LeagueConfigFormModel {
|
||||
recurrenceStrategy: 'weekly' as const,
|
||||
raceStartTime: '20:00',
|
||||
timezoneId: 'UTC',
|
||||
seasonStartDate: getDefaultSeasonStartDate(),
|
||||
seasonStartDate: defaultSeasonStartDate,
|
||||
},
|
||||
stewarding: {
|
||||
decisionMode: 'admin_only',
|
||||
@@ -214,6 +237,7 @@ function createDefaultForm(): LeagueConfigFormModel {
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
},
|
||||
seasonName: getDefaultSeasonName(defaultSeasonStartDate),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,7 +253,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
// Initialize form from localStorage or defaults
|
||||
const [form, setForm] = useState<LeagueConfigFormModel>(() =>
|
||||
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
|
||||
createDefaultForm(),
|
||||
);
|
||||
|
||||
@@ -405,20 +429,30 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
case 2:
|
||||
return 'Will you compete for global rankings or race with friends?';
|
||||
case 3:
|
||||
return 'Will drivers compete individually or as part of teams?';
|
||||
return 'Define how races in this season will run.';
|
||||
case 4:
|
||||
return 'Configure session durations and plan your season calendar.';
|
||||
return 'Plan when this season’s races happen.';
|
||||
case 5:
|
||||
return 'Select a scoring preset, enable championships, and set drop rules.';
|
||||
return 'Choose how points and drop scores work for this season.';
|
||||
case 6:
|
||||
return 'Configure how protests are handled and penalties decided.';
|
||||
return 'Set how protests and stewarding work for this season.';
|
||||
case 7:
|
||||
return 'Everything looks good? Launch your new league!';
|
||||
return 'Review your league and first season before launching.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepContextLabel = (currentStep: Step): string => {
|
||||
if (currentStep === 1 || currentStep === 2) {
|
||||
return 'League setup';
|
||||
}
|
||||
if (currentStep >= 3 && currentStep <= 6) {
|
||||
return 'Season setup';
|
||||
}
|
||||
return 'League & Season summary';
|
||||
};
|
||||
|
||||
const currentStepData = steps.find((s) => s.id === step);
|
||||
const CurrentStepIcon = currentStepData?.icon ?? FileText;
|
||||
|
||||
@@ -435,7 +469,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
Create a new league
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-500">
|
||||
Set up your racing series in {steps.length} easy steps
|
||||
We'll also set up your first season in {steps.length} easy steps.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
A league is your long-term brand. Each season is a block of races you can run again and again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +594,12 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
|
||||
{getStepTitle(step)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span>{getStepTitle(step)}</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full border border-charcoal-outline bg-iron-gray/60 text-[11px] font-medium text-gray-300">
|
||||
{getStepContextLabel(step)}
|
||||
</span>
|
||||
</div>
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{getStepSubtitle(step)}
|
||||
@@ -575,15 +617,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
|
||||
{/* Step content with min-height for consistency */}
|
||||
<div className="min-h-[320px]">
|
||||
{step === 1 && (
|
||||
<div className="animate-fade-in">
|
||||
<LeagueBasicsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.basics ?? {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<LeagueBasicsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.basics ?? {}}
|
||||
/>
|
||||
<div className="rounded-xl border border-charcoal-outline bg-iron-gray/40 p-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
|
||||
First season
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Name the first season that will run in this league.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mt-2">
|
||||
<label className="text-sm font-medium text-gray-300">
|
||||
Season name
|
||||
</label>
|
||||
<Input
|
||||
value={form.seasonName ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
seasonName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., Season 1 (2025)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="animate-fade-in">
|
||||
@@ -600,7 +672,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
<LeagueStructureSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
@@ -610,7 +690,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
<LeagueTimingsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
@@ -621,6 +709,14 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
|
||||
{step === 5 && (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
{/* Scoring Pattern Selection */}
|
||||
<ScoringPatternSection
|
||||
scoring={form.scoring}
|
||||
@@ -658,7 +754,15 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
<LeagueStewardingSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
@@ -744,7 +848,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="text-center text-xs text-gray-500 mt-4">
|
||||
You can edit all settings after creating your league
|
||||
This will create your league and its first season. You can edit both later.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
loadLeagueProtests,
|
||||
removeLeagueMember as removeLeagueMemberCommand,
|
||||
updateLeagueMemberRole as updateLeagueMemberRoleCommand,
|
||||
loadLeagueSeasons,
|
||||
type LeagueJoinRequestViewModel,
|
||||
type LeagueOwnerSummaryViewModel,
|
||||
type LeagueAdminProtestsViewModel,
|
||||
type LeagueSeasonSummaryViewModel,
|
||||
} from '@/lib/presenters/LeagueAdminPresenter';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter';
|
||||
@@ -51,13 +53,15 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
const [ownerSummary, setOwnerSummary] = useState<LeagueOwnerSummaryViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members');
|
||||
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'seasons' | 'settings' | 'protests' | 'sponsorships' | 'fees' | 'wallet' | 'prizes' | 'liveries'>('members');
|
||||
const [downloadingLiveryPack, setDownloadingLiveryPack] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [protestsViewModel, setProtestsViewModel] = useState<LeagueAdminProtestsViewModel | null>(null);
|
||||
const [protestsLoading, setProtestsLoading] = useState(false);
|
||||
const [seasons, setSeasons] = useState<LeagueSeasonSummaryViewModel[]>([]);
|
||||
const [seasonsLoading, setSeasonsLoading] = useState(false);
|
||||
|
||||
const loadJoinRequests = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -104,6 +108,22 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
loadConfig();
|
||||
}, [league.id]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSeasonsVm() {
|
||||
setSeasonsLoading(true);
|
||||
try {
|
||||
const items = await loadLeagueSeasons(league.id);
|
||||
setSeasons(items);
|
||||
} catch (err) {
|
||||
console.error('Failed to load seasons:', err);
|
||||
} finally {
|
||||
setSeasonsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSeasonsVm();
|
||||
}, [league.id]);
|
||||
|
||||
// Load protests for this league's races
|
||||
useEffect(() => {
|
||||
async function loadProtests() {
|
||||
@@ -257,6 +277,16 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
>
|
||||
Races
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('seasons')}
|
||||
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
|
||||
activeTab === 'seasons'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Seasons
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sponsorships')}
|
||||
className={`pb-3 px-1 font-medium transition-colors whitespace-nowrap ${
|
||||
@@ -429,6 +459,101 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'seasons' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Seasons</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Plan, run, and review seasons inside this league.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{seasonsLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">Loading seasons…</div>
|
||||
) : seasons.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<Calendar className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">No seasons yet.</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
Your first season is created when you set up the league.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const activeCount = seasons.filter((s) => s.status === 'active').length;
|
||||
return seasons.map((season) => {
|
||||
const start = season.startDate ? new Date(season.startDate) : null;
|
||||
const end = season.endDate ? new Date(season.endDate) : null;
|
||||
const hasParallel = activeCount > 1 && season.status === 'active';
|
||||
|
||||
const statusConfig: { label: string; className: string } = (() => {
|
||||
switch (season.status) {
|
||||
case 'planned':
|
||||
return { label: 'Planned', className: 'bg-charcoal-outline/40 text-gray-300 border-charcoal-outline/60' };
|
||||
case 'active':
|
||||
return { label: 'Active', className: 'bg-performance-green/15 text-performance-green border-performance-green/40' };
|
||||
case 'completed':
|
||||
return { label: 'Completed', className: 'bg-primary-blue/15 text-primary-blue border-primary-blue/40' };
|
||||
case 'archived':
|
||||
return { label: 'Archived', className: 'bg-gray-600/20 text-gray-300 border-gray-600/40' };
|
||||
case 'cancelled':
|
||||
case 'canceled':
|
||||
return { label: 'Cancelled', className: 'bg-red-500/10 text-red-400 border-red-500/40' };
|
||||
default:
|
||||
return { label: season.status, className: 'bg-charcoal-outline/40 text-gray-300 border-charcoal-outline/60' };
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={season.seasonId}
|
||||
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{season.name}
|
||||
</h3>
|
||||
{season.isPrimary && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-primary-blue/10 text-primary-blue border border-primary-blue/40">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
{hasParallel && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-warning-amber/10 text-warning-amber border border-warning-amber/40">
|
||||
Parallel
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{start
|
||||
? end
|
||||
? `${start.toLocaleDateString()} – ${end.toLocaleDateString()}`
|
||||
: `Starts ${start.toLocaleDateString()}`
|
||||
: 'No dates configured yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${statusConfig.className}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'protests' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
|
||||
@@ -101,7 +101,8 @@ function FeatureBadge({
|
||||
}
|
||||
|
||||
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
|
||||
const { basics, structure, timings, scoring, championships, dropPolicy } = form;
|
||||
const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form;
|
||||
const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName;
|
||||
|
||||
const modeLabel =
|
||||
structure.mode === 'solo'
|
||||
@@ -147,10 +148,30 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
}
|
||||
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
|
||||
};
|
||||
const dropRuleInfo = getDropRuleInfo();
|
||||
|
||||
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
|
||||
|
||||
const seasonStartLabel =
|
||||
timings.seasonStartDate
|
||||
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
: null;
|
||||
|
||||
const stewardingLabel = (() => {
|
||||
switch (stewarding.decisionMode) {
|
||||
case 'admin_only':
|
||||
return 'Admin-only decisions';
|
||||
case 'steward_vote':
|
||||
return 'Steward panel voting';
|
||||
default:
|
||||
return stewarding.decisionMode;
|
||||
}
|
||||
})();
|
||||
|
||||
const dropRuleInfo = getDropRuleInfo();
|
||||
|
||||
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
|
||||
|
||||
const getScoringEmoji = () => {
|
||||
if (!preset) return '🏁';
|
||||
@@ -173,91 +194,115 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
(timings.qualifyingMinutes ?? 0) +
|
||||
(timings.sprintRaceMinutes ?? 0) +
|
||||
(timings.mainRaceMinutes ?? 0);
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Banner */}
|
||||
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
|
||||
<Rocket className="w-7 h-7 text-primary-blue" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{basics.name || 'Your New League'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
{basics.description || 'Ready to launch your racing series!'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Ranked/Unranked Badge */}
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
|
||||
isRanked
|
||||
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
|
||||
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
|
||||
}`}>
|
||||
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
|
||||
<span className="font-semibold">{visibilityLabel}</span>
|
||||
<span className="text-[10px] opacity-70">• {visibilityDescription}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
|
||||
<Gamepad2 className="w-3 h-3" />
|
||||
iRacing
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
|
||||
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
|
||||
{modeLabel}
|
||||
</span>
|
||||
{/* League Summary */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-300">League summary</h3>
|
||||
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
|
||||
<Rocket className="w-7 h-7 text-primary-blue" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-white mb-1 truncate">
|
||||
{basics.name || 'Your New League'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
{basics.description || 'Ready to launch your racing series!'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Ranked/Unranked Badge */}
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
|
||||
isRanked
|
||||
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
|
||||
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
|
||||
}`}>
|
||||
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
|
||||
<span className="font-semibold">{visibilityLabel}</span>
|
||||
<span className="text-[10px] opacity-70">• {visibilityDescription}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
|
||||
<Gamepad2 className="w-3 h-3" />
|
||||
iRacing
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
|
||||
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
|
||||
{modeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Capacity */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
|
||||
<Users className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{capacityValue}</div>
|
||||
<div className="text-xs text-gray-500">{capacityLabel}</div>
|
||||
|
||||
{/* Season Summary */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-300">First season summary</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
|
||||
<span>{seasonName || 'First season of this league'}</span>
|
||||
{seasonStartLabel && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Starts {seasonStartLabel}</span>
|
||||
</>
|
||||
)}
|
||||
{typeof timings.roundsPlanned === 'number' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{timings.roundsPlanned} rounds planned</span>
|
||||
</>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>Stewarding: {stewardingLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Rounds */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
|
||||
<Flag className="w-5 h-5 text-performance-green" />
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Capacity */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
|
||||
<Users className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{capacityValue}</div>
|
||||
<div className="text-xs text-gray-500">{capacityLabel}</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">rounds</div>
|
||||
</div>
|
||||
|
||||
{/* Weekend Duration */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
|
||||
<Timer className="w-5 h-5 text-warning-amber" />
|
||||
|
||||
{/* Rounds */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
|
||||
<Flag className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">rounds</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
|
||||
<div className="text-xs text-gray-500">min/weekend</div>
|
||||
</div>
|
||||
|
||||
{/* Championships */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
|
||||
<Award className="w-5 h-5 text-neon-aqua" />
|
||||
|
||||
{/* Weekend Duration */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
|
||||
<Timer className="w-5 h-5 text-warning-amber" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
|
||||
<div className="text-xs text-gray-500">min/weekend</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
|
||||
|
||||
{/* Championships */}
|
||||
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
|
||||
<Award className="w-5 h-5 text-neon-aqua" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">championships</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">championships</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Detail Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Schedule Card */}
|
||||
@@ -273,7 +318,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
<InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} />
|
||||
</div>
|
||||
</ReviewCard>
|
||||
|
||||
|
||||
{/* Scoring Card */}
|
||||
<ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System">
|
||||
<div className="space-y-3">
|
||||
@@ -288,7 +333,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
<span className="px-2 py-0.5 rounded bg-primary-blue/20 text-[10px] font-medium text-primary-blue">Custom</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Drop Rule */}
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline/50">
|
||||
@@ -302,7 +347,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
</div>
|
||||
</ReviewCard>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Championships Section */}
|
||||
<ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -339,7 +384,7 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
|
||||
)}
|
||||
</div>
|
||||
</ReviewCard>
|
||||
|
||||
|
||||
{/* Ready to launch message */}
|
||||
<div className="rounded-xl bg-performance-green/5 border border-performance-green/20 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -167,7 +167,10 @@ export function LeagueSponsorshipsSection({
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Sponsorships</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Define pricing for main and secondary sponsor slots
|
||||
Define pricing for sponsor slots in this league. Sponsors pay per season.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
These sponsors are attached to seasons in this league, so you can change partners from season to season.
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function PendingSponsorshipRequests({
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">No pending sponsorship requests</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
When sponsors apply to sponsor this {entityType}, their requests will appear here.
|
||||
When sponsors apply to sponsor this {entityType}, their requests will appear here. Sponsorships are attached to seasons, so you can change partners from season to season.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -233,7 +233,7 @@ export default function PendingSponsorshipRequests({
|
||||
<div className="text-xs text-gray-500 mt-4">
|
||||
<p>
|
||||
<strong className="text-gray-400">Note:</strong> Accepting a request will activate the sponsorship.
|
||||
The sponsor will be charged and you'll receive the payment minus 10% platform fee.
|
||||
The sponsor will be charged per season and you'll receive the payment minus 10% platform fee.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,6 +127,13 @@ function getEntityIcon(type: EntityType) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSponsorshipTagline(type: EntityType): string {
|
||||
if (type === 'league') {
|
||||
return 'Reach engaged sim racers by sponsoring a season in this league.';
|
||||
}
|
||||
return `Reach engaged sim racers by sponsoring this ${getEntityLabel(type).toLowerCase()}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT
|
||||
// ============================================================================
|
||||
@@ -227,7 +234,7 @@ export default function SponsorInsightsCard({
|
||||
<h3 className="text-lg font-semibold text-white">Sponsorship Opportunity</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Reach engaged sim racers by sponsoring this {entityType}
|
||||
{getSponsorshipTagline(entityType)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -412,7 +419,7 @@ export default function SponsorInsightsCard({
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500">
|
||||
10% platform fee applies • Logos burned on all liveries
|
||||
10% platform fee applies • Logos burned on all liveries • Sponsorships are attached to seasons, so you can change partners from season to season
|
||||
{appliedTiers.size > 0 && ' • Application pending review'}
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -113,6 +113,7 @@ import {
|
||||
AcceptSponsorshipRequestUseCase,
|
||||
RejectSponsorshipRequestUseCase,
|
||||
} from '@gridpilot/racing/application';
|
||||
import { ListSeasonsForLeagueUseCase } from '@gridpilot/racing/application/use-cases/SeasonUseCases';
|
||||
import { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
|
||||
import { GetProfileOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetProfileOverviewUseCase';
|
||||
import { UpdateDriverProfileUseCase } from '@gridpilot/racing/application/use-cases/UpdateDriverProfileUseCase';
|
||||
@@ -1007,6 +1008,14 @@ export function configureDIContainer(): void {
|
||||
)
|
||||
);
|
||||
|
||||
container.registerInstance(
|
||||
DI_TOKENS.ListSeasonsForLeagueUseCase,
|
||||
new ListSeasonsForLeagueUseCase(
|
||||
leagueRepository,
|
||||
seasonRepository,
|
||||
)
|
||||
);
|
||||
|
||||
const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter();
|
||||
container.registerInstance(
|
||||
DI_TOKENS.ListLeagueScoringPresetsUseCase,
|
||||
@@ -1349,7 +1358,11 @@ export function configureDIContainer(): void {
|
||||
|
||||
container.registerInstance(
|
||||
DI_TOKENS.AcceptSponsorshipRequestUseCase,
|
||||
new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepository, seasonSponsorshipRepository)
|
||||
new AcceptSponsorshipRequestUseCase(
|
||||
sponsorshipRequestRepository,
|
||||
seasonSponsorshipRepository,
|
||||
seasonRepository,
|
||||
)
|
||||
);
|
||||
|
||||
container.registerInstance(
|
||||
|
||||
@@ -100,6 +100,7 @@ import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application
|
||||
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
|
||||
import type { ListSeasonsForLeagueUseCase } from '@gridpilot/racing/application/use-cases/SeasonUseCases';
|
||||
import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support';
|
||||
|
||||
/**
|
||||
@@ -254,6 +255,11 @@ class DIContainer {
|
||||
return getDIContainer().resolve<GetAllLeaguesWithCapacityAndScoringUseCase>(DI_TOKENS.GetAllLeaguesWithCapacityAndScoringUseCase);
|
||||
}
|
||||
|
||||
get listSeasonsForLeagueUseCase(): ListSeasonsForLeagueUseCase {
|
||||
this.ensureInitialized();
|
||||
return getDIContainer().resolve<ListSeasonsForLeagueUseCase>(DI_TOKENS.ListSeasonsForLeagueUseCase);
|
||||
}
|
||||
|
||||
get listLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUseCase {
|
||||
this.ensureInitialized();
|
||||
return getDIContainer().resolve<ListLeagueScoringPresetsUseCase>(DI_TOKENS.ListLeagueScoringPresetsUseCase);
|
||||
@@ -650,6 +656,10 @@ export function getGetAllLeaguesWithCapacityAndScoringUseCase(): GetAllLeaguesWi
|
||||
return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringUseCase;
|
||||
}
|
||||
|
||||
export function getListSeasonsForLeagueUseCase(): ListSeasonsForLeagueUseCase {
|
||||
return DIContainer.getInstance().listSeasonsForLeagueUseCase;
|
||||
}
|
||||
|
||||
export function getGetLeagueScoringConfigUseCase(): GetLeagueScoringConfigUseCase {
|
||||
return DIContainer.getInstance().getLeagueScoringConfigUseCase;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export const DI_TOKENS = {
|
||||
PreviewLeagueScheduleUseCase: Symbol.for('PreviewLeagueScheduleUseCase'),
|
||||
GetRaceWithSOFUseCase: Symbol.for('GetRaceWithSOFUseCase'),
|
||||
GetLeagueStatsUseCase: Symbol.for('GetLeagueStatsUseCase'),
|
||||
ListSeasonsForLeagueUseCase: Symbol.for('ListSeasonsForLeagueUseCase'),
|
||||
GetRacesPageDataUseCase: Symbol.for('GetRacesPageDataUseCase'),
|
||||
GetAllRacesPageDataUseCase: Symbol.for('GetAllRacesPageDataUseCase'),
|
||||
GetRaceDetailUseCase: Symbol.for('GetRaceDetailUseCase'),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getProtestRepository,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getListSeasonsForLeagueUseCase,
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export interface LeagueJoinRequestViewModel {
|
||||
@@ -63,6 +64,16 @@ export interface LeagueAdminPermissionsViewModel {
|
||||
canUpdateRoles: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueSeasonSummaryViewModel {
|
||||
seasonId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
isPrimary: boolean;
|
||||
isParallelActive: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueAdminViewModel {
|
||||
joinRequests: LeagueJoinRequestViewModel[];
|
||||
ownerSummary: LeagueOwnerSummaryViewModel | null;
|
||||
@@ -356,4 +367,20 @@ export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminP
|
||||
racesById,
|
||||
driversById,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
|
||||
const useCase = getListSeasonsForLeagueUseCase();
|
||||
const result = await useCase.execute({ leagueId });
|
||||
const activeCount = result.items.filter((s) => s.status === 'active').length;
|
||||
|
||||
return result.items.map((s) => ({
|
||||
seasonId: s.seasonId,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
...(s.startDate ? { startDate: s.startDate } : {}),
|
||||
...(s.endDate ? { endDate: s.endDate } : {}),
|
||||
isPrimary: s.isPrimary ?? false,
|
||||
isParallelActive: activeCount > 1 && s.status === 'active',
|
||||
}));
|
||||
}
|
||||
@@ -30,15 +30,19 @@ export class RecordEngagementUseCase
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const event = EngagementEvent.create({
|
||||
const baseProps: Omit<Parameters<typeof EngagementEvent.create>[0], 'timestamp'> = {
|
||||
id: eventId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: input.actorId,
|
||||
actorType: input.actorType,
|
||||
sessionId: input.sessionId,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
|
||||
const event = EngagementEvent.create({
|
||||
...baseProps,
|
||||
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
|
||||
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
|
||||
});
|
||||
|
||||
await this.engagementRepository.save(event);
|
||||
|
||||
@@ -31,16 +31,20 @@ export class RecordPageViewUseCase
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const pageView = PageView.create({
|
||||
const baseProps: Omit<Parameters<typeof PageView.create>[0], 'timestamp'> = {
|
||||
id: pageViewId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
visitorId: input.visitorId,
|
||||
visitorType: input.visitorType,
|
||||
sessionId: input.sessionId,
|
||||
referrer: input.referrer,
|
||||
userAgent: input.userAgent,
|
||||
country: input.country,
|
||||
};
|
||||
|
||||
const pageView = PageView.create({
|
||||
...baseProps,
|
||||
...(input.visitorId !== undefined ? { visitorId: input.visitorId } : {}),
|
||||
...(input.referrer !== undefined ? { referrer: input.referrer } : {}),
|
||||
...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
|
||||
...(input.country !== undefined ? { country: input.country } : {}),
|
||||
});
|
||||
|
||||
await this.pageViewRepository.save(pageView);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
SnapshotEntityType,
|
||||
SnapshotPeriod,
|
||||
} from '../types/AnalyticsSnapshot';
|
||||
export type { SnapshotEntityType, SnapshotPeriod } from '../types/AnalyticsSnapshot';
|
||||
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
|
||||
|
||||
export class AnalyticsSnapshot implements IEntity<string> {
|
||||
|
||||
@@ -56,10 +56,21 @@ export class PageView implements IEntity<string> {
|
||||
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
|
||||
this.validate(props);
|
||||
|
||||
return new PageView({
|
||||
...props,
|
||||
const baseProps: PageViewProps = {
|
||||
id: props.id,
|
||||
entityType: props.entityType,
|
||||
entityId: props.entityId,
|
||||
visitorType: props.visitorType,
|
||||
sessionId: props.sessionId,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
});
|
||||
...(props.visitorId !== undefined ? { visitorId: props.visitorId } : {}),
|
||||
...(props.referrer !== undefined ? { referrer: props.referrer } : {}),
|
||||
...(props.userAgent !== undefined ? { userAgent: props.userAgent } : {}),
|
||||
...(props.country !== undefined ? { country: props.country } : {}),
|
||||
...(props.durationMs !== undefined ? { durationMs: props.durationMs } : {}),
|
||||
};
|
||||
|
||||
return new PageView(baseProps);
|
||||
}
|
||||
|
||||
private static validate(props: Omit<PageViewProps, 'timestamp'>): void {
|
||||
@@ -88,8 +99,17 @@ export class PageView implements IEntity<string> {
|
||||
throw new Error('Duration must be non-negative');
|
||||
}
|
||||
|
||||
return new PageView({
|
||||
...this,
|
||||
return PageView.create({
|
||||
id: this.id,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
visitorType: this.visitorType,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: this.timestamp,
|
||||
...(this.visitorId !== undefined ? { visitorId: this.visitorId } : {}),
|
||||
...(this.referrer !== undefined ? { referrer: this.referrer } : {}),
|
||||
...(this.userAgent !== undefined ? { userAgent: this.userAgent } : {}),
|
||||
...(this.country !== undefined ? { country: this.country } : {}),
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,14 +24,16 @@ export class StartAutomationSessionUseCase
|
||||
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
return {
|
||||
const dto: SessionDTO = {
|
||||
sessionId: session.id,
|
||||
state: session.state.value,
|
||||
currentStep: session.currentStep.value,
|
||||
config: session.config,
|
||||
startedAt: session.startedAt,
|
||||
completedAt: session.completedAt,
|
||||
errorMessage: session.errorMessage,
|
||||
...(session.startedAt ? { startedAt: session.startedAt } : {}),
|
||||
...(session.completedAt ? { completedAt: session.completedAt } : {}),
|
||||
...(session.errorMessage ? { errorMessage: session.errorMessage } : {}),
|
||||
};
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,18 @@ export class CheckoutConfirmation {
|
||||
return new CheckoutConfirmation(value);
|
||||
}
|
||||
|
||||
static confirmed(): CheckoutConfirmation {
|
||||
return CheckoutConfirmation.create('confirmed');
|
||||
}
|
||||
|
||||
static cancelled(_reason?: string): CheckoutConfirmation {
|
||||
return CheckoutConfirmation.create('cancelled');
|
||||
}
|
||||
|
||||
static timeout(): CheckoutConfirmation {
|
||||
return CheckoutConfirmation.create('timeout');
|
||||
}
|
||||
|
||||
get value(): CheckoutConfirmationDecision {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
@@ -54,10 +54,6 @@ export class SessionState implements IValueObject<SessionStateProps> {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: SessionState): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
isPending(): boolean {
|
||||
return this._value === 'PENDING';
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ export class StepId implements IValueObject<StepIdProps> {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: StepId): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
isModalStep(): boolean {
|
||||
return this._value === 6 || this._value === 9 || this._value === 12;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ interface PlaywrightAuthSessionConfig {
|
||||
* - Exposing the IAuthenticationService port for application layer
|
||||
*/
|
||||
export class PlaywrightAuthSessionService implements AuthenticationServicePort {
|
||||
private readonly browserSession: PlaywrightBrowserSession;
|
||||
private readonly cookieStore: SessionCookieStore;
|
||||
private readonly authFlow: IPlaywrightAuthFlow;
|
||||
private readonly logger?: LoggerPort;
|
||||
private readonly browserSession: PlaywrightBrowserSession;
|
||||
private readonly cookieStore: SessionCookieStore;
|
||||
private readonly authFlow: IPlaywrightAuthFlow;
|
||||
private readonly logger: LoggerPort | undefined;
|
||||
|
||||
private readonly navigationTimeoutMs: number;
|
||||
private readonly loginWaitTimeoutMs: number;
|
||||
|
||||
@@ -43,9 +43,9 @@ const EXPIRY_BUFFER_SECONDS = 300;
|
||||
|
||||
export class SessionCookieStore {
|
||||
private readonly storagePath: string;
|
||||
private logger?: LoggerPort;
|
||||
private readonly logger: LoggerPort | undefined;
|
||||
|
||||
constructor(userDataDir: string, logger?: LoggerPort) {
|
||||
constructor(userDataDir: string, logger: LoggerPort | undefined) {
|
||||
this.storagePath = path.join(userDataDir, 'session-state.json');
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
private browserSession: PlaywrightBrowserSession;
|
||||
private connected = false;
|
||||
private isConnecting = false;
|
||||
private logger?: LoggerPort;
|
||||
private logger: LoggerPort | undefined;
|
||||
private cookieStore: SessionCookieStore;
|
||||
private authService: PlaywrightAuthSessionService;
|
||||
private overlayInjected = false;
|
||||
@@ -438,7 +438,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
private static readonly PAUSE_CHECK_INTERVAL = 300;
|
||||
|
||||
/** Checkout confirmation callback - called before clicking checkout button */
|
||||
private checkoutConfirmationCallback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>;
|
||||
private checkoutConfirmationCallback: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation> =
|
||||
async () => CheckoutConfirmation.cancelled('No checkout confirmation callback configured');
|
||||
|
||||
/** Page state validator instance */
|
||||
private pageStateValidator: PageStateValidator;
|
||||
@@ -448,7 +449,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
private domInteractor!: IRacingDomInteractor;
|
||||
private readonly stepOrchestrator: WizardStepOrchestrator;
|
||||
|
||||
constructor(config: PlaywrightConfig = {}, logger?: LoggerPort, browserModeLoader?: BrowserModeConfigLoader) {
|
||||
constructor(config: PlaywrightConfig = {}, logger: LoggerPort | undefined, browserModeLoader?: BrowserModeConfigLoader) {
|
||||
this.config = {
|
||||
headless: true,
|
||||
timeout: 10000,
|
||||
@@ -627,7 +628,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
async connect(forceHeaded: boolean = false): Promise<AutomationResultDTO> {
|
||||
const result = await this.browserSession.connect(forceHeaded);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
const errorMessage = result.error ?? 'Unknown automation connection error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
this.syncSessionStateFromBrowser();
|
||||
@@ -1333,12 +1335,10 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
|
||||
// Get ONLY the direct text of this element, excluding child element text
|
||||
// This prevents false positives when a checkout button exists elsewhere on the page
|
||||
const directText = await element.evaluate((el) => {
|
||||
// Get only direct text nodes, not text from child elements
|
||||
const directText = await element.evaluate((el: Node) => {
|
||||
let text = '';
|
||||
const childNodes = Array.from(el.childNodes);
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
const node = childNodes[i];
|
||||
const childNodes = Array.from((el as HTMLElement).childNodes);
|
||||
for (const node of childNodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent || '';
|
||||
}
|
||||
@@ -2296,8 +2296,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
|
||||
if (!el) return;
|
||||
el.value = val;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, { sel: selector, val: value });
|
||||
return { success: true, fieldName, valueSet: value };
|
||||
} catch (evalErr) {
|
||||
@@ -2495,11 +2495,11 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
// If element is a checkbox/input, set checked; otherwise try to toggle aria-checked or click
|
||||
if ('checked' in el) {
|
||||
(el as HTMLInputElement).checked = Boolean(should);
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
// Fallback: set aria-checked attribute and dispatch click
|
||||
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
|
||||
try { (el as HTMLElement).click(); } catch { /* ignore */ }
|
||||
}
|
||||
} catch {
|
||||
@@ -3013,7 +3013,12 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
setCheckoutConfirmationCallback(
|
||||
callback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>
|
||||
): void {
|
||||
this.checkoutConfirmationCallback = callback;
|
||||
if (callback) {
|
||||
this.checkoutConfirmationCallback = callback;
|
||||
} else {
|
||||
this.checkoutConfirmationCallback = async () =>
|
||||
CheckoutConfirmation.cancelled('No checkout confirmation callback configured');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Overlay Methods =====
|
||||
@@ -3085,7 +3090,13 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
const progress = Math.round((step / this.totalSteps) * 100);
|
||||
const personality = OVERLAY_PERSONALITY_MESSAGES[Math.floor(Math.random() * OVERLAY_PERSONALITY_MESSAGES.length)];
|
||||
|
||||
await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }) => {
|
||||
await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }: {
|
||||
actionMsg: string;
|
||||
progressPct: number;
|
||||
stepNum: number;
|
||||
totalSteps: number;
|
||||
personalityMsg: string | undefined;
|
||||
}) => {
|
||||
const actionEl = document.getElementById('gridpilot-action');
|
||||
const progressEl = document.getElementById('gridpilot-progress');
|
||||
const stepTextEl = document.getElementById('gridpilot-step-text');
|
||||
@@ -3094,9 +3105,9 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
|
||||
|
||||
if (actionEl) actionEl.textContent = actionMsg;
|
||||
if (progressEl) progressEl.style.width = `${progressPct}%`;
|
||||
if (stepTextEl) stepTextEl.textContent = actionMsg;
|
||||
if (stepTextEl) stepTextEl.textContent = actionMsg ?? '';
|
||||
if (stepCountEl) stepCountEl.textContent = `Step ${stepNum} of ${totalSteps}`;
|
||||
if (personalityEl) personalityEl.textContent = personalityMsg;
|
||||
if (personalityEl) personalityEl.textContent = personalityMsg ?? '';
|
||||
}, {
|
||||
actionMsg: actionMessage,
|
||||
progressPct: progress,
|
||||
|
||||
@@ -26,7 +26,7 @@ interface WizardStepOrchestratorDeps {
|
||||
navigator: IRacingDomNavigator;
|
||||
interactor: IRacingDomInteractor;
|
||||
authService: AuthenticationServicePort;
|
||||
logger?: LoggerPort;
|
||||
logger?: LoggerPort | undefined;
|
||||
totalSteps: number;
|
||||
getCheckoutConfirmationCallback: () =>
|
||||
| ((
|
||||
@@ -68,7 +68,7 @@ export class WizardStepOrchestrator {
|
||||
private readonly navigator: IRacingDomNavigator;
|
||||
private readonly interactor: IRacingDomInteractor;
|
||||
private readonly authService: AuthenticationServicePort;
|
||||
private readonly logger?: LoggerPort;
|
||||
private readonly logger: LoggerPort | undefined;
|
||||
private readonly totalSteps: number;
|
||||
private readonly getCheckoutConfirmationCallbackInternal: WizardStepOrchestratorDeps['getCheckoutConfirmationCallback'];
|
||||
private readonly overlay: WizardStepOrchestratorDeps['overlay'];
|
||||
|
||||
@@ -63,7 +63,7 @@ export class IRacingDomInteractor {
|
||||
return { success: false, fieldName, valueSet: value, error: `Unknown form field: ${fieldName}` };
|
||||
}
|
||||
|
||||
const selector = fieldMap[fieldName];
|
||||
const selector = fieldMap[fieldName as keyof typeof fieldMap] ?? IRACING_SELECTORS.fields.textInput;
|
||||
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
|
||||
|
||||
this.log('debug', 'Filling form field', { fieldName, selector, mode: this.config.mode });
|
||||
@@ -90,8 +90,8 @@ export class IRacingDomInteractor {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
|
||||
if (!el) return;
|
||||
el.value = val;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, { sel: selector, val: value });
|
||||
return { success: true, fieldName, valueSet: value };
|
||||
} catch (evalErr) {
|
||||
@@ -514,10 +514,10 @@ export class IRacingDomInteractor {
|
||||
try {
|
||||
if ('checked' in el) {
|
||||
(el as HTMLInputElement).checked = Boolean(should);
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
|
||||
try {
|
||||
(el as HTMLElement).click();
|
||||
} catch {
|
||||
@@ -621,8 +621,10 @@ export class IRacingDomInteractor {
|
||||
try {
|
||||
el.value = String(val);
|
||||
el.setAttribute('data-value', String(val));
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
const inputEvent = new Event('input', { bubbles: true });
|
||||
const changeEvent = new Event('change', { bubbles: true });
|
||||
(el as any).dispatchEvent(inputEvent);
|
||||
(el as any).dispatchEvent(changeEvent);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -67,10 +67,13 @@ export class IRacingDomNavigator {
|
||||
if (!this.isRealMode()) {
|
||||
const stepMatch = url.match(/step-(\d+)-/);
|
||||
if (stepMatch) {
|
||||
const stepNumber = parseInt(stepMatch[1], 10);
|
||||
await page.evaluate((step) => {
|
||||
document.body.setAttribute('data-step', String(step));
|
||||
}, stepNumber);
|
||||
const [, stepStr] = stepMatch;
|
||||
if (stepStr) {
|
||||
const stepNumber = parseInt(stepStr, 10);
|
||||
await page.evaluate((step) => {
|
||||
document.body.setAttribute('data-step', String(step));
|
||||
}, stepNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,10 +136,9 @@ export class SafeClickService {
|
||||
.evaluate((el) => {
|
||||
let text = '';
|
||||
const childNodes = Array.from(el.childNodes);
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
const node = childNodes[i];
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent || '';
|
||||
for (const child of childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
text += child.textContent || '';
|
||||
}
|
||||
}
|
||||
return text.trim();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export class FixtureServer implements IFixtureServer {
|
||||
|
||||
let fileName: string;
|
||||
if (urlPath === '/') {
|
||||
fileName = STEP_TO_FIXTURE[1];
|
||||
fileName = STEP_TO_FIXTURE[1] ?? '01-hosted-racing.html';
|
||||
} else {
|
||||
fileName = urlPath.replace(/^\//, '');
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO'
|
||||
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
|
||||
import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO';
|
||||
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
|
||||
import type { IAutomationLifecycleEmitter, LifecycleCallback } from '../../../IAutomationLifecycleEmitter';
|
||||
import type { IAutomationLifecycleEmitter, LifecycleCallback } from '../../IAutomationLifecycleEmitter';
|
||||
|
||||
interface MockConfig {
|
||||
simulateFailures?: boolean;
|
||||
|
||||
@@ -38,7 +38,7 @@ export class Achievement implements IEntity<string> {
|
||||
readonly description: string;
|
||||
readonly category: AchievementCategory;
|
||||
readonly rarity: AchievementRarity;
|
||||
readonly iconUrl?: string;
|
||||
readonly iconUrl: string;
|
||||
readonly points: number;
|
||||
readonly requirements: AchievementRequirement[];
|
||||
readonly isSecret: boolean;
|
||||
@@ -50,7 +50,7 @@ export class Achievement implements IEntity<string> {
|
||||
this.description = props.description;
|
||||
this.category = props.category;
|
||||
this.rarity = props.rarity;
|
||||
this.iconUrl = props.iconUrl;
|
||||
this.iconUrl = props.iconUrl ?? '';
|
||||
this.points = props.points;
|
||||
this.requirements = props.requirements;
|
||||
this.isSecret = props.isSecret;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface SponsorAccountProps {
|
||||
passwordHash: string;
|
||||
companyName: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
createdAt?: Date;
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export class SponsorAccount {
|
||||
this.passwordHash = props.passwordHash;
|
||||
this.companyName = props.companyName;
|
||||
this.isActive = props.isActive;
|
||||
this.createdAt = props.createdAt;
|
||||
this.createdAt = props.createdAt ?? new Date();
|
||||
this.lastLoginAt = props.lastLoginAt;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export class SponsorAccount {
|
||||
companyName: this.companyName,
|
||||
isActive: false,
|
||||
createdAt: this.createdAt,
|
||||
lastLoginAt: this.lastLoginAt,
|
||||
...(this.lastLoginAt ? { lastLoginAt: this.lastLoginAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export class SponsorAccount {
|
||||
companyName: this.companyName,
|
||||
isActive: this.isActive,
|
||||
createdAt: this.createdAt,
|
||||
lastLoginAt: this.lastLoginAt,
|
||||
...(this.lastLoginAt ? { lastLoginAt: this.lastLoginAt } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ export interface UserProps {
|
||||
export class User {
|
||||
private readonly id: UserId;
|
||||
private displayName: string;
|
||||
private email?: string;
|
||||
private iracingCustomerId?: string;
|
||||
private primaryDriverId?: string;
|
||||
private avatarUrl?: string;
|
||||
private email: string | undefined;
|
||||
private iracingCustomerId: string | undefined;
|
||||
private primaryDriverId: string | undefined;
|
||||
private avatarUrl: string | undefined;
|
||||
|
||||
private constructor(props: UserProps) {
|
||||
if (!props.displayName || !props.displayName.trim()) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export class UserAchievement implements IEntity<string> {
|
||||
readonly userId: string;
|
||||
readonly achievementId: string;
|
||||
readonly earnedAt: Date;
|
||||
readonly notifiedAt?: Date;
|
||||
readonly notifiedAt: Date | undefined;
|
||||
readonly progress: number;
|
||||
|
||||
private constructor(props: UserAchievementProps) {
|
||||
@@ -53,8 +53,12 @@ export class UserAchievement implements IEntity<string> {
|
||||
*/
|
||||
markNotified(): UserAchievement {
|
||||
return new UserAchievement({
|
||||
...this,
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
achievementId: this.achievementId,
|
||||
earnedAt: this.earnedAt,
|
||||
notifiedAt: new Date(),
|
||||
progress: this.progress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +68,11 @@ export class UserAchievement implements IEntity<string> {
|
||||
updateProgress(progress: number): UserAchievement {
|
||||
const clampedProgress = Math.max(0, Math.min(100, progress));
|
||||
return new UserAchievement({
|
||||
...this,
|
||||
id: this.id,
|
||||
userId: this.userId,
|
||||
achievementId: this.achievementId,
|
||||
earnedAt: this.earnedAt,
|
||||
...(this.notifiedAt ? { notifiedAt: this.notifiedAt } : {}),
|
||||
progress: clampedProgress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface DiscordAdapterConfig {
|
||||
|
||||
export class DiscordNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'discord';
|
||||
private webhookUrl?: string;
|
||||
private webhookUrl: string | undefined;
|
||||
|
||||
constructor(config: DiscordAdapterConfig = {}) {
|
||||
this.webhookUrl = config.webhookUrl;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
|
||||
@@ -29,6 +30,7 @@ export class AcceptSponsorshipRequestUseCase
|
||||
constructor(
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
) {}
|
||||
|
||||
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
|
||||
@@ -50,9 +52,15 @@ export class AcceptSponsorshipRequestUseCase
|
||||
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
if (request.entityType === 'season') {
|
||||
const season = await this.seasonRepository.findById(request.entityId);
|
||||
if (!season) {
|
||||
throw new Error('Season not found for sponsorship request');
|
||||
}
|
||||
|
||||
const sponsorship = SeasonSponsorship.create({
|
||||
id: sponsorshipId,
|
||||
seasonId: request.entityId,
|
||||
seasonId: season.id,
|
||||
leagueId: season.leagueId,
|
||||
sponsorId: request.sponsorId,
|
||||
tier: request.tier,
|
||||
pricing: request.offeredAmount,
|
||||
|
||||
460
packages/racing/application/use-cases/SeasonUseCases.ts
Normal file
460
packages/racing/application/use-cases/SeasonUseCases.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import { Season } from '../../domain/entities/Season';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
|
||||
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
|
||||
import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig';
|
||||
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
|
||||
import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig';
|
||||
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
|
||||
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
|
||||
import type { Weekday } from '../../domain/types/Weekday';
|
||||
import { normalizeVisibility } from '../dto/LeagueConfigFormDTO';
|
||||
import { LeagueVisibility } from '../../domain/value-objects/LeagueVisibility';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* DTOs and helpers shared across Season-focused use cases.
|
||||
*/
|
||||
|
||||
export interface CreateSeasonForLeagueCommand {
|
||||
leagueId: string;
|
||||
name: string;
|
||||
gameId: string;
|
||||
sourceSeasonId?: string;
|
||||
/**
|
||||
* Optional high-level wizard config used to derive schedule/scoring/drop/stewarding.
|
||||
* When omitted, the Season will be created with minimal metadata only.
|
||||
*/
|
||||
config?: LeagueConfigFormModel;
|
||||
}
|
||||
|
||||
export interface CreateSeasonForLeagueResultDTO {
|
||||
seasonId: string;
|
||||
}
|
||||
|
||||
export interface SeasonSummaryDTO {
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
name: string;
|
||||
status: import('../../domain/entities/Season').SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface ListSeasonsForLeagueQuery {
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export interface ListSeasonsForLeagueResultDTO {
|
||||
items: SeasonSummaryDTO[];
|
||||
}
|
||||
|
||||
export interface GetSeasonDetailsQuery {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
}
|
||||
|
||||
export interface SeasonDetailsDTO {
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
gameId: string;
|
||||
name: string;
|
||||
status: import('../../domain/entities/Season').SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
maxDrivers?: number;
|
||||
schedule?: {
|
||||
startDate: Date;
|
||||
plannedRounds: number;
|
||||
};
|
||||
scoring?: {
|
||||
scoringPresetId: string;
|
||||
customScoringEnabled: boolean;
|
||||
};
|
||||
dropPolicy?: {
|
||||
strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy;
|
||||
n?: number;
|
||||
};
|
||||
stewarding?: {
|
||||
decisionMode: import('../../domain/entities/League').StewardingDecisionMode;
|
||||
requiredVotes?: number;
|
||||
requireDefense: boolean;
|
||||
defenseTimeLimit: number;
|
||||
voteTimeLimit: number;
|
||||
protestDeadlineHours: number;
|
||||
stewardingClosesHours: number;
|
||||
notifyAccusedOnProtest: boolean;
|
||||
notifyOnVoteRequired: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type SeasonLifecycleTransition =
|
||||
| 'activate'
|
||||
| 'complete'
|
||||
| 'archive'
|
||||
| 'cancel';
|
||||
|
||||
export interface ManageSeasonLifecycleCommand {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
transition: SeasonLifecycleTransition;
|
||||
}
|
||||
|
||||
export interface ManageSeasonLifecycleResultDTO {
|
||||
seasonId: string;
|
||||
status: import('../../domain/entities/Season').SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateSeasonForLeagueUseCase
|
||||
*
|
||||
* Creates a new Season for an existing League, optionally cloning or deriving
|
||||
* configuration from a source Season or a league config form.
|
||||
*/
|
||||
export class CreateSeasonForLeagueUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: CreateSeasonForLeagueCommand,
|
||||
): Promise<CreateSeasonForLeagueResultDTO> {
|
||||
const league = await this.leagueRepository.findById(command.leagueId);
|
||||
if (!league) {
|
||||
throw new Error(`League not found: ${command.leagueId}`);
|
||||
}
|
||||
|
||||
let baseSeasonProps: {
|
||||
schedule?: SeasonSchedule;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
} = {};
|
||||
|
||||
if (command.sourceSeasonId) {
|
||||
const source = await this.seasonRepository.findById(command.sourceSeasonId);
|
||||
if (!source) {
|
||||
throw new Error(`Source Season not found: ${command.sourceSeasonId}`);
|
||||
}
|
||||
baseSeasonProps = {
|
||||
...(source.schedule !== undefined ? { schedule: source.schedule } : {}),
|
||||
...(source.scoringConfig !== undefined
|
||||
? { scoringConfig: source.scoringConfig }
|
||||
: {}),
|
||||
...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}),
|
||||
...(source.stewardingConfig !== undefined
|
||||
? { stewardingConfig: source.stewardingConfig }
|
||||
: {}),
|
||||
...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}),
|
||||
};
|
||||
} else if (command.config) {
|
||||
baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config);
|
||||
}
|
||||
|
||||
const seasonId = uuidv4();
|
||||
|
||||
const season = Season.create({
|
||||
id: seasonId,
|
||||
leagueId: league.id,
|
||||
gameId: command.gameId,
|
||||
name: command.name,
|
||||
year: new Date().getFullYear(),
|
||||
status: 'planned',
|
||||
...(baseSeasonProps?.schedule
|
||||
? { schedule: baseSeasonProps.schedule }
|
||||
: {}),
|
||||
...(baseSeasonProps?.scoringConfig
|
||||
? { scoringConfig: baseSeasonProps.scoringConfig }
|
||||
: {}),
|
||||
...(baseSeasonProps?.dropPolicy
|
||||
? { dropPolicy: baseSeasonProps.dropPolicy }
|
||||
: {}),
|
||||
...(baseSeasonProps?.stewardingConfig
|
||||
? { stewardingConfig: baseSeasonProps.stewardingConfig }
|
||||
: {}),
|
||||
...(baseSeasonProps?.maxDrivers !== undefined
|
||||
? { maxDrivers: baseSeasonProps.maxDrivers }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await this.seasonRepository.add(season);
|
||||
|
||||
return { seasonId };
|
||||
}
|
||||
|
||||
private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): {
|
||||
schedule?: SeasonSchedule;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
} {
|
||||
const schedule = this.buildScheduleFromTimings(config);
|
||||
const scoringConfig = new SeasonScoringConfig({
|
||||
scoringPresetId: config.scoring.patternId ?? 'custom',
|
||||
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
|
||||
});
|
||||
const dropPolicy = new SeasonDropPolicy({
|
||||
strategy: config.dropPolicy.strategy,
|
||||
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
|
||||
});
|
||||
const stewardingConfig = new SeasonStewardingConfig({
|
||||
decisionMode: config.stewarding.decisionMode,
|
||||
...(config.stewarding.requiredVotes !== undefined
|
||||
? { requiredVotes: config.stewarding.requiredVotes }
|
||||
: {}),
|
||||
requireDefense: config.stewarding.requireDefense,
|
||||
defenseTimeLimit: config.stewarding.defenseTimeLimit,
|
||||
voteTimeLimit: config.stewarding.voteTimeLimit,
|
||||
protestDeadlineHours: config.stewarding.protestDeadlineHours,
|
||||
stewardingClosesHours: config.stewarding.stewardingClosesHours,
|
||||
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
|
||||
});
|
||||
|
||||
const structure = config.structure;
|
||||
const maxDrivers =
|
||||
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
|
||||
? structure.maxDrivers
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(schedule !== undefined ? { schedule } : {}),
|
||||
scoringConfig,
|
||||
dropPolicy,
|
||||
stewardingConfig,
|
||||
...(maxDrivers !== undefined ? { maxDrivers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildScheduleFromTimings(
|
||||
config: LeagueConfigFormModel,
|
||||
): SeasonSchedule | undefined {
|
||||
const { timings } = config;
|
||||
if (!timings.seasonStartDate || !timings.raceStartTime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startDate = new Date(timings.seasonStartDate);
|
||||
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
|
||||
const timezoneId = timings.timezoneId ?? 'UTC';
|
||||
const timezone = new LeagueTimezone(timezoneId);
|
||||
|
||||
const plannedRounds =
|
||||
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
|
||||
? timings.roundsPlanned
|
||||
: timings.sessionCount;
|
||||
|
||||
const recurrence = (() => {
|
||||
const weekdays: WeekdaySet =
|
||||
timings.weekdays && timings.weekdays.length > 0
|
||||
? WeekdaySet.fromArray(
|
||||
timings.weekdays as unknown as Weekday[],
|
||||
)
|
||||
: WeekdaySet.fromArray(['Mon']);
|
||||
switch (timings.recurrenceStrategy) {
|
||||
case 'everyNWeeks':
|
||||
return RecurrenceStrategyFactory.everyNWeeks(
|
||||
timings.intervalWeeks ?? 2,
|
||||
weekdays,
|
||||
);
|
||||
case 'monthlyNthWeekday': {
|
||||
const pattern = new MonthlyRecurrencePattern({
|
||||
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
|
||||
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday,
|
||||
});
|
||||
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
|
||||
}
|
||||
case 'weekly':
|
||||
default:
|
||||
return RecurrenceStrategyFactory.weekly(weekdays);
|
||||
}
|
||||
})();
|
||||
|
||||
return new SeasonSchedule({
|
||||
startDate,
|
||||
timeOfDay,
|
||||
timezone,
|
||||
recurrence,
|
||||
plannedRounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ListSeasonsForLeagueUseCase
|
||||
*/
|
||||
export class ListSeasonsForLeagueUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: ListSeasonsForLeagueQuery,
|
||||
): Promise<ListSeasonsForLeagueResultDTO> {
|
||||
const league = await this.leagueRepository.findById(query.leagueId);
|
||||
if (!league) {
|
||||
throw new Error(`League not found: ${query.leagueId}`);
|
||||
}
|
||||
|
||||
const seasons = await this.seasonRepository.listByLeague(league.id);
|
||||
const items: SeasonSummaryDTO[] = seasons.map((s) => ({
|
||||
seasonId: s.id,
|
||||
leagueId: s.leagueId,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
...(s.startDate !== undefined ? { startDate: s.startDate } : {}),
|
||||
...(s.endDate !== undefined ? { endDate: s.endDate } : {}),
|
||||
// League currently does not track primarySeasonId, so mark false for now.
|
||||
isPrimary: false,
|
||||
}));
|
||||
|
||||
return { items };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GetSeasonDetailsUseCase
|
||||
*/
|
||||
export class GetSeasonDetailsUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSeasonDetailsQuery): Promise<SeasonDetailsDTO> {
|
||||
const league = await this.leagueRepository.findById(query.leagueId);
|
||||
if (!league) {
|
||||
throw new Error(`League not found: ${query.leagueId}`);
|
||||
}
|
||||
|
||||
const season = await this.seasonRepository.findById(query.seasonId);
|
||||
if (!season || season.leagueId !== league.id) {
|
||||
throw new Error(
|
||||
`Season ${query.seasonId} does not belong to league ${league.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
seasonId: season.id,
|
||||
leagueId: season.leagueId,
|
||||
gameId: season.gameId,
|
||||
name: season.name,
|
||||
status: season.status,
|
||||
...(season.startDate !== undefined ? { startDate: season.startDate } : {}),
|
||||
...(season.endDate !== undefined ? { endDate: season.endDate } : {}),
|
||||
...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}),
|
||||
...(season.schedule
|
||||
? {
|
||||
schedule: {
|
||||
startDate: season.schedule.startDate,
|
||||
plannedRounds: season.schedule.plannedRounds,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(season.scoringConfig
|
||||
? {
|
||||
scoring: {
|
||||
scoringPresetId: season.scoringConfig.scoringPresetId,
|
||||
customScoringEnabled:
|
||||
season.scoringConfig.customScoringEnabled ?? false,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(season.dropPolicy
|
||||
? {
|
||||
dropPolicy: {
|
||||
strategy: season.dropPolicy.strategy,
|
||||
...(season.dropPolicy.n !== undefined
|
||||
? { n: season.dropPolicy.n }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(season.stewardingConfig
|
||||
? {
|
||||
stewarding: {
|
||||
decisionMode: season.stewardingConfig.decisionMode,
|
||||
...(season.stewardingConfig.requiredVotes !== undefined
|
||||
? { requiredVotes: season.stewardingConfig.requiredVotes }
|
||||
: {}),
|
||||
requireDefense: season.stewardingConfig.requireDefense,
|
||||
defenseTimeLimit: season.stewardingConfig.defenseTimeLimit,
|
||||
voteTimeLimit: season.stewardingConfig.voteTimeLimit,
|
||||
protestDeadlineHours:
|
||||
season.stewardingConfig.protestDeadlineHours,
|
||||
stewardingClosesHours:
|
||||
season.stewardingConfig.stewardingClosesHours,
|
||||
notifyAccusedOnProtest:
|
||||
season.stewardingConfig.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired:
|
||||
season.stewardingConfig.notifyOnVoteRequired,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ManageSeasonLifecycleUseCase
|
||||
*/
|
||||
export class ManageSeasonLifecycleUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: ManageSeasonLifecycleCommand,
|
||||
): Promise<ManageSeasonLifecycleResultDTO> {
|
||||
const league = await this.leagueRepository.findById(command.leagueId);
|
||||
if (!league) {
|
||||
throw new Error(`League not found: ${command.leagueId}`);
|
||||
}
|
||||
|
||||
const season = await this.seasonRepository.findById(command.seasonId);
|
||||
if (!season || season.leagueId !== league.id) {
|
||||
throw new Error(
|
||||
`Season ${command.seasonId} does not belong to league ${league.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
let updated: Season;
|
||||
switch (command.transition) {
|
||||
case 'activate':
|
||||
updated = season.activate();
|
||||
break;
|
||||
case 'complete':
|
||||
updated = season.complete();
|
||||
break;
|
||||
case 'archive':
|
||||
updated = season.archive();
|
||||
break;
|
||||
case 'cancel':
|
||||
updated = season.cancel();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported Season lifecycle transition`);
|
||||
}
|
||||
|
||||
await this.seasonRepository.update(updated);
|
||||
|
||||
return {
|
||||
seasonId: updated.id,
|
||||
status: updated.status,
|
||||
...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}),
|
||||
...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ export interface DriverLiveryProps {
|
||||
userDecals: LiveryDecal[];
|
||||
leagueOverrides: DecalOverride[];
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
validatedAt?: Date;
|
||||
updatedAt: Date | undefined;
|
||||
validatedAt: Date | undefined;
|
||||
}
|
||||
|
||||
export class DriverLivery implements IEntity<string> {
|
||||
@@ -39,8 +39,8 @@ export class DriverLivery implements IEntity<string> {
|
||||
readonly userDecals: LiveryDecal[];
|
||||
readonly leagueOverrides: DecalOverride[];
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt?: Date;
|
||||
readonly validatedAt?: Date;
|
||||
readonly updatedAt: Date | undefined;
|
||||
readonly validatedAt: Date | undefined;
|
||||
|
||||
private constructor(props: DriverLiveryProps) {
|
||||
this.id = props.id;
|
||||
@@ -50,7 +50,7 @@ export class DriverLivery implements IEntity<string> {
|
||||
this.uploadedImageUrl = props.uploadedImageUrl;
|
||||
this.userDecals = props.userDecals;
|
||||
this.leagueOverrides = props.leagueOverrides;
|
||||
this.createdAt = props.createdAt;
|
||||
this.createdAt = props.createdAt ?? new Date();
|
||||
this.updatedAt = props.updatedAt;
|
||||
this.validatedAt = props.validatedAt;
|
||||
}
|
||||
@@ -101,9 +101,16 @@ export class DriverLivery implements IEntity<string> {
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
...this,
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: [...this.userDecals, decal],
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,9 +125,16 @@ export class DriverLivery implements IEntity<string> {
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
...this,
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: updatedDecals,
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,16 +145,23 @@ export class DriverLivery implements IEntity<string> {
|
||||
const index = this.userDecals.findIndex(d => d.id === decalId);
|
||||
|
||||
if (index === -1) {
|
||||
throw new RacingDomainError('Decal not found in livery');
|
||||
throw new RacingDomainValidationError('Decal not found in livery');
|
||||
}
|
||||
|
||||
const updatedDecals = [...this.userDecals];
|
||||
updatedDecals[index] = updatedDecal;
|
||||
|
||||
return new DriverLivery({
|
||||
...this,
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: updatedDecals,
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,9 +184,16 @@ export class DriverLivery implements IEntity<string> {
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
...this,
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: this.userDecals,
|
||||
leagueOverrides: updatedOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,9 +206,16 @@ export class DriverLivery implements IEntity<string> {
|
||||
);
|
||||
|
||||
return new DriverLivery({
|
||||
...this,
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: this.userDecals,
|
||||
leagueOverrides: updatedOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: new Date(),
|
||||
validatedAt: this.validatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,7 +233,15 @@ export class DriverLivery implements IEntity<string> {
|
||||
*/
|
||||
markAsValidated(): DriverLivery {
|
||||
return new DriverLivery({
|
||||
...this,
|
||||
id: this.id,
|
||||
driverId: this.driverId,
|
||||
gameId: this.gameId,
|
||||
carId: this.carId,
|
||||
uploadedImageUrl: this.uploadedImageUrl,
|
||||
userDecals: this.userDecals,
|
||||
leagueOverrides: this.leagueOverrides,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
validatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface LiveryTemplateProps {
|
||||
baseImageUrl: string;
|
||||
adminDecals: LiveryDecal[];
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
updatedAt: Date | undefined;
|
||||
}
|
||||
|
||||
export class LiveryTemplate implements IEntity<string> {
|
||||
@@ -28,7 +28,7 @@ export class LiveryTemplate implements IEntity<string> {
|
||||
readonly baseImageUrl: string;
|
||||
readonly adminDecals: LiveryDecal[];
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt?: Date;
|
||||
readonly updatedAt: Date | undefined;
|
||||
|
||||
private constructor(props: LiveryTemplateProps) {
|
||||
this.id = props.id;
|
||||
@@ -37,7 +37,7 @@ export class LiveryTemplate implements IEntity<string> {
|
||||
this.carId = props.carId;
|
||||
this.baseImageUrl = props.baseImageUrl;
|
||||
this.adminDecals = props.adminDecals;
|
||||
this.createdAt = props.createdAt;
|
||||
this.createdAt = props.createdAt ?? new Date();
|
||||
this.updatedAt = props.updatedAt;
|
||||
}
|
||||
|
||||
@@ -113,9 +113,9 @@ export class LiveryTemplate implements IEntity<string> {
|
||||
*/
|
||||
updateDecal(decalId: string, updatedDecal: LiveryDecal): LiveryTemplate {
|
||||
const index = this.adminDecals.findIndex(d => d.id === decalId);
|
||||
|
||||
|
||||
if (index === -1) {
|
||||
throw new RacingDomainError('Decal not found in template');
|
||||
throw new RacingDomainValidationError('Decal not found in template');
|
||||
}
|
||||
|
||||
const updatedDecals = [...this.adminDecals];
|
||||
|
||||
@@ -19,9 +19,9 @@ export interface PrizeProps {
|
||||
driverId?: string;
|
||||
status: PrizeStatus;
|
||||
createdAt: Date;
|
||||
awardedAt?: Date;
|
||||
paidAt?: Date;
|
||||
description?: string;
|
||||
awardedAt: Date | undefined;
|
||||
paidAt: Date | undefined;
|
||||
description: string | undefined;
|
||||
}
|
||||
|
||||
export class Prize implements IEntity<string> {
|
||||
@@ -29,12 +29,12 @@ export class Prize implements IEntity<string> {
|
||||
readonly seasonId: string;
|
||||
readonly position: number;
|
||||
readonly amount: Money;
|
||||
readonly driverId?: string;
|
||||
readonly driverId: string | undefined;
|
||||
readonly status: PrizeStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly awardedAt?: Date;
|
||||
readonly paidAt?: Date;
|
||||
readonly description?: string;
|
||||
readonly awardedAt: Date | undefined;
|
||||
readonly paidAt: Date | undefined;
|
||||
readonly description: string | undefined;
|
||||
|
||||
private constructor(props: PrizeProps) {
|
||||
this.id = props.id;
|
||||
@@ -43,7 +43,7 @@ export class Prize implements IEntity<string> {
|
||||
this.amount = props.amount;
|
||||
this.driverId = props.driverId;
|
||||
this.status = props.status;
|
||||
this.createdAt = props.createdAt;
|
||||
this.createdAt = props.createdAt ?? new Date();
|
||||
this.awardedAt = props.awardedAt;
|
||||
this.paidAt = props.paidAt;
|
||||
this.description = props.description;
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
export type SeasonStatus = 'planned' | 'active' | 'completed';
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
export type SeasonStatus =
|
||||
| 'planned'
|
||||
| 'active'
|
||||
| 'completed'
|
||||
| 'archived'
|
||||
| 'cancelled';
|
||||
|
||||
import {
|
||||
RacingDomainInvariantError,
|
||||
RacingDomainValidationError,
|
||||
} from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
import type { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import type { SeasonScoringConfig } from '../value-objects/SeasonScoringConfig';
|
||||
import type { SeasonDropPolicy } from '../value-objects/SeasonDropPolicy';
|
||||
import type { SeasonStewardingConfig } from '../value-objects/SeasonStewardingConfig';
|
||||
|
||||
export class Season implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
@@ -13,6 +25,11 @@ export class Season implements IEntity<string> {
|
||||
readonly status: SeasonStatus;
|
||||
readonly startDate: Date | undefined;
|
||||
readonly endDate: Date | undefined;
|
||||
readonly schedule: SeasonSchedule | undefined;
|
||||
readonly scoringConfig: SeasonScoringConfig | undefined;
|
||||
readonly dropPolicy: SeasonDropPolicy | undefined;
|
||||
readonly stewardingConfig: SeasonStewardingConfig | undefined;
|
||||
readonly maxDrivers: number | undefined;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
@@ -24,6 +41,11 @@ export class Season implements IEntity<string> {
|
||||
status: SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
schedule?: SeasonSchedule;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
@@ -34,6 +56,11 @@ export class Season implements IEntity<string> {
|
||||
this.status = props.status;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
this.schedule = props.schedule;
|
||||
this.scoringConfig = props.scoringConfig;
|
||||
this.dropPolicy = props.dropPolicy;
|
||||
this.stewardingConfig = props.stewardingConfig;
|
||||
this.maxDrivers = props.maxDrivers;
|
||||
}
|
||||
|
||||
static create(props: {
|
||||
@@ -46,6 +73,11 @@ export class Season implements IEntity<string> {
|
||||
status?: SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
schedule?: SeasonSchedule;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
}): Season {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season ID is required');
|
||||
@@ -75,6 +107,15 @@ export class Season implements IEntity<string> {
|
||||
status,
|
||||
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
|
||||
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
|
||||
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
|
||||
...(props.scoringConfig !== undefined
|
||||
? { scoringConfig: props.scoringConfig }
|
||||
: {}),
|
||||
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
|
||||
...(props.stewardingConfig !== undefined
|
||||
? { stewardingConfig: props.stewardingConfig }
|
||||
: {}),
|
||||
...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,4 +139,281 @@ export class Season implements IEntity<string> {
|
||||
isCompleted(): boolean {
|
||||
return this.status === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if season is planned (not yet active)
|
||||
*/
|
||||
isPlanned(): boolean {
|
||||
return this.status === 'planned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the season from planned state.
|
||||
*/
|
||||
activate(): Season {
|
||||
if (this.status !== 'planned') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only planned seasons can be activated',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'active',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {
|
||||
startDate: new Date(),
|
||||
}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the season as completed.
|
||||
*/
|
||||
complete(): Season {
|
||||
if (this.status !== 'active') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only active seasons can be completed',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'completed',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {
|
||||
endDate: new Date(),
|
||||
}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a completed season.
|
||||
*/
|
||||
archive(): Season {
|
||||
if (!this.isCompleted()) {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Only completed seasons can be archived',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'archived',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a planned or active season.
|
||||
*/
|
||||
cancel(): Season {
|
||||
if (this.status === 'completed' || this.status === 'archived') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'Cannot cancel a completed or archived season',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
return this;
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: 'cancelled',
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {
|
||||
endDate: new Date(),
|
||||
}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update schedule while keeping other properties intact.
|
||||
*/
|
||||
withSchedule(schedule: SeasonSchedule): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
schedule,
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scoring configuration for the season.
|
||||
*/
|
||||
withScoringConfig(scoringConfig: SeasonScoringConfig): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
scoringConfig,
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update drop policy for the season.
|
||||
*/
|
||||
withDropPolicy(dropPolicy: SeasonDropPolicy): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
dropPolicy,
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stewarding configuration for the season.
|
||||
*/
|
||||
withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
stewardingConfig,
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update max driver capacity for the season.
|
||||
*/
|
||||
withMaxDrivers(maxDrivers: number | undefined): Season {
|
||||
if (maxDrivers !== undefined && maxDrivers <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'Season maxDrivers must be greater than 0 when provided',
|
||||
);
|
||||
}
|
||||
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined
|
||||
? { scoringConfig: this.scoringConfig }
|
||||
: {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined
|
||||
? { stewardingConfig: this.stewardingConfig }
|
||||
: {}),
|
||||
...(maxDrivers !== undefined ? { maxDrivers } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,40 +11,53 @@ import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { Money } from '../value-objects/Money';
|
||||
|
||||
export type SponsorshipTier = 'main' | 'secondary';
|
||||
export type SponsorshipStatus = 'pending' | 'active' | 'cancelled';
|
||||
export type SponsorshipStatus = 'pending' | 'active' | 'ended' | 'cancelled';
|
||||
|
||||
export interface SeasonSponsorshipProps {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
/**
|
||||
* Optional denormalized leagueId for fast league-level aggregations.
|
||||
* Must always match the owning Season's leagueId when present.
|
||||
*/
|
||||
leagueId?: string;
|
||||
sponsorId: string;
|
||||
tier: SponsorshipTier;
|
||||
pricing: Money;
|
||||
status: SponsorshipStatus;
|
||||
createdAt: Date;
|
||||
activatedAt?: Date;
|
||||
endedAt?: Date;
|
||||
cancelledAt?: Date;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class SeasonSponsorship implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly seasonId: string;
|
||||
readonly leagueId: string | undefined;
|
||||
readonly sponsorId: string;
|
||||
readonly tier: SponsorshipTier;
|
||||
readonly pricing: Money;
|
||||
readonly status: SponsorshipStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly activatedAt: Date | undefined;
|
||||
readonly endedAt: Date | undefined;
|
||||
readonly cancelledAt: Date | undefined;
|
||||
readonly description: string | undefined;
|
||||
|
||||
private constructor(props: SeasonSponsorshipProps) {
|
||||
this.id = props.id;
|
||||
this.seasonId = props.seasonId;
|
||||
this.leagueId = props.leagueId;
|
||||
this.sponsorId = props.sponsorId;
|
||||
this.tier = props.tier;
|
||||
this.pricing = props.pricing;
|
||||
this.status = props.status;
|
||||
this.createdAt = props.createdAt;
|
||||
this.activatedAt = props.activatedAt;
|
||||
this.endedAt = props.endedAt;
|
||||
this.cancelledAt = props.cancelledAt;
|
||||
this.description = props.description;
|
||||
}
|
||||
|
||||
@@ -57,12 +70,15 @@ export class SeasonSponsorship implements IEntity<string> {
|
||||
return new SeasonSponsorship({
|
||||
id: props.id,
|
||||
seasonId: props.seasonId,
|
||||
...(props.leagueId !== undefined ? { leagueId: props.leagueId } : {}),
|
||||
sponsorId: props.sponsorId,
|
||||
tier: props.tier,
|
||||
pricing: props.pricing,
|
||||
status: props.status ?? 'pending',
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
...(props.activatedAt !== undefined ? { activatedAt: props.activatedAt } : {}),
|
||||
...(props.endedAt !== undefined ? { endedAt: props.endedAt } : {}),
|
||||
...(props.cancelledAt !== undefined ? { cancelledAt: props.cancelledAt } : {}),
|
||||
...(props.description !== undefined ? { description: props.description } : {}),
|
||||
});
|
||||
}
|
||||
@@ -105,15 +121,56 @@ export class SeasonSponsorship implements IEntity<string> {
|
||||
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
|
||||
}
|
||||
|
||||
if (this.status === 'ended') {
|
||||
throw new RacingDomainInvariantError('Cannot activate an ended SeasonSponsorship');
|
||||
}
|
||||
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing: this.pricing,
|
||||
status: 'active',
|
||||
createdAt: this.createdAt,
|
||||
activatedAt: new Date(),
|
||||
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
|
||||
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
|
||||
};
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
this.description !== undefined
|
||||
? { ...base, description: this.description }
|
||||
: base;
|
||||
|
||||
return new SeasonSponsorship(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the sponsorship as ended (completed term)
|
||||
*/
|
||||
end(): SeasonSponsorship {
|
||||
if (this.status === 'cancelled') {
|
||||
throw new RacingDomainInvariantError('Cannot end a cancelled SeasonSponsorship');
|
||||
}
|
||||
|
||||
if (this.status === 'ended') {
|
||||
throw new RacingDomainInvariantError('SeasonSponsorship is already ended');
|
||||
}
|
||||
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing: this.pricing,
|
||||
status: 'ended',
|
||||
createdAt: this.createdAt,
|
||||
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
|
||||
endedAt: new Date(),
|
||||
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
|
||||
};
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
@@ -135,22 +192,55 @@ export class SeasonSponsorship implements IEntity<string> {
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing: this.pricing,
|
||||
status: 'cancelled',
|
||||
createdAt: this.createdAt,
|
||||
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
|
||||
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
|
||||
cancelledAt: new Date(),
|
||||
};
|
||||
|
||||
const withActivated =
|
||||
this.activatedAt !== undefined
|
||||
? { ...base, activatedAt: this.activatedAt }
|
||||
: base;
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
this.description !== undefined
|
||||
? { ...withActivated, description: this.description }
|
||||
: withActivated;
|
||||
? { ...base, description: this.description }
|
||||
: base;
|
||||
|
||||
return new SeasonSponsorship(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pricing/terms when allowed
|
||||
*/
|
||||
withPricing(pricing: Money): SeasonSponsorship {
|
||||
if (pricing.amount <= 0) {
|
||||
throw new RacingDomainValidationError('SeasonSponsorship pricing must be greater than zero');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled' || this.status === 'ended') {
|
||||
throw new RacingDomainInvariantError('Cannot update pricing for ended or cancelled SeasonSponsorship');
|
||||
}
|
||||
|
||||
const base: SeasonSponsorshipProps = {
|
||||
id: this.id,
|
||||
seasonId: this.seasonId,
|
||||
...(this.leagueId !== undefined ? { leagueId: this.leagueId } : {}),
|
||||
sponsorId: this.sponsorId,
|
||||
tier: this.tier,
|
||||
pricing,
|
||||
status: this.status,
|
||||
createdAt: this.createdAt,
|
||||
...(this.activatedAt !== undefined ? { activatedAt: this.activatedAt } : {}),
|
||||
...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
|
||||
...(this.cancelledAt !== undefined ? { cancelledAt: this.cancelledAt } : {}),
|
||||
};
|
||||
|
||||
const next: SeasonSponsorshipProps =
|
||||
this.description !== undefined
|
||||
? { ...base, description: this.description }
|
||||
: base;
|
||||
|
||||
return new SeasonSponsorship(next);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ export interface TransactionProps {
|
||||
netAmount: Money;
|
||||
status: TransactionStatus;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
description?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
completedAt: Date | undefined;
|
||||
description: string | undefined;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export class Transaction implements IEntity<string> {
|
||||
@@ -41,9 +41,9 @@ export class Transaction implements IEntity<string> {
|
||||
readonly netAmount: Money;
|
||||
readonly status: TransactionStatus;
|
||||
readonly createdAt: Date;
|
||||
readonly completedAt?: Date;
|
||||
readonly description?: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
readonly completedAt: Date | undefined;
|
||||
readonly description: string | undefined;
|
||||
readonly metadata: Record<string, unknown> | undefined;
|
||||
|
||||
private constructor(props: TransactionProps) {
|
||||
this.id = props.id;
|
||||
|
||||
@@ -2,6 +2,34 @@ import type { Season } from '../entities/Season';
|
||||
|
||||
export interface ISeasonRepository {
|
||||
findById(id: string): Promise<Season | null>;
|
||||
/**
|
||||
* Backward-compatible alias retained for existing callers.
|
||||
* Prefer listByLeague for new usage.
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Season[]>;
|
||||
/**
|
||||
* Backward-compatible alias retained for existing callers.
|
||||
* Prefer add for new usage.
|
||||
*/
|
||||
create(season: Season): Promise<Season>;
|
||||
|
||||
/**
|
||||
* Add a new Season aggregate.
|
||||
*/
|
||||
add(season: Season): Promise<void>;
|
||||
|
||||
/**
|
||||
* Persist changes to an existing Season aggregate.
|
||||
*/
|
||||
update(season: Season): Promise<void>;
|
||||
|
||||
/**
|
||||
* List all Seasons for a given League.
|
||||
*/
|
||||
listByLeague(leagueId: string): Promise<Season[]>;
|
||||
|
||||
/**
|
||||
* List Seasons for a League that are currently active.
|
||||
*/
|
||||
listActiveByLeague(leagueId: string): Promise<Season[]>;
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import type { SeasonSponsorship, SponsorshipTier } from '../entities/SeasonSpons
|
||||
export interface ISeasonSponsorshipRepository {
|
||||
findById(id: string): Promise<SeasonSponsorship | null>;
|
||||
findBySeasonId(seasonId: string): Promise<SeasonSponsorship[]>;
|
||||
/**
|
||||
* Convenience lookup for aggregating sponsorships at league level.
|
||||
* Implementations should rely on the denormalized leagueId where present,
|
||||
* falling back to joining through Seasons if needed.
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]>;
|
||||
findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]>;
|
||||
findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise<SeasonSponsorship[]>;
|
||||
create(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship>;
|
||||
|
||||
@@ -9,10 +9,20 @@ export interface MonthlyRecurrencePatternProps {
|
||||
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
|
||||
readonly ordinal: 1 | 2 | 3 | 4;
|
||||
readonly weekday: Weekday;
|
||||
|
||||
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
|
||||
this.ordinal = ordinal;
|
||||
this.weekday = weekday;
|
||||
|
||||
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
|
||||
constructor(props: MonthlyRecurrencePatternProps);
|
||||
constructor(
|
||||
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
|
||||
weekday?: Weekday,
|
||||
) {
|
||||
if (typeof ordinalOrProps === 'object') {
|
||||
this.ordinal = ordinalOrProps.ordinal;
|
||||
this.weekday = ordinalOrProps.weekday;
|
||||
} else {
|
||||
this.ordinal = ordinalOrProps;
|
||||
this.weekday = weekday as Weekday;
|
||||
}
|
||||
}
|
||||
|
||||
get props(): MonthlyRecurrencePatternProps {
|
||||
|
||||
59
packages/racing/domain/value-objects/SeasonDropPolicy.ts
Normal file
59
packages/racing/domain/value-objects/SeasonDropPolicy.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export type SeasonDropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
|
||||
|
||||
export interface SeasonDropPolicyProps {
|
||||
strategy: SeasonDropStrategy;
|
||||
/**
|
||||
* Number of results to consider for strategies that require a count.
|
||||
* - bestNResults: keep best N
|
||||
* - dropWorstN: drop worst N
|
||||
*/
|
||||
n?: number;
|
||||
}
|
||||
|
||||
export class SeasonDropPolicy implements IValueObject<SeasonDropPolicyProps> {
|
||||
readonly strategy: SeasonDropStrategy;
|
||||
readonly n?: number;
|
||||
|
||||
constructor(props: SeasonDropPolicyProps) {
|
||||
if (!props.strategy) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonDropPolicy.strategy is required',
|
||||
);
|
||||
}
|
||||
|
||||
if (props.strategy === 'bestNResults' || props.strategy === 'dropWorstN') {
|
||||
if (props.n === undefined || !Number.isInteger(props.n) || props.n <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonDropPolicy.n must be a positive integer when using bestNResults or dropWorstN',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.strategy === 'none' && props.n !== undefined) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonDropPolicy.n must be undefined when strategy is none',
|
||||
);
|
||||
}
|
||||
|
||||
this.strategy = props.strategy;
|
||||
if (props.n !== undefined) {
|
||||
this.n = props.n;
|
||||
}
|
||||
}
|
||||
|
||||
get props(): SeasonDropPolicyProps {
|
||||
return {
|
||||
strategy: this.strategy,
|
||||
...(this.n !== undefined ? { n: this.n } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonDropPolicyProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.strategy === b.strategy && a.n === b.n;
|
||||
}
|
||||
}
|
||||
66
packages/racing/domain/value-objects/SeasonScoringConfig.ts
Normal file
66
packages/racing/domain/value-objects/SeasonScoringConfig.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Value Object: SeasonScoringConfig
|
||||
*
|
||||
* Represents the scoring configuration owned by a Season.
|
||||
* It is intentionally lightweight and primarily captures which
|
||||
* preset (or custom mode) is applied for this Season.
|
||||
*
|
||||
* Detailed championship scoring rules are still modeled via
|
||||
* `LeagueScoringConfig` and related types.
|
||||
*/
|
||||
export interface SeasonScoringConfigProps {
|
||||
/**
|
||||
* Identifier of the scoring preset applied to this Season.
|
||||
* Examples:
|
||||
* - 'sprint-main-driver'
|
||||
* - 'club-default'
|
||||
* - 'endurance-main-double'
|
||||
* - 'custom'
|
||||
*/
|
||||
scoringPresetId: string;
|
||||
|
||||
/**
|
||||
* Whether the Season uses custom scoring rather than a pure preset.
|
||||
* When true, `scoringPresetId` acts as a label rather than a strict preset key.
|
||||
*/
|
||||
customScoringEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class SeasonScoringConfig
|
||||
implements IValueObject<SeasonScoringConfigProps>
|
||||
{
|
||||
readonly scoringPresetId: string;
|
||||
readonly customScoringEnabled: boolean;
|
||||
|
||||
constructor(params: SeasonScoringConfigProps) {
|
||||
if (!params.scoringPresetId || params.scoringPresetId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonScoringConfig.scoringPresetId must be a non-empty string',
|
||||
);
|
||||
}
|
||||
|
||||
this.scoringPresetId = params.scoringPresetId.trim();
|
||||
this.customScoringEnabled = Boolean(params.customScoringEnabled);
|
||||
}
|
||||
|
||||
get props(): SeasonScoringConfigProps {
|
||||
return {
|
||||
scoringPresetId: this.scoringPresetId,
|
||||
...(this.customScoringEnabled
|
||||
? { customScoringEnabled: this.customScoringEnabled }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonScoringConfigProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return (
|
||||
a.scoringPresetId === b.scoringPresetId &&
|
||||
Boolean(a.customScoringEnabled) === Boolean(b.customScoringEnabled)
|
||||
);
|
||||
}
|
||||
}
|
||||
131
packages/racing/domain/value-objects/SeasonStewardingConfig.ts
Normal file
131
packages/racing/domain/value-objects/SeasonStewardingConfig.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
import type { StewardingDecisionMode } from '../entities/League';
|
||||
|
||||
export interface SeasonStewardingConfigProps {
|
||||
decisionMode: StewardingDecisionMode;
|
||||
requiredVotes?: number;
|
||||
requireDefense: boolean;
|
||||
defenseTimeLimit: number;
|
||||
voteTimeLimit: number;
|
||||
protestDeadlineHours: number;
|
||||
stewardingClosesHours: number;
|
||||
notifyAccusedOnProtest: boolean;
|
||||
notifyOnVoteRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: SeasonStewardingConfig
|
||||
*
|
||||
* Encapsulates stewarding configuration owned by a Season.
|
||||
* Shape intentionally mirrors LeagueStewardingFormDTO used by the wizard.
|
||||
*/
|
||||
export class SeasonStewardingConfig
|
||||
implements IValueObject<SeasonStewardingConfigProps>
|
||||
{
|
||||
readonly decisionMode: StewardingDecisionMode;
|
||||
readonly requiredVotes?: number;
|
||||
readonly requireDefense: boolean;
|
||||
readonly defenseTimeLimit: number;
|
||||
readonly voteTimeLimit: number;
|
||||
readonly protestDeadlineHours: number;
|
||||
readonly stewardingClosesHours: number;
|
||||
readonly notifyAccusedOnProtest: boolean;
|
||||
readonly notifyOnVoteRequired: boolean;
|
||||
|
||||
constructor(props: SeasonStewardingConfigProps) {
|
||||
if (!props.decisionMode) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.decisionMode is required',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(props.decisionMode === 'steward_vote' ||
|
||||
props.decisionMode === 'member_vote' ||
|
||||
props.decisionMode === 'steward_veto' ||
|
||||
props.decisionMode === 'member_veto') &&
|
||||
(props.requiredVotes === undefined ||
|
||||
!Number.isInteger(props.requiredVotes) ||
|
||||
props.requiredVotes <= 0)
|
||||
) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.requiredVotes must be a positive integer for voting/veto modes',
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.defenseTimeLimit) || props.defenseTimeLimit < 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.defenseTimeLimit must be a non-negative integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.voteTimeLimit) || props.voteTimeLimit <= 0) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.voteTimeLimit must be a positive integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(props.protestDeadlineHours) ||
|
||||
props.protestDeadlineHours <= 0
|
||||
) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.protestDeadlineHours must be a positive integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(props.stewardingClosesHours) ||
|
||||
props.stewardingClosesHours <= 0
|
||||
) {
|
||||
throw new RacingDomainValidationError(
|
||||
'SeasonStewardingConfig.stewardingClosesHours must be a positive integer (hours)',
|
||||
);
|
||||
}
|
||||
|
||||
this.decisionMode = props.decisionMode;
|
||||
if (props.requiredVotes !== undefined) {
|
||||
this.requiredVotes = props.requiredVotes;
|
||||
}
|
||||
this.requireDefense = props.requireDefense;
|
||||
this.defenseTimeLimit = props.defenseTimeLimit;
|
||||
this.voteTimeLimit = props.voteTimeLimit;
|
||||
this.protestDeadlineHours = props.protestDeadlineHours;
|
||||
this.stewardingClosesHours = props.stewardingClosesHours;
|
||||
this.notifyAccusedOnProtest = props.notifyAccusedOnProtest;
|
||||
this.notifyOnVoteRequired = props.notifyOnVoteRequired;
|
||||
}
|
||||
|
||||
get props(): SeasonStewardingConfigProps {
|
||||
return {
|
||||
decisionMode: this.decisionMode,
|
||||
...(this.requiredVotes !== undefined
|
||||
? { requiredVotes: this.requiredVotes }
|
||||
: {}),
|
||||
requireDefense: this.requireDefense,
|
||||
defenseTimeLimit: this.defenseTimeLimit,
|
||||
voteTimeLimit: this.voteTimeLimit,
|
||||
protestDeadlineHours: this.protestDeadlineHours,
|
||||
stewardingClosesHours: this.stewardingClosesHours,
|
||||
notifyAccusedOnProtest: this.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired: this.notifyOnVoteRequired,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SeasonStewardingConfigProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return (
|
||||
a.decisionMode === b.decisionMode &&
|
||||
a.requiredVotes === b.requiredVotes &&
|
||||
a.requireDefense === b.requireDefense &&
|
||||
a.defenseTimeLimit === b.defenseTimeLimit &&
|
||||
a.voteTimeLimit === b.voteTimeLimit &&
|
||||
a.protestDeadlineHours === b.protestDeadlineHours &&
|
||||
a.stewardingClosesHours === b.stewardingClosesHours &&
|
||||
a.notifyAccusedOnProtest === b.notifyAccusedOnProtest &&
|
||||
a.notifyOnVoteRequired === b.notifyOnVoteRequired
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export interface WeekdaySetProps {
|
||||
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
|
||||
private readonly days: Weekday[];
|
||||
|
||||
static fromArray(days: Weekday[]): WeekdaySet {
|
||||
return new WeekdaySet(days);
|
||||
}
|
||||
|
||||
constructor(days: Weekday[]) {
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
|
||||
|
||||
@@ -282,10 +282,34 @@ export class InMemorySeasonRepository implements ISeasonRepository {
|
||||
}
|
||||
|
||||
async create(season: Season): Promise<Season> {
|
||||
// Backward-compatible alias for add()
|
||||
this.seasons.push(season);
|
||||
return season;
|
||||
}
|
||||
|
||||
async add(season: Season): Promise<void> {
|
||||
this.seasons.push(season);
|
||||
}
|
||||
|
||||
async update(season: Season): Promise<void> {
|
||||
const index = this.seasons.findIndex((s) => s.id === season.id);
|
||||
if (index === -1) {
|
||||
this.seasons.push(season);
|
||||
return;
|
||||
}
|
||||
this.seasons[index] = season;
|
||||
}
|
||||
|
||||
async listByLeague(leagueId: string): Promise<Season[]> {
|
||||
return this.seasons.filter((s) => s.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async listActiveByLeague(leagueId: string): Promise<Season[]> {
|
||||
return this.seasons.filter(
|
||||
(s) => s.leagueId === leagueId && s.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
seed(season: Season): void {
|
||||
this.seasons.push(season);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export class InMemorySeasonSponsorshipRepository implements ISeasonSponsorshipRe
|
||||
return Array.from(this.sponsorships.values()).filter(s => s.seasonId === seasonId);
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]> {
|
||||
return Array.from(this.sponsorships.values()).filter(s => s.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]> {
|
||||
return Array.from(this.sponsorships.values()).filter(s => s.sponsorId === sponsorId);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ export class GetCurrentUserSocialUseCase
|
||||
avatarUrl: '',
|
||||
isOnline: false,
|
||||
lastSeen: new Date(),
|
||||
primaryLeagueId: undefined,
|
||||
primaryTeamId: undefined,
|
||||
}));
|
||||
|
||||
const currentUser: CurrentUserSocialDTO = {
|
||||
@@ -44,8 +42,6 @@ export class GetCurrentUserSocialUseCase
|
||||
displayName: '',
|
||||
avatarUrl: '',
|
||||
countryCode: '',
|
||||
primaryTeamId: undefined,
|
||||
primaryLeagueId: undefined,
|
||||
};
|
||||
|
||||
const viewModel: CurrentUserSocialViewModel = {
|
||||
|
||||
@@ -33,22 +33,27 @@ export class GetUserFeedUseCase
|
||||
}
|
||||
|
||||
function mapFeedItemToDTO(item: FeedItem): FeedItemDTO {
|
||||
return {
|
||||
const mappedType = (item.type as string).replace(/-/g, '_') as FeedItemDTO['type'];
|
||||
|
||||
const dto: FeedItemDTO = {
|
||||
id: item.id,
|
||||
timestamp:
|
||||
item.timestamp instanceof Date
|
||||
? item.timestamp.toISOString()
|
||||
: new Date(item.timestamp).toISOString(),
|
||||
type: item.type,
|
||||
actorFriendId: item.actorFriendId,
|
||||
actorDriverId: item.actorDriverId,
|
||||
leagueId: item.leagueId,
|
||||
raceId: item.raceId,
|
||||
teamId: item.teamId,
|
||||
position: item.position,
|
||||
type: mappedType,
|
||||
headline: item.headline,
|
||||
body: item.body,
|
||||
ctaLabel: item.ctaLabel,
|
||||
ctaHref: item.ctaHref,
|
||||
};
|
||||
|
||||
if (item.actorFriendId !== undefined) dto.actorFriendId = item.actorFriendId;
|
||||
if (item.actorDriverId !== undefined) dto.actorDriverId = item.actorDriverId;
|
||||
if (item.leagueId !== undefined) dto.leagueId = item.leagueId;
|
||||
if (item.raceId !== undefined) dto.raceId = item.raceId;
|
||||
if (item.teamId !== undefined) dto.teamId = item.teamId;
|
||||
if (item.position !== undefined) dto.position = item.position;
|
||||
if (item.body !== undefined) dto.body = item.body;
|
||||
if (item.ctaLabel !== undefined) dto.ctaLabel = item.ctaLabel;
|
||||
if (item.ctaHref !== undefined) dto.ctaHref = item.ctaHref;
|
||||
|
||||
return dto;
|
||||
}
|
||||
449
tests/unit/racing-application/SeasonUseCases.test.ts
Normal file
449
tests/unit/racing-application/SeasonUseCases.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
InMemorySeasonRepository,
|
||||
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
|
||||
import { Season } from '@gridpilot/racing/domain/entities/Season';
|
||||
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||
import {
|
||||
CreateSeasonForLeagueUseCase,
|
||||
ListSeasonsForLeagueUseCase,
|
||||
GetSeasonDetailsUseCase,
|
||||
ManageSeasonLifecycleUseCase,
|
||||
type CreateSeasonForLeagueCommand,
|
||||
type ManageSeasonLifecycleCommand,
|
||||
} from '@gridpilot/racing/application/use-cases/SeasonUseCases';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application/dto/LeagueConfigFormDTO';
|
||||
|
||||
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
|
||||
return {
|
||||
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
|
||||
findAll: async () => seed,
|
||||
create: async (league: any) => league,
|
||||
update: async (league: any) => league,
|
||||
} as unknown as ILeagueRepository;
|
||||
}
|
||||
|
||||
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
|
||||
return {
|
||||
basics: {
|
||||
name: 'Test League',
|
||||
visibility: 'ranked',
|
||||
gameId: 'iracing',
|
||||
...overrides?.basics,
|
||||
},
|
||||
structure: {
|
||||
mode: 'solo',
|
||||
maxDrivers: 30,
|
||||
...overrides?.structure,
|
||||
},
|
||||
championships: {
|
||||
enableDriverChampionship: true,
|
||||
enableTeamChampionship: false,
|
||||
enableNationsChampionship: false,
|
||||
enableTrophyChampionship: false,
|
||||
...overrides?.championships,
|
||||
},
|
||||
scoring: {
|
||||
patternId: 'sprint-main-driver',
|
||||
customScoringEnabled: false,
|
||||
...overrides?.scoring,
|
||||
},
|
||||
dropPolicy: {
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
...overrides?.dropPolicy,
|
||||
},
|
||||
timings: {
|
||||
qualifyingMinutes: 10,
|
||||
mainRaceMinutes: 30,
|
||||
sessionCount: 8,
|
||||
seasonStartDate: '2025-01-01',
|
||||
raceStartTime: '20:00',
|
||||
timezoneId: 'UTC',
|
||||
recurrenceStrategy: 'weekly',
|
||||
weekdays: ['Mon'],
|
||||
...overrides?.timings,
|
||||
},
|
||||
stewarding: {
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
...overrides?.stewarding,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('InMemorySeasonRepository', () => {
|
||||
it('add and findById provide a roundtrip for Season', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Test Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(season);
|
||||
const loaded = await repo.findById(season.id);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.id).toBe(season.id);
|
||||
expect(loaded!.leagueId).toBe(season.leagueId);
|
||||
expect(loaded!.status).toBe('planned');
|
||||
});
|
||||
|
||||
it('update persists changed Season state', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Initial Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(season);
|
||||
const activated = season.activate();
|
||||
|
||||
await repo.update(activated);
|
||||
|
||||
const loaded = await repo.findById(season.id);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('listByLeague returns only seasons for that league', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const s1 = Season.create({
|
||||
id: 's1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'L1 S1',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 's2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'L1 S2',
|
||||
status: 'active',
|
||||
});
|
||||
const s3 = Season.create({
|
||||
id: 's3',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'L2 S1',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await repo.add(s1);
|
||||
await repo.add(s2);
|
||||
await repo.add(s3);
|
||||
|
||||
const league1Seasons = await repo.listByLeague('league-1');
|
||||
const league2Seasons = await repo.listByLeague('league-2');
|
||||
|
||||
expect(league1Seasons.map((s) => s.id).sort()).toEqual(['s1', 's2']);
|
||||
expect(league2Seasons.map((s) => s.id)).toEqual(['s3']);
|
||||
});
|
||||
|
||||
it('listActiveByLeague returns only active seasons for a league', async () => {
|
||||
const repo = new InMemorySeasonRepository();
|
||||
const s1 = Season.create({
|
||||
id: 's1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Planned',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 's2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Active',
|
||||
status: 'active',
|
||||
});
|
||||
const s3 = Season.create({
|
||||
id: 's3',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Completed',
|
||||
status: 'completed',
|
||||
});
|
||||
const s4 = Season.create({
|
||||
id: 's4',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'Other League Active',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await repo.add(s1);
|
||||
await repo.add(s2);
|
||||
await repo.add(s3);
|
||||
await repo.add(s4);
|
||||
|
||||
const activeInLeague1 = await repo.listActiveByLeague('league-1');
|
||||
|
||||
expect(activeInLeague1.map((s) => s.id)).toEqual(['s2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateSeasonForLeagueUseCase', () => {
|
||||
it('creates a planned Season for an existing league with config-derived props', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const config = createLeagueConfigFormModel({
|
||||
basics: {
|
||||
name: 'League With Config',
|
||||
visibility: 'ranked',
|
||||
gameId: 'iracing',
|
||||
},
|
||||
scoring: {
|
||||
patternId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
},
|
||||
dropPolicy: {
|
||||
strategy: 'dropWorstN',
|
||||
n: 2,
|
||||
},
|
||||
// Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation,
|
||||
// focusing this test on scoring/drop/stewarding/maxDrivers mapping.
|
||||
timings: {
|
||||
qualifyingMinutes: 10,
|
||||
mainRaceMinutes: 30,
|
||||
sessionCount: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const command: CreateSeasonForLeagueCommand = {
|
||||
leagueId: 'league-1',
|
||||
name: 'Season from Config',
|
||||
gameId: 'iracing',
|
||||
config,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.seasonId).toBeDefined();
|
||||
|
||||
const created = await seasonRepo.findById(result.seasonId);
|
||||
expect(created).not.toBeNull();
|
||||
const season = created!;
|
||||
|
||||
expect(season.leagueId).toBe('league-1');
|
||||
expect(season.gameId).toBe('iracing');
|
||||
expect(season.name).toBe('Season from Config');
|
||||
expect(season.status).toBe('planned');
|
||||
|
||||
// Schedule is optional when timings lack seasonStartDate / raceStartTime.
|
||||
expect(season.schedule).toBeUndefined();
|
||||
expect(season.scoringConfig).toBeDefined();
|
||||
expect(season.scoringConfig!.scoringPresetId).toBe('club-default');
|
||||
expect(season.scoringConfig!.customScoringEnabled).toBe(true);
|
||||
|
||||
expect(season.dropPolicy).toBeDefined();
|
||||
expect(season.dropPolicy!.strategy).toBe('dropWorstN');
|
||||
expect(season.dropPolicy!.n).toBe(2);
|
||||
|
||||
expect(season.stewardingConfig).toBeDefined();
|
||||
expect(season.maxDrivers).toBe(30);
|
||||
});
|
||||
|
||||
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const sourceSeason = Season.create({
|
||||
id: 'source-season',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Source Season',
|
||||
status: 'planned',
|
||||
}).withMaxDrivers(40);
|
||||
|
||||
await seasonRepo.add(sourceSeason);
|
||||
|
||||
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const command: CreateSeasonForLeagueCommand = {
|
||||
leagueId: 'league-1',
|
||||
name: 'Cloned Season',
|
||||
gameId: 'iracing',
|
||||
sourceSeasonId: 'source-season',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
const created = await seasonRepo.findById(result.seasonId);
|
||||
|
||||
expect(created).not.toBeNull();
|
||||
const season = created!;
|
||||
|
||||
expect(season.id).not.toBe(sourceSeason.id);
|
||||
expect(season.leagueId).toBe(sourceSeason.leagueId);
|
||||
expect(season.gameId).toBe(sourceSeason.gameId);
|
||||
expect(season.status).toBe('planned');
|
||||
expect(season.maxDrivers).toBe(sourceSeason.maxDrivers);
|
||||
expect(season.schedule).toBe(sourceSeason.schedule);
|
||||
expect(season.scoringConfig).toBe(sourceSeason.scoringConfig);
|
||||
expect(season.dropPolicy).toBe(sourceSeason.dropPolicy);
|
||||
expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListSeasonsForLeagueUseCase', () => {
|
||||
it('lists seasons for a league with summaries', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const s1 = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Season One',
|
||||
status: 'planned',
|
||||
});
|
||||
const s2 = Season.create({
|
||||
id: 'season-2',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Season Two',
|
||||
status: 'active',
|
||||
});
|
||||
const sOtherLeague = Season.create({
|
||||
id: 'season-3',
|
||||
leagueId: 'league-2',
|
||||
gameId: 'iracing',
|
||||
name: 'Season Other',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
await seasonRepo.add(s1);
|
||||
await seasonRepo.add(s2);
|
||||
await seasonRepo.add(sOtherLeague);
|
||||
|
||||
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
||||
|
||||
expect(result.items.map((i) => i.seasonId).sort()).toEqual([
|
||||
'season-1',
|
||||
'season-2',
|
||||
]);
|
||||
expect(result.items.every((i) => i.leagueId === 'league-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetSeasonDetailsUseCase', () => {
|
||||
it('returns full details for a season belonging to the league', async () => {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Detailed Season',
|
||||
status: 'planned',
|
||||
}).withMaxDrivers(24);
|
||||
|
||||
await seasonRepo.add(season);
|
||||
|
||||
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
const dto = await useCase.execute({
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
});
|
||||
|
||||
expect(dto.seasonId).toBe('season-1');
|
||||
expect(dto.leagueId).toBe('league-1');
|
||||
expect(dto.gameId).toBe('iracing');
|
||||
expect(dto.name).toBe('Detailed Season');
|
||||
expect(dto.status).toBe('planned');
|
||||
expect(dto.maxDrivers).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageSeasonLifecycleUseCase', () => {
|
||||
function setupLifecycleTest() {
|
||||
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
|
||||
const seasonRepo = new InMemorySeasonRepository();
|
||||
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Lifecycle Season',
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
seasonRepo.seed(season);
|
||||
|
||||
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo);
|
||||
|
||||
return { leagueRepo, seasonRepo, useCase, season };
|
||||
}
|
||||
|
||||
it('applies activate → complete → archive transitions and persists state', async () => {
|
||||
const { useCase, seasonRepo, season } = setupLifecycleTest();
|
||||
|
||||
const activateCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'activate',
|
||||
};
|
||||
|
||||
const activated = await useCase.execute(activateCommand);
|
||||
expect(activated.status).toBe('active');
|
||||
|
||||
const completeCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'complete',
|
||||
};
|
||||
|
||||
const completed = await useCase.execute(completeCommand);
|
||||
expect(completed.status).toBe('completed');
|
||||
|
||||
const archiveCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'archive',
|
||||
};
|
||||
|
||||
const archived = await useCase.execute(archiveCommand);
|
||||
expect(archived.status).toBe('archived');
|
||||
|
||||
const persisted = await seasonRepo.findById(season.id);
|
||||
expect(persisted!.status).toBe('archived');
|
||||
});
|
||||
|
||||
it('propagates domain invariant errors for invalid transitions', async () => {
|
||||
const { useCase, seasonRepo, season } = setupLifecycleTest();
|
||||
|
||||
const completeCommand: ManageSeasonLifecycleCommand = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: season.id,
|
||||
transition: 'complete',
|
||||
};
|
||||
|
||||
await expect(useCase.execute(completeCommand)).rejects.toThrow();
|
||||
|
||||
const persisted = await seasonRepo.findById(season.id);
|
||||
expect(persisted!.status).toBe('planned');
|
||||
});
|
||||
});
|
||||
509
tests/unit/racing/domain/Season.test.ts
Normal file
509
tests/unit/racing/domain/Season.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
RacingDomainInvariantError,
|
||||
RacingDomainValidationError,
|
||||
} from '@gridpilot/racing/domain/errors/RacingDomainError';
|
||||
import {
|
||||
Season,
|
||||
type SeasonStatus,
|
||||
} from '@gridpilot/racing/domain/entities/Season';
|
||||
import { SeasonScoringConfig } from '@gridpilot/racing/domain/value-objects/SeasonScoringConfig';
|
||||
import {
|
||||
SeasonDropPolicy,
|
||||
type SeasonDropStrategy,
|
||||
} from '@gridpilot/racing/domain/value-objects/SeasonDropPolicy';
|
||||
import { SeasonStewardingConfig } from '@gridpilot/racing/domain/value-objects/SeasonStewardingConfig';
|
||||
|
||||
function createMinimalSeason(overrides?: Partial<Season> & { status?: SeasonStatus }) {
|
||||
return Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Test Season',
|
||||
status: overrides?.status ?? 'planned',
|
||||
});
|
||||
}
|
||||
|
||||
describe('Season aggregate lifecycle', () => {
|
||||
it('transitions Planned → Active → Completed → Archived with timestamps', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
|
||||
const activated = planned.activate();
|
||||
expect(activated.status).toBe('active');
|
||||
expect(activated.startDate).toBeInstanceOf(Date);
|
||||
expect(activated.endDate).toBeUndefined();
|
||||
|
||||
const completed = activated.complete();
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.startDate).toEqual(activated.startDate);
|
||||
expect(completed.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const archived = completed.archive();
|
||||
expect(archived.status).toBe('archived');
|
||||
expect(archived.startDate).toEqual(completed.startDate);
|
||||
expect(archived.endDate).toEqual(completed.endDate);
|
||||
});
|
||||
|
||||
it('throws when activating a non-planned season', () => {
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => active.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => completed.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.activate()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.activate()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('throws when completing a non-active season', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => planned.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => completed.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.complete()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.complete()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('throws when archiving a non-completed season', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(() => planned.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => active.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.archive()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => cancelled.archive()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('allows cancelling planned or active seasons and rejects completed/archived', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
|
||||
const cancelledFromPlanned = planned.cancel();
|
||||
expect(cancelledFromPlanned.status).toBe('cancelled');
|
||||
expect(cancelledFromPlanned.startDate).toBe(planned.startDate);
|
||||
expect(cancelledFromPlanned.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const cancelledFromActive = active.cancel();
|
||||
expect(cancelledFromActive.status).toBe('cancelled');
|
||||
expect(cancelledFromActive.startDate).toBe(active.startDate);
|
||||
expect(cancelledFromActive.endDate).toBeInstanceOf(Date);
|
||||
|
||||
expect(() => completed.cancel()).toThrow(RacingDomainInvariantError);
|
||||
expect(() => archived.cancel()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('cancel is idempotent for already cancelled seasons', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const cancelled = planned.cancel();
|
||||
|
||||
const cancelledAgain = cancelled.cancel();
|
||||
expect(cancelledAgain).toBe(cancelled);
|
||||
});
|
||||
|
||||
it('canWithdrawFromWallet only when completed', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
const active = createMinimalSeason({ status: 'active' });
|
||||
const completed = createMinimalSeason({ status: 'completed' });
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
const cancelled = createMinimalSeason({ status: 'cancelled' });
|
||||
|
||||
expect(planned.canWithdrawFromWallet()).toBe(false);
|
||||
expect(active.canWithdrawFromWallet()).toBe(false);
|
||||
expect(completed.canWithdrawFromWallet()).toBe(true);
|
||||
expect(archived.canWithdrawFromWallet()).toBe(false);
|
||||
expect(cancelled.canWithdrawFromWallet()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Season configuration updates', () => {
|
||||
function createBaseSeason() {
|
||||
return Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Config Season',
|
||||
status: 'planned',
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: undefined,
|
||||
maxDrivers: 24,
|
||||
});
|
||||
}
|
||||
|
||||
it('withScoringConfig returns a new Season with updated scoringConfig only', () => {
|
||||
const season = createBaseSeason();
|
||||
const scoringConfig = new SeasonScoringConfig({
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
const updated = season.withScoringConfig(scoringConfig);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.scoringConfig).toBe(scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.dropPolicy).toBe(season.dropPolicy);
|
||||
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withDropPolicy returns a new Season with updated dropPolicy only', () => {
|
||||
const season = createBaseSeason();
|
||||
const dropPolicy = new SeasonDropPolicy({
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
});
|
||||
|
||||
const updated = season.withDropPolicy(dropPolicy);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.dropPolicy).toBe(dropPolicy);
|
||||
expect(updated.scoringConfig).toBe(season.scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.stewardingConfig).toBe(season.stewardingConfig);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withStewardingConfig returns a new Season with updated stewardingConfig only', () => {
|
||||
const season = createBaseSeason();
|
||||
const stewardingConfig = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const updated = season.withStewardingConfig(stewardingConfig);
|
||||
|
||||
expect(updated).not.toBe(season);
|
||||
expect(updated.stewardingConfig).toBe(stewardingConfig);
|
||||
expect(updated.scoringConfig).toBe(season.scoringConfig);
|
||||
expect(updated.schedule).toBe(season.schedule);
|
||||
expect(updated.dropPolicy).toBe(season.dropPolicy);
|
||||
expect(updated.maxDrivers).toBe(season.maxDrivers);
|
||||
});
|
||||
|
||||
it('withMaxDrivers updates maxDrivers when positive', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
const updated = season.withMaxDrivers(30);
|
||||
|
||||
expect(updated.maxDrivers).toBe(30);
|
||||
expect(updated.id).toBe(season.id);
|
||||
expect(updated.leagueId).toBe(season.leagueId);
|
||||
expect(updated.gameId).toBe(season.gameId);
|
||||
});
|
||||
|
||||
it('withMaxDrivers allows undefined to clear value', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
const updated = season.withMaxDrivers(undefined);
|
||||
|
||||
expect(updated.maxDrivers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('withMaxDrivers rejects non-positive values', () => {
|
||||
const season = createBaseSeason();
|
||||
|
||||
expect(() => season.withMaxDrivers(0)).toThrow(
|
||||
RacingDomainValidationError,
|
||||
);
|
||||
expect(() => season.withMaxDrivers(-5)).toThrow(
|
||||
RacingDomainValidationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonScoringConfig', () => {
|
||||
it('constructs from preset id and customScoringEnabled', () => {
|
||||
const config = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
expect(config.scoringPresetId).toBe('club-default');
|
||||
expect(config.customScoringEnabled).toBe(true);
|
||||
expect(config.props.scoringPresetId).toBe('club-default');
|
||||
expect(config.props.customScoringEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes customScoringEnabled to false when omitted', () => {
|
||||
const config = new SeasonScoringConfig({
|
||||
scoringPresetId: 'sprint-main-driver',
|
||||
});
|
||||
|
||||
expect(config.customScoringEnabled).toBe(false);
|
||||
expect(config.props.customScoringEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when scoringPresetId is empty', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonScoringConfig({
|
||||
// @ts-expect-error intentional invalid input
|
||||
scoringPresetId: ' ',
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('equals compares by preset id and customScoringEnabled', () => {
|
||||
const a = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: false,
|
||||
});
|
||||
const b = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: false,
|
||||
});
|
||||
const c = new SeasonScoringConfig({
|
||||
scoringPresetId: 'club-default',
|
||||
customScoringEnabled: true,
|
||||
});
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonDropPolicy', () => {
|
||||
it('allows strategy "none" with undefined n', () => {
|
||||
const policy = new SeasonDropPolicy({ strategy: 'none' });
|
||||
|
||||
expect(policy.strategy).toBe('none');
|
||||
expect(policy.n).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when strategy "none" has n defined', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy: 'none',
|
||||
n: 1,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('requires positive integer n for "bestNResults" and "dropWorstN"', () => {
|
||||
const strategies: SeasonDropStrategy[] = ['bestNResults', 'dropWorstN'];
|
||||
|
||||
for (const strategy of strategies) {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy,
|
||||
n: 0,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonDropPolicy({
|
||||
strategy,
|
||||
n: -1,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
}
|
||||
|
||||
const okBest = new SeasonDropPolicy({
|
||||
strategy: 'bestNResults',
|
||||
n: 3,
|
||||
});
|
||||
const okDrop = new SeasonDropPolicy({
|
||||
strategy: 'dropWorstN',
|
||||
n: 2,
|
||||
});
|
||||
|
||||
expect(okBest.n).toBe(3);
|
||||
expect(okDrop.n).toBe(2);
|
||||
});
|
||||
|
||||
it('equals compares strategy and n', () => {
|
||||
const a = new SeasonDropPolicy({ strategy: 'none' });
|
||||
const b = new SeasonDropPolicy({ strategy: 'none' });
|
||||
const c = new SeasonDropPolicy({ strategy: 'bestNResults', n: 3 });
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SeasonStewardingConfig', () => {
|
||||
it('creates a valid config with voting mode and requiredVotes', () => {
|
||||
const config = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
expect(config.decisionMode).toBe('steward_vote');
|
||||
expect(config.requiredVotes).toBe(3);
|
||||
expect(config.requireDefense).toBe(true);
|
||||
expect(config.defenseTimeLimit).toBe(24);
|
||||
expect(config.voteTimeLimit).toBe(24);
|
||||
expect(config.protestDeadlineHours).toBe(48);
|
||||
expect(config.stewardingClosesHours).toBe(72);
|
||||
expect(config.notifyAccusedOnProtest).toBe(true);
|
||||
expect(config.notifyOnVoteRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when decisionMode is missing', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
// @ts-expect-error intentional invalid
|
||||
decisionMode: undefined,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('requires requiredVotes for voting/veto modes', () => {
|
||||
const votingModes = [
|
||||
'steward_vote',
|
||||
'member_vote',
|
||||
'steward_veto',
|
||||
'member_veto',
|
||||
] as const;
|
||||
|
||||
for (const mode of votingModes) {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: mode,
|
||||
// @ts-expect-error intentional invalid
|
||||
requiredVotes: undefined,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates numeric limits as non-negative / positive integers', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: -1,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 0,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 0,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 0,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
}),
|
||||
).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('equals compares all props', () => {
|
||||
const a = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const b = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_vote',
|
||||
requiredVotes: 3,
|
||||
requireDefense: true,
|
||||
defenseTimeLimit: 24,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 48,
|
||||
stewardingClosesHours: 72,
|
||||
notifyAccusedOnProtest: true,
|
||||
notifyOnVoteRequired: true,
|
||||
});
|
||||
|
||||
const c = new SeasonStewardingConfig({
|
||||
decisionMode: 'steward_decides',
|
||||
requireDefense: false,
|
||||
defenseTimeLimit: 0,
|
||||
voteTimeLimit: 24,
|
||||
protestDeadlineHours: 24,
|
||||
stewardingClosesHours: 48,
|
||||
notifyAccusedOnProtest: false,
|
||||
notifyOnVoteRequired: false,
|
||||
});
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
@@ -12,7 +13,7 @@
|
||||
"alwaysStrict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"packages/**/*",
|
||||
"apps/**/*",
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
watch: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['tests/setup/vitest.setup.ts'],
|
||||
include: ['tests/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
|
||||
|
||||
Reference in New Issue
Block a user