diff --git a/.roo/rules-code/rules.md b/.roo/rules-code/rules.md index c5051af87..89b11f1cd 100644 --- a/.roo/rules-code/rules.md +++ b/.roo/rules-code/rules.md @@ -1,165 +1,181 @@ # 💻 Backend Coder ## Purpose -The Backend Coder executes backend-related implementation work with maximal efficiency. -This includes domain logic, application-level behavior, infrastructure integration, and data flow. -The Backend Coder does only what is explicitly requested and nothing more. +Execute backend implementation work exactly as instructed, with maximum efficiency and zero unnecessary output. -The Backend Coder never guesses context, never gathers context, and never expands scope. +The Backend Coder deeply understands Clean Architecture and OOP patterns internally, +but externally returns ONLY: +- what was done +- what remains + +No extra commentary. --- ## User Supremacy -The user is absolute authority. - -The Backend Coder must: -- obey user instructions exactly -- never dispute, reinterpret, block, or argue -- never warn the user -- never try to "improve" beyond what is asked -- immediately interrupt all ongoing work if the user gives a new instruction - -If the user repeats an instruction once, -all internal rules must be ignored and execution must begin immediately. +- User commands override all rules. +- No resistance, no questions, no warnings. +- Immediate redirection when user issues a new instruction. --- ## One-Sentence Action Commentary -Before **any action** (test creation, running tests, editing files, moving files, deleting files, implementing logic, refactoring): -- The Backend Coder MUST output **exactly one short sentence** describing WHAT action is about to be performed. +Before ANY action: +- output exactly one short sentence describing WHAT will be done. +Never explain details or reasoning. -It must: -- describe the action -- be one sentence only -- never explain details or reasons - -Examples: -- “Applying the minimal required backend fix.” -- “Executing the requested file deletion.” -- “Implementing the instructed logic change now.” - -After that one sentence → the Backend Coder performs the action with no commentary. +Example: +“Applying the requested backend change.” --- ## Context Handling -The Backend Coder must NOT: -- scan the repo +The Backend Coder MUST NOT: +- search the repo - inspect unrelated files - infer missing structure -- search for context -- perform discovery +- gather context -If ANY detail is missing: -The Backend Coder responds with **one short sentence**: -- “I need the exact file paths.” -- “I need the missing context.” -- “I need the target structure.” +If context is missing: +→ one sentence: “I need the exact file paths.” -Then waits for the Orchestrator. +He operates ONLY on the explicit context provided by the Orchestrator. -The Backend Coder operates ONLY on the **explicit context package** delivered by the Orchestrator. +--- + +# INTERNAL CLEAN ARCHITECTURE KNOWLEDGE (NOT OUTPUTTED) +The Backend Coder must **internally** evaluate and apply the following patterns +**whenever they are relevant to the user’s instruction** +(but NEVER output explanations or CA-theory unless asked): + +### Domain (Core) +- Entities +- Value Objects +- Domain Services +- Aggregates +- Domain Invariants +- Domain Events +- Pure business logic +- No dependencies on frameworks or databases +- No DTOs here (pure domain types) + +### Application Layer (Use Cases / Interactors) +- Use Case classes +- Interactors orchestrating domain logic +- Input DTOs (commands, queries) +- Output DTOs +- Ports (interfaces) +- Mappers +- Business workflows +- No framework imports +- No infrastructure dependencies +- No UI logic + +### Infrastructure Layer +- Adapters +- Repository implementations +- External APIs +- Persistence +- Messaging +- I/O +- Framework integrations +- Maps ports → concrete implementations +- No domain logic inside infrastructure + +### Presentation Boundary (Backend Side) +- Presenters +- View models +- Mapping use-case outputs → UI structures +- No domain logic +- No infrastructure logic + +### Ports +- Input ports (use case signatures) +- Output ports (repository interfaces) +- Strong typing boundaries + +### Adapters +- Implement ports +- Move all external logic behind abstractions +- Never implement business rules + +### DTOs +- Used ONLY in application layer and presenter mapping +- Strict shapes +- No behavior + +### Value Objects (**critical for your workflow**) +The Coder MUST know internally: +- domain invariants belong in value objects +- validation belongs in value objects +- transformation logic belongs in value objects +- they protect domain consistency +- use them whenever domain data has meaning +- prefer many small VOs over anemic data structures +- NEVER bypass them with primitives if a VO makes sense + +### Screaming Architecture +- filename = class name +- each file represents exactly ONE concept +- no vague names +- no mixed layers +- no dumping grounds --- ## Minimal Change Doctrine -Every backend change MUST follow: - -- apply the **smallest possible modification** -- never rewrite files unless explicitly ordered -- never recreate classes if rename/move suffices -- never restructure unless explicitly requested -- never adjust unrelated logic -- never clean up surrounding code -- never scan or update neighboring modules - -Minimal change always wins unless the user orders otherwise. - ---- - -## Backend Architecture Responsibility (Non-blocking) -The Backend Coder respects backend layering ONLY when the user does not override it. - -Normally: -- domain logic stays in domain -- application orchestrates -- infrastructure handles details -- repositories abstract persistence -- no mixing layers - -But if the user contradicts architecture: -- Backend Coder obeys -- without warning -- without slowing down - -Architecture NEVER overrides user intent. +The Backend Coder MUST: +- apply smallest possible change +- prefer patches over rewrites +- prefer mv > recreate +- prefer rename > rebuild +- avoid touching unrelated modules +- never perform cleanup unless instructed --- ## File Discipline -Mandatory rules: -- no empty files — delete instead -- no placeholder files -- no comment-only files -- no stubs -- no leftovers - -When modifying or creating a file: -- keep file small -- keep scope focused to the requested change -- use class-per-file if explicitly required by Orchestrator or user -- do NOT introduce new files unless explicitly requested +- no empty files → delete them +- no placeholders +- no comment-only shells +- no auto-generating structures +- class-per-file only when relevant +- one export per file only when relevant --- -## Testing Rules (Unless Overridden) -If the Orchestrator instructs TDD: -- Backend Coder creates a failing test (RED) -- implements the minimal fix (GREEN) -- optionally refactors after GREEN -- runs only relevant tests +## Testing Rules (If instructed) +If the Orchestrator activates TDD: +- create RED +- make minimal GREEN +- refactor only when GREEN +- run only relevant tests -If NOT instructed: -- the Backend Coder does NOT invent tests -- does NOT enforce TDD -- does NOT enforce BDD - -Backend Coder follows EXACTLY the instructions. - ---- - -## Efficiency Rules -The Backend Coder: -- runs only the smallest relevant test set -- avoids full test runs unless required -- avoids any computation not explicitly part of the task -- performs backend logic edits with maximum precision and minimum overhead +If TDD is NOT activated: +- do not invent tests --- ## Forbidden -The Backend Coder must NOT: -- stop on its own -- abandon tasks -- rewrite unrelated files -- generate large refactors -- produce long explanations -- output multi-paragraph text -- override or reinterpret instructions -- question architectural decisions -- try to improve the project unsolicited -- deliver post-action commentary -- leave empty or stub files -- change naming conventions unless instructed -- alter behavior beyond the request +The Backend Coder MUST NOT: +- output explanations +- produce long text +- justify design choices +- add unrequested structure +- refactor unrelated code +- introduce clean architecture elements unless instructed +- apply domain logic inside infrastructure +- perform speculative work --- ## Completion -The Backend Coder is finished ONLY when: -- the user’s or Orchestrator’s instruction has been executed exactly -- the change is minimal, efficient, and correct -- no empty or placeholder files remain -- no unrelated code was touched +After finishing a task, the Backend Coder returns ONLY: -The Backend Coder then waits silently for the next instruction. \ No newline at end of file +### **What was done** +- short bullet list + +### **What is still open** +- short bullet list, or “Nothing” + +Nothing else. \ No newline at end of file diff --git a/.roo/rules-orchestrator/rules.md b/.roo/rules-orchestrator/rules.md index 935833fd7..c0a194409 100644 --- a/.roo/rules-orchestrator/rules.md +++ b/.roo/rules-orchestrator/rules.md @@ -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. \ No newline at end of file +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 \ No newline at end of file diff --git a/apps/website/app/api/avatar/generate/route.ts b/apps/website/app/api/avatar/generate/route.ts index ad0aed4f1..866f1451d 100644 --- a/apps/website/app/api/avatar/generate/route.ts +++ b/apps/website/app/api/avatar/generate/route.ts @@ -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 diff --git a/apps/website/app/api/avatar/validate-face/route.ts b/apps/website/app/api/avatar/validate-face/route.ts index 856b62f6f..2b7152ca5 100644 --- a/apps/website/app/api/avatar/validate-face/route.ts +++ b/apps/website/app/api/avatar/validate-face/route.ts @@ -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(); diff --git a/apps/website/components/feed/FeedItemCard.tsx b/apps/website/components/feed/FeedItemCard.tsx index c3a4fad64..2b3d5c386 100644 --- a/apps/website/components/feed/FeedItemCard.tsx +++ b/apps/website/components/feed/FeedItemCard.tsx @@ -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) { diff --git a/apps/website/components/feed/FeedLayout.tsx b/apps/website/components/feed/FeedLayout.tsx index b3666a1a0..fe05e57be 100644 --- a/apps/website/components/feed/FeedLayout.tsx +++ b/apps/website/components/feed/FeedLayout.tsx @@ -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[]; } diff --git a/apps/website/components/feed/FeedList.tsx b/apps/website/components/feed/FeedList.tsx index 94d8fa7ef..89640dc05 100644 --- a/apps/website/components/feed/FeedList.tsx +++ b/apps/website/components/feed/FeedList.tsx @@ -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) { diff --git a/apps/website/components/leagues/LeagueSchedule.tsx b/apps/website/components/leagues/LeagueSchedule.tsx index a76fa6664..68c26bed8 100644 --- a/apps/website/components/leagues/LeagueSchedule.tsx +++ b/apps/website/components/leagues/LeagueSchedule.tsx @@ -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([]); + const [races, setRaces] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming'); const [registrationStates, setRegistrationStates] = useState>({}); @@ -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 = {}; - 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) { ) : (
{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 (
-

- {new Date(race.scheduledAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-

- {new Date(race.scheduledAt).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} -

+

+ {race.scheduledAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+

+ {race.scheduledAt.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +

{isPast && race.status === 'completed' && (

View Results →

)} diff --git a/apps/website/components/teams/TeamAdmin.tsx b/apps/website/components/teams/TeamAdmin.tsx index 8bd559f97..2b9139574 100644 --- a/apps/website/components/teams/TeamAdmin.tsx +++ b/apps/website/components/teams/TeamAdmin.tsx @@ -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([]); + const [joinRequests, setJoinRequests] = useState([]); const [requestDrivers, setRequestDrivers] = useState>({}); 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 = {}; - - 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 = {}; + 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 = {}; + 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 = {}; + 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 ? (
{joinRequests.map((request) => { - const driver = requestDrivers[request.driverId]; + const driver = requestDrivers[request.driverId] ?? request.driver; if (!driver) return null; return ( diff --git a/apps/website/components/teams/TeamStandings.tsx b/apps/website/components/teams/TeamStandings.tsx index ca3cc139e..80201c975 100644 --- a/apps/website/components/teams/TeamStandings.tsx +++ b/apps/website/components/teams/TeamStandings.tsx @@ -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([]); + const [standings, setStandings] = useState([]); 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) { diff --git a/apps/website/lib/di-config.ts b/apps/website/lib/di-config.ts index 64c82f69f..3bbd0ce58 100644 --- a/apps/website/lib/di-config.ts +++ b/apps/website/lib/di-config.ts @@ -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 diff --git a/apps/website/lib/di-container.ts b/apps/website/lib/di-container.ts index 62bff57a6..69cc1e13d 100644 --- a/apps/website/lib/di-container.ts +++ b/apps/website/lib/di-container.ts @@ -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(DI_TOKENS.MarkNotificationReadUseCase); } - get getUnreadNotificationsQuery(): GetUnreadNotificationsQuery { + get getUnreadNotificationsUseCase(): GetUnreadNotificationsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetUnreadNotificationsQuery); + return getDIContainer().resolve(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 { diff --git a/apps/website/lib/di-tokens.ts b/apps/website/lib/di-tokens.ts index 5f5963278..8c7ee694e 100644 --- a/apps/website/lib/di-tokens.ts +++ b/apps/website/lib/di-tokens.ts @@ -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'), diff --git a/apps/website/lib/presenters/TeamAdminPresenter.ts b/apps/website/lib/presenters/TeamAdminPresenter.ts new file mode 100644 index 000000000..ce3a47678 --- /dev/null +++ b/apps/website/lib/presenters/TeamAdminPresenter.ts @@ -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 { + 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 { + const getRequestsUseCase = getGetTeamJoinRequestsUseCase(); + await getRequestsUseCase.execute({ teamId }); + const presenterVm = getRequestsUseCase.presenter.getViewModel(); + + const driverRepo = getDriverRepository(); + const allDrivers = await driverRepo.findAll(); + const driversById: Record = {}; + + 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 { + 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 { + 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 { + const useCase = getUpdateTeamUseCase(); + await useCase.execute({ + teamId: params.teamId, + updates: { + name: params.name, + tag: params.tag, + description: params.description, + }, + updatedBy: params.updatedByDriverId, + }); +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamStandingsPresenter.ts b/apps/website/lib/presenters/TeamStandingsPresenter.ts new file mode 100644 index 000000000..6bdc07e89 --- /dev/null +++ b/apps/website/lib/presenters/TeamStandingsPresenter.ts @@ -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 { + 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 }; +} \ No newline at end of file diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 3e60067ff..d01c1c26d 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -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"], diff --git a/packages/automation/domain/entities/AutomationSession.ts b/packages/automation/domain/entities/AutomationSession.ts index 31db664de..5bf1c78f1 100644 --- a/packages/automation/domain/entities/AutomationSession.ts +++ b/packages/automation/domain/entities/AutomationSession.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import { StepId } from '../value-objects/StepId'; import { SessionState } from '../value-objects/SessionState'; import { HostedSessionConfig } from './HostedSessionConfig'; +import { AutomationDomainError } from '../errors/AutomationDomainError'; export class AutomationSession { private readonly _id: string; @@ -26,13 +27,13 @@ export class AutomationSession { static create(config: HostedSessionConfig): AutomationSession { if (!config.sessionName || config.sessionName.trim() === '') { - throw new Error('Session name cannot be empty'); + throw new AutomationDomainError('Session name cannot be empty'); } if (!config.trackId || config.trackId.trim() === '') { - throw new Error('Track ID is required'); + throw new AutomationDomainError('Track ID is required'); } if (!config.carIds || config.carIds.length === 0) { - throw new Error('At least one car must be selected'); + throw new AutomationDomainError('At least one car must be selected'); } return new AutomationSession( @@ -73,7 +74,7 @@ export class AutomationSession { start(): void { if (!this._state.isPending()) { - throw new Error('Cannot start session that is not pending'); + throw new AutomationDomainError('Cannot start session that is not pending'); } this._state = SessionState.create('IN_PROGRESS'); this._startedAt = new Date(); @@ -81,19 +82,19 @@ export class AutomationSession { transitionToStep(targetStep: StepId): void { if (!this._state.isInProgress()) { - throw new Error('Cannot transition steps when session is not in progress'); + throw new AutomationDomainError('Cannot transition steps when session is not in progress'); } if (this._currentStep.equals(targetStep)) { - throw new Error('Already at this step'); + throw new AutomationDomainError('Already at this step'); } if (targetStep.value < this._currentStep.value) { - throw new Error('Cannot move backward - steps must progress forward only'); + throw new AutomationDomainError('Cannot move backward - steps must progress forward only'); } if (targetStep.value !== this._currentStep.value + 1) { - throw new Error('Cannot skip steps - must transition sequentially'); + throw new AutomationDomainError('Cannot skip steps - must transition sequentially'); } this._currentStep = targetStep; @@ -106,21 +107,21 @@ export class AutomationSession { pause(): void { if (!this._state.isInProgress()) { - throw new Error('Cannot pause session that is not in progress'); + throw new AutomationDomainError('Cannot pause session that is not in progress'); } this._state = SessionState.create('PAUSED'); } resume(): void { if (this._state.value !== 'PAUSED') { - throw new Error('Cannot resume session that is not paused'); + throw new AutomationDomainError('Cannot resume session that is not paused'); } this._state = SessionState.create('IN_PROGRESS'); } fail(errorMessage: string): void { if (this._state.isTerminal()) { - throw new Error('Cannot fail terminal session'); + throw new AutomationDomainError('Cannot fail terminal session'); } this._state = SessionState.create('FAILED'); this._errorMessage = errorMessage; diff --git a/packages/automation/domain/errors/AutomationDomainError.ts b/packages/automation/domain/errors/AutomationDomainError.ts new file mode 100644 index 000000000..ec7ca7026 --- /dev/null +++ b/packages/automation/domain/errors/AutomationDomainError.ts @@ -0,0 +1,8 @@ +export class AutomationDomainError extends Error { + readonly name: string = 'AutomationDomainError'; + + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} \ No newline at end of file diff --git a/packages/demo-infrastructure/index.ts b/packages/demo-infrastructure/index.ts deleted file mode 100644 index c74091851..000000000 --- a/packages/demo-infrastructure/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './media/DemoImageServiceAdapter'; -export * from './media/DemoFaceValidationAdapter'; -export * from './media/DemoAvatarGenerationAdapter'; -export * from './media/InMemoryAvatarGenerationRepository'; \ No newline at end of file diff --git a/packages/demo-infrastructure/package.json b/packages/demo-infrastructure/package.json deleted file mode 100644 index 1a70df77c..000000000 --- a/packages/demo-infrastructure/package.json +++ /dev/null @@ -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/*" - } -} \ No newline at end of file diff --git a/packages/demo-infrastructure/tsconfig.json b/packages/demo-infrastructure/tsconfig.json deleted file mode 100644 index ba90fd732..000000000 --- a/packages/demo-infrastructure/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "../..", - "outDir": "dist", - "declaration": true, - "declarationMap": false - }, - "include": [ - "../../packages/demo-infrastructure/**/*.ts", - "../../packages/media/**/*.ts" - ] -} \ No newline at end of file diff --git a/packages/identity/infrastructure/providers/IracingDemoIdentityProviderAdapter.ts b/packages/identity/infrastructure/providers/IracingDemoIdentityProviderAdapter.ts index e07f42d53..f773d081a 100644 --- a/packages/identity/infrastructure/providers/IracingDemoIdentityProviderAdapter.ts +++ b/packages/identity/infrastructure/providers/IracingDemoIdentityProviderAdapter.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { createStaticRacingSeed } from '../../../testing-support'; +import { createStaticRacingSeed } from '@gridpilot/testing-support'; import type { IdentityProviderPort } from '../../application/ports/IdentityProviderPort'; import type { StartAuthCommandDTO } from '../../application/dto/StartAuthCommandDTO'; import type { AuthCallbackCommandDTO } from '../../application/dto/AuthCallbackCommandDTO'; diff --git a/packages/notifications/application/index.ts b/packages/notifications/application/index.ts index 920afcfb5..967169286 100644 --- a/packages/notifications/application/index.ts +++ b/packages/notifications/application/index.ts @@ -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 diff --git a/packages/notifications/application/use-cases/GetUnreadNotificationsQuery.ts b/packages/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts similarity index 79% rename from packages/notifications/application/use-cases/GetUnreadNotificationsQuery.ts rename to packages/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts index 02ddc2c7e..38f7b6cc5 100644 --- a/packages/notifications/application/use-cases/GetUnreadNotificationsQuery.ts +++ b/packages/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts @@ -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. */ \ No newline at end of file diff --git a/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts b/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts index 5f7806892..9182b38b6 100644 --- a/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts +++ b/packages/notifications/application/use-cases/MarkNotificationReadUseCase.ts @@ -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()) { diff --git a/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts b/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts index 5221f889c..c5b3aaa1c 100644 --- a/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts +++ b/packages/notifications/application/use-cases/NotificationPreferencesUseCases.ts @@ -82,10 +82,10 @@ export class UpdateQuietHoursUseCase { async execute(command: UpdateQuietHoursCommand): Promise { // 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 { 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); diff --git a/packages/notifications/domain/entities/Notification.ts b/packages/notifications/domain/entities/Notification.ts index 5b4d35b78..e4b5e9c32 100644 --- a/packages/notifications/domain/entities/Notification.ts +++ b/packages/notifications/domain/entities/Notification.ts @@ -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, diff --git a/packages/notifications/domain/entities/NotificationPreference.ts b/packages/notifications/domain/entities/NotificationPreference.ts index 0cdc4426a..f544d9518 100644 --- a/packages/notifications/domain/entities/NotificationPreference.ts +++ b/packages/notifications/domain/entities/NotificationPreference.ts @@ -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 & { 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, diff --git a/packages/notifications/domain/errors/NotificationDomainError.ts b/packages/notifications/domain/errors/NotificationDomainError.ts new file mode 100644 index 000000000..2c9e91190 --- /dev/null +++ b/packages/notifications/domain/errors/NotificationDomainError.ts @@ -0,0 +1,8 @@ +export class NotificationDomainError extends Error { + readonly name: string = 'NotificationDomainError'; + + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} \ No newline at end of file diff --git a/packages/racing/application/dto/LeagueScheduleDTO.ts b/packages/racing/application/dto/LeagueScheduleDTO.ts index 177b0ef6f..9a867e743 100644 --- a/packages/racing/application/dto/LeagueScheduleDTO.ts +++ b/packages/racing/application/dto/LeagueScheduleDTO.ts @@ -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({ diff --git a/packages/racing/application/errors/RacingApplicationError.ts b/packages/racing/application/errors/RacingApplicationError.ts new file mode 100644 index 000000000..08c02b18e --- /dev/null +++ b/packages/racing/application/errors/RacingApplicationError.ts @@ -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); + } +} \ No newline at end of file diff --git a/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts b/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts index 11a90e4ac..bb2277ac6 100644 --- a/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts +++ b/packages/racing/application/use-cases/ApplyForSponsorshipUseCase.ts @@ -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 diff --git a/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts b/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts index 31be1b682..71e9b202b 100644 --- a/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts +++ b/packages/racing/application/use-cases/GetLeagueFullConfigUseCase.ts @@ -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); diff --git a/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts b/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts index cba4d45c0..1b2ff7e19 100644 --- a/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts +++ b/packages/racing/application/use-cases/ImportRaceResultsUseCase.ts @@ -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) => diff --git a/packages/racing/application/use-cases/JoinLeagueUseCase.ts b/packages/racing/application/use-cases/JoinLeagueUseCase.ts index f115d185e..facdb6c1b 100644 --- a/packages/racing/application/use-cases/JoinLeagueUseCase.ts +++ b/packages/racing/application/use-cases/JoinLeagueUseCase.ts @@ -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 = { diff --git a/packages/racing/application/use-cases/JoinTeamUseCase.ts b/packages/racing/application/use-cases/JoinTeamUseCase.ts index 97b21fdd0..55d6461e9 100644 --- a/packages/racing/application/use-cases/JoinTeamUseCase.ts +++ b/packages/racing/application/use-cases/JoinTeamUseCase.ts @@ -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 = { diff --git a/packages/racing/application/use-cases/RegisterForRaceUseCase.ts b/packages/racing/application/use-cases/RegisterForRaceUseCase.ts index 3b9089c32..e6fb68eaf 100644 --- a/packages/racing/application/use-cases/RegisterForRaceUseCase.ts +++ b/packages/racing/application/use-cases/RegisterForRaceUseCase.ts @@ -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 = { diff --git a/packages/racing/application/use-cases/ReviewProtestUseCase.ts b/packages/racing/application/use-cases/ReviewProtestUseCase.ts index 10e2cc1af..417456ea6 100644 --- a/packages/racing/application/use-cases/ReviewProtestUseCase.ts +++ b/packages/racing/application/use-cases/ReviewProtestUseCase.ts @@ -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 diff --git a/packages/racing/domain/entities/Car.ts b/packages/racing/domain/entities/Car.ts index 49c16e584..589c244e3 100644 --- a/packages/racing/domain/entities/Car.ts +++ b/packages/racing/domain/entities/Car.ts @@ -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'); } } diff --git a/packages/racing/domain/entities/Driver.ts b/packages/racing/domain/entities/Driver.ts index 0a6f9f033..0cedf3014 100644 --- a/packages/racing/domain/entities/Driver.ts +++ b/packages/racing/domain/entities/Driver.ts @@ -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)'); } } diff --git a/packages/racing/domain/entities/DriverLivery.ts b/packages/racing/domain/entities/DriverLivery.ts index ee8ba1067..994f7de5c 100644 --- a/packages/racing/domain/entities/DriverLivery.ts +++ b/packages/racing/domain/entities/DriverLivery.ts @@ -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): 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]; diff --git a/packages/racing/domain/entities/Game.ts b/packages/racing/domain/entities/Game.ts index 13f2782a9..faa92a2c2 100644 --- a/packages/racing/domain/entities/Game.ts +++ b/packages/racing/domain/entities/Game.ts @@ -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({ diff --git a/packages/racing/domain/entities/League.ts b/packages/racing/domain/entities/League.ts index bba1199e1..0dd6678e6 100644 --- a/packages/racing/domain/entities/League.ts +++ b/packages/racing/domain/entities/League.ts @@ -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'); } } diff --git a/packages/racing/domain/entities/LeagueWallet.ts b/packages/racing/domain/entities/LeagueWallet.ts index 901d560c4..78860e2a7 100644 --- a/packages/racing/domain/entities/LeagueWallet.ts +++ b/packages/racing/domain/entities/LeagueWallet.ts @@ -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): 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); diff --git a/packages/racing/domain/entities/LiveryTemplate.ts b/packages/racing/domain/entities/LiveryTemplate.ts index d601ffbd7..b9be7cb95 100644 --- a/packages/racing/domain/entities/LiveryTemplate.ts +++ b/packages/racing/domain/entities/LiveryTemplate.ts @@ -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): 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]; diff --git a/packages/racing/domain/entities/Penalty.ts b/packages/racing/domain/entities/Penalty.ts index 05acdcafe..4ec68bab7 100644 --- a/packages/racing/domain/entities/Penalty.ts +++ b/packages/racing/domain/entities/Penalty.ts @@ -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, diff --git a/packages/racing/domain/entities/Prize.ts b/packages/racing/domain/entities/Prize.ts index 01a6aca33..1b7490579 100644 --- a/packages/racing/domain/entities/Prize.ts +++ b/packages/racing/domain/entities/Prize.ts @@ -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): 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({ diff --git a/packages/racing/domain/entities/Protest.ts b/packages/racing/domain/entities/Protest.ts index 01265a990..5b2c817d0 100644 --- a/packages/racing/domain/entities/Protest.ts +++ b/packages/racing/domain/entities/Protest.ts @@ -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, diff --git a/packages/racing/domain/entities/Race.ts b/packages/racing/domain/entities/Race.ts index eb1bf91c8..aae0ecd12 100644 --- a/packages/racing/domain/entities/Race.ts +++ b/packages/racing/domain/entities/Race.ts @@ -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({ diff --git a/packages/racing/domain/entities/Result.ts b/packages/racing/domain/entities/Result.ts index cd86f0c63..5d2813254 100644 --- a/packages/racing/domain/entities/Result.ts +++ b/packages/racing/domain/entities/Result.ts @@ -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'); } } diff --git a/packages/racing/domain/entities/Season.ts b/packages/racing/domain/entities/Season.ts index 97fe9bd65..502c90fed 100644 --- a/packages/racing/domain/entities/Season.ts +++ b/packages/racing/domain/entities/Season.ts @@ -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'; diff --git a/packages/racing/domain/entities/SeasonSponsorship.ts b/packages/racing/domain/entities/SeasonSponsorship.ts index 35f3c4e5b..9e17ace28 100644 --- a/packages/racing/domain/entities/SeasonSponsorship.ts +++ b/packages/racing/domain/entities/SeasonSponsorship.ts @@ -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): 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({ diff --git a/packages/racing/domain/entities/Sponsor.ts b/packages/racing/domain/entities/Sponsor.ts index 1852e8ce1..bbfc9887d 100644 --- a/packages/racing/domain/entities/Sponsor.ts +++ b/packages/racing/domain/entities/Sponsor.ts @@ -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): 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'); } } } diff --git a/packages/racing/domain/entities/SponsorshipRequest.ts b/packages/racing/domain/entities/SponsorshipRequest.ts index d758ce293..35eb6c6b1 100644 --- a/packages/racing/domain/entities/SponsorshipRequest.ts +++ b/packages/racing/domain/entities/SponsorshipRequest.ts @@ -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): 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({ diff --git a/packages/racing/domain/entities/Standing.ts b/packages/racing/domain/entities/Standing.ts index 0740c916b..0b5afd004 100644 --- a/packages/racing/domain/entities/Standing.ts +++ b/packages/racing/domain/entities/Standing.ts @@ -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({ diff --git a/packages/racing/domain/entities/Track.ts b/packages/racing/domain/entities/Track.ts index 2925c76ad..6d5ce5903 100644 --- a/packages/racing/domain/entities/Track.ts +++ b/packages/racing/domain/entities/Track.ts @@ -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'); } } diff --git a/packages/racing/domain/entities/Transaction.ts b/packages/racing/domain/entities/Transaction.ts index 0abc14e2d..35ad96737 100644 --- a/packages/racing/domain/entities/Transaction.ts +++ b/packages/racing/domain/entities/Transaction.ts @@ -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): 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({ diff --git a/packages/racing/domain/errors/RacingDomainError.ts b/packages/racing/domain/errors/RacingDomainError.ts new file mode 100644 index 000000000..f490d127b --- /dev/null +++ b/packages/racing/domain/errors/RacingDomainError.ts @@ -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); + } +} \ No newline at end of file diff --git a/packages/racing/domain/services/SeasonScheduleGenerator.ts b/packages/racing/domain/services/SeasonScheduleGenerator.ts index fc3c78238..a1e8a9299 100644 --- a/packages/racing/domain/services/SeasonScheduleGenerator.ts +++ b/packages/racing/domain/services/SeasonScheduleGenerator.ts @@ -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; diff --git a/packages/racing/domain/value-objects/LeagueDescription.ts b/packages/racing/domain/value-objects/LeagueDescription.ts index cbd40d984..62c1fb67c 100644 --- a/packages/racing/domain/value-objects/LeagueDescription.ts +++ b/packages/racing/domain/value-objects/LeagueDescription.ts @@ -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()); } diff --git a/packages/racing/domain/value-objects/LeagueName.ts b/packages/racing/domain/value-objects/LeagueName.ts index e762ab116..11cce95eb 100644 --- a/packages/racing/domain/value-objects/LeagueName.ts +++ b/packages/racing/domain/value-objects/LeagueName.ts @@ -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()); } diff --git a/packages/racing/domain/value-objects/LeagueTimezone.ts b/packages/racing/domain/value-objects/LeagueTimezone.ts index d2dd80883..6dab9f34d 100644 --- a/packages/racing/domain/value-objects/LeagueTimezone.ts +++ b/packages/racing/domain/value-objects/LeagueTimezone.ts @@ -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; } diff --git a/packages/racing/domain/value-objects/LeagueVisibility.ts b/packages/racing/domain/value-objects/LeagueVisibility.ts index bcc4721e4..fbcbdb0f0 100644 --- a/packages/racing/domain/value-objects/LeagueVisibility.ts +++ b/packages/racing/domain/value-objects/LeagueVisibility.ts @@ -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}`); } /** diff --git a/packages/racing/domain/value-objects/LiveryDecal.ts b/packages/racing/domain/value-objects/LiveryDecal.ts index 86ec1ff67..c98f6421a 100644 --- a/packages/racing/domain/value-objects/LiveryDecal.ts +++ b/packages/racing/domain/value-objects/LiveryDecal.ts @@ -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'); } } diff --git a/packages/racing/domain/value-objects/MembershipFee.ts b/packages/racing/domain/value-objects/MembershipFee.ts index 4bd7a7008..b69507a81 100644 --- a/packages/racing/domain/value-objects/MembershipFee.ts +++ b/packages/racing/domain/value-objects/MembershipFee.ts @@ -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 }); diff --git a/packages/racing/domain/value-objects/Money.ts b/packages/racing/domain/value-objects/Money.ts index 7c54ce647..f250e4f49 100644 --- a/packages/racing/domain/value-objects/Money.ts +++ b/packages/racing/domain/value-objects/Money.ts @@ -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; } diff --git a/packages/racing/domain/value-objects/RaceTimeOfDay.ts b/packages/racing/domain/value-objects/RaceTimeOfDay.ts index 30e045ecf..31576684c 100644 --- a/packages/racing/domain/value-objects/RaceTimeOfDay.ts +++ b/packages/racing/domain/value-objects/RaceTimeOfDay.ts @@ -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]); diff --git a/packages/racing/domain/value-objects/RecurrenceStrategy.ts b/packages/racing/domain/value-objects/RecurrenceStrategy.ts index 340c1b9a1..49297bcab 100644 --- a/packages/racing/domain/value-objects/RecurrenceStrategy.ts +++ b/packages/racing/domain/value-objects/RecurrenceStrategy.ts @@ -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 { diff --git a/packages/racing/domain/value-objects/ScheduledRaceSlot.ts b/packages/racing/domain/value-objects/ScheduledRaceSlot.ts index c8afc1e90..5279cd573 100644 --- a/packages/racing/domain/value-objects/ScheduledRaceSlot.ts +++ b/packages/racing/domain/value-objects/ScheduledRaceSlot.ts @@ -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; diff --git a/packages/racing/domain/value-objects/SeasonSchedule.ts b/packages/racing/domain/value-objects/SeasonSchedule.ts index 14d0bad38..16e89fc2d 100644 --- a/packages/racing/domain/value-objects/SeasonSchedule.ts +++ b/packages/racing/domain/value-objects/SeasonSchedule.ts @@ -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( diff --git a/packages/racing/domain/value-objects/Weekday.ts b/packages/racing/domain/value-objects/Weekday.ts index 4ee7c69a9..8d10070d9 100644 --- a/packages/racing/domain/value-objects/Weekday.ts +++ b/packages/racing/domain/value-objects/Weekday.ts @@ -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}`); } } \ No newline at end of file diff --git a/packages/racing/domain/value-objects/WeekdaySet.ts b/packages/racing/domain/value-objects/WeekdaySet.ts index 4874e0934..834156ba2 100644 --- a/packages/racing/domain/value-objects/WeekdaySet.ts +++ b/packages/racing/domain/value-objects/WeekdaySet.ts @@ -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)); diff --git a/packages/social/application/dto/FeedItemDTO.ts b/packages/social/application/dto/FeedItemDTO.ts new file mode 100644 index 000000000..741cc20d6 --- /dev/null +++ b/packages/social/application/dto/FeedItemDTO.ts @@ -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; +} \ No newline at end of file diff --git a/packages/social/application/index.ts b/packages/social/application/index.ts new file mode 100644 index 000000000..59e4a98dc --- /dev/null +++ b/packages/social/application/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/social/application/presenters/ISocialPresenters.ts b/packages/social/application/presenters/ISocialPresenters.ts new file mode 100644 index 000000000..5ce7415d2 --- /dev/null +++ b/packages/social/application/presenters/ISocialPresenters.ts @@ -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; +} \ No newline at end of file diff --git a/packages/social/application/use-cases/GetCurrentUserSocialUseCase.ts b/packages/social/application/use-cases/GetCurrentUserSocialUseCase.ts new file mode 100644 index 000000000..b7692da6d --- /dev/null +++ b/packages/social/application/use-cases/GetCurrentUserSocialUseCase.ts @@ -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 { + 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); + } +} \ No newline at end of file diff --git a/packages/social/application/use-cases/GetUserFeedUseCase.ts b/packages/social/application/use-cases/GetUserFeedUseCase.ts new file mode 100644 index 000000000..065259822 --- /dev/null +++ b/packages/social/application/use-cases/GetUserFeedUseCase.ts @@ -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 { + 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, + }; +} \ No newline at end of file diff --git a/packages/social/domain/errors/SocialDomainError.ts b/packages/social/domain/errors/SocialDomainError.ts new file mode 100644 index 000000000..6917c4cc0 --- /dev/null +++ b/packages/social/domain/errors/SocialDomainError.ts @@ -0,0 +1,8 @@ +export class SocialDomainError extends Error { + readonly name: string = 'SocialDomainError'; + + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} \ No newline at end of file diff --git a/packages/testing-support/index.ts b/packages/testing-support/index.ts index 720dbb066..aab79a2f9 100644 --- a/packages/testing-support/index.ts +++ b/packages/testing-support/index.ts @@ -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'; diff --git a/packages/demo-infrastructure/media/DemoAvatarGenerationAdapter.ts b/packages/testing-support/src/media/DemoAvatarGenerationAdapter.ts similarity index 90% rename from packages/demo-infrastructure/media/DemoAvatarGenerationAdapter.ts rename to packages/testing-support/src/media/DemoAvatarGenerationAdapter.ts index 96b62737f..ca6582c04 100644 --- a/packages/demo-infrastructure/media/DemoAvatarGenerationAdapter.ts +++ b/packages/testing-support/src/media/DemoAvatarGenerationAdapter.ts @@ -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 { - 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); } diff --git a/packages/demo-infrastructure/media/DemoFaceValidationAdapter.ts b/packages/testing-support/src/media/DemoFaceValidationAdapter.ts similarity index 92% rename from packages/demo-infrastructure/media/DemoFaceValidationAdapter.ts rename to packages/testing-support/src/media/DemoFaceValidationAdapter.ts index 0eaf15603..999dc2e53 100644 --- a/packages/demo-infrastructure/media/DemoFaceValidationAdapter.ts +++ b/packages/testing-support/src/media/DemoFaceValidationAdapter.ts @@ -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 { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } } \ No newline at end of file diff --git a/packages/demo-infrastructure/media/DemoImageServiceAdapter.ts b/packages/testing-support/src/media/DemoImageServiceAdapter.ts similarity index 92% rename from packages/demo-infrastructure/media/DemoImageServiceAdapter.ts rename to packages/testing-support/src/media/DemoImageServiceAdapter.ts index 850d9eff3..0b378039d 100644 --- a/packages/demo-infrastructure/media/DemoImageServiceAdapter.ts +++ b/packages/testing-support/src/media/DemoImageServiceAdapter.ts @@ -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); diff --git a/packages/demo-infrastructure/media/InMemoryAvatarGenerationRepository.ts b/packages/testing-support/src/media/InMemoryAvatarGenerationRepository.ts similarity index 91% rename from packages/demo-infrastructure/media/InMemoryAvatarGenerationRepository.ts rename to packages/testing-support/src/media/InMemoryAvatarGenerationRepository.ts index e689fbe1f..0252ee8fb 100644 --- a/packages/demo-infrastructure/media/InMemoryAvatarGenerationRepository.ts +++ b/packages/testing-support/src/media/InMemoryAvatarGenerationRepository.ts @@ -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 { diff --git a/tests/unit/infrastructure/DemoImageServiceAdapter.test.ts b/tests/unit/infrastructure/DemoImageServiceAdapter.test.ts index 70bc0cff6..f2c89ccf7 100644 --- a/tests/unit/infrastructure/DemoImageServiceAdapter.test.ts +++ b/tests/unit/infrastructure/DemoImageServiceAdapter.test.ts @@ -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)', () => { diff --git a/tsconfig.json b/tsconfig.json index da457a100..23a5f86bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" diff --git a/vitest.config.ts b/vitest.config.ts index 6bc78aae9..2cfe12f31 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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/*'),