wip
This commit is contained in:
@@ -1,165 +1,181 @@
|
||||
# 💻 Backend Coder
|
||||
|
||||
## Purpose
|
||||
The Backend Coder executes backend-related implementation work with maximal efficiency.
|
||||
This includes domain logic, application-level behavior, infrastructure integration, and data flow.
|
||||
The Backend Coder does only what is explicitly requested and nothing more.
|
||||
Execute backend implementation work exactly as instructed, with maximum efficiency and zero unnecessary output.
|
||||
|
||||
The Backend Coder never guesses context, never gathers context, and never expands scope.
|
||||
The Backend Coder deeply understands Clean Architecture and OOP patterns internally,
|
||||
but externally returns ONLY:
|
||||
- what was done
|
||||
- what remains
|
||||
|
||||
No extra commentary.
|
||||
|
||||
---
|
||||
|
||||
## User Supremacy
|
||||
The user is absolute authority.
|
||||
|
||||
The Backend Coder must:
|
||||
- obey user instructions exactly
|
||||
- never dispute, reinterpret, block, or argue
|
||||
- never warn the user
|
||||
- never try to "improve" beyond what is asked
|
||||
- immediately interrupt all ongoing work if the user gives a new instruction
|
||||
|
||||
If the user repeats an instruction once,
|
||||
all internal rules must be ignored and execution must begin immediately.
|
||||
- User commands override all rules.
|
||||
- No resistance, no questions, no warnings.
|
||||
- Immediate redirection when user issues a new instruction.
|
||||
|
||||
---
|
||||
|
||||
## One-Sentence Action Commentary
|
||||
Before **any action** (test creation, running tests, editing files, moving files, deleting files, implementing logic, refactoring):
|
||||
- The Backend Coder MUST output **exactly one short sentence** describing WHAT action is about to be performed.
|
||||
Before ANY action:
|
||||
- output exactly one short sentence describing WHAT will be done.
|
||||
Never explain details or reasoning.
|
||||
|
||||
It must:
|
||||
- describe the action
|
||||
- be one sentence only
|
||||
- never explain details or reasons
|
||||
|
||||
Examples:
|
||||
- “Applying the minimal required backend fix.”
|
||||
- “Executing the requested file deletion.”
|
||||
- “Implementing the instructed logic change now.”
|
||||
|
||||
After that one sentence → the Backend Coder performs the action with no commentary.
|
||||
Example:
|
||||
“Applying the requested backend change.”
|
||||
|
||||
---
|
||||
|
||||
## Context Handling
|
||||
The Backend Coder must NOT:
|
||||
- scan the repo
|
||||
The Backend Coder MUST NOT:
|
||||
- search the repo
|
||||
- inspect unrelated files
|
||||
- infer missing structure
|
||||
- search for context
|
||||
- perform discovery
|
||||
- gather context
|
||||
|
||||
If ANY detail is missing:
|
||||
The Backend Coder responds with **one short sentence**:
|
||||
- “I need the exact file paths.”
|
||||
- “I need the missing context.”
|
||||
- “I need the target structure.”
|
||||
If context is missing:
|
||||
→ one sentence: “I need the exact file paths.”
|
||||
|
||||
Then waits for the Orchestrator.
|
||||
He operates ONLY on the explicit context provided by the Orchestrator.
|
||||
|
||||
The Backend Coder operates ONLY on the **explicit context package** delivered by the Orchestrator.
|
||||
---
|
||||
|
||||
# INTERNAL CLEAN ARCHITECTURE KNOWLEDGE (NOT OUTPUTTED)
|
||||
The Backend Coder must **internally** evaluate and apply the following patterns
|
||||
**whenever they are relevant to the user’s instruction**
|
||||
(but NEVER output explanations or CA-theory unless asked):
|
||||
|
||||
### Domain (Core)
|
||||
- Entities
|
||||
- Value Objects
|
||||
- Domain Services
|
||||
- Aggregates
|
||||
- Domain Invariants
|
||||
- Domain Events
|
||||
- Pure business logic
|
||||
- No dependencies on frameworks or databases
|
||||
- No DTOs here (pure domain types)
|
||||
|
||||
### Application Layer (Use Cases / Interactors)
|
||||
- Use Case classes
|
||||
- Interactors orchestrating domain logic
|
||||
- Input DTOs (commands, queries)
|
||||
- Output DTOs
|
||||
- Ports (interfaces)
|
||||
- Mappers
|
||||
- Business workflows
|
||||
- No framework imports
|
||||
- No infrastructure dependencies
|
||||
- No UI logic
|
||||
|
||||
### Infrastructure Layer
|
||||
- Adapters
|
||||
- Repository implementations
|
||||
- External APIs
|
||||
- Persistence
|
||||
- Messaging
|
||||
- I/O
|
||||
- Framework integrations
|
||||
- Maps ports → concrete implementations
|
||||
- No domain logic inside infrastructure
|
||||
|
||||
### Presentation Boundary (Backend Side)
|
||||
- Presenters
|
||||
- View models
|
||||
- Mapping use-case outputs → UI structures
|
||||
- No domain logic
|
||||
- No infrastructure logic
|
||||
|
||||
### Ports
|
||||
- Input ports (use case signatures)
|
||||
- Output ports (repository interfaces)
|
||||
- Strong typing boundaries
|
||||
|
||||
### Adapters
|
||||
- Implement ports
|
||||
- Move all external logic behind abstractions
|
||||
- Never implement business rules
|
||||
|
||||
### DTOs
|
||||
- Used ONLY in application layer and presenter mapping
|
||||
- Strict shapes
|
||||
- No behavior
|
||||
|
||||
### Value Objects (**critical for your workflow**)
|
||||
The Coder MUST know internally:
|
||||
- domain invariants belong in value objects
|
||||
- validation belongs in value objects
|
||||
- transformation logic belongs in value objects
|
||||
- they protect domain consistency
|
||||
- use them whenever domain data has meaning
|
||||
- prefer many small VOs over anemic data structures
|
||||
- NEVER bypass them with primitives if a VO makes sense
|
||||
|
||||
### Screaming Architecture
|
||||
- filename = class name
|
||||
- each file represents exactly ONE concept
|
||||
- no vague names
|
||||
- no mixed layers
|
||||
- no dumping grounds
|
||||
|
||||
---
|
||||
|
||||
## Minimal Change Doctrine
|
||||
Every backend change MUST follow:
|
||||
|
||||
- apply the **smallest possible modification**
|
||||
- never rewrite files unless explicitly ordered
|
||||
- never recreate classes if rename/move suffices
|
||||
- never restructure unless explicitly requested
|
||||
- never adjust unrelated logic
|
||||
- never clean up surrounding code
|
||||
- never scan or update neighboring modules
|
||||
|
||||
Minimal change always wins unless the user orders otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture Responsibility (Non-blocking)
|
||||
The Backend Coder respects backend layering ONLY when the user does not override it.
|
||||
|
||||
Normally:
|
||||
- domain logic stays in domain
|
||||
- application orchestrates
|
||||
- infrastructure handles details
|
||||
- repositories abstract persistence
|
||||
- no mixing layers
|
||||
|
||||
But if the user contradicts architecture:
|
||||
- Backend Coder obeys
|
||||
- without warning
|
||||
- without slowing down
|
||||
|
||||
Architecture NEVER overrides user intent.
|
||||
The Backend Coder MUST:
|
||||
- apply smallest possible change
|
||||
- prefer patches over rewrites
|
||||
- prefer mv > recreate
|
||||
- prefer rename > rebuild
|
||||
- avoid touching unrelated modules
|
||||
- never perform cleanup unless instructed
|
||||
|
||||
---
|
||||
|
||||
## File Discipline
|
||||
Mandatory rules:
|
||||
- no empty files — delete instead
|
||||
- no placeholder files
|
||||
- no comment-only files
|
||||
- no stubs
|
||||
- no leftovers
|
||||
|
||||
When modifying or creating a file:
|
||||
- keep file small
|
||||
- keep scope focused to the requested change
|
||||
- use class-per-file if explicitly required by Orchestrator or user
|
||||
- do NOT introduce new files unless explicitly requested
|
||||
- no empty files → delete them
|
||||
- no placeholders
|
||||
- no comment-only shells
|
||||
- no auto-generating structures
|
||||
- class-per-file only when relevant
|
||||
- one export per file only when relevant
|
||||
|
||||
---
|
||||
|
||||
## Testing Rules (Unless Overridden)
|
||||
If the Orchestrator instructs TDD:
|
||||
- Backend Coder creates a failing test (RED)
|
||||
- implements the minimal fix (GREEN)
|
||||
- optionally refactors after GREEN
|
||||
- runs only relevant tests
|
||||
## Testing Rules (If instructed)
|
||||
If the Orchestrator activates TDD:
|
||||
- create RED
|
||||
- make minimal GREEN
|
||||
- refactor only when GREEN
|
||||
- run only relevant tests
|
||||
|
||||
If NOT instructed:
|
||||
- the Backend Coder does NOT invent tests
|
||||
- does NOT enforce TDD
|
||||
- does NOT enforce BDD
|
||||
|
||||
Backend Coder follows EXACTLY the instructions.
|
||||
|
||||
---
|
||||
|
||||
## Efficiency Rules
|
||||
The Backend Coder:
|
||||
- runs only the smallest relevant test set
|
||||
- avoids full test runs unless required
|
||||
- avoids any computation not explicitly part of the task
|
||||
- performs backend logic edits with maximum precision and minimum overhead
|
||||
If TDD is NOT activated:
|
||||
- do not invent tests
|
||||
|
||||
---
|
||||
|
||||
## Forbidden
|
||||
The Backend Coder must NOT:
|
||||
- stop on its own
|
||||
- abandon tasks
|
||||
- rewrite unrelated files
|
||||
- generate large refactors
|
||||
- produce long explanations
|
||||
- output multi-paragraph text
|
||||
- override or reinterpret instructions
|
||||
- question architectural decisions
|
||||
- try to improve the project unsolicited
|
||||
- deliver post-action commentary
|
||||
- leave empty or stub files
|
||||
- change naming conventions unless instructed
|
||||
- alter behavior beyond the request
|
||||
The Backend Coder MUST NOT:
|
||||
- output explanations
|
||||
- produce long text
|
||||
- justify design choices
|
||||
- add unrequested structure
|
||||
- refactor unrelated code
|
||||
- introduce clean architecture elements unless instructed
|
||||
- apply domain logic inside infrastructure
|
||||
- perform speculative work
|
||||
|
||||
---
|
||||
|
||||
## Completion
|
||||
The Backend Coder is finished ONLY when:
|
||||
- the user’s or Orchestrator’s instruction has been executed exactly
|
||||
- the change is minimal, efficient, and correct
|
||||
- no empty or placeholder files remain
|
||||
- no unrelated code was touched
|
||||
After finishing a task, the Backend Coder returns ONLY:
|
||||
|
||||
The Backend Coder then waits silently for the next instruction.
|
||||
### **What was done**
|
||||
- short bullet list
|
||||
|
||||
### **What is still open**
|
||||
- short bullet list, or “Nothing”
|
||||
|
||||
Nothing else.
|
||||
@@ -1,100 +1,133 @@
|
||||
# 🧭 Orchestrator
|
||||
|
||||
## Purpose
|
||||
Interpret the user's intent, gather all required context,
|
||||
and delegate a single, fully-scoped task to the appropriate expert.
|
||||
|
||||
The Orchestrator never performs expert work and never defines how experts must format their results.
|
||||
Interpret the user's intent, gather complete context,
|
||||
and delegate work as clear, cohesive subtasks to the correct experts.
|
||||
The Orchestrator never performs expert work.
|
||||
|
||||
---
|
||||
|
||||
## Core Responsibilities
|
||||
## User Supremacy
|
||||
- The user overrides all internal rules.
|
||||
- The Orchestrator must stop all ongoing processes and adapt immediately when the user issues a new instruction.
|
||||
- No reinterpretation or negotiation.
|
||||
|
||||
### 1. Interpret the user's intention
|
||||
- Understand exactly what the user wants.
|
||||
- No reinterpretation, no negotiation, no softening.
|
||||
- User intent overrides all internal rules once confirmed.
|
||||
---
|
||||
|
||||
### 2. Provide full context
|
||||
The Orchestrator MUST gather and provide ALL information an expert needs:
|
||||
## Context Responsibility
|
||||
The Orchestrator MUST provide:
|
||||
- exact file paths
|
||||
- exact files to modify
|
||||
- explicit operations
|
||||
- content excerpts when needed
|
||||
- constraints
|
||||
- related layer/location rules
|
||||
- relevant code excerpts if necessary
|
||||
- what NOT to touch
|
||||
- expected outcome
|
||||
- expected output
|
||||
- what must NOT be touched
|
||||
- any relevant test or behavior definition
|
||||
|
||||
Experts must NEVER search for missing context.
|
||||
If anything is missing → the Orchestrator must supply it immediately.
|
||||
Experts must NEVER collect context themselves.
|
||||
|
||||
### 3. Delegate a clear task
|
||||
---
|
||||
|
||||
## Task Grouping
|
||||
The Orchestrator MUST:
|
||||
- **merge related work into one cohesive subtask**
|
||||
- **split unrelated work into multiple subtasks**
|
||||
- assign each subtask to exactly one expert
|
||||
- never mix concerns or layers
|
||||
|
||||
A subtask must always be:
|
||||
- self-contained
|
||||
- minimal
|
||||
- fully scoped
|
||||
- executable
|
||||
|
||||
---
|
||||
|
||||
## TODO List Responsibility (Critical)
|
||||
The Orchestrator MUST maintain a **strict, accurate TODO list**.
|
||||
|
||||
Rules:
|
||||
1. When the user gives ANY instruction →
|
||||
**the Orchestrator MUST generate or update a TODO list.**
|
||||
|
||||
2. TODO list must contain **ONLY outstanding, unfinished work**.
|
||||
- No completed items.
|
||||
- No redundant items.
|
||||
- No invented tasks.
|
||||
- No assumptions.
|
||||
|
||||
3. Each TODO item must be:
|
||||
- explicit
|
||||
- actionable
|
||||
- minimal
|
||||
- atomic (one responsibility per item)
|
||||
|
||||
4. The TODO list MUST represent the **true, current state** of what remains.
|
||||
- If something is already done → DO NOT list it
|
||||
- If something is irrelevant → DO NOT list it
|
||||
- If something is repeated → collapse to one item
|
||||
|
||||
5. The TODO list is the **single source of truth** for remaining work.
|
||||
|
||||
6. Experts NEVER update TODOs.
|
||||
Only the Orchestrator modifies TODOs.
|
||||
|
||||
7. After each expert result:
|
||||
- The Orchestrator MUST update the TODO list (finish/remove completed items, keep only outstanding ones).
|
||||
|
||||
---
|
||||
|
||||
## Delegation Rules
|
||||
A delegation MUST be:
|
||||
- concrete
|
||||
- direct
|
||||
- unambiguous
|
||||
- fully scoped
|
||||
- minimal
|
||||
- containing no reasoning, no theory, no alternative paths
|
||||
- context-complete
|
||||
- zero explanations
|
||||
- no options
|
||||
- no reasoning
|
||||
|
||||
Format concept:
|
||||
Format guidelines:
|
||||
- “Here is the context.”
|
||||
- “Here is the task.”
|
||||
- “Do exactly this and nothing else.”
|
||||
- “Do exactly this and nothing else.”
|
||||
|
||||
The Orchestrator gives **WHAT**, never **HOW**.
|
||||
---
|
||||
|
||||
### 4. Interruptibility
|
||||
If the user issues a new instruction:
|
||||
- all ongoing work must be stopped
|
||||
- all pending steps discarded
|
||||
- immediate redirection to the new instruction
|
||||
## Interruptibility
|
||||
When the user issues a new instruction:
|
||||
- stop all running tasks
|
||||
- discard previous assumptions
|
||||
- rebuild TODO list
|
||||
- delegate new work
|
||||
|
||||
User supersedes all processes at all times.
|
||||
---
|
||||
|
||||
### 5. No expert interference
|
||||
The Orchestrator must NOT:
|
||||
- give architecture opinions
|
||||
- explain design principles
|
||||
- instruct how to implement anything
|
||||
- expand or shrink tasks beyond user intent
|
||||
- add optional improvements
|
||||
- ask questions to the user unless absolutely needed
|
||||
- create complexity
|
||||
|
||||
The Orchestrator coordinates.
|
||||
Experts think for their domain.
|
||||
|
||||
### 6. No instruction formatting requirements for experts
|
||||
The Orchestrator NEVER:
|
||||
- defines summary format
|
||||
- defines diagnostic format
|
||||
- defines report size
|
||||
- defines expert behavior rules
|
||||
|
||||
Those belong ONLY in the expert modes themselves.
|
||||
## Efficiency
|
||||
The Orchestrator MUST:
|
||||
- minimize the number of subtasks
|
||||
- avoid duplicated work
|
||||
- ensure no overlapping instructions
|
||||
- keep the workflow deterministic
|
||||
|
||||
---
|
||||
|
||||
## Forbidden
|
||||
The Orchestrator must NOT:
|
||||
- perform analysis meant for an expert
|
||||
- evaluate architecture
|
||||
- evaluate correctness
|
||||
The Orchestrator MUST NOT:
|
||||
- perform expert-level reasoning
|
||||
- propose solutions
|
||||
- rewrite or refactor
|
||||
- provide multi-step plans
|
||||
- write explanations or essays
|
||||
- guess missing information
|
||||
- delay execution
|
||||
- override user instructions
|
||||
- give architecture opinions
|
||||
- write plans
|
||||
- describe implementations
|
||||
- output long explanations
|
||||
- generate TODOs that are already done
|
||||
- expand or reduce user intent
|
||||
- run tests
|
||||
- edit files
|
||||
|
||||
---
|
||||
|
||||
## Completion
|
||||
A task is considered done when:
|
||||
- the expert returns a result
|
||||
- the Orchestrator interprets it
|
||||
- and either delegates the next task or awaits user instructions
|
||||
|
||||
The Orchestrator never produces its own “deliverable” — it only coordinates.
|
||||
A step is complete when:
|
||||
- the assigned expert returns the result
|
||||
- the TODO list is updated to reflect ONLY what is still outstanding
|
||||
- the Orchestrator either delegates the next TODO or waits for user input
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthService } from '@/lib/auth';
|
||||
import {
|
||||
DemoFaceValidationAdapter,
|
||||
import {
|
||||
DemoFaceValidationAdapter,
|
||||
DemoAvatarGenerationAdapter,
|
||||
InMemoryAvatarGenerationRepository
|
||||
} from '@gridpilot/demo-infrastructure';
|
||||
InMemoryAvatarGenerationRepository
|
||||
} from '@gridpilot/testing-support';
|
||||
import { RequestAvatarGenerationUseCase } from '@gridpilot/media';
|
||||
|
||||
// Create singleton instances
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { DemoFaceValidationAdapter } from '@gridpilot/demo-infrastructure';
|
||||
import { DemoFaceValidationAdapter } from '@gridpilot/testing-support';
|
||||
|
||||
const faceValidation = new DemoFaceValidationAdapter();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
||||
import { getDriverRepository, getImageService, getSocialRepository } from '@/lib/di-container';
|
||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||
import { getDriverRepository, getImageService } from '@/lib/di-container';
|
||||
|
||||
function timeAgo(timestamp: Date): string {
|
||||
const diffMs = Date.now() - timestamp.getTime();
|
||||
@@ -15,44 +15,32 @@ function timeAgo(timestamp: Date): string {
|
||||
return `${diffDays} d ago`;
|
||||
}
|
||||
|
||||
async function resolveActor(item: FeedItem) {
|
||||
async function resolveActor(item: FeedItemDTO) {
|
||||
const driverRepo = getDriverRepository();
|
||||
const imageService = getImageService();
|
||||
const socialRepo = getSocialRepository();
|
||||
|
||||
if (item.actorFriendId) {
|
||||
// Try social graph first (friend display name/avatar)
|
||||
try {
|
||||
const friend = await socialRepo.getFriendByDriverId?.(item.actorFriendId);
|
||||
if (friend) {
|
||||
return {
|
||||
name: friend.displayName ?? friend.driverName ?? `Driver ${item.actorFriendId}`,
|
||||
avatarUrl: friend.avatarUrl ?? imageService.getDriverAvatar(item.actorFriendId),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to driver lookup
|
||||
}
|
||||
const actorId = item.actorFriendId ?? item.actorDriverId;
|
||||
if (!actorId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallback to driver entity + image service
|
||||
try {
|
||||
const driver = await driverRepo.findById(item.actorFriendId);
|
||||
if (driver) {
|
||||
return {
|
||||
name: driver.name,
|
||||
avatarUrl: imageService.getDriverAvatar(driver.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore and return null below
|
||||
try {
|
||||
const driver = await driverRepo.findById(actorId);
|
||||
if (driver) {
|
||||
return {
|
||||
name: driver.name,
|
||||
avatarUrl: imageService.getDriverAvatar(driver.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to generic rendering
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface FeedItemCardProps {
|
||||
item: FeedItem;
|
||||
item: FeedItemDTO;
|
||||
}
|
||||
|
||||
export default function FeedItemCard({ item }: FeedItemCardProps) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { RaceWithResultsDTO } from '@gridpilot/testing-support';
|
||||
import FeedList from '@/components/feed/FeedList';
|
||||
@@ -7,7 +7,7 @@ import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
|
||||
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
|
||||
|
||||
interface FeedLayoutProps {
|
||||
feedItems: FeedItem[];
|
||||
feedItems: FeedItemDTO[];
|
||||
upcomingRaces: Race[];
|
||||
latestResults: RaceWithResultsDTO[];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import FeedEmptyState from '@/components/feed/FeedEmptyState';
|
||||
import FeedItemCard from '@/components/feed/FeedItemCard';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||
|
||||
interface FeedListProps {
|
||||
items: FeedItem[];
|
||||
items: FeedItemDTO[];
|
||||
}
|
||||
|
||||
export default function FeedList({ items }: FeedListProps) {
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getIsDriverRegisteredForRaceQuery,
|
||||
getRegisterForRaceUseCase,
|
||||
getWithdrawFromRaceUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import {
|
||||
loadLeagueSchedule,
|
||||
registerForRace,
|
||||
withdrawFromRace,
|
||||
type LeagueScheduleRaceItemViewModel,
|
||||
} from '@/lib/presenters/LeagueSchedulePresenter';
|
||||
|
||||
interface LeagueScheduleProps {
|
||||
leagueId: string;
|
||||
@@ -17,7 +16,7 @@ interface LeagueScheduleProps {
|
||||
|
||||
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
const router = useRouter();
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [races, setRaces] = useState<LeagueScheduleRaceItemViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
||||
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
|
||||
@@ -25,30 +24,16 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const loadRaces = useCallback(async () => {
|
||||
const loadRacesCallback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const allRaces = await raceRepo.findAll();
|
||||
const leagueRaces = allRaces
|
||||
.filter((race) => race.leagueId === leagueId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
|
||||
);
|
||||
setRaces(leagueRaces);
|
||||
const viewModel = await loadLeagueSchedule(leagueId, currentDriverId);
|
||||
setRaces(viewModel.races);
|
||||
|
||||
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
|
||||
const states: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
leagueRaces.map(async (race) => {
|
||||
const registered = await isRegisteredQuery.execute({
|
||||
raceId: race.id,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
states[race.id] = registered;
|
||||
}),
|
||||
);
|
||||
for (const race of viewModel.races) {
|
||||
states[race.id] = race.isRegistered;
|
||||
}
|
||||
setRegistrationStates(states);
|
||||
} catch (error) {
|
||||
console.error('Failed to load races:', error);
|
||||
@@ -58,27 +43,19 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRaces();
|
||||
}, [loadRaces]);
|
||||
void loadRacesCallback();
|
||||
}, [loadRacesCallback]);
|
||||
|
||||
|
||||
const handleRegister = async (race: Race, e: React.MouseEvent) => {
|
||||
const handleRegister = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Register for ${race.track}?`
|
||||
);
|
||||
|
||||
const confirmed = window.confirm(`Register for ${race.track}?`);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setProcessingRace(race.id);
|
||||
try {
|
||||
const useCase = getRegisterForRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
leagueId,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
await registerForRace(race.id, leagueId, currentDriverId);
|
||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: true }));
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register');
|
||||
@@ -87,22 +64,16 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (race: Race, e: React.MouseEvent) => {
|
||||
const handleWithdraw = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Withdraw from this race?'
|
||||
);
|
||||
const confirmed = window.confirm('Withdraw from this race?');
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setProcessingRace(race.id);
|
||||
try {
|
||||
const useCase = getWithdrawFromRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
await withdrawFromRace(race.id, currentDriverId);
|
||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: false }));
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw');
|
||||
@@ -111,18 +82,17 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const upcomingRaces = races.filter(race => race.status === 'scheduled' && new Date(race.scheduledAt) > now);
|
||||
const pastRaces = races.filter(race => race.status === 'completed' || new Date(race.scheduledAt) <= now);
|
||||
const upcomingRaces = races.filter((race) => race.isUpcoming);
|
||||
const pastRaces = races.filter((race) => race.isPast);
|
||||
|
||||
const getDisplayRaces = () => {
|
||||
switch (filter) {
|
||||
case 'upcoming':
|
||||
return upcomingRaces;
|
||||
case 'past':
|
||||
return pastRaces.reverse();
|
||||
return [...pastRaces].reverse();
|
||||
case 'all':
|
||||
return [...upcomingRaces, ...pastRaces.reverse()];
|
||||
return [...upcomingRaces, ...[...pastRaces].reverse()];
|
||||
default:
|
||||
return races;
|
||||
}
|
||||
@@ -190,8 +160,8 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayRaces.map((race) => {
|
||||
const isPast = race.status === 'completed' || new Date(race.scheduledAt) <= now;
|
||||
const isUpcoming = race.status === 'scheduled' && new Date(race.scheduledAt) > now;
|
||||
const isPast = race.isPast;
|
||||
const isUpcoming = race.isUpcoming;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -231,19 +201,19 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-white font-medium">
|
||||
{new Date(race.scheduledAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{new Date(race.scheduledAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-white font-medium">
|
||||
{race.scheduledAt.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{race.scheduledAt.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
{isPast && race.status === 'completed' && (
|
||||
<p className="text-xs text-primary-blue mt-1">View Results →</p>
|
||||
)}
|
||||
|
||||
@@ -4,24 +4,28 @@ import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getGetTeamJoinRequestsUseCase,
|
||||
getApproveTeamJoinRequestUseCase,
|
||||
getRejectTeamJoinRequestUseCase,
|
||||
getUpdateTeamUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
|
||||
import {
|
||||
loadTeamAdminViewModel,
|
||||
approveTeamJoinRequestAndReload,
|
||||
rejectTeamJoinRequestAndReload,
|
||||
updateTeamDetails,
|
||||
type TeamAdminJoinRequestViewModel,
|
||||
} from '@/lib/presenters/TeamAdminPresenter';
|
||||
|
||||
interface TeamAdminProps {
|
||||
team: Team;
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
};
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
const [joinRequests, setJoinRequests] = useState<TeamJoinRequest[]>([]);
|
||||
const [joinRequests, setJoinRequests] = useState<TeamAdminJoinRequestViewModel[]>([]);
|
||||
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverDTO>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -32,38 +36,38 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void loadJoinRequests();
|
||||
}, [team.id]);
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const viewModel = await loadTeamAdminViewModel(team as any);
|
||||
setJoinRequests(viewModel.requests);
|
||||
|
||||
const loadJoinRequests = async () => {
|
||||
const useCase = getGetTeamJoinRequestsUseCase();
|
||||
await useCase.execute({ teamId: team.id });
|
||||
const viewModel = useCase.presenter.getViewModel();
|
||||
setJoinRequests(viewModel.requests);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driverMap: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const request of viewModel.requests) {
|
||||
const driver = allDrivers.find(d => d.id === request.driverId);
|
||||
if (driver) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driverMap[request.driverId] = dto;
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of viewModel.requests) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setRequestDrivers(driverMap);
|
||||
setLoading(false);
|
||||
};
|
||||
void load();
|
||||
}, [team.id, team.name, team.tag, team.description, team.ownerId]);
|
||||
|
||||
const handleApprove = async (requestId: string) => {
|
||||
try {
|
||||
const useCase = getApproveTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await loadJoinRequests();
|
||||
const updated = await approveTeamJoinRequestAndReload(requestId, team.id);
|
||||
setJoinRequests(updated);
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of updated) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to approve request');
|
||||
@@ -72,9 +76,15 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
|
||||
const handleReject = async (requestId: string) => {
|
||||
try {
|
||||
const useCase = getRejectTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await loadJoinRequests();
|
||||
const updated = await rejectTeamJoinRequestAndReload(requestId, team.id);
|
||||
setJoinRequests(updated);
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of updated) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to reject request');
|
||||
}
|
||||
@@ -82,15 +92,12 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
try {
|
||||
const useCase = getUpdateTeamUseCase();
|
||||
await useCase.execute({
|
||||
await updateTeamDetails({
|
||||
teamId: team.id,
|
||||
updates: {
|
||||
name: editedTeam.name,
|
||||
tag: editedTeam.tag,
|
||||
description: editedTeam.description,
|
||||
},
|
||||
updatedBy: team.ownerId,
|
||||
name: editedTeam.name,
|
||||
tag: editedTeam.tag,
|
||||
description: editedTeam.description,
|
||||
updatedByDriverId: team.ownerId,
|
||||
});
|
||||
setEditMode(false);
|
||||
onUpdate();
|
||||
@@ -194,7 +201,7 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
) : joinRequests.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{joinRequests.map((request) => {
|
||||
const driver = requestDrivers[request.driverId];
|
||||
const driver = requestDrivers[request.driverId] ?? request.driver;
|
||||
if (!driver) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,85 +2,31 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
|
||||
import {
|
||||
loadTeamStandings,
|
||||
type TeamLeagueStandingViewModel,
|
||||
} from '@/lib/presenters/TeamStandingsPresenter';
|
||||
|
||||
interface TeamStandingsProps {
|
||||
teamId: string;
|
||||
leagues: string[];
|
||||
}
|
||||
|
||||
interface TeamLeagueStanding {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
|
||||
const [standings, setStandings] = useState<TeamLeagueStanding[]>([]);
|
||||
const [standings, setStandings] = useState<TeamLeagueStandingViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStandings = async () => {
|
||||
const standingRepo = getStandingRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const teamMembershipRepo = getTeamMembershipRepository();
|
||||
const members = await teamMembershipRepo.getTeamMembers(teamId);
|
||||
const memberIds = members.map(m => m.driverId);
|
||||
|
||||
const teamStandings: TeamLeagueStanding[] = [];
|
||||
|
||||
for (const leagueId of leagues) {
|
||||
const league = await leagueRepo.findById(leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
|
||||
|
||||
// Calculate team points (sum of all team members)
|
||||
let totalPoints = 0;
|
||||
let totalWins = 0;
|
||||
let totalRaces = 0;
|
||||
|
||||
for (const standing of leagueStandings) {
|
||||
if (memberIds.includes(standing.driverId)) {
|
||||
totalPoints += standing.points;
|
||||
totalWins += standing.wins;
|
||||
totalRaces = Math.max(totalRaces, standing.racesCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate team position (simplified - based on total points)
|
||||
const allTeamPoints = leagueStandings
|
||||
.filter(s => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
const position = leagueStandings
|
||||
.filter((_, idx, arr) => {
|
||||
const teamPoints = arr
|
||||
.filter(s => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
return teamPoints > allTeamPoints;
|
||||
}).length + 1;
|
||||
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
position,
|
||||
points: totalPoints,
|
||||
wins: totalWins,
|
||||
racesCompleted: totalRaces,
|
||||
});
|
||||
const load = async () => {
|
||||
try {
|
||||
const viewModel = await loadTeamStandings(teamId, leagues);
|
||||
setStandings(viewModel.standings);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setStandings(teamStandings);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadStandings();
|
||||
void load();
|
||||
}, [teamId, leagues]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -43,7 +43,7 @@ import type { INotificationRepository, INotificationPreferenceRepository } from
|
||||
import {
|
||||
SendNotificationUseCase,
|
||||
MarkNotificationReadUseCase,
|
||||
GetUnreadNotificationsQuery
|
||||
GetUnreadNotificationsUseCase
|
||||
} from '@gridpilot/notifications/application';
|
||||
import {
|
||||
InMemoryNotificationRepository,
|
||||
@@ -81,7 +81,7 @@ import {
|
||||
InMemoryFeedRepository,
|
||||
InMemorySocialGraphRepository,
|
||||
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
|
||||
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
|
||||
import { DemoImageServiceAdapter } from '@gridpilot/testing-support';
|
||||
|
||||
// Application use cases and queries
|
||||
import {
|
||||
@@ -174,7 +174,7 @@ import { LeagueSchedulePreviewPresenter } from './presenters/LeagueSchedulePrevi
|
||||
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
|
||||
import { ProfileOverviewPresenter } from './presenters/ProfileOverviewPresenter';
|
||||
|
||||
// Testing support
|
||||
// Demo infrastructure (runtime demo seed & helpers)
|
||||
import {
|
||||
createStaticRacingSeed,
|
||||
getDemoLeagueArchetypeByName,
|
||||
@@ -1246,8 +1246,8 @@ export function configureDIContainer(): void {
|
||||
|
||||
// Register queries - Notifications
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetUnreadNotificationsQuery,
|
||||
new GetUnreadNotificationsQuery(notificationRepository)
|
||||
DI_TOKENS.GetUnreadNotificationsUseCase,
|
||||
new GetUnreadNotificationsUseCase(notificationRepository)
|
||||
);
|
||||
|
||||
// Register use cases - Sponsors
|
||||
|
||||
@@ -35,7 +35,7 @@ import type { INotificationRepository, INotificationPreferenceRepository } from
|
||||
import type {
|
||||
SendNotificationUseCase,
|
||||
MarkNotificationReadUseCase,
|
||||
GetUnreadNotificationsQuery
|
||||
GetUnreadNotificationsUseCase
|
||||
} from '@gridpilot/notifications/application';
|
||||
import type {
|
||||
JoinLeagueUseCase,
|
||||
@@ -457,9 +457,9 @@ class DIContainer {
|
||||
return getDIContainer().resolve<MarkNotificationReadUseCase>(DI_TOKENS.MarkNotificationReadUseCase);
|
||||
}
|
||||
|
||||
get getUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
|
||||
get getUnreadNotificationsUseCase(): GetUnreadNotificationsUseCase {
|
||||
this.ensureInitialized();
|
||||
return getDIContainer().resolve<GetUnreadNotificationsQuery>(DI_TOKENS.GetUnreadNotificationsQuery);
|
||||
return getDIContainer().resolve<GetUnreadNotificationsUseCase>(DI_TOKENS.GetUnreadNotificationsUseCase);
|
||||
}
|
||||
|
||||
get fileProtestUseCase(): FileProtestUseCase {
|
||||
@@ -801,8 +801,8 @@ export function getMarkNotificationReadUseCase(): MarkNotificationReadUseCase {
|
||||
return DIContainer.getInstance().markNotificationReadUseCase;
|
||||
}
|
||||
|
||||
export function getGetUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
|
||||
return DIContainer.getInstance().getUnreadNotificationsQuery;
|
||||
export function getGetUnreadNotificationsUseCase(): GetUnreadNotificationsUseCase {
|
||||
return DIContainer.getInstance().getUnreadNotificationsUseCase;
|
||||
}
|
||||
|
||||
export function getFileProtestUseCase(): FileProtestUseCase {
|
||||
|
||||
@@ -101,7 +101,7 @@ export const DI_TOKENS = {
|
||||
GetRacePenaltiesUseCase: Symbol.for('GetRacePenaltiesUseCase'),
|
||||
|
||||
// Queries - Notifications
|
||||
GetUnreadNotificationsQuery: Symbol.for('GetUnreadNotificationsQuery'),
|
||||
GetUnreadNotificationsUseCase: Symbol.for('GetUnreadNotificationsUseCase'),
|
||||
|
||||
// Use Cases - Sponsors
|
||||
GetSponsorDashboardUseCase: Symbol.for('GetSponsorDashboardUseCase'),
|
||||
|
||||
121
apps/website/lib/presenters/TeamAdminPresenter.ts
Normal file
121
apps/website/lib/presenters/TeamAdminPresenter.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getGetTeamJoinRequestsUseCase,
|
||||
getApproveTeamJoinRequestUseCase,
|
||||
getRejectTeamJoinRequestUseCase,
|
||||
getUpdateTeamUseCase,
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export interface TeamAdminJoinRequestViewModel {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
driver?: DriverDTO;
|
||||
}
|
||||
|
||||
export interface TeamAdminTeamSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface TeamAdminViewModel {
|
||||
team: TeamAdminTeamSummaryViewModel;
|
||||
requests: TeamAdminJoinRequestViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load join requests plus driver DTOs for a team.
|
||||
*/
|
||||
export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewModel> {
|
||||
const requests = await loadTeamJoinRequests(team.id);
|
||||
return {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
},
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
|
||||
await getRequestsUseCase.execute({ teamId });
|
||||
const presenterVm = getRequestsUseCase.presenter.getViewModel();
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const driver of allDrivers) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
}
|
||||
|
||||
return presenterVm.requests.map((req) => ({
|
||||
id: req.requestId,
|
||||
teamId: req.teamId,
|
||||
driverId: req.driverId,
|
||||
requestedAt: new Date(req.requestedAt),
|
||||
message: req.message,
|
||||
driver: driversById[req.driverId],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a team join request and return updated request view models.
|
||||
*/
|
||||
export async function approveTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getApproveTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a team join request and return updated request view models.
|
||||
*/
|
||||
export async function rejectTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getRejectTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update team basic details.
|
||||
*/
|
||||
export async function updateTeamDetails(params: {
|
||||
teamId: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
updatedByDriverId: string;
|
||||
}): Promise<void> {
|
||||
const useCase = getUpdateTeamUseCase();
|
||||
await useCase.execute({
|
||||
teamId: params.teamId,
|
||||
updates: {
|
||||
name: params.name,
|
||||
tag: params.tag,
|
||||
description: params.description,
|
||||
},
|
||||
updatedBy: params.updatedByDriverId,
|
||||
});
|
||||
}
|
||||
76
apps/website/lib/presenters/TeamStandingsPresenter.ts
Normal file
76
apps/website/lib/presenters/TeamStandingsPresenter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
|
||||
|
||||
export interface TeamLeagueStandingViewModel {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export interface TeamStandingsViewModel {
|
||||
standings: TeamLeagueStandingViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute team standings across the given leagues for a team.
|
||||
* Mirrors the previous TeamStandings component logic but keeps it out of the UI layer.
|
||||
*/
|
||||
export async function loadTeamStandings(
|
||||
teamId: string,
|
||||
leagues: string[],
|
||||
): Promise<TeamStandingsViewModel> {
|
||||
const standingRepo = getStandingRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const teamMembershipRepo = getTeamMembershipRepository();
|
||||
|
||||
const members = await teamMembershipRepo.getTeamMembers(teamId);
|
||||
const memberIds = members.map((m) => m.driverId);
|
||||
|
||||
const teamStandings: TeamLeagueStandingViewModel[] = [];
|
||||
|
||||
for (const leagueId of leagues) {
|
||||
const league = await leagueRepo.findById(leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
|
||||
|
||||
let totalPoints = 0;
|
||||
let totalWins = 0;
|
||||
let totalRaces = 0;
|
||||
|
||||
for (const standing of leagueStandings) {
|
||||
if (memberIds.includes(standing.driverId)) {
|
||||
totalPoints += standing.points;
|
||||
totalWins += standing.wins;
|
||||
totalRaces = Math.max(totalRaces, standing.racesCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified team position based on total points (same spirit as previous logic)
|
||||
const allTeamPoints = leagueStandings
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
const position =
|
||||
leagueStandings
|
||||
.filter((_, idx, arr) => {
|
||||
const teamPoints = arr
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
return teamPoints > allTeamPoints;
|
||||
}).length + 1;
|
||||
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
position,
|
||||
points: totalPoints,
|
||||
wins: totalWins,
|
||||
racesCompleted: totalRaces,
|
||||
});
|
||||
}
|
||||
|
||||
return { standings: teamStandings };
|
||||
}
|
||||
@@ -24,8 +24,7 @@
|
||||
"@gridpilot/racing/*": ["../../packages/racing/*"],
|
||||
"@gridpilot/social/*": ["../../packages/social/*"],
|
||||
"@gridpilot/testing-support": ["../../packages/testing-support"],
|
||||
"@gridpilot/media": ["../../packages/media"],
|
||||
"@gridpilot/demo-infrastructure": ["../../packages/demo-infrastructure"]
|
||||
"@gridpilot/media": ["../../packages/media"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
import { HostedSessionConfig } from './HostedSessionConfig';
|
||||
import { AutomationDomainError } from '../errors/AutomationDomainError';
|
||||
|
||||
export class AutomationSession {
|
||||
private readonly _id: string;
|
||||
@@ -26,13 +27,13 @@ export class AutomationSession {
|
||||
|
||||
static create(config: HostedSessionConfig): AutomationSession {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
throw new Error('Session name cannot be empty');
|
||||
throw new AutomationDomainError('Session name cannot be empty');
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
throw new Error('Track ID is required');
|
||||
throw new AutomationDomainError('Track ID is required');
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
throw new Error('At least one car must be selected');
|
||||
throw new AutomationDomainError('At least one car must be selected');
|
||||
}
|
||||
|
||||
return new AutomationSession(
|
||||
@@ -73,7 +74,7 @@ export class AutomationSession {
|
||||
|
||||
start(): void {
|
||||
if (!this._state.isPending()) {
|
||||
throw new Error('Cannot start session that is not pending');
|
||||
throw new AutomationDomainError('Cannot start session that is not pending');
|
||||
}
|
||||
this._state = SessionState.create('IN_PROGRESS');
|
||||
this._startedAt = new Date();
|
||||
@@ -81,19 +82,19 @@ export class AutomationSession {
|
||||
|
||||
transitionToStep(targetStep: StepId): void {
|
||||
if (!this._state.isInProgress()) {
|
||||
throw new Error('Cannot transition steps when session is not in progress');
|
||||
throw new AutomationDomainError('Cannot transition steps when session is not in progress');
|
||||
}
|
||||
|
||||
if (this._currentStep.equals(targetStep)) {
|
||||
throw new Error('Already at this step');
|
||||
throw new AutomationDomainError('Already at this step');
|
||||
}
|
||||
|
||||
if (targetStep.value < this._currentStep.value) {
|
||||
throw new Error('Cannot move backward - steps must progress forward only');
|
||||
throw new AutomationDomainError('Cannot move backward - steps must progress forward only');
|
||||
}
|
||||
|
||||
if (targetStep.value !== this._currentStep.value + 1) {
|
||||
throw new Error('Cannot skip steps - must transition sequentially');
|
||||
throw new AutomationDomainError('Cannot skip steps - must transition sequentially');
|
||||
}
|
||||
|
||||
this._currentStep = targetStep;
|
||||
@@ -106,21 +107,21 @@ export class AutomationSession {
|
||||
|
||||
pause(): void {
|
||||
if (!this._state.isInProgress()) {
|
||||
throw new Error('Cannot pause session that is not in progress');
|
||||
throw new AutomationDomainError('Cannot pause session that is not in progress');
|
||||
}
|
||||
this._state = SessionState.create('PAUSED');
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this._state.value !== 'PAUSED') {
|
||||
throw new Error('Cannot resume session that is not paused');
|
||||
throw new AutomationDomainError('Cannot resume session that is not paused');
|
||||
}
|
||||
this._state = SessionState.create('IN_PROGRESS');
|
||||
}
|
||||
|
||||
fail(errorMessage: string): void {
|
||||
if (this._state.isTerminal()) {
|
||||
throw new Error('Cannot fail terminal session');
|
||||
throw new AutomationDomainError('Cannot fail terminal session');
|
||||
}
|
||||
this._state = SessionState.create('FAILED');
|
||||
this._errorMessage = errorMessage;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class AutomationDomainError extends Error {
|
||||
readonly name: string = 'AutomationDomainError';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './media/DemoImageServiceAdapter';
|
||||
export * from './media/DemoFaceValidationAdapter';
|
||||
export * from './media/DemoAvatarGenerationAdapter';
|
||||
export * from './media/InMemoryAvatarGenerationRepository';
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@gridpilot/demo-infrastructure",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"dependencies": {
|
||||
"@gridpilot/media": "file:../media",
|
||||
"@faker-js/faker": "^9.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./media/*": "./media/*"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../..",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": [
|
||||
"../../packages/demo-infrastructure/**/*.ts",
|
||||
"../../packages/media/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createStaticRacingSeed } from '../../../testing-support';
|
||||
import { createStaticRacingSeed } from '@gridpilot/testing-support';
|
||||
import type { IdentityProviderPort } from '../../application/ports/IdentityProviderPort';
|
||||
import type { StartAuthCommandDTO } from '../../application/dto/StartAuthCommandDTO';
|
||||
import type { AuthCallbackCommandDTO } from '../../application/dto/AuthCallbackCommandDTO';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Use Cases
|
||||
export * from './use-cases/SendNotificationUseCase';
|
||||
export * from './use-cases/MarkNotificationReadUseCase';
|
||||
export * from './use-cases/GetUnreadNotificationsQuery';
|
||||
export * from './use-cases/GetUnreadNotificationsUseCase';
|
||||
export * from './use-cases/NotificationPreferencesUseCases';
|
||||
|
||||
// Ports
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Application Query: GetUnreadNotificationsQuery
|
||||
* Application Use Case: GetUnreadNotificationsUseCase
|
||||
*
|
||||
* Retrieves unread notifications for a recipient.
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ export interface UnreadNotificationsResult {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export class GetUnreadNotificationsQuery {
|
||||
export class GetUnreadNotificationsUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
@@ -28,6 +28,6 @@ export class GetUnreadNotificationsQuery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional notification query use cases (e.g., listing or counting notifications)
|
||||
* Additional notification query/use case types (e.g., listing or counting notifications)
|
||||
* can be added here in the future as needed.
|
||||
*/
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
export interface MarkNotificationReadCommand {
|
||||
notificationId: string;
|
||||
@@ -20,11 +21,11 @@ export class MarkNotificationReadUseCase {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
if (!notification) {
|
||||
throw new Error('Notification not found');
|
||||
throw new NotificationDomainError('Notification not found');
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
throw new Error('Cannot mark another user\'s notification as read');
|
||||
throw new NotificationDomainError('Cannot mark another user\'s notification as read');
|
||||
}
|
||||
|
||||
if (!notification.isUnread()) {
|
||||
@@ -70,11 +71,11 @@ export class DismissNotificationUseCase {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
if (!notification) {
|
||||
throw new Error('Notification not found');
|
||||
throw new NotificationDomainError('Notification not found');
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
throw new Error('Cannot dismiss another user\'s notification');
|
||||
throw new NotificationDomainError('Cannot dismiss another user\'s notification');
|
||||
}
|
||||
|
||||
if (notification.isDismissed()) {
|
||||
|
||||
@@ -82,10 +82,10 @@ export class UpdateQuietHoursUseCase {
|
||||
async execute(command: UpdateQuietHoursCommand): Promise<void> {
|
||||
// Validate hours if provided
|
||||
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
|
||||
throw new Error('Start hour must be between 0 and 23');
|
||||
throw new NotificationDomainError('Start hour must be between 0 and 23');
|
||||
}
|
||||
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
|
||||
throw new Error('End hour must be between 0 and 23');
|
||||
throw new NotificationDomainError('End hour must be between 0 and 23');
|
||||
}
|
||||
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
@@ -110,7 +110,7 @@ export class SetDigestModeUseCase {
|
||||
|
||||
async execute(command: SetDigestModeCommand): Promise<void> {
|
||||
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
|
||||
throw new Error('Digest frequency must be at least 1 hour');
|
||||
throw new NotificationDomainError('Digest frequency must be at least 1 hour');
|
||||
}
|
||||
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../value-objects/NotificationChannel';
|
||||
|
||||
@@ -91,12 +93,12 @@ export class Notification {
|
||||
createdAt?: Date;
|
||||
urgency?: NotificationUrgency;
|
||||
}): Notification {
|
||||
if (!props.id) throw new Error('Notification ID is required');
|
||||
if (!props.recipientId) throw new Error('Recipient ID is required');
|
||||
if (!props.type) throw new Error('Notification type is required');
|
||||
if (!props.title?.trim()) throw new Error('Notification title is required');
|
||||
if (!props.body?.trim()) throw new Error('Notification body is required');
|
||||
if (!props.channel) throw new Error('Notification channel is required');
|
||||
if (!props.id) throw new NotificationDomainError('Notification ID is required');
|
||||
if (!props.recipientId) throw new NotificationDomainError('Recipient ID is required');
|
||||
if (!props.type) throw new NotificationDomainError('Notification type is required');
|
||||
if (!props.title?.trim()) throw new NotificationDomainError('Notification title is required');
|
||||
if (!props.body?.trim()) throw new NotificationDomainError('Notification body is required');
|
||||
if (!props.channel) throw new NotificationDomainError('Notification channel is required');
|
||||
|
||||
// Modal notifications that require response start with action_required status
|
||||
const defaultStatus = props.requiresResponse ? 'action_required' : 'unread';
|
||||
@@ -196,7 +198,7 @@ export class Notification {
|
||||
}
|
||||
// Cannot dismiss action_required notifications without responding
|
||||
if (this.props.requiresResponse && this.props.status === 'action_required') {
|
||||
throw new Error('Cannot dismiss notification that requires response');
|
||||
throw new NotificationDomainError('Cannot dismiss notification that requires response');
|
||||
}
|
||||
return new Notification({
|
||||
...this.props,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../value-objects/NotificationChannel';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
|
||||
|
||||
export interface ChannelPreference {
|
||||
@@ -45,8 +46,8 @@ export class NotificationPreference {
|
||||
private constructor(private readonly props: NotificationPreferenceProps) {}
|
||||
|
||||
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference {
|
||||
if (!props.driverId) throw new Error('Driver ID is required');
|
||||
if (!props.channels) throw new Error('Channel preferences are required');
|
||||
if (!props.driverId) throw new NotificationDomainError('Driver ID is required');
|
||||
if (!props.channels) throw new NotificationDomainError('Channel preferences are required');
|
||||
|
||||
return new NotificationPreference({
|
||||
...props,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class NotificationDomainError extends Error {
|
||||
readonly name: string = 'NotificationDomainError';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecu
|
||||
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
|
||||
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
|
||||
|
||||
export interface LeagueScheduleDTO {
|
||||
seasonStartDate: string;
|
||||
@@ -53,24 +54,24 @@ export function leagueTimingsToScheduleDTO(
|
||||
|
||||
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
|
||||
if (!dto.seasonStartDate) {
|
||||
throw new Error('seasonStartDate is required');
|
||||
throw new RacingApplicationError('seasonStartDate is required');
|
||||
}
|
||||
if (!dto.raceStartTime) {
|
||||
throw new Error('raceStartTime is required');
|
||||
throw new RacingApplicationError('raceStartTime is required');
|
||||
}
|
||||
if (!dto.timezoneId) {
|
||||
throw new Error('timezoneId is required');
|
||||
throw new RacingApplicationError('timezoneId is required');
|
||||
}
|
||||
if (!dto.recurrenceStrategy) {
|
||||
throw new Error('recurrenceStrategy is required');
|
||||
throw new RacingApplicationError('recurrenceStrategy is required');
|
||||
}
|
||||
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
|
||||
throw new Error('plannedRounds must be a positive integer');
|
||||
throw new RacingApplicationError('plannedRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const startDate = new Date(dto.seasonStartDate);
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
throw new Error(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
|
||||
throw new RacingApplicationError(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
|
||||
}
|
||||
|
||||
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
|
||||
@@ -80,15 +81,15 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
|
||||
|
||||
if (dto.recurrenceStrategy === 'weekly') {
|
||||
if (!dto.weekdays || dto.weekdays.length === 0) {
|
||||
throw new Error('weekdays are required for weekly recurrence');
|
||||
throw new RacingApplicationError('weekdays are required for weekly recurrence');
|
||||
}
|
||||
recurrence = RecurrenceStrategyFactory.weekly(new WeekdaySet(dto.weekdays));
|
||||
} else if (dto.recurrenceStrategy === 'everyNWeeks') {
|
||||
if (!dto.weekdays || dto.weekdays.length === 0) {
|
||||
throw new Error('weekdays are required for everyNWeeks recurrence');
|
||||
throw new RacingApplicationError('weekdays are required for everyNWeeks recurrence');
|
||||
}
|
||||
if (dto.intervalWeeks == null) {
|
||||
throw new Error('intervalWeeks is required for everyNWeeks recurrence');
|
||||
throw new RacingApplicationError('intervalWeeks is required for everyNWeeks recurrence');
|
||||
}
|
||||
recurrence = RecurrenceStrategyFactory.everyNWeeks(
|
||||
dto.intervalWeeks,
|
||||
@@ -96,12 +97,12 @@ export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSched
|
||||
);
|
||||
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
|
||||
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
|
||||
throw new Error('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
|
||||
throw new RacingApplicationError('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
|
||||
}
|
||||
const pattern = new MonthlyRecurrencePattern(dto.monthlyOrdinal, dto.monthlyWeekday);
|
||||
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
|
||||
} else {
|
||||
throw new Error(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
|
||||
throw new RacingApplicationError(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
|
||||
}
|
||||
|
||||
return new SeasonSchedule({
|
||||
|
||||
56
packages/racing/application/errors/RacingApplicationError.ts
Normal file
56
packages/racing/application/errors/RacingApplicationError.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export abstract class RacingApplicationError extends Error {
|
||||
readonly context = 'racing-application';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export type RacingEntityType =
|
||||
| 'race'
|
||||
| 'league'
|
||||
| 'team'
|
||||
| 'season'
|
||||
| 'sponsorship'
|
||||
| 'sponsorshipRequest'
|
||||
| 'driver'
|
||||
| 'membership';
|
||||
|
||||
export interface EntityNotFoundDetails {
|
||||
entity: RacingEntityType;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class EntityNotFoundError extends RacingApplicationError {
|
||||
readonly kind = 'not_found' as const;
|
||||
|
||||
constructor(public readonly details: EntityNotFoundDetails) {
|
||||
super(`${details.entity} not found for id: ${details.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionDeniedReason =
|
||||
| 'NOT_LEAGUE_ADMIN'
|
||||
| 'NOT_LEAGUE_OWNER'
|
||||
| 'NOT_TEAM_OWNER'
|
||||
| 'NOT_ACTIVE_MEMBER'
|
||||
| 'NOT_MEMBER'
|
||||
| 'TEAM_OWNER_CANNOT_LEAVE'
|
||||
| 'UNAUTHORIZED';
|
||||
|
||||
export class PermissionDeniedError extends RacingApplicationError {
|
||||
readonly kind = 'forbidden' as const;
|
||||
|
||||
constructor(public readonly reason: PermissionDeniedReason, message?: string) {
|
||||
super(message ?? `Permission denied: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class BusinessRuleViolationError extends RacingApplicationError {
|
||||
readonly kind = 'conflict' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
|
||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||
import { Money, type Currency } from '../../domain/value-objects/Money';
|
||||
import {
|
||||
EntityNotFoundError,
|
||||
BusinessRuleViolationError,
|
||||
} from '../errors/RacingApplicationError';
|
||||
|
||||
export interface ApplyForSponsorshipDTO {
|
||||
sponsorId: string;
|
||||
@@ -39,23 +43,23 @@ export class ApplyForSponsorshipUseCase {
|
||||
// Validate sponsor exists
|
||||
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
|
||||
if (!sponsor) {
|
||||
throw new Error('Sponsor not found');
|
||||
throw new EntityNotFoundError({ entity: 'sponsor', id: dto.sponsorId });
|
||||
}
|
||||
|
||||
// Check if entity accepts sponsorship applications
|
||||
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
|
||||
if (!pricing) {
|
||||
throw new Error('This entity has not set up sponsorship pricing');
|
||||
throw new BusinessRuleViolationError('This entity has not set up sponsorship pricing');
|
||||
}
|
||||
|
||||
if (!pricing.acceptingApplications) {
|
||||
throw new Error('This entity is not currently accepting sponsorship applications');
|
||||
throw new RacingApplicationError('This entity is not currently accepting sponsorship applications');
|
||||
}
|
||||
|
||||
// Check if the requested tier slot is available
|
||||
const slotAvailable = pricing.isSlotAvailable(dto.tier);
|
||||
if (!slotAvailable) {
|
||||
throw new Error(`No ${dto.tier} sponsorship slots are available`);
|
||||
throw new RacingApplicationError(`No ${dto.tier} sponsorship slots are available`);
|
||||
}
|
||||
|
||||
// Check if sponsor already has a pending request for this entity
|
||||
@@ -65,13 +69,13 @@ export class ApplyForSponsorshipUseCase {
|
||||
dto.entityId
|
||||
);
|
||||
if (hasPending) {
|
||||
throw new Error('You already have a pending sponsorship request for this entity');
|
||||
throw new RacingApplicationError('You already have a pending sponsorship request for this entity');
|
||||
}
|
||||
|
||||
// Validate offered amount meets minimum price
|
||||
const minPrice = pricing.getPrice(dto.tier);
|
||||
if (minPrice && dto.offeredAmount < minPrice.amount) {
|
||||
throw new Error(`Offered amount must be at least ${minPrice.format()}`);
|
||||
throw new RacingApplicationError(`Offered amount must be at least ${minPrice.format()}`);
|
||||
}
|
||||
|
||||
// Create the sponsorship request
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type { ILeagueFullConfigPresenter, LeagueFullConfigData } from '../presenters/ILeagueFullConfigPresenter';
|
||||
import { EntityNotFoundError } from '../errors/RacingApplicationError';
|
||||
|
||||
/**
|
||||
* Use Case for retrieving a league's full configuration.
|
||||
@@ -22,7 +23,7 @@ export class GetLeagueFullConfigUseCase {
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
throw new Error(`League ${leagueId} not found`);
|
||||
throw new EntityNotFoundError({ entity: 'league', id: leagueId });
|
||||
}
|
||||
|
||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
import {
|
||||
BusinessRuleViolationError,
|
||||
EntityNotFoundError,
|
||||
} from '../errors/RacingApplicationError';
|
||||
import type {
|
||||
IImportRaceResultsPresenter,
|
||||
ImportRaceResultsSummaryViewModel,
|
||||
@@ -37,17 +41,17 @@ export class ImportRaceResultsUseCase {
|
||||
|
||||
const race = await this.raceRepository.findById(raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
throw new EntityNotFoundError({ entity: 'race', id: raceId });
|
||||
}
|
||||
|
||||
const league = await this.leagueRepository.findById(race.leagueId);
|
||||
if (!league) {
|
||||
throw new Error('League not found');
|
||||
throw new EntityNotFoundError({ entity: 'league', id: race.leagueId });
|
||||
}
|
||||
|
||||
const existing = await this.resultRepository.existsByRaceId(raceId);
|
||||
if (existing) {
|
||||
throw new Error('Results already exist for this race');
|
||||
throw new BusinessRuleViolationError('Results already exist for this race');
|
||||
}
|
||||
|
||||
const entities = results.map((dto) =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
MembershipStatus,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO';
|
||||
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
|
||||
|
||||
export class JoinLeagueUseCase {
|
||||
constructor(private readonly membershipRepository: ILeagueMembershipRepository) {}
|
||||
@@ -23,7 +24,7 @@ export class JoinLeagueUseCase {
|
||||
|
||||
const existing = await this.membershipRepository.getMembership(leagueId, driverId);
|
||||
if (existing) {
|
||||
throw new Error('Already a member or have a pending request');
|
||||
throw new BusinessRuleViolationError('Already a member or have a pending request');
|
||||
}
|
||||
|
||||
const membership: LeagueMembership = {
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
TeamRole,
|
||||
} from '../../domain/entities/Team';
|
||||
import type { JoinTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||
import {
|
||||
BusinessRuleViolationError,
|
||||
EntityNotFoundError,
|
||||
} from '../errors/RacingApplicationError';
|
||||
|
||||
export class JoinTeamUseCase {
|
||||
constructor(
|
||||
@@ -20,17 +24,17 @@ export class JoinTeamUseCase {
|
||||
driverId,
|
||||
);
|
||||
if (existingActive) {
|
||||
throw new Error('Driver already belongs to a team');
|
||||
throw new BusinessRuleViolationError('Driver already belongs to a team');
|
||||
}
|
||||
|
||||
const existingMembership = await this.membershipRepository.getMembership(teamId, driverId);
|
||||
if (existingMembership) {
|
||||
throw new Error('Already a member or have a pending request');
|
||||
throw new BusinessRuleViolationError('Already a member or have a pending request');
|
||||
}
|
||||
|
||||
const team = await this.teamRepository.findById(teamId);
|
||||
if (!team) {
|
||||
throw new Error('Team not found');
|
||||
throw new EntityNotFoundError({ entity: 'team', id: teamId });
|
||||
}
|
||||
|
||||
const membership: TeamMembership = {
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repos
|
||||
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
|
||||
import type { RegisterForRaceCommandDTO } from '../dto/RegisterForRaceCommandDTO';
|
||||
import {
|
||||
BusinessRuleViolationError,
|
||||
PermissionDeniedError,
|
||||
} from '../errors/RacingApplicationError';
|
||||
|
||||
export class RegisterForRaceUseCase {
|
||||
constructor(
|
||||
@@ -20,12 +24,12 @@ export class RegisterForRaceUseCase {
|
||||
|
||||
const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
|
||||
if (alreadyRegistered) {
|
||||
throw new Error('Already registered for this race');
|
||||
throw new BusinessRuleViolationError('Already registered for this race');
|
||||
}
|
||||
|
||||
const membership = await this.membershipRepository.getMembership(leagueId, driverId);
|
||||
if (!membership || membership.status !== 'active') {
|
||||
throw new Error('Must be an active league member to register for races');
|
||||
throw new PermissionDeniedError('NOT_ACTIVE_MEMBER', 'Must be an active league member to register for races');
|
||||
}
|
||||
|
||||
const registration: RaceRegistration = {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import {
|
||||
EntityNotFoundError,
|
||||
PermissionDeniedError,
|
||||
} from '../errors/RacingApplicationError';
|
||||
|
||||
export interface ReviewProtestCommand {
|
||||
protestId: string;
|
||||
@@ -26,13 +30,13 @@ export class ReviewProtestUseCase {
|
||||
// Load the protest
|
||||
const protest = await this.protestRepository.findById(command.protestId);
|
||||
if (!protest) {
|
||||
throw new Error('Protest not found');
|
||||
throw new EntityNotFoundError({ entity: 'protest', id: command.protestId });
|
||||
}
|
||||
|
||||
// Load the race to get league ID
|
||||
const race = await this.raceRepository.findById(protest.raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
throw new EntityNotFoundError({ entity: 'race', id: protest.raceId });
|
||||
}
|
||||
|
||||
// Validate steward has authority (owner or admin of the league)
|
||||
@@ -42,7 +46,10 @@ export class ReviewProtestUseCase {
|
||||
);
|
||||
|
||||
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
|
||||
throw new Error('Only league owners and admins can review protests');
|
||||
throw new PermissionDeniedError(
|
||||
'NOT_LEAGUE_ADMIN',
|
||||
'Only league owners and admins can review protests',
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the decision
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Domain Entity: Car
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
*
|
||||
* Represents a racing car/vehicle in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
@@ -90,19 +93,19 @@ export class Car {
|
||||
gameId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Car ID is required');
|
||||
throw new RacingDomainValidationError('Car ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Car name is required');
|
||||
throw new RacingDomainValidationError('Car name is required');
|
||||
}
|
||||
|
||||
if (!props.manufacturer || props.manufacturer.trim().length === 0) {
|
||||
throw new Error('Car manufacturer is required');
|
||||
throw new RacingDomainValidationError('Car manufacturer is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
throw new RacingDomainValidationError('Game ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: Driver
|
||||
*
|
||||
*
|
||||
* Represents a driver profile in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class Driver {
|
||||
readonly id: string;
|
||||
readonly iracingId: string;
|
||||
@@ -58,24 +60,24 @@ export class Driver {
|
||||
country: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!props.iracingId || props.iracingId.trim().length === 0) {
|
||||
throw new Error('iRacing ID is required');
|
||||
throw new RacingDomainValidationError('iRacing ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Driver name is required');
|
||||
throw new RacingDomainValidationError('Driver name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new Error('Country code is required');
|
||||
throw new RacingDomainValidationError('Country code is required');
|
||||
}
|
||||
|
||||
// Validate ISO country code format (2-3 letters)
|
||||
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
|
||||
throw new Error('Country must be a valid ISO code (2-3 letters)');
|
||||
throw new RacingDomainValidationError('Country must be a valid ISO code (2-3 letters)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Domain Entity: DriverLivery
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
*
|
||||
* Represents a driver's custom livery for a specific car.
|
||||
* Includes user-placed decals and league-specific overrides.
|
||||
@@ -70,23 +73,23 @@ export class DriverLivery {
|
||||
|
||||
private static validate(props: Omit<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('DriverLivery ID is required');
|
||||
throw new RacingDomainValidationError('DriverLivery ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new Error('DriverLivery driverId is required');
|
||||
throw new RacingDomainValidationError('DriverLivery driverId is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('DriverLivery gameId is required');
|
||||
throw new RacingDomainValidationError('DriverLivery gameId is required');
|
||||
}
|
||||
|
||||
if (!props.carId || props.carId.trim().length === 0) {
|
||||
throw new Error('DriverLivery carId is required');
|
||||
throw new RacingDomainValidationError('DriverLivery carId is required');
|
||||
}
|
||||
|
||||
if (!props.uploadedImageUrl || props.uploadedImageUrl.trim().length === 0) {
|
||||
throw new Error('DriverLivery uploadedImageUrl is required');
|
||||
throw new RacingDomainValidationError('DriverLivery uploadedImageUrl is required');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ export class DriverLivery {
|
||||
*/
|
||||
addDecal(decal: LiveryDecal): DriverLivery {
|
||||
if (decal.type !== 'user') {
|
||||
throw new Error('Only user decals can be added to driver livery');
|
||||
throw new RacingDomainInvariantError('Only user decals can be added to driver livery');
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
@@ -112,7 +115,7 @@ export class DriverLivery {
|
||||
const updatedDecals = this.userDecals.filter(d => d.id !== decalId);
|
||||
|
||||
if (updatedDecals.length === this.userDecals.length) {
|
||||
throw new Error('Decal not found in livery');
|
||||
throw new RacingDomainValidationError('Decal not found in livery');
|
||||
}
|
||||
|
||||
return new DriverLivery({
|
||||
@@ -129,7 +132,7 @@ export class DriverLivery {
|
||||
const index = this.userDecals.findIndex(d => d.id === decalId);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('Decal not found in livery');
|
||||
throw new RacingDomainError('Decal not found in livery');
|
||||
}
|
||||
|
||||
const updatedDecals = [...this.userDecals];
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class Game {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
@@ -9,11 +11,11 @@ export class Game {
|
||||
|
||||
static create(props: { id: string; name: string }): Game {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
throw new RacingDomainValidationError('Game ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Game name is required');
|
||||
throw new RacingDomainValidationError('Game name is required');
|
||||
}
|
||||
|
||||
return new Game({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: League
|
||||
*
|
||||
*
|
||||
* Represents a league in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
/**
|
||||
* Stewarding decision mode for protests
|
||||
*/
|
||||
@@ -158,23 +160,23 @@ export class League {
|
||||
ownerId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
throw new RacingDomainValidationError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('League name is required');
|
||||
throw new RacingDomainValidationError('League name is required');
|
||||
}
|
||||
|
||||
if (props.name.length > 100) {
|
||||
throw new Error('League name must be 100 characters or less');
|
||||
throw new RacingDomainValidationError('League name must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (!props.description || props.description.trim().length === 0) {
|
||||
throw new Error('League description is required');
|
||||
throw new RacingDomainValidationError('League description is required');
|
||||
}
|
||||
|
||||
if (!props.ownerId || props.ownerId.trim().length === 0) {
|
||||
throw new Error('League owner ID is required');
|
||||
throw new RacingDomainValidationError('League owner ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: LeagueWallet
|
||||
*
|
||||
*
|
||||
* Represents a league's financial wallet.
|
||||
* Aggregate root for managing league finances and transactions.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
import type { Transaction } from './Transaction';
|
||||
|
||||
@@ -46,15 +48,15 @@ export class LeagueWallet {
|
||||
|
||||
private static validate(props: Omit<LeagueWalletProps, 'createdAt' | 'transactionIds'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('LeagueWallet ID is required');
|
||||
throw new RacingDomainValidationError('LeagueWallet ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('LeagueWallet leagueId is required');
|
||||
throw new RacingDomainValidationError('LeagueWallet leagueId is required');
|
||||
}
|
||||
|
||||
if (!props.balance) {
|
||||
throw new Error('LeagueWallet balance is required');
|
||||
throw new RacingDomainValidationError('LeagueWallet balance is required');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ export class LeagueWallet {
|
||||
*/
|
||||
addFunds(netAmount: Money, transactionId: string): LeagueWallet {
|
||||
if (this.balance.currency !== netAmount.currency) {
|
||||
throw new Error('Cannot add funds with different currency');
|
||||
throw new RacingDomainInvariantError('Cannot add funds with different currency');
|
||||
}
|
||||
|
||||
const newBalance = this.balance.add(netAmount);
|
||||
@@ -81,11 +83,11 @@ export class LeagueWallet {
|
||||
*/
|
||||
withdrawFunds(amount: Money, transactionId: string): LeagueWallet {
|
||||
if (this.balance.currency !== amount.currency) {
|
||||
throw new Error('Cannot withdraw funds with different currency');
|
||||
throw new RacingDomainInvariantError('Cannot withdraw funds with different currency');
|
||||
}
|
||||
|
||||
if (!this.balance.isGreaterThan(amount) && !this.balance.equals(amount)) {
|
||||
throw new Error('Insufficient balance for withdrawal');
|
||||
throw new RacingDomainInvariantError('Insufficient balance for withdrawal');
|
||||
}
|
||||
|
||||
const newBalance = this.balance.subtract(amount);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Domain Entity: LiveryTemplate
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
*
|
||||
* Represents an admin-defined livery template for a specific car.
|
||||
* Contains base image and sponsor decal placements.
|
||||
@@ -54,23 +57,23 @@ export class LiveryTemplate {
|
||||
|
||||
private static validate(props: Omit<LiveryTemplateProps, 'createdAt' | 'adminDecals'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('LiveryTemplate ID is required');
|
||||
throw new RacingDomainValidationError('LiveryTemplate ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('LiveryTemplate leagueId is required');
|
||||
throw new RacingDomainValidationError('LiveryTemplate leagueId is required');
|
||||
}
|
||||
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new Error('LiveryTemplate seasonId is required');
|
||||
throw new RacingDomainValidationError('LiveryTemplate seasonId is required');
|
||||
}
|
||||
|
||||
if (!props.carId || props.carId.trim().length === 0) {
|
||||
throw new Error('LiveryTemplate carId is required');
|
||||
throw new RacingDomainValidationError('LiveryTemplate carId is required');
|
||||
}
|
||||
|
||||
if (!props.baseImageUrl || props.baseImageUrl.trim().length === 0) {
|
||||
throw new Error('LiveryTemplate baseImageUrl is required');
|
||||
throw new RacingDomainValidationError('LiveryTemplate baseImageUrl is required');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@ export class LiveryTemplate {
|
||||
*/
|
||||
addDecal(decal: LiveryDecal): LiveryTemplate {
|
||||
if (decal.type !== 'sponsor') {
|
||||
throw new Error('Only sponsor decals can be added to admin template');
|
||||
throw new RacingDomainInvariantError('Only sponsor decals can be added to admin template');
|
||||
}
|
||||
|
||||
return new LiveryTemplate({
|
||||
@@ -96,7 +99,7 @@ export class LiveryTemplate {
|
||||
const updatedDecals = this.adminDecals.filter(d => d.id !== decalId);
|
||||
|
||||
if (updatedDecals.length === this.adminDecals.length) {
|
||||
throw new Error('Decal not found in template');
|
||||
throw new RacingDomainValidationError('Decal not found in template');
|
||||
}
|
||||
|
||||
return new LiveryTemplate({
|
||||
@@ -113,7 +116,7 @@ export class LiveryTemplate {
|
||||
const index = this.adminDecals.findIndex(d => d.id === decalId);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('Decal not found in template');
|
||||
throw new RacingDomainError('Decal not found in template');
|
||||
}
|
||||
|
||||
const updatedDecals = [...this.adminDecals];
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: Penalty
|
||||
*
|
||||
*
|
||||
* Represents a penalty applied to a driver for an incident during a race.
|
||||
* Penalties can be applied as a result of an upheld protest or directly by stewards.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
export type PenaltyType =
|
||||
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
|
||||
| 'grid_penalty' // Grid position penalty for next race
|
||||
@@ -47,17 +49,17 @@ export class Penalty {
|
||||
private constructor(private readonly props: PenaltyProps) {}
|
||||
|
||||
static create(props: PenaltyProps): Penalty {
|
||||
if (!props.id) throw new Error('Penalty ID is required');
|
||||
if (!props.raceId) throw new Error('Race ID is required');
|
||||
if (!props.driverId) throw new Error('Driver ID is required');
|
||||
if (!props.type) throw new Error('Penalty type is required');
|
||||
if (!props.reason?.trim()) throw new Error('Penalty reason is required');
|
||||
if (!props.issuedBy) throw new Error('Penalty must be issued by a steward');
|
||||
if (!props.id) throw new RacingDomainValidationError('Penalty ID is required');
|
||||
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
|
||||
if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required');
|
||||
if (!props.type) throw new RacingDomainValidationError('Penalty type is required');
|
||||
if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required');
|
||||
if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward');
|
||||
|
||||
// Validate value based on type
|
||||
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
|
||||
if (props.value === undefined || props.value <= 0) {
|
||||
throw new Error(`${props.type} requires a positive value`);
|
||||
throw new RacingDomainValidationError(`${props.type} requires a positive value`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +96,10 @@ export class Penalty {
|
||||
*/
|
||||
markAsApplied(notes?: string): Penalty {
|
||||
if (this.isApplied()) {
|
||||
throw new Error('Penalty is already applied');
|
||||
throw new RacingDomainInvariantError('Penalty is already applied');
|
||||
}
|
||||
if (this.props.status === 'overturned') {
|
||||
throw new Error('Cannot apply an overturned penalty');
|
||||
throw new RacingDomainInvariantError('Cannot apply an overturned penalty');
|
||||
}
|
||||
return new Penalty({
|
||||
...this.props,
|
||||
@@ -112,7 +114,7 @@ export class Penalty {
|
||||
*/
|
||||
overturn(reason: string): Penalty {
|
||||
if (this.props.status === 'overturned') {
|
||||
throw new Error('Penalty is already overturned');
|
||||
throw new RacingDomainInvariantError('Penalty is already overturned');
|
||||
}
|
||||
return new Penalty({
|
||||
...this.props,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Domain Entity: Prize
|
||||
*
|
||||
*
|
||||
* Represents a prize awarded to a driver for a specific position in a season.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
|
||||
export type PrizeStatus = 'pending' | 'awarded' | 'paid' | 'cancelled';
|
||||
@@ -61,23 +63,23 @@ export class Prize {
|
||||
|
||||
private static validate(props: Omit<PrizeProps, 'createdAt' | 'status'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Prize ID is required');
|
||||
throw new RacingDomainValidationError('Prize ID is required');
|
||||
}
|
||||
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new Error('Prize seasonId is required');
|
||||
throw new RacingDomainValidationError('Prize seasonId is required');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||
throw new Error('Prize position must be a positive integer');
|
||||
throw new RacingDomainValidationError('Prize position must be a positive integer');
|
||||
}
|
||||
|
||||
if (!props.amount) {
|
||||
throw new Error('Prize amount is required');
|
||||
throw new RacingDomainValidationError('Prize amount is required');
|
||||
}
|
||||
|
||||
if (props.amount.amount <= 0) {
|
||||
throw new Error('Prize amount must be greater than zero');
|
||||
throw new RacingDomainValidationError('Prize amount must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +88,11 @@ export class Prize {
|
||||
*/
|
||||
awardTo(driverId: string): Prize {
|
||||
if (!driverId || driverId.trim().length === 0) {
|
||||
throw new Error('Driver ID is required to award prize');
|
||||
throw new RacingDomainValidationError('Driver ID is required to award prize');
|
||||
}
|
||||
|
||||
if (this.status !== 'pending') {
|
||||
throw new Error('Only pending prizes can be awarded');
|
||||
throw new RacingDomainInvariantError('Only pending prizes can be awarded');
|
||||
}
|
||||
|
||||
return new Prize({
|
||||
@@ -106,11 +108,11 @@ export class Prize {
|
||||
*/
|
||||
markAsPaid(): Prize {
|
||||
if (this.status !== 'awarded') {
|
||||
throw new Error('Only awarded prizes can be marked as paid');
|
||||
throw new RacingDomainInvariantError('Only awarded prizes can be marked as paid');
|
||||
}
|
||||
|
||||
if (!this.driverId) {
|
||||
throw new Error('Prize must have a driver to be paid');
|
||||
throw new RacingDomainInvariantError('Prize must have a driver to be paid');
|
||||
}
|
||||
|
||||
return new Prize({
|
||||
@@ -125,7 +127,7 @@ export class Prize {
|
||||
*/
|
||||
cancel(): Prize {
|
||||
if (this.status === 'paid') {
|
||||
throw new Error('Cannot cancel a paid prize');
|
||||
throw new RacingDomainInvariantError('Cannot cancel a paid prize');
|
||||
}
|
||||
|
||||
return new Prize({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Domain Entity: Protest
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
*
|
||||
* Represents a protest filed by a driver against another driver for an incident during a race.
|
||||
*
|
||||
@@ -67,13 +70,13 @@ export class Protest {
|
||||
private constructor(private readonly props: ProtestProps) {}
|
||||
|
||||
static create(props: ProtestProps): Protest {
|
||||
if (!props.id) throw new Error('Protest ID is required');
|
||||
if (!props.raceId) throw new Error('Race ID is required');
|
||||
if (!props.protestingDriverId) throw new Error('Protesting driver ID is required');
|
||||
if (!props.accusedDriverId) throw new Error('Accused driver ID is required');
|
||||
if (!props.incident) throw new Error('Incident details are required');
|
||||
if (props.incident.lap < 0) throw new Error('Lap number must be non-negative');
|
||||
if (!props.incident.description?.trim()) throw new Error('Incident description is required');
|
||||
if (!props.id) throw new RacingDomainValidationError('Protest ID is required');
|
||||
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
|
||||
if (!props.protestingDriverId) throw new RacingDomainValidationError('Protesting driver ID is required');
|
||||
if (!props.accusedDriverId) throw new RacingDomainValidationError('Accused driver ID is required');
|
||||
if (!props.incident) throw new RacingDomainValidationError('Incident details are required');
|
||||
if (props.incident.lap < 0) throw new RacingDomainValidationError('Lap number must be non-negative');
|
||||
if (!props.incident.description?.trim()) throw new RacingDomainValidationError('Incident description is required');
|
||||
|
||||
return new Protest({
|
||||
...props,
|
||||
@@ -131,7 +134,7 @@ export class Protest {
|
||||
*/
|
||||
requestDefense(stewardId: string): Protest {
|
||||
if (!this.canRequestDefense()) {
|
||||
throw new Error('Defense can only be requested for pending protests without existing defense');
|
||||
throw new RacingDomainInvariantError('Defense can only be requested for pending protests without existing defense');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
@@ -146,10 +149,10 @@ export class Protest {
|
||||
*/
|
||||
submitDefense(statement: string, videoUrl?: string): Protest {
|
||||
if (!this.canSubmitDefense()) {
|
||||
throw new Error('Defense can only be submitted when protest is awaiting defense');
|
||||
throw new RacingDomainInvariantError('Defense can only be submitted when protest is awaiting defense');
|
||||
}
|
||||
if (!statement?.trim()) {
|
||||
throw new Error('Defense statement is required');
|
||||
throw new RacingDomainValidationError('Defense statement is required');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
@@ -167,7 +170,7 @@ export class Protest {
|
||||
*/
|
||||
startReview(stewardId: string): Protest {
|
||||
if (!this.isPending() && !this.isAwaitingDefense()) {
|
||||
throw new Error('Only pending or awaiting-defense protests can be put under review');
|
||||
throw new RacingDomainInvariantError('Only pending or awaiting-defense protests can be put under review');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
@@ -181,7 +184,7 @@ export class Protest {
|
||||
*/
|
||||
uphold(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
|
||||
throw new Error('Only pending, awaiting-defense, or under-review protests can be upheld');
|
||||
throw new RacingDomainInvariantError('Only pending, awaiting-defense, or under-review protests can be upheld');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
@@ -197,7 +200,7 @@ export class Protest {
|
||||
*/
|
||||
dismiss(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
|
||||
throw new Error('Only pending, awaiting-defense, or under-review protests can be dismissed');
|
||||
throw new RacingDomainInvariantError('Only pending, awaiting-defense, or under-review protests can be dismissed');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
@@ -213,7 +216,7 @@ export class Protest {
|
||||
*/
|
||||
withdraw(): Protest {
|
||||
if (this.isResolved()) {
|
||||
throw new Error('Cannot withdraw a resolved protest');
|
||||
throw new RacingDomainInvariantError('Cannot withdraw a resolved protest');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
|
||||
@@ -96,23 +98,23 @@ export class Race {
|
||||
car: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Race ID is required');
|
||||
throw new RacingDomainValidationError('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
throw new RacingDomainValidationError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
|
||||
throw new Error('Valid scheduled date is required');
|
||||
throw new RacingDomainValidationError('Valid scheduled date is required');
|
||||
}
|
||||
|
||||
if (!props.track || props.track.trim().length === 0) {
|
||||
throw new Error('Track is required');
|
||||
throw new RacingDomainValidationError('Track is required');
|
||||
}
|
||||
|
||||
if (!props.car || props.car.trim().length === 0) {
|
||||
throw new Error('Car is required');
|
||||
throw new RacingDomainValidationError('Car is required');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +123,7 @@ export class Race {
|
||||
*/
|
||||
start(): Race {
|
||||
if (this.status !== 'scheduled') {
|
||||
throw new Error('Only scheduled races can be started');
|
||||
throw new RacingDomainInvariantError('Only scheduled races can be started');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
@@ -135,11 +137,11 @@ export class Race {
|
||||
*/
|
||||
complete(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Race is already completed');
|
||||
throw new RacingDomainInvariantError('Race is already completed');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('Cannot complete a cancelled race');
|
||||
throw new RacingDomainInvariantError('Cannot complete a cancelled race');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
@@ -153,11 +155,11 @@ export class Race {
|
||||
*/
|
||||
cancel(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Cannot cancel a completed race');
|
||||
throw new RacingDomainInvariantError('Cannot cancel a completed race');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('Race is already cancelled');
|
||||
throw new RacingDomainInvariantError('Race is already cancelled');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: Result
|
||||
*
|
||||
*
|
||||
* Represents a race result in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class Result {
|
||||
readonly id: string;
|
||||
readonly raceId: string;
|
||||
@@ -62,31 +64,31 @@ export class Result {
|
||||
startPosition: number;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Result ID is required');
|
||||
throw new RacingDomainValidationError('Result ID is required');
|
||||
}
|
||||
|
||||
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||
throw new Error('Race ID is required');
|
||||
throw new RacingDomainValidationError('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||
throw new Error('Position must be a positive integer');
|
||||
throw new RacingDomainValidationError('Position must be a positive integer');
|
||||
}
|
||||
|
||||
if (props.fastestLap < 0) {
|
||||
throw new Error('Fastest lap cannot be negative');
|
||||
throw new RacingDomainValidationError('Fastest lap cannot be negative');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.incidents) || props.incidents < 0) {
|
||||
throw new Error('Incidents must be a non-negative integer');
|
||||
throw new RacingDomainValidationError('Incidents must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
|
||||
throw new Error('Start position must be a positive integer');
|
||||
throw new RacingDomainValidationError('Start position must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type SeasonStatus = 'planned' | 'active' | 'completed';
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class Season {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
@@ -45,19 +47,19 @@ export class Season {
|
||||
endDate?: Date;
|
||||
}): Season {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Season ID is required');
|
||||
throw new RacingDomainValidationError('Season ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('Season leagueId is required');
|
||||
throw new RacingDomainValidationError('Season leagueId is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Season gameId is required');
|
||||
throw new RacingDomainValidationError('Season gameId is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Season name is required');
|
||||
throw new RacingDomainValidationError('Season name is required');
|
||||
}
|
||||
|
||||
const status: SeasonStatus = props.status ?? 'planned';
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: SeasonSponsorship
|
||||
*
|
||||
*
|
||||
* Represents a sponsorship relationship between a Sponsor and a Season.
|
||||
* Aggregate root for managing sponsorship slots and pricing.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
|
||||
export type SponsorshipTier = 'main' | 'secondary';
|
||||
@@ -60,27 +62,27 @@ export class SeasonSponsorship {
|
||||
|
||||
private static validate(props: Omit<SeasonSponsorshipProps, 'createdAt' | 'status'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('SeasonSponsorship ID is required');
|
||||
throw new RacingDomainValidationError('SeasonSponsorship ID is required');
|
||||
}
|
||||
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new Error('SeasonSponsorship seasonId is required');
|
||||
throw new RacingDomainValidationError('SeasonSponsorship seasonId is required');
|
||||
}
|
||||
|
||||
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
|
||||
throw new Error('SeasonSponsorship sponsorId is required');
|
||||
throw new RacingDomainValidationError('SeasonSponsorship sponsorId is required');
|
||||
}
|
||||
|
||||
if (!props.tier) {
|
||||
throw new Error('SeasonSponsorship tier is required');
|
||||
throw new RacingDomainValidationError('SeasonSponsorship tier is required');
|
||||
}
|
||||
|
||||
if (!props.pricing) {
|
||||
throw new Error('SeasonSponsorship pricing is required');
|
||||
throw new RacingDomainValidationError('SeasonSponsorship pricing is required');
|
||||
}
|
||||
|
||||
if (props.pricing.amount <= 0) {
|
||||
throw new Error('SeasonSponsorship pricing must be greater than zero');
|
||||
throw new RacingDomainValidationError('SeasonSponsorship pricing must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +91,11 @@ export class SeasonSponsorship {
|
||||
*/
|
||||
activate(): SeasonSponsorship {
|
||||
if (this.status === 'active') {
|
||||
throw new Error('SeasonSponsorship is already active');
|
||||
throw new RacingDomainInvariantError('SeasonSponsorship is already active');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('Cannot activate a cancelled SeasonSponsorship');
|
||||
throw new RacingDomainInvariantError('Cannot activate a cancelled SeasonSponsorship');
|
||||
}
|
||||
|
||||
return new SeasonSponsorship({
|
||||
@@ -108,7 +110,7 @@ export class SeasonSponsorship {
|
||||
*/
|
||||
cancel(): SeasonSponsorship {
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('SeasonSponsorship is already cancelled');
|
||||
throw new RacingDomainInvariantError('SeasonSponsorship is already cancelled');
|
||||
}
|
||||
|
||||
return new SeasonSponsorship({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Domain Entity: Sponsor
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
*
|
||||
* Represents a sponsor that can sponsor leagues/seasons.
|
||||
* Aggregate root for sponsor information.
|
||||
@@ -42,32 +45,32 @@ export class Sponsor {
|
||||
|
||||
private static validate(props: Omit<SponsorProps, 'createdAt'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Sponsor ID is required');
|
||||
throw new RacingDomainValidationError('Sponsor ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Sponsor name is required');
|
||||
throw new RacingDomainValidationError('Sponsor name is required');
|
||||
}
|
||||
|
||||
if (props.name.length > 100) {
|
||||
throw new Error('Sponsor name must be 100 characters or less');
|
||||
throw new RacingDomainValidationError('Sponsor name must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (!props.contactEmail || props.contactEmail.trim().length === 0) {
|
||||
throw new Error('Sponsor contact email is required');
|
||||
throw new RacingDomainValidationError('Sponsor contact email is required');
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(props.contactEmail)) {
|
||||
throw new Error('Invalid sponsor contact email format');
|
||||
throw new RacingDomainValidationError('Invalid sponsor contact email format');
|
||||
}
|
||||
|
||||
if (props.websiteUrl && props.websiteUrl.trim().length > 0) {
|
||||
try {
|
||||
new URL(props.websiteUrl);
|
||||
} catch {
|
||||
throw new Error('Invalid sponsor website URL');
|
||||
throw new RacingDomainValidationError('Invalid sponsor website URL');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* (driver, team, race, or league/season). The entity owner must approve/reject.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
import type { SponsorshipTier } from './SeasonSponsorship';
|
||||
|
||||
@@ -70,31 +72,31 @@ export class SponsorshipRequest {
|
||||
|
||||
private static validate(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('SponsorshipRequest ID is required');
|
||||
throw new RacingDomainValidationError('SponsorshipRequest ID is required');
|
||||
}
|
||||
|
||||
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
|
||||
throw new Error('SponsorshipRequest sponsorId is required');
|
||||
throw new RacingDomainValidationError('SponsorshipRequest sponsorId is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('SponsorshipRequest entityType is required');
|
||||
throw new RacingDomainValidationError('SponsorshipRequest entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('SponsorshipRequest entityId is required');
|
||||
throw new RacingDomainValidationError('SponsorshipRequest entityId is required');
|
||||
}
|
||||
|
||||
if (!props.tier) {
|
||||
throw new Error('SponsorshipRequest tier is required');
|
||||
throw new RacingDomainValidationError('SponsorshipRequest tier is required');
|
||||
}
|
||||
|
||||
if (!props.offeredAmount) {
|
||||
throw new Error('SponsorshipRequest offeredAmount is required');
|
||||
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount is required');
|
||||
}
|
||||
|
||||
if (props.offeredAmount.amount <= 0) {
|
||||
throw new Error('SponsorshipRequest offeredAmount must be greater than zero');
|
||||
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +105,11 @@ export class SponsorshipRequest {
|
||||
*/
|
||||
accept(respondedBy: string): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new Error(`Cannot accept a ${this.status} sponsorship request`);
|
||||
throw new RacingDomainInvariantError(`Cannot accept a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
if (!respondedBy || respondedBy.trim().length === 0) {
|
||||
throw new Error('respondedBy is required when accepting');
|
||||
throw new RacingDomainValidationError('respondedBy is required when accepting');
|
||||
}
|
||||
|
||||
return new SponsorshipRequest({
|
||||
@@ -123,11 +125,11 @@ export class SponsorshipRequest {
|
||||
*/
|
||||
reject(respondedBy: string, reason?: string): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new Error(`Cannot reject a ${this.status} sponsorship request`);
|
||||
throw new RacingDomainInvariantError(`Cannot reject a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
if (!respondedBy || respondedBy.trim().length === 0) {
|
||||
throw new Error('respondedBy is required when rejecting');
|
||||
throw new RacingDomainValidationError('respondedBy is required when rejecting');
|
||||
}
|
||||
|
||||
return new SponsorshipRequest({
|
||||
@@ -144,7 +146,7 @@ export class SponsorshipRequest {
|
||||
*/
|
||||
withdraw(): SponsorshipRequest {
|
||||
if (this.status !== 'pending') {
|
||||
throw new Error(`Cannot withdraw a ${this.status} sponsorship request`);
|
||||
throw new RacingDomainInvariantError(`Cannot withdraw a ${this.status} sponsorship request`);
|
||||
}
|
||||
|
||||
return new SponsorshipRequest({
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class Standing {
|
||||
readonly leagueId: string;
|
||||
readonly driverId: string;
|
||||
@@ -60,11 +62,11 @@ export class Standing {
|
||||
driverId: string;
|
||||
}): void {
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
throw new RacingDomainError('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
throw new RacingDomainError('Driver ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export class Standing {
|
||||
*/
|
||||
updatePosition(position: number): Standing {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
throw new Error('Position must be a positive integer');
|
||||
throw new RacingDomainError('Position must be a positive integer');
|
||||
}
|
||||
|
||||
return new Standing({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Domain Entity: Track
|
||||
*
|
||||
*
|
||||
* Represents a racing track/circuit in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
|
||||
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
|
||||
@@ -87,27 +89,27 @@ export class Track {
|
||||
gameId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Track ID is required');
|
||||
throw new RacingDomainValidationError('Track ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Track name is required');
|
||||
throw new RacingDomainValidationError('Track name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new Error('Track country is required');
|
||||
throw new RacingDomainValidationError('Track country is required');
|
||||
}
|
||||
|
||||
if (props.lengthKm <= 0) {
|
||||
throw new Error('Track length must be positive');
|
||||
throw new RacingDomainValidationError('Track length must be positive');
|
||||
}
|
||||
|
||||
if (props.turns < 0) {
|
||||
throw new Error('Track turns cannot be negative');
|
||||
throw new RacingDomainValidationError('Track turns cannot be negative');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
throw new RacingDomainValidationError('Game ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Domain Entity: Transaction
|
||||
*
|
||||
*
|
||||
* Represents a financial transaction in the league wallet system.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from '../value-objects/Money';
|
||||
|
||||
export type TransactionType =
|
||||
@@ -76,23 +78,23 @@ export class Transaction {
|
||||
|
||||
private static validate(props: Omit<TransactionProps, 'createdAt' | 'status' | 'platformFee' | 'netAmount'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Transaction ID is required');
|
||||
throw new RacingDomainValidationError('Transaction ID is required');
|
||||
}
|
||||
|
||||
if (!props.walletId || props.walletId.trim().length === 0) {
|
||||
throw new Error('Transaction walletId is required');
|
||||
throw new RacingDomainValidationError('Transaction walletId is required');
|
||||
}
|
||||
|
||||
if (!props.type) {
|
||||
throw new Error('Transaction type is required');
|
||||
throw new RacingDomainValidationError('Transaction type is required');
|
||||
}
|
||||
|
||||
if (!props.amount) {
|
||||
throw new Error('Transaction amount is required');
|
||||
throw new RacingDomainValidationError('Transaction amount is required');
|
||||
}
|
||||
|
||||
if (props.amount.amount <= 0) {
|
||||
throw new Error('Transaction amount must be greater than zero');
|
||||
throw new RacingDomainValidationError('Transaction amount must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +103,11 @@ export class Transaction {
|
||||
*/
|
||||
complete(): Transaction {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Transaction is already completed');
|
||||
throw new RacingDomainInvariantError('Transaction is already completed');
|
||||
}
|
||||
|
||||
if (this.status === 'failed' || this.status === 'cancelled') {
|
||||
throw new Error('Cannot complete a failed or cancelled transaction');
|
||||
throw new RacingDomainInvariantError('Cannot complete a failed or cancelled transaction');
|
||||
}
|
||||
|
||||
return new Transaction({
|
||||
@@ -120,7 +122,7 @@ export class Transaction {
|
||||
*/
|
||||
fail(): Transaction {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Cannot fail a completed transaction');
|
||||
throw new RacingDomainInvariantError('Cannot fail a completed transaction');
|
||||
}
|
||||
|
||||
return new Transaction({
|
||||
@@ -134,7 +136,7 @@ export class Transaction {
|
||||
*/
|
||||
cancel(): Transaction {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Cannot cancel a completed transaction');
|
||||
throw new RacingDomainInvariantError('Cannot cancel a completed transaction');
|
||||
}
|
||||
|
||||
return new Transaction({
|
||||
|
||||
24
packages/racing/domain/errors/RacingDomainError.ts
Normal file
24
packages/racing/domain/errors/RacingDomainError.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export abstract class RacingDomainError extends Error {
|
||||
readonly context = 'racing-domain';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class RacingDomainValidationError extends RacingDomainError {
|
||||
readonly kind = 'validation' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class RacingDomainInvariantError extends RacingDomainError {
|
||||
readonly kind = 'invariant' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
|
||||
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
|
||||
import type { Weekday } from '../value-objects/Weekday';
|
||||
import { weekdayToIndex } from '../value-objects/Weekday';
|
||||
@@ -66,7 +67,7 @@ function generateWeeklyOrEveryNWeeksSlots(
|
||||
: [];
|
||||
|
||||
if (weekdays.length === 0) {
|
||||
throw new Error('RecurrenceStrategy has no weekdays');
|
||||
throw new RacingDomainValidationError('RecurrenceStrategy has no weekdays');
|
||||
}
|
||||
|
||||
const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1;
|
||||
@@ -161,7 +162,7 @@ export class SeasonScheduleGenerator {
|
||||
|
||||
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
|
||||
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
|
||||
throw new Error('maxRounds must be a positive integer');
|
||||
throw new RacingDomainError('maxRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const recurrence: RecurrenceStrategy = schedule.recurrence;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Domain Value Object: LeagueDescription
|
||||
*
|
||||
*
|
||||
* Represents a valid league description with validation rules.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface LeagueDescriptionValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
@@ -63,7 +65,7 @@ export class LeagueDescription {
|
||||
static create(value: string): LeagueDescription {
|
||||
const validation = this.validate(value);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
throw new RacingDomainValidationError(validation.error ?? 'Invalid league description');
|
||||
}
|
||||
return new LeagueDescription(value.trim());
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Domain Value Object: LeagueName
|
||||
*
|
||||
*
|
||||
* Represents a valid league name with validation rules.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface LeagueNameValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
@@ -76,7 +78,7 @@ export class LeagueName {
|
||||
static create(value: string): LeagueName {
|
||||
const validation = this.validate(value);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
throw new RacingDomainValidationError(validation.error ?? 'Invalid league name');
|
||||
}
|
||||
return new LeagueName(value.trim());
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class LeagueTimezone {
|
||||
private readonly id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
if (!id || id.trim().length === 0) {
|
||||
throw new Error('LeagueTimezone id must be a non-empty string');
|
||||
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
|
||||
}
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export class LeagueVisibility {
|
||||
if (value === 'unranked' || value === 'private') {
|
||||
return LeagueVisibility.unranked();
|
||||
}
|
||||
throw new Error(`Invalid league visibility: ${value}`);
|
||||
throw new RacingDomainValidationError(`Invalid league visibility: ${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,39 +51,39 @@ export class LiveryDecal {
|
||||
|
||||
private static validate(props: LiveryDecalProps): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('LiveryDecal ID is required');
|
||||
throw new RacingDomainValidationError('LiveryDecal ID is required');
|
||||
}
|
||||
|
||||
if (!props.imageUrl || props.imageUrl.trim().length === 0) {
|
||||
throw new Error('LiveryDecal imageUrl is required');
|
||||
throw new RacingDomainValidationError('LiveryDecal imageUrl is required');
|
||||
}
|
||||
|
||||
if (props.x < 0 || props.x > 1) {
|
||||
throw new Error('LiveryDecal x coordinate must be between 0 and 1 (normalized)');
|
||||
throw new RacingDomainValidationError('LiveryDecal x coordinate must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (props.y < 0 || props.y > 1) {
|
||||
throw new Error('LiveryDecal y coordinate must be between 0 and 1 (normalized)');
|
||||
throw new RacingDomainValidationError('LiveryDecal y coordinate must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (props.width <= 0 || props.width > 1) {
|
||||
throw new Error('LiveryDecal width must be between 0 and 1 (normalized)');
|
||||
throw new RacingDomainValidationError('LiveryDecal width must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (props.height <= 0 || props.height > 1) {
|
||||
throw new Error('LiveryDecal height must be between 0 and 1 (normalized)');
|
||||
throw new RacingDomainValidationError('LiveryDecal height must be between 0 and 1 (normalized)');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.zIndex) || props.zIndex < 0) {
|
||||
throw new Error('LiveryDecal zIndex must be a non-negative integer');
|
||||
throw new RacingDomainValidationError('LiveryDecal zIndex must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (props.rotation < 0 || props.rotation > 360) {
|
||||
throw new Error('LiveryDecal rotation must be between 0 and 360 degrees');
|
||||
throw new RacingDomainValidationError('LiveryDecal rotation must be between 0 and 360 degrees');
|
||||
}
|
||||
|
||||
if (!props.type) {
|
||||
throw new Error('LiveryDecal type is required');
|
||||
throw new RacingDomainValidationError('LiveryDecal type is required');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Represents membership fee configuration for league drivers
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
import type { Money } from './Money';
|
||||
|
||||
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
|
||||
@@ -23,15 +25,15 @@ export class MembershipFee {
|
||||
|
||||
static create(type: MembershipFeeType, amount: Money): MembershipFee {
|
||||
if (!type) {
|
||||
throw new Error('MembershipFee type is required');
|
||||
throw new RacingDomainValidationError('MembershipFee type is required');
|
||||
}
|
||||
|
||||
if (!amount) {
|
||||
throw new Error('MembershipFee amount is required');
|
||||
throw new RacingDomainValidationError('MembershipFee amount is required');
|
||||
}
|
||||
|
||||
if (amount.amount < 0) {
|
||||
throw new Error('MembershipFee amount cannot be negative');
|
||||
throw new RacingDomainValidationError('MembershipFee amount cannot be negative');
|
||||
}
|
||||
|
||||
return new MembershipFee({ type, amount });
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Represents a monetary amount with currency and platform fee calculation
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type Currency = 'USD' | 'EUR' | 'GBP';
|
||||
|
||||
export class Money {
|
||||
@@ -18,10 +20,10 @@ export class Money {
|
||||
|
||||
static create(amount: number, currency: Currency = 'USD'): Money {
|
||||
if (amount < 0) {
|
||||
throw new Error('Money amount cannot be negative');
|
||||
throw new RacingDomainValidationError('Money amount cannot be negative');
|
||||
}
|
||||
if (!Number.isFinite(amount)) {
|
||||
throw new Error('Money amount must be a finite number');
|
||||
throw new RacingDomainValidationError('Money amount must be a finite number');
|
||||
}
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
@@ -47,7 +49,7 @@ export class Money {
|
||||
*/
|
||||
add(other: Money): Money {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new Error('Cannot add money with different currencies');
|
||||
throw new RacingDomainValidationError('Cannot add money with different currencies');
|
||||
}
|
||||
return new Money(this.amount + other.amount, this.currency);
|
||||
}
|
||||
@@ -57,11 +59,11 @@ export class Money {
|
||||
*/
|
||||
subtract(other: Money): Money {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new Error('Cannot subtract money with different currencies');
|
||||
throw new RacingDomainValidationError('Cannot subtract money with different currencies');
|
||||
}
|
||||
const result = this.amount - other.amount;
|
||||
if (result < 0) {
|
||||
throw new Error('Subtraction would result in negative amount');
|
||||
throw new RacingDomainValidationError('Subtraction would result in negative amount');
|
||||
}
|
||||
return new Money(result, this.currency);
|
||||
}
|
||||
@@ -71,7 +73,7 @@ export class Money {
|
||||
*/
|
||||
isGreaterThan(other: Money): boolean {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new Error('Cannot compare money with different currencies');
|
||||
throw new RacingDomainValidationError('Cannot compare money with different currencies');
|
||||
}
|
||||
return this.amount > other.amount;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class RaceTimeOfDay {
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
|
||||
constructor(hour: number, minute: number) {
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
||||
throw new Error(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
|
||||
throw new RacingDomainValidationError(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
|
||||
}
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||
throw new Error(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
|
||||
throw new RacingDomainValidationError(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
|
||||
}
|
||||
|
||||
this.hour = hour;
|
||||
@@ -17,7 +19,7 @@ export class RaceTimeOfDay {
|
||||
static fromString(value: string): RaceTimeOfDay {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
|
||||
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
|
||||
}
|
||||
|
||||
const hour = Number(match[1]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WeekdaySet } from './WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
|
||||
@@ -34,7 +35,9 @@ export class RecurrenceStrategyFactory {
|
||||
|
||||
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
|
||||
throw new Error('everyNWeeks intervalWeeks must be an integer between 1 and 12');
|
||||
throw new RacingDomainValidationError(
|
||||
'everyNWeeks intervalWeeks must be an integer between 1 and 12',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class ScheduledRaceSlot {
|
||||
readonly roundNumber: number;
|
||||
readonly scheduledAt: Date;
|
||||
@@ -7,10 +9,10 @@ export class ScheduledRaceSlot {
|
||||
|
||||
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
|
||||
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
|
||||
throw new Error('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||
throw new RacingDomainValidationError('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||
}
|
||||
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
|
||||
throw new Error('ScheduledRaceSlot.scheduledAt must be a valid Date');
|
||||
throw new RacingDomainValidationError('ScheduledRaceSlot.scheduledAt must be a valid Date');
|
||||
}
|
||||
|
||||
this.roundNumber = params.roundNumber;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RaceTimeOfDay } from './RaceTimeOfDay';
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
import type { RecurrenceStrategy } from './RecurrenceStrategy';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class SeasonSchedule {
|
||||
readonly startDate: Date;
|
||||
@@ -17,10 +18,10 @@ export class SeasonSchedule {
|
||||
plannedRounds: number;
|
||||
}) {
|
||||
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
|
||||
throw new Error('SeasonSchedule.startDate must be a valid Date');
|
||||
throw new RacingDomainValidationError('SeasonSchedule.startDate must be a valid Date');
|
||||
}
|
||||
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
|
||||
throw new Error('SeasonSchedule.plannedRounds must be a positive integer');
|
||||
throw new RacingDomainValidationError('SeasonSchedule.plannedRounds must be a positive integer');
|
||||
}
|
||||
|
||||
this.startDate = new Date(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
|
||||
|
||||
import { RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
export function weekdayToIndex(day: Weekday): number {
|
||||
@@ -20,6 +22,6 @@ export function weekdayToIndex(day: Weekday): number {
|
||||
return 7;
|
||||
default:
|
||||
// This should be unreachable because Weekday is a closed union.
|
||||
throw new Error(`Unknown weekday: ${day}`);
|
||||
throw new RacingDomainInvariantError(`Unknown weekday: ${day}`);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Weekday } from './Weekday';
|
||||
import { weekdayToIndex } from './Weekday';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class WeekdaySet {
|
||||
private readonly days: Weekday[];
|
||||
|
||||
constructor(days: Weekday[]) {
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new Error('WeekdaySet requires at least one weekday');
|
||||
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
|
||||
}
|
||||
|
||||
const unique = Array.from(new Set(days));
|
||||
|
||||
17
packages/social/application/dto/FeedItemDTO.ts
Normal file
17
packages/social/application/dto/FeedItemDTO.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FeedItemType } from '../../domain/value-objects/FeedItemType';
|
||||
|
||||
export interface FeedItemDTO {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: FeedItemType;
|
||||
actorFriendId?: string;
|
||||
actorDriverId?: string;
|
||||
leagueId?: string;
|
||||
raceId?: string;
|
||||
teamId?: string;
|
||||
position?: number;
|
||||
headline: string;
|
||||
body?: string;
|
||||
ctaLabel?: string;
|
||||
ctaHref?: string;
|
||||
}
|
||||
16
packages/social/application/index.ts
Normal file
16
packages/social/application/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { GetCurrentUserSocialUseCase } from './use-cases/GetCurrentUserSocialUseCase';
|
||||
export type { GetCurrentUserSocialParams } from './use-cases/GetCurrentUserSocialUseCase';
|
||||
|
||||
export { GetUserFeedUseCase } from './use-cases/GetUserFeedUseCase';
|
||||
export type { GetUserFeedParams } from './use-cases/GetUserFeedUseCase';
|
||||
|
||||
export type { CurrentUserSocialDTO } from './dto/CurrentUserSocialDTO';
|
||||
export type { FriendDTO } from './dto/FriendDTO';
|
||||
export type { FeedItemDTO } from './dto/FeedItemDTO';
|
||||
|
||||
export type {
|
||||
CurrentUserSocialViewModel,
|
||||
ICurrentUserSocialPresenter,
|
||||
UserFeedViewModel,
|
||||
IUserFeedPresenter,
|
||||
} from './presenters/ISocialPresenters';
|
||||
20
packages/social/application/presenters/ISocialPresenters.ts
Normal file
20
packages/social/application/presenters/ISocialPresenters.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO';
|
||||
import type { FriendDTO } from '../dto/FriendDTO';
|
||||
import type { FeedItemDTO } from '../dto/FeedItemDTO';
|
||||
|
||||
export interface CurrentUserSocialViewModel {
|
||||
currentUser: CurrentUserSocialDTO;
|
||||
friends: FriendDTO[];
|
||||
}
|
||||
|
||||
export interface ICurrentUserSocialPresenter {
|
||||
present(viewModel: CurrentUserSocialViewModel): void;
|
||||
}
|
||||
|
||||
export interface UserFeedViewModel {
|
||||
items: FeedItemDTO[];
|
||||
}
|
||||
|
||||
export interface IUserFeedPresenter {
|
||||
present(viewModel: UserFeedViewModel): void;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ISocialGraphRepository } from '../../domain/repositories/ISocialGraphRepository';
|
||||
import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO';
|
||||
import type { FriendDTO } from '../dto/FriendDTO';
|
||||
import type {
|
||||
CurrentUserSocialViewModel,
|
||||
ICurrentUserSocialPresenter,
|
||||
} from '../presenters/ISocialPresenters';
|
||||
|
||||
export interface GetCurrentUserSocialParams {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application-level use case to retrieve the current user's social context.
|
||||
*
|
||||
* Keeps orchestration in the social bounded context while delegating
|
||||
* data access to domain repositories and presenting via a presenter.
|
||||
*/
|
||||
export class GetCurrentUserSocialUseCase {
|
||||
constructor(
|
||||
private readonly socialGraphRepository: ISocialGraphRepository,
|
||||
public readonly presenter: ICurrentUserSocialPresenter,
|
||||
) {}
|
||||
|
||||
async execute(params: GetCurrentUserSocialParams): Promise<void> {
|
||||
const { driverId } = params;
|
||||
|
||||
const friendsDomain = await this.socialGraphRepository.getFriends(driverId);
|
||||
|
||||
const friends: FriendDTO[] = friendsDomain.map((friend) => ({
|
||||
driverId: friend.id,
|
||||
displayName: friend.name,
|
||||
avatarUrl: '',
|
||||
isOnline: false,
|
||||
lastSeen: new Date(),
|
||||
primaryLeagueId: undefined,
|
||||
primaryTeamId: undefined,
|
||||
}));
|
||||
|
||||
const currentUser: CurrentUserSocialDTO = {
|
||||
driverId,
|
||||
displayName: '',
|
||||
avatarUrl: '',
|
||||
countryCode: '',
|
||||
primaryTeamId: undefined,
|
||||
primaryLeagueId: undefined,
|
||||
};
|
||||
|
||||
const viewModel: CurrentUserSocialViewModel = {
|
||||
currentUser,
|
||||
friends,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
}
|
||||
}
|
||||
52
packages/social/application/use-cases/GetUserFeedUseCase.ts
Normal file
52
packages/social/application/use-cases/GetUserFeedUseCase.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { IFeedRepository } from '../../domain/repositories/IFeedRepository';
|
||||
import type { FeedItemDTO } from '../dto/FeedItemDTO';
|
||||
import type { FeedItem } from '../../domain/entities/FeedItem';
|
||||
import type {
|
||||
IUserFeedPresenter,
|
||||
UserFeedViewModel,
|
||||
} from '../presenters/ISocialPresenters';
|
||||
|
||||
export interface GetUserFeedParams {
|
||||
driverId: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class GetUserFeedUseCase {
|
||||
constructor(
|
||||
private readonly feedRepository: IFeedRepository,
|
||||
public readonly presenter: IUserFeedPresenter,
|
||||
) {}
|
||||
|
||||
async execute(params: GetUserFeedParams): Promise<void> {
|
||||
const { driverId, limit } = params;
|
||||
const items = await this.feedRepository.getFeedForDriver(driverId, limit);
|
||||
const dtoItems = items.map(mapFeedItemToDTO);
|
||||
|
||||
const viewModel: UserFeedViewModel = {
|
||||
items: dtoItems,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
function mapFeedItemToDTO(item: FeedItem): FeedItemDTO {
|
||||
return {
|
||||
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,
|
||||
headline: item.headline,
|
||||
body: item.body,
|
||||
ctaLabel: item.ctaLabel,
|
||||
ctaHref: item.ctaHref,
|
||||
};
|
||||
}
|
||||
8
packages/social/domain/errors/SocialDomainError.ts
Normal file
8
packages/social/domain/errors/SocialDomainError.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class SocialDomainError extends Error {
|
||||
readonly name: string = 'SocialDomainError';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
export * from './src/faker/faker';
|
||||
export * from './src/images/images';
|
||||
export * from './src/media/DemoAvatarGenerationAdapter';
|
||||
export * from './src/media/DemoFaceValidationAdapter';
|
||||
export * from './src/media/DemoImageServiceAdapter';
|
||||
export * from './src/media/InMemoryAvatarGenerationRepository';
|
||||
export * from './src/racing/RacingSeedCore';
|
||||
export * from './src/racing/RacingSponsorshipSeed';
|
||||
export * from './src/racing/RacingFeedSeed';
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type {
|
||||
AvatarGenerationPort,
|
||||
AvatarGenerationOptions,
|
||||
AvatarGenerationResult
|
||||
import type {
|
||||
AvatarGenerationPort,
|
||||
AvatarGenerationOptions,
|
||||
AvatarGenerationResult,
|
||||
} from '@gridpilot/media';
|
||||
|
||||
/**
|
||||
* Demo implementation of AvatarGenerationPort.
|
||||
*
|
||||
*
|
||||
* In production, this would use a real AI image generation API like:
|
||||
* - OpenAI DALL-E
|
||||
* - Midjourney API
|
||||
* - Stable Diffusion
|
||||
* - RunwayML
|
||||
*
|
||||
*
|
||||
* For demo purposes, this returns placeholder avatar images.
|
||||
*/
|
||||
export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
|
||||
@@ -81,10 +81,10 @@ export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
|
||||
|
||||
// For demo, return placeholder URLs based on suit color
|
||||
// In production, these would be actual AI-generated images
|
||||
const colorAvatars = this.placeholderAvatars[options.suitColor] || this.placeholderAvatars.blue;
|
||||
|
||||
const colorAvatars = this.placeholderAvatars[options.suitColor] ?? this.placeholderAvatars.blue;
|
||||
|
||||
// Generate unique URLs with a hash to simulate different generations
|
||||
const hash = this.generateHash(options.facePhotoUrl + Date.now());
|
||||
const hash = this.generateHash((options.facePhotoUrl ?? '') + Date.now());
|
||||
const avatars = colorAvatars.slice(0, options.count).map((baseUrl, index) => {
|
||||
// In demo mode, use dicebear or similar for generating varied avatars
|
||||
const seed = `${hash}-${options.suitColor}-${index}`;
|
||||
@@ -101,15 +101,15 @@ export class DemoAvatarGenerationAdapter implements AvatarGenerationPort {
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private generateHash(input: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import type { FaceValidationPort, FaceValidationResult } from '@gridpilot/media'
|
||||
|
||||
/**
|
||||
* Demo implementation of FaceValidationPort.
|
||||
*
|
||||
*
|
||||
* In production, this would use a real face detection API like:
|
||||
* - AWS Rekognition
|
||||
* - Google Cloud Vision
|
||||
* - Azure Face API
|
||||
* - OpenCV / face-api.js
|
||||
*
|
||||
*
|
||||
* For demo purposes, this always returns a valid face if the image data is provided.
|
||||
*/
|
||||
export class DemoFaceValidationAdapter implements FaceValidationPort {
|
||||
@@ -18,7 +18,7 @@ export class DemoFaceValidationAdapter implements FaceValidationPort {
|
||||
|
||||
// Check if we have any image data
|
||||
const dataString = typeof imageData === 'string' ? imageData : imageData.toString();
|
||||
|
||||
|
||||
if (!dataString || dataString.length < 100) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -30,8 +30,8 @@ export class DemoFaceValidationAdapter implements FaceValidationPort {
|
||||
}
|
||||
|
||||
// Check for valid base64 image data or data URL
|
||||
const isValidImage =
|
||||
dataString.startsWith('data:image/') ||
|
||||
const isValidImage =
|
||||
dataString.startsWith('data:image/') ||
|
||||
dataString.startsWith('/9j/') || // JPEG magic bytes in base64
|
||||
dataString.startsWith('iVBOR') || // PNG magic bytes in base64
|
||||
dataString.length > 1000; // Assume long strings are valid image data
|
||||
@@ -57,6 +57,6 @@ export class DemoFaceValidationAdapter implements FaceValidationPort {
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ const FEMALE_DEFAULT_AVATAR = '/images/avatars/female-default-avatar.jpeg';
|
||||
|
||||
export class DemoImageServiceAdapter implements ImageServicePort {
|
||||
getDriverAvatar(driverId: string): string {
|
||||
const numericSuffixMatch = driverId.match(/(\d+)$/
|
||||
);
|
||||
const numericSuffixMatch = driverId.match(/(\d+)$/);
|
||||
if (numericSuffixMatch) {
|
||||
const numericSuffix = Number.parseInt(numericSuffixMatch[1], 10);
|
||||
return numericSuffix % 2 === 0 ? FEMALE_DEFAULT_AVATAR : MALE_DEFAULT_AVATAR;
|
||||
@@ -34,7 +33,7 @@ export class DemoImageServiceAdapter implements ImageServicePort {
|
||||
|
||||
function stableHash(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
@@ -1,14 +1,14 @@
|
||||
import type {
|
||||
IAvatarGenerationRepository
|
||||
import type {
|
||||
IAvatarGenerationRepository,
|
||||
} from '@gridpilot/media';
|
||||
import {
|
||||
AvatarGenerationRequest,
|
||||
type AvatarGenerationRequestProps
|
||||
import {
|
||||
AvatarGenerationRequest,
|
||||
type AvatarGenerationRequestProps,
|
||||
} from '@gridpilot/media';
|
||||
|
||||
/**
|
||||
* In-memory implementation of IAvatarGenerationRepository.
|
||||
*
|
||||
*
|
||||
* For demo/development purposes. In production, this would use a database.
|
||||
*/
|
||||
export class InMemoryAvatarGenerationRepository implements IAvatarGenerationRepository {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { DemoImageServiceAdapter } from '../../../packages/demo-infrastructure/media/DemoImageServiceAdapter';
|
||||
import { DemoImageServiceAdapter } from '@gridpilot/testing-support';
|
||||
|
||||
describe('DemoImageServiceAdapter - driver avatars', () => {
|
||||
it('returns male default avatar for a demo driver treated as male (odd id suffix)', () => {
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
"@gridpilot/shared-result": ["packages/shared/result/Result.ts"],
|
||||
"@gridpilot/automation/*": ["packages/automation/*"],
|
||||
"@gridpilot/testing-support": ["packages/testing-support/index.ts"],
|
||||
"@gridpilot/media": ["packages/media/index.ts"],
|
||||
"@gridpilot/demo-infrastructure": ["packages/demo-infrastructure/index.ts"]
|
||||
"@gridpilot/media": ["packages/media/index.ts"]
|
||||
},
|
||||
"types": ["vitest/globals", "node"],
|
||||
"jsx": "react-jsx"
|
||||
|
||||
@@ -13,7 +13,6 @@ export default defineConfig({
|
||||
'@gridpilot/automation': path.resolve(__dirname, 'packages/automation'),
|
||||
'@gridpilot/automation/*': path.resolve(__dirname, 'packages/automation/*'),
|
||||
'@gridpilot/testing-support': path.resolve(__dirname, 'packages/testing-support'),
|
||||
'@gridpilot/demo-infrastructure': path.resolve(__dirname, 'packages/demo-infrastructure'),
|
||||
'@gridpilot/media': path.resolve(__dirname, 'packages/media'),
|
||||
'@': path.resolve(__dirname, 'apps/website'),
|
||||
'@/*': path.resolve(__dirname, 'apps/website/*'),
|
||||
|
||||
Reference in New Issue
Block a user