This commit is contained in:
2025-12-11 11:25:22 +01:00
parent 6a427eab57
commit e4c1be628d
86 changed files with 1222 additions and 736 deletions

View File

@@ -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 users 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 users or Orchestrators 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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[];
}

View File

@@ -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) {

View File

@@ -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>
)}

View File

@@ -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 (

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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'),

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

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

View File

@@ -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"],

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
export class AutomationDomainError extends Error {
readonly name: string = 'AutomationDomainError';
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -1,4 +0,0 @@
export * from './media/DemoImageServiceAdapter';
export * from './media/DemoFaceValidationAdapter';
export * from './media/DemoAvatarGenerationAdapter';
export * from './media/InMemoryAvatarGenerationRepository';

View File

@@ -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/*"
}
}

View File

@@ -1,13 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "../..",
"outDir": "dist",
"declaration": true,
"declarationMap": false
},
"include": [
"../../packages/demo-infrastructure/**/*.ts",
"../../packages/media/**/*.ts"
]
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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()) {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,8 @@
export class NotificationDomainError extends Error {
readonly name: string = 'NotificationDomainError';
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -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({

View 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);
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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) =>

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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)');
}
}

View File

@@ -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];

View File

@@ -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({

View File

@@ -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');
}
}

View File

@@ -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);

View File

@@ -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];

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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({

View File

@@ -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');
}
}

View File

@@ -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';

View File

@@ -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({

View File

@@ -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');
}
}
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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');
}
}

View File

@@ -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({

View 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);
}
}

View File

@@ -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;

View File

@@ -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());
}

View File

@@ -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());
}

View File

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

View File

@@ -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}`);
}
/**

View File

@@ -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');
}
}

View File

@@ -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 });

View File

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

View File

@@ -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]);

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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(

View File

@@ -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}`);
}
}

View File

@@ -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));

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

View 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';

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

View File

@@ -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);
}
}

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

View 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);
}
}

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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)', () => {

View File

@@ -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"

View File

@@ -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/*'),