From fefd8d1cd6dd9ddcc5151ab6f4963cc237f97ca9 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 12 Jan 2026 01:01:49 +0100 Subject: [PATCH] website refactor --- .eslintrc.json | 482 +++++--- apps/website/.eslintrc.json | 101 +- apps/website/app/actions/logoutAction.ts | 42 + .../website/app/auth/forgot-password/page.tsx | 2 +- apps/website/app/auth/login/page.tsx | 2 +- apps/website/app/auth/reset-password/page.tsx | 2 +- apps/website/app/auth/signup/page.tsx | 2 +- .../app/dashboard/DashboardPageClient.tsx | 94 +- .../app/dashboard/DashboardViewDataBuilder.ts | 88 -- apps/website/app/dashboard/page.tsx | 18 +- apps/website/app/drivers/[id]/page.tsx | 4 +- .../website/app/leaderboards/drivers/page.tsx | 4 +- apps/website/app/leaderboards/page.tsx | 6 +- apps/website/app/leagues/[id]/layout.tsx | 4 +- .../[id]/roster/admin/RosterAdminPage.tsx | 2 +- .../app/leagues/[id]/schedule/admin/page.tsx | 4 +- .../app/leagues/[id]/schedule/page.tsx | 31 +- .../app/leagues/[id]/settings/page.tsx | 6 +- .../app/leagues/[id]/sponsorships/page.tsx | 4 +- .../app/leagues/[id]/standings/page.tsx | 75 +- .../[id]/stewarding/StewardingTemplate.tsx | 2 +- .../app/leagues/[id]/stewarding/page.tsx | 8 +- .../stewarding/protests/[protestId]/page.tsx | 6 +- apps/website/app/leagues/[id]/wallet/page.tsx | 2 +- apps/website/app/onboarding/page.tsx | 2 +- .../leagues/ProfileLeaguesPageClient.tsx | 17 + apps/website/app/profile/leagues/page.tsx | 216 +--- apps/website/app/profile/page.tsx | 4 +- .../app/profile/sponsorship-requests/page.tsx | 4 +- apps/website/app/races/[id]/results/page.tsx | 6 +- .../app/races/[id]/stewarding/page.tsx | 7 +- apps/website/app/races/all/page.tsx | 2 +- apps/website/app/sponsor/billing/page.tsx | 2 +- apps/website/app/sponsor/campaigns/page.tsx | 2 +- apps/website/app/sponsor/settings/page.tsx | 7 +- apps/website/app/teams/TeamsPageClient.tsx | 91 ++ .../app/teams/[id]/TeamDetailPageClient.tsx | 68 + apps/website/app/teams/[id]/page.tsx | 110 +- apps/website/app/teams/leaderboard/page.tsx | 4 +- apps/website/app/teams/page.tsx | 107 +- .../components/admin/AdminDashboardPage.tsx | 6 +- apps/website/components/admin/AdminLayout.tsx | 27 +- .../components/admin/AdminUsersPage.tsx | 6 +- .../components/dev/DebugModeToggle.tsx | 24 +- apps/website/components/dev/DevToolbar.tsx | 2 +- .../dev/sections/NotificationSendSection.tsx | 2 +- .../components/drivers/CreateDriverForm.tsx | 2 +- .../components/drivers/DriverProfile.tsx | 2 +- .../components/drivers/ProfileStats.tsx | 2 +- .../components/landing/AlternatingSection.tsx | 2 +- apps/website/components/landing/Hero.tsx | 2 +- .../components/leagues/CreateLeagueForm.tsx | 2 +- .../components/leagues/CreateLeagueWizard.tsx | 14 +- .../components/leagues/JoinLeagueButton.tsx | 4 +- .../components/leagues/LeagueActivityFeed.tsx | 2 +- .../components/leagues/LeagueMembers.tsx | 4 +- .../leagues/LeagueOwnershipTransfer.tsx | 2 +- .../components/leagues/LeagueSchedule.tsx | 8 +- .../leagues/LeagueSponsorshipsSection.tsx | 6 +- .../components/leagues/MembershipStatus.tsx | 2 +- .../components/leagues/QuickPenaltyModal.tsx | 14 +- .../components/leagues/ReviewProtestModal.tsx | 2 +- .../components/leagues/ScheduleRaceForm.tsx | 2 +- .../onboarding/OnboardingWizard.tsx | 6 +- apps/website/components/profile/UserPill.tsx | 15 +- .../components/races/FileProtestModal.tsx | 2 +- .../components/shared/CapabilityGate.tsx | 2 +- .../sponsors/SponsorInsightsCard.tsx | 2 +- .../components/teams/CreateTeamForm.tsx | 4 +- .../components/teams/FeaturedRecruiting.tsx | 15 +- .../components/teams/JoinTeamButton.tsx | 4 +- .../components/teams/SkillLevelSection.tsx | 18 +- apps/website/components/teams/TeamAdmin.tsx | 15 +- .../teams/TeamLeaderboardPreview.tsx | 13 +- apps/website/components/teams/TeamRoster.tsx | 11 +- .../components/teams/TeamStandings.tsx | 2 +- apps/website/hooks/league/useCreateLeague.ts | 19 - apps/website/hooks/league/useLeagueDetail.ts | 16 - .../hooks/league/useLeagueRosterAdmin.ts | 82 -- .../website/hooks/league/useLeagueSchedule.ts | 16 - .../league/useLeagueScheduleAdminPageData.ts | 38 - .../hooks/league/useLeagueWalletPageData.ts | 47 - .../lib/api/base/GracefulDegradation.ts | 6 +- apps/website/lib/auth/AuthContext.tsx | 6 +- .../lib/contracts/page-queries/PageQuery.ts | 26 + apps/website/lib/di/hooks/useInject.ts | 4 +- apps/website/{ => lib}/hooks/auth/index.ts | 0 .../{ => lib}/hooks/auth/useCurrentSession.ts | 0 .../{ => lib}/hooks/auth/useForgotPassword.ts | 0 apps/website/{ => lib}/hooks/auth/useLogin.ts | 0 .../website/{ => lib}/hooks/auth/useLogout.ts | 0 .../{ => lib}/hooks/auth/useResetPassword.ts | 0 .../website/{ => lib}/hooks/auth/useSignup.ts | 0 apps/website/{ => lib}/hooks/driver/index.ts | 0 .../{ => lib}/hooks/driver/useCreateDriver.ts | 0 .../hooks/driver/useCurrentDriver.ts | 0 .../hooks/driver/useDriverProfile.ts | 0 .../hooks/driver/useDriverProfilePageData.ts | 0 .../hooks/driver/useFindDriverById.ts | 0 .../hooks/driver/useUpdateDriverProfile.ts | 0 .../{ => lib}/hooks/league/useAllLeagues.ts | 0 .../lib/hooks/league/useCreateLeague.ts | 9 + .../league/useCreateLeagueWithBlockers.ts | 50 + .../hooks/league/useLeagueAdminStatus.ts | 0 .../lib/hooks/league/useLeagueDetail.ts | 54 + .../league/useLeagueMembershipMutation.ts | 0 .../hooks/league/useLeagueMemberships.ts | 4 +- .../{ => lib}/hooks/league/useLeagueRaces.ts | 0 .../lib/hooks/league/useLeagueRosterAdmin.ts | 94 ++ .../lib/hooks/league/useLeagueSchedule.ts | 43 + .../league/useLeagueScheduleAdminPageData.ts | 75 ++ .../hooks/league/useLeagueSeasons.ts | 0 .../hooks/league/useLeagueSettings.ts | 0 .../league/useLeagueSponsorshipsPageData.ts | 2 +- .../hooks/league/useLeagueStewardingData.ts | 0 .../league/useLeagueStewardingMutations.ts | 0 .../hooks/league/useLeagueWalletPageData.ts | 53 + .../useLeagueWalletWithdrawalWithBlockers.ts | 58 + .../hooks/league/usePenaltyMutation.ts | 0 .../hooks/league/useProtestDetail.ts | 0 .../hooks/league/useSponsorshipRequests.ts | 0 .../{ => lib}/hooks/onboarding/index.ts | 0 .../hooks/onboarding/useCompleteOnboarding.ts | 0 .../hooks/onboarding/useGenerateAvatars.ts | 0 .../hooks/onboarding/useValidateFacePhoto.ts | 0 .../hooks/race/useAllRacesPageData.ts | 0 .../{ => lib}/hooks/race/useFileProtest.ts | 0 .../hooks/race/useRaceResultsPageData.ts | 0 .../hooks/race/useRegisterForRace.ts | 0 .../hooks/race/useWithdrawFromRace.ts | 0 apps/website/{ => lib}/hooks/sponsor/index.ts | 0 .../hooks/sponsor/useAvailableLeagues.ts | 0 .../hooks/sponsor/useSponsorBilling.ts | 0 .../hooks/sponsor/useSponsorDashboard.ts | 0 .../hooks/sponsor/useSponsorLeagueDetail.ts | 0 .../hooks/sponsor/useSponsorSponsorships.ts | 0 .../sponsor/useSponsorshipRequestsPageData.ts | 0 apps/website/{ => lib}/hooks/team/index.ts | 0 .../{ => lib}/hooks/team/useAllTeams.ts | 0 .../hooks/team/useApproveJoinRequest.ts | 0 .../{ => lib}/hooks/team/useCreateTeam.ts | 0 .../{ => lib}/hooks/team/useJoinTeam.ts | 0 .../{ => lib}/hooks/team/useLeaveTeam.ts | 0 .../hooks/team/useRejectJoinRequest.ts | 0 .../{ => lib}/hooks/team/useTeamDetails.ts | 0 .../hooks/team/useTeamJoinRequests.ts | 0 .../{ => lib}/hooks/team/useTeamMembers.ts | 0 .../{ => lib}/hooks/team/useTeamMembership.ts | 0 .../{ => lib}/hooks/team/useTeamRoster.ts | 18 +- .../{ => lib}/hooks/team/useTeamStandings.ts | 0 .../{ => lib}/hooks/team/useUpdateTeam.ts | 0 apps/website/{ => lib}/hooks/useCapability.ts | 0 .../{ => lib}/hooks/useEffectiveDriverId.ts | 0 .../hooks/useLeagueScoringPresets.ts | 0 .../{ => lib}/hooks/useLeagueWizardService.ts | 0 .../hooks/usePenaltyTypesReference.ts | 0 .../{ => lib}/hooks/useScrollProgress.ts | 0 .../website/lib/infrastructure/ErrorReplay.ts | 9 +- .../page-dtos/DashboardPageDto.ts | 84 ++ .../{ => page-queries}/DashboardPageQuery.ts | 69 +- .../page-queries/ProfileLeaguesPageQuery.ts | 132 ++ .../{ => page-queries}/ProfilePageQuery.ts | 13 +- .../page-queries/TeamDetailPageQuery.ts | 137 ++ .../page-queries/TeamsPageQuery.ts | 98 ++ .../page-query-result/PageQueryResult.ts | 18 + apps/website/lib/page/PageDataFetcher.ts | 6 +- .../AdminViewModelPresenter.ts} | 13 +- .../lib/presenters/DashboardPresenter.ts | 91 ++ .../lib/presenters/ProfileLeaguesPresenter.ts | 25 + .../lib/presenters/TeamDetailPresenter.ts | 47 + apps/website/lib/presenters/TeamsPresenter.ts | 21 + .../services/AdminViewModelService.test.ts | 8 - .../analytics/AnalyticsService.test.ts | 6 +- .../services/analytics/AnalyticsService.ts | 33 - .../analytics/DashboardService.test.ts | 2 +- .../services/analytics/DashboardService.ts | 31 - .../lib/services/auth/AuthService.test.ts | 4 +- apps/website/lib/services/auth/AuthService.ts | 75 -- .../lib/services/auth/SessionService.test.ts | 4 +- .../lib/services/auth/SessionService.ts | 2 +- .../dashboard/DashboardService.test.ts | 4 +- .../services/dashboard/DashboardService.ts | 113 -- .../drivers/DriverRegistrationService.test.ts | 4 +- .../drivers/DriverRegistrationService.ts | 25 - .../services/drivers/DriverService.test.ts | 8 +- .../lib/services/drivers/DriverService.ts | 8 +- .../services/landing/LandingService.test.ts | 6 +- .../lib/services/landing/LandingService.ts | 103 -- .../leagues/LeagueMembershipService.test.ts | 4 +- .../leagues/LeagueMembershipService.ts | 10 +- .../services/leagues/LeagueService.test.ts | 116 +- .../lib/services/leagues/LeagueService.ts | 536 +------- .../leagues/LeagueSettingsService.test.ts | 4 +- .../services/leagues/LeagueSettingsService.ts | 283 ----- .../leagues/LeagueStewardingService.test.ts | 2 +- .../leagues/LeagueStewardingService.ts | 179 --- .../leagues/LeagueWalletService.test.ts | 24 +- .../services/leagues/LeagueWalletService.ts | 71 -- .../services/leagues/LeagueWizardService.ts | 23 - .../lib/services/media/AvatarService.test.ts | 8 +- .../lib/services/media/AvatarService.ts | 47 - .../lib/services/media/MediaService.test.ts | 8 +- .../lib/services/media/MediaService.ts | 44 - .../services/onboarding/OnboardingService.ts | 60 - .../payments/MembershipFeeService.test.ts | 4 +- .../services/payments/MembershipFeeService.ts | 34 - .../services/payments/PaymentService.test.ts | 2 +- .../lib/services/payments/PaymentService.ts | 92 -- .../services/payments/WalletService.test.ts | 2 +- .../lib/services/payments/WalletService.ts | 37 - .../services/penalties/PenaltyService.test.ts | 2 +- .../lib/services/penalties/PenaltyService.ts | 4 +- .../lib/services/policy/PolicyService.ts | 2 +- .../services/protests/ProtestService.test.ts | 8 +- .../lib/services/protests/ProtestService.ts | 166 --- .../services/races/RaceResultsService.test.ts | 10 +- .../lib/services/races/RaceResultsService.ts | 178 --- .../website/lib/services/races/RaceService.ts | 167 --- .../races/RaceStewardingService.test.ts | 8 +- .../services/races/RaceStewardingService.ts | 70 -- .../services/sponsors/SponsorService.test.ts | 8 +- .../lib/services/sponsors/SponsorService.ts | 109 -- .../sponsors/SponsorshipService.test.ts | 6 +- .../services/sponsors/SponsorshipService.ts | 72 -- .../services/teams/TeamJoinService.test.ts | 2 +- .../lib/services/teams/TeamJoinService.ts | 49 - .../lib/services/teams/TeamService.test.ts | 8 +- .../website/lib/services/teams/TeamService.ts | 113 -- .../AllLeaguesWithCapacityAndScoringDTO.ts | 2 +- .../CompleteOnboardingViewModel.ts | 2 +- .../lib/view-models/CreateLeagueViewModel.ts | 2 +- .../DriverLeaderboardItemViewModel.ts | 2 +- .../view-models/DriverLeaderboardViewModel.ts | 2 +- .../DriverRegistrationStatusViewModel.ts | 2 +- .../lib/view-models/DriverSummaryViewModel.ts | 2 +- .../view-models/LeagueDetailPageViewModel.ts | 82 +- .../view-models/LeagueJoinRequestViewModel.ts | 2 +- .../lib/view-models/LeagueMemberViewModel.ts | 2 +- .../view-models/LeagueMembershipsViewModel.ts | 2 +- .../LeagueScoringChampionshipViewModel.ts | 6 +- .../LeagueScoringConfigViewModel.ts | 18 +- .../view-models/LeagueStandingsViewModel.ts | 6 +- .../view-models/LeagueWalletViewModel.test.ts | 14 +- .../website/lib/view-models/MediaViewModel.ts | 2 +- .../MembershipFeeViewModel.test.ts | 6 +- .../lib/view-models/MembershipFeeViewModel.ts | 2 +- .../lib/view-models/PaymentViewModel.ts | 2 +- .../website/lib/view-models/PrizeViewModel.ts | 2 +- .../lib/view-models/ProtestDriverViewModel.ts | 2 +- .../lib/view-models/ProtestViewModel.ts | 52 +- .../view-models/RaceDetailEntryViewModel.ts | 2 +- .../RaceDetailUserResultViewModel.ts | 2 +- .../view-models/RaceDetailViewModel.test.ts | 12 +- .../lib/view-models/RaceDetailViewModel.ts | 81 -- .../lib/view-models/RaceResultViewModel.ts | 2 +- .../view-models/RaceResultsDataTransformer.ts | 14 +- .../view-models/RaceResultsDetailViewModel.ts | 4 +- .../lib/view-models/RaceStatsViewModel.ts | 2 +- apps/website/lib/view-models/RaceViewModel.ts | 12 +- .../lib/view-models/RaceWithSOFViewModel.ts | 4 +- .../lib/view-models/RacesPageViewModel.ts | 29 +- .../RecordEngagementOutputViewModel.ts | 2 +- .../RecordPageViewOutputViewModel.ts | 2 +- .../lib/view-models/RemoveMemberViewModel.ts | 2 +- .../RequestAvatarGenerationViewModel.ts | 2 +- .../lib/view-models/SessionViewModel.ts | 29 +- .../view-models/SponsorDashboardViewModel.ts | 138 --- .../SponsorSponsorshipsViewModel.ts | 2 +- .../view-models/SponsorshipDetailViewModel.ts | 2 +- .../lib/view-models/SponsorshipViewModel.ts | 26 +- .../lib/view-models/StandingEntryViewModel.ts | 2 +- .../WalletTransactionViewModel.test.ts | 6 +- .../lib/view-models/WalletViewModel.ts | 2 +- apps/website/lib/view-models/index.ts | 187 +-- apps/website/templates/DashboardTemplate.tsx | 70 +- .../templates/LeagueRulebookTemplate.tsx | 6 +- .../templates/LeagueScheduleTemplate.tsx | 6 +- .../templates/ProfileLeaguesTemplate.tsx | 109 ++ apps/website/templates/RaceDetailTemplate.tsx | 4 +- apps/website/templates/TeamDetailTemplate.tsx | 10 +- apps/website/templates/TeamsTemplate.tsx | 412 ++----- .../view-data}/DashboardViewData.ts | 2 +- .../view-data/ProfileLeaguesViewData.ts | 16 + .../templates/view-data/TeamDetailViewData.ts | 40 + .../templates/view-data/TeamsViewData.ts | 16 + .../guardrails/ArchitectureGuardrails.ts | 1097 +++++++++++++++++ .../tests/guardrails/GuardrailViolation.ts | 25 + .../tests/guardrails/allowed-violations.ts | 235 ++++ .../architecture-guardrails.test.ts | 142 +++ apps/website/tsconfig.json | 1 - .../website/WEBSITE_GUARDRAILS.md | 215 ++++ package-lock.json | 13 + plans/website-architecture-violations.md | 40 +- vitest.website.config.ts | 1 + 294 files changed, 4628 insertions(+), 4991 deletions(-) create mode 100644 apps/website/app/actions/logoutAction.ts delete mode 100644 apps/website/app/dashboard/DashboardViewDataBuilder.ts create mode 100644 apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx create mode 100644 apps/website/app/teams/TeamsPageClient.tsx create mode 100644 apps/website/app/teams/[id]/TeamDetailPageClient.tsx delete mode 100644 apps/website/hooks/league/useCreateLeague.ts delete mode 100644 apps/website/hooks/league/useLeagueDetail.ts delete mode 100644 apps/website/hooks/league/useLeagueRosterAdmin.ts delete mode 100644 apps/website/hooks/league/useLeagueSchedule.ts delete mode 100644 apps/website/hooks/league/useLeagueScheduleAdminPageData.ts delete mode 100644 apps/website/hooks/league/useLeagueWalletPageData.ts create mode 100644 apps/website/lib/contracts/page-queries/PageQuery.ts rename apps/website/{ => lib}/hooks/auth/index.ts (100%) rename apps/website/{ => lib}/hooks/auth/useCurrentSession.ts (100%) rename apps/website/{ => lib}/hooks/auth/useForgotPassword.ts (100%) rename apps/website/{ => lib}/hooks/auth/useLogin.ts (100%) rename apps/website/{ => lib}/hooks/auth/useLogout.ts (100%) rename apps/website/{ => lib}/hooks/auth/useResetPassword.ts (100%) rename apps/website/{ => lib}/hooks/auth/useSignup.ts (100%) rename apps/website/{ => lib}/hooks/driver/index.ts (100%) rename apps/website/{ => lib}/hooks/driver/useCreateDriver.ts (100%) rename apps/website/{ => lib}/hooks/driver/useCurrentDriver.ts (100%) rename apps/website/{ => lib}/hooks/driver/useDriverProfile.ts (100%) rename apps/website/{ => lib}/hooks/driver/useDriverProfilePageData.ts (100%) rename apps/website/{ => lib}/hooks/driver/useFindDriverById.ts (100%) rename apps/website/{ => lib}/hooks/driver/useUpdateDriverProfile.ts (100%) rename apps/website/{ => lib}/hooks/league/useAllLeagues.ts (100%) create mode 100644 apps/website/lib/hooks/league/useCreateLeague.ts create mode 100644 apps/website/lib/hooks/league/useCreateLeagueWithBlockers.ts rename apps/website/{ => lib}/hooks/league/useLeagueAdminStatus.ts (100%) create mode 100644 apps/website/lib/hooks/league/useLeagueDetail.ts rename apps/website/{ => lib}/hooks/league/useLeagueMembershipMutation.ts (100%) rename apps/website/{ => lib}/hooks/league/useLeagueMemberships.ts (87%) rename apps/website/{ => lib}/hooks/league/useLeagueRaces.ts (100%) create mode 100644 apps/website/lib/hooks/league/useLeagueRosterAdmin.ts create mode 100644 apps/website/lib/hooks/league/useLeagueSchedule.ts create mode 100644 apps/website/lib/hooks/league/useLeagueScheduleAdminPageData.ts rename apps/website/{ => lib}/hooks/league/useLeagueSeasons.ts (100%) rename apps/website/{ => lib}/hooks/league/useLeagueSettings.ts (100%) rename apps/website/{ => lib}/hooks/league/useLeagueSponsorshipsPageData.ts (91%) rename apps/website/{ => lib}/hooks/league/useLeagueStewardingData.ts (100%) rename apps/website/{ => lib}/hooks/league/useLeagueStewardingMutations.ts (100%) create mode 100644 apps/website/lib/hooks/league/useLeagueWalletPageData.ts create mode 100644 apps/website/lib/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts rename apps/website/{ => lib}/hooks/league/usePenaltyMutation.ts (100%) rename apps/website/{ => lib}/hooks/league/useProtestDetail.ts (100%) rename apps/website/{ => lib}/hooks/league/useSponsorshipRequests.ts (100%) rename apps/website/{ => lib}/hooks/onboarding/index.ts (100%) rename apps/website/{ => lib}/hooks/onboarding/useCompleteOnboarding.ts (100%) rename apps/website/{ => lib}/hooks/onboarding/useGenerateAvatars.ts (100%) rename apps/website/{ => lib}/hooks/onboarding/useValidateFacePhoto.ts (100%) rename apps/website/{ => lib}/hooks/race/useAllRacesPageData.ts (100%) rename apps/website/{ => lib}/hooks/race/useFileProtest.ts (100%) rename apps/website/{ => lib}/hooks/race/useRaceResultsPageData.ts (100%) rename apps/website/{ => lib}/hooks/race/useRegisterForRace.ts (100%) rename apps/website/{ => lib}/hooks/race/useWithdrawFromRace.ts (100%) rename apps/website/{ => lib}/hooks/sponsor/index.ts (100%) rename apps/website/{ => lib}/hooks/sponsor/useAvailableLeagues.ts (100%) rename apps/website/{ => lib}/hooks/sponsor/useSponsorBilling.ts (100%) rename apps/website/{ => lib}/hooks/sponsor/useSponsorDashboard.ts (100%) rename apps/website/{ => lib}/hooks/sponsor/useSponsorLeagueDetail.ts (100%) rename apps/website/{ => lib}/hooks/sponsor/useSponsorSponsorships.ts (100%) rename apps/website/{ => lib}/hooks/sponsor/useSponsorshipRequestsPageData.ts (100%) rename apps/website/{ => lib}/hooks/team/index.ts (100%) rename apps/website/{ => lib}/hooks/team/useAllTeams.ts (100%) rename apps/website/{ => lib}/hooks/team/useApproveJoinRequest.ts (100%) rename apps/website/{ => lib}/hooks/team/useCreateTeam.ts (100%) rename apps/website/{ => lib}/hooks/team/useJoinTeam.ts (100%) rename apps/website/{ => lib}/hooks/team/useLeaveTeam.ts (100%) rename apps/website/{ => lib}/hooks/team/useRejectJoinRequest.ts (100%) rename apps/website/{ => lib}/hooks/team/useTeamDetails.ts (100%) rename apps/website/{ => lib}/hooks/team/useTeamJoinRequests.ts (100%) rename apps/website/{ => lib}/hooks/team/useTeamMembers.ts (100%) rename apps/website/{ => lib}/hooks/team/useTeamMembership.ts (100%) rename apps/website/{ => lib}/hooks/team/useTeamRoster.ts (74%) rename apps/website/{ => lib}/hooks/team/useTeamStandings.ts (100%) rename apps/website/{ => lib}/hooks/team/useUpdateTeam.ts (100%) rename apps/website/{ => lib}/hooks/useCapability.ts (100%) rename apps/website/{ => lib}/hooks/useEffectiveDriverId.ts (100%) rename apps/website/{ => lib}/hooks/useLeagueScoringPresets.ts (100%) rename apps/website/{ => lib}/hooks/useLeagueWizardService.ts (100%) rename apps/website/{ => lib}/hooks/usePenaltyTypesReference.ts (100%) rename apps/website/{ => lib}/hooks/useScrollProgress.ts (100%) create mode 100644 apps/website/lib/page-queries/page-dtos/DashboardPageDto.ts rename apps/website/lib/page-queries/{ => page-queries}/DashboardPageQuery.ts (60%) create mode 100644 apps/website/lib/page-queries/page-queries/ProfileLeaguesPageQuery.ts rename apps/website/lib/page-queries/{ => page-queries}/ProfilePageQuery.ts (90%) create mode 100644 apps/website/lib/page-queries/page-queries/TeamDetailPageQuery.ts create mode 100644 apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts create mode 100644 apps/website/lib/page-queries/page-query-result/PageQueryResult.ts rename apps/website/lib/{services/AdminViewModelService.ts => presenters/AdminViewModelPresenter.ts} (78%) create mode 100644 apps/website/lib/presenters/DashboardPresenter.ts create mode 100644 apps/website/lib/presenters/ProfileLeaguesPresenter.ts create mode 100644 apps/website/lib/presenters/TeamDetailPresenter.ts create mode 100644 apps/website/lib/presenters/TeamsPresenter.ts delete mode 100644 apps/website/lib/services/AdminViewModelService.test.ts delete mode 100644 apps/website/lib/services/analytics/AnalyticsService.ts delete mode 100644 apps/website/lib/services/analytics/DashboardService.ts delete mode 100644 apps/website/lib/services/auth/AuthService.ts delete mode 100644 apps/website/lib/services/dashboard/DashboardService.ts delete mode 100644 apps/website/lib/services/drivers/DriverRegistrationService.ts delete mode 100644 apps/website/lib/services/landing/LandingService.ts delete mode 100644 apps/website/lib/services/leagues/LeagueSettingsService.ts delete mode 100644 apps/website/lib/services/leagues/LeagueStewardingService.ts delete mode 100644 apps/website/lib/services/leagues/LeagueWalletService.ts delete mode 100644 apps/website/lib/services/leagues/LeagueWizardService.ts delete mode 100644 apps/website/lib/services/media/AvatarService.ts delete mode 100644 apps/website/lib/services/media/MediaService.ts delete mode 100644 apps/website/lib/services/onboarding/OnboardingService.ts delete mode 100644 apps/website/lib/services/payments/MembershipFeeService.ts delete mode 100644 apps/website/lib/services/payments/PaymentService.ts delete mode 100644 apps/website/lib/services/payments/WalletService.ts delete mode 100644 apps/website/lib/services/protests/ProtestService.ts delete mode 100644 apps/website/lib/services/races/RaceResultsService.ts delete mode 100644 apps/website/lib/services/races/RaceService.ts delete mode 100644 apps/website/lib/services/races/RaceStewardingService.ts delete mode 100644 apps/website/lib/services/sponsors/SponsorService.ts delete mode 100644 apps/website/lib/services/sponsors/SponsorshipService.ts delete mode 100644 apps/website/lib/services/teams/TeamJoinService.ts delete mode 100644 apps/website/lib/services/teams/TeamService.ts delete mode 100644 apps/website/lib/view-models/RaceDetailViewModel.ts delete mode 100644 apps/website/lib/view-models/SponsorDashboardViewModel.ts create mode 100644 apps/website/templates/ProfileLeaguesTemplate.tsx rename apps/website/{app/dashboard => templates/view-data}/DashboardViewData.ts (99%) create mode 100644 apps/website/templates/view-data/ProfileLeaguesViewData.ts create mode 100644 apps/website/templates/view-data/TeamDetailViewData.ts create mode 100644 apps/website/templates/view-data/TeamsViewData.ts create mode 100644 apps/website/tests/guardrails/ArchitectureGuardrails.ts create mode 100644 apps/website/tests/guardrails/GuardrailViolation.ts create mode 100644 apps/website/tests/guardrails/allowed-violations.ts create mode 100644 apps/website/tests/guardrails/architecture-guardrails.test.ts diff --git a/.eslintrc.json b/.eslintrc.json index 4affc7476..9f568d678 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,225 +3,245 @@ "es2022": true, "node": true }, - "parserOptions": { - "sourceType": "module", - "ecmaVersion": 2022 - }, - "ignorePatterns": ["**/dist/**", "**/*.d.ts"], - "settings": { - "import/resolver": { - "typescript": {} - }, - "boundaries/elements": [ - { - "type": "website", - "pattern": "apps/website/**/*" - }, - { - "type": "api", - "pattern": "apps/api/**/*" - }, - { - "type": "adapters", - "pattern": ["adapters/**/*", "@adapters/**/*"] - }, - { - "type": "core", - "pattern": ["core/**/*", "@core/**/*"] - } - ] - }, + "ignorePatterns": [ + "**/dist/**", + "**/*.d.ts" + ], "overrides": [ { - "files": ["**/index.ts", "**/index.tsx"], + "files": [ + "**/index.ts", + "**/index.tsx" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "Program", - "message": "index.ts files are forbidden. Use explicit file names instead (e.g., UserService.ts, not index.ts)." + "message": "index.ts files are forbidden. Use explicit file names instead (e.g., UserService.ts, not index.ts).", + "selector": "Program" } ] } }, { - "files": ["core/*/application/ports/*/*.ts"], + "files": [ + "core/*/application/ports/*/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSInterfaceDeclaration[id.name=/^Get.*Port$/]", - "message": "Port interface names should not start with 'Get'. Use descriptive names without the 'Get' prefix." + "message": "Port interface names should not start with 'Get'. Use descriptive names without the 'Get' prefix.", + "selector": "TSInterfaceDeclaration[id.name=/^Get.*Port$/]" } ] } }, { - "files": ["core/**/*.ts"], + "files": [ + "core/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSClassDeclaration[id.name=/Blocker$/], TSInterfaceDeclaration[id.name=/Blocker$/]", - "message": "Blocker classes/interfaces are not allowed in core. Use Guards in backend." + "message": "Blocker classes/interfaces are not allowed in core. Use Guards in backend.", + "selector": "TSClassDeclaration[id.name=/Blocker$/], TSInterfaceDeclaration[id.name=/Blocker$/]" }, { - "selector": "TSClassDeclaration[id.name=/Presenter$/], TSInterfaceDeclaration[id.name=/Presenter$/]", - "message": "Presenter classes/interfaces are not allowed in core. Presenters belong in API or frontend layers." + "message": "Presenter classes/interfaces are not allowed in core. Presenters belong in API or frontend layers.", + "selector": "TSClassDeclaration[id.name=/Presenter$/], TSInterfaceDeclaration[id.name=/Presenter$/]" }, { - "selector": "TSClassDeclaration[id.name=/Dto$/], TSInterfaceDeclaration[id.name=/Dto$/]", - "message": "DTO classes/interfaces are not allowed in core. DTOs belong in API or frontend layers." + "message": "DTO classes/interfaces are not allowed in core. DTOs belong in API or frontend layers.", + "selector": "TSClassDeclaration[id.name=/Dto$/], TSInterfaceDeclaration[id.name=/Dto$/]" }, { - "selector": "TSClassDeclaration[id.name=/ViewModel$/], TSInterfaceDeclaration[id.name=/ViewModel$/]", - "message": "ViewModel classes/interfaces are not allowed in core. View Models belong in frontend." + "message": "ViewModel classes/interfaces are not allowed in core. View Models belong in frontend.", + "selector": "TSClassDeclaration[id.name=/ViewModel$/], TSInterfaceDeclaration[id.name=/ViewModel$/]" }, { - "selector": "TSClassDeclaration[id.name=/CommandModel$/], TSInterfaceDeclaration[id.name=/CommandModel$/]", - "message": "CommandModel classes/interfaces are not allowed in core. Command Models belong in frontend." + "message": "CommandModel classes/interfaces are not allowed in core. Command Models belong in frontend.", + "selector": "TSClassDeclaration[id.name=/CommandModel$/], TSInterfaceDeclaration[id.name=/CommandModel$/]" } ] } }, { - "files": ["core/**/application/dto/**/*.ts", "core/**/application/dtos/**/*.ts"], + "files": [ + "core/**/application/dto/**/*.ts", + "core/**/application/dtos/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "Program", - "message": "core/*/application/dto is forbidden. Use application result models + output ports; DTOs belong in API/website layers." + "message": "core/*/application/dto is forbidden. Use application result models + output ports; DTOs belong in API/website layers.", + "selector": "Program" } ] } }, { - "files": ["core/**/infrastructure/**/*.ts"], + "files": [ + "core/**/infrastructure/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "Program", - "message": "core/*/infrastructure is forbidden. Implementations must live in adapters/ and be wired in apps/." + "message": "core/*/infrastructure is forbidden. Implementations must live in adapters/ and be wired in apps/.", + "selector": "Program" } ] } }, { - "files": ["core/**/domain/ports/**/*.ts"], + "files": [ + "core/**/domain/ports/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "Program", - "message": "core/*/domain/ports is forbidden. Ports belong in application/ports (or shared application layer), not domain." + "message": "core/*/domain/ports is forbidden. Ports belong in application/ports (or shared application layer), not domain.", + "selector": "Program" } ] } }, { - "files": ["core/**/shared/presentation/**/*.ts"], + "files": [ + "core/**/shared/presentation/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "Program", - "message": "core/shared/presentation is forbidden. Presentation belongs in API or website layers." + "message": "core/shared/presentation is forbidden. Presentation belongs in API or website layers.", + "selector": "Program" } ] } }, { - "files": ["apps/website/**/*.ts"], + "files": [ + "apps/website/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSClassDeclaration[id.name=/Guard$/], TSInterfaceDeclaration[id.name=/Guard$/]", - "message": "Guard classes/interfaces are not allowed in frontend. Use Blockers in frontend." + "message": "Guard classes/interfaces are not allowed in frontend. Use Blockers in frontend.", + "selector": "TSClassDeclaration[id.name=/Guard$/], TSInterfaceDeclaration[id.name=/Guard$/]" } ] } }, { - "files": ["apps/api/**/*.ts", "apps/website/lib/dtos/**/*.ts"], + "files": [ + "apps/api/**/*.ts", + "apps/website/lib/dtos/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSEnumDeclaration[id.name=/^(?!.*Enum$).+/]", - "message": "Transport enums must end with 'Enum'." + "message": "Transport enums must end with 'Enum'.", + "selector": "TSEnumDeclaration[id.name=/^(?!.*Enum$).+/]" } ] } }, { - "files": ["core/*/application/use-cases/*.ts"], + "files": [ + "core/*/application/use-cases/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSClassDeclaration[id.name=/^(?!.*UseCase$).+/]", - "message": "Use Case classes must end with 'UseCase'." + "message": "Use Case classes must end with 'UseCase'.", + "selector": "TSClassDeclaration[id.name=/^(?!.*UseCase$).+/]" } ] } }, { - "files": ["core/*/application/services/*.ts"], + "files": [ + "core/*/application/services/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSClassDeclaration[id.name=/^(?!.*Service$).+/]", - "message": "Application Service classes must end with 'Service'." + "message": "Application Service classes must end with 'Service'.", + "selector": "TSClassDeclaration[id.name=/^(?!.*Service$).+/]" } ] } }, { - "files": ["apps/website/lib/view-models/*.ts"], + "files": [ + "apps/website/lib/view-models/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSClassDeclaration[id.name=/^(?!.*ViewModel$).+/]", - "message": "View Model classes must end with 'ViewModel'." + "message": "View Model classes must end with 'ViewModel'.", + "selector": "TSClassDeclaration[id.name=/^(?!.*ViewModel$).+/]" } ] } }, { - "files": ["apps/website/lib/commands/*.ts"], + "files": [ + "apps/website/lib/commands/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "TSClassDeclaration[id.name=/^(?!.*CommandModel$).+/]", - "message": "Command Model classes must end with 'CommandModel'." + "message": "Command Model classes must end with 'CommandModel'.", + "selector": "TSClassDeclaration[id.name=/^(?!.*CommandModel$).+/]" } ] } }, { - "files": ["apps/website/app/**/page.tsx", "apps/website/app/**/page.ts", "apps/website/app/**/layout.tsx", "apps/website/app/**/layout.ts"], + "files": [ + "apps/website/app/**/page.tsx", + "apps/website/app/**/page.ts", + "apps/website/app/**/layout.tsx", + "apps/website/app/**/layout.ts" + ], "rules": { "import/no-default-export": "off", "no-restricted-syntax": [ "error", { - "selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]", - "message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor')." + "message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').", + "selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]" } ] } }, { - "files": ["**/*.ts", "**/*.tsx"], + "extends": [ + "plugin:import/recommended", + "plugin:import/typescript" + ], + "files": [ + "**/*.ts", + "**/*.tsx" + ], "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint", "boundaries", "import"], - "extends": ["plugin:import/recommended", "plugin:import/typescript"], + "plugins": [ + "@typescript-eslint", + "boundaries", + "import" + ], "rules": { "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-unused-vars": "error", @@ -231,20 +251,39 @@ "default": "disallow", "rules": [ { - "from": ["website"], - "allow": ["website"] + "allow": [ + "website" + ], + "from": [ + "website" + ] }, { - "from": ["api"], - "allow": ["api", "adapters", "core"] + "allow": [ + "api", + "adapters", + "core" + ], + "from": [ + "api" + ] }, { - "from": ["adapters"], - "allow": ["adapters", "core"] + "allow": [ + "adapters", + "core" + ], + "from": [ + "adapters" + ] }, { - "from": ["core"], - "allow": ["core"] + "allow": [ + "core" + ], + "from": [ + "core" + ] } ] } @@ -254,171 +293,240 @@ "no-restricted-syntax": [ "error", { - "selector": "ExportDefaultDeclaration", - "message": "Default exports are forbidden. Use named exports instead." + "message": "Default exports are forbidden. Use named exports instead.", + "selector": "ExportDefaultDeclaration" }, { - "selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]", - "message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor')." + "message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').", + "selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]" } ] } }, { - "files": ["core/**/*.ts"], + "files": [ + "core/**/*.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "selector": "ExportDefaultDeclaration", - "message": "Default exports are forbidden. Use named exports instead." + "message": "Default exports are forbidden. Use named exports instead.", + "selector": "ExportDefaultDeclaration" } ] } }, { - "files": ["apps/website/**/*.tsx", "apps/website/**/*.ts"], + "files": [ + "apps/website/**/*.tsx", + "apps/website/**/*.ts" + ], "rules": { - "no-restricted-syntax": [ - "error", - { - "selector": "TSInterfaceDeclaration[id.name=/ViewModel$/], TSTypeAliasDeclaration[id.name=/ViewModel$/], TSClassDeclaration[id.name=/ViewModel$/]", - "message": "ViewModel types must be defined in apps/website/lib/view-models, not in components." - }, - { - "selector": "TSInterfaceDeclaration[id.name=/DTO$/], TSTypeAliasDeclaration[id.name=/DTO$/], TSClassDeclaration[id.name=/DTO$/]", - "message": "DTO types are forbidden in website components. Use ViewModels instead." - } - ], "no-restricted-imports": [ "error", { "paths": [ { - "name": "@core/racing", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/racing" }, { - "name": "@core/analytics", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/analytics" }, { - "name": "@core/identity", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/identity" }, { - "name": "@core/media", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/media" }, { - "name": "@core/notifications", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/notifications" }, { - "name": "@core/payments", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/payments" }, { - "name": "@core/shared", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/shared" }, { - "name": "@core/social", - "message": "Imports from @core are forbidden in website components" + "message": "Imports from @core are forbidden in website components", + "name": "@core/social" }, { - "name": "@adapters", - "message": "Imports from @adapters are forbidden in website components" + "message": "Imports from @adapters are forbidden in website components", + "name": "@adapters" }, { - "name": "@api", - "message": "Imports from @api are forbidden in website components" + "message": "Imports from @api are forbidden in website components", + "name": "@api" } ], "patterns": [ { - "group": ["@core/*"], + "group": [ + "@core/*" + ], "message": "Imports from @core are forbidden in website components" }, { - "group": ["@adapters/*"], + "group": [ + "@adapters/*" + ], "message": "Imports from @adapters are forbidden in website components" }, { - "group": ["@api/*"], + "group": [ + "@api/*" + ], "message": "Imports from @api are forbidden in website components" } ] } + ], + "no-restricted-syntax": [ + "error", + { + "message": "ViewModel types must be defined in apps/website/lib/view-models, not in components.", + "selector": "TSInterfaceDeclaration[id.name=/ViewModel$/], TSTypeAliasDeclaration[id.name=/ViewModel$/], TSClassDeclaration[id.name=/ViewModel$/]" + }, + { + "message": "DTO types are forbidden in website components. Use ViewModels instead.", + "selector": "TSInterfaceDeclaration[id.name=/DTO$/], TSTypeAliasDeclaration[id.name=/DTO$/], TSClassDeclaration[id.name=/DTO$/]" + } + ] + } + }, + { + "files": [ + "apps/api/**/*.test.ts", + "apps/api/**/*.test.tsx" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "error", + "no-restricted-syntax": "error" + } + }, + { + "files": [ + "tests/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "message": "Integration tests must use in-memory adapters, not core directly", + "name": "@core/*" + }, + { + "message": "Integration tests must use in-memory adapters only", + "name": "@adapters/*" + } + ] + } ] } }, { - "files": ["apps/api/**/*.test.ts", "apps/api/**/*.test.tsx"], + "files": [ + "tests/e2e/**/*.ts" + ], "rules": { - "@typescript-eslint/no-explicit-any": "off", - "no-restricted-syntax": "off" + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/inmemory/**" + ], + "message": "E2E tests must use TypeORM/PostgreSQL, not in-memory adapters" + } + ] + } + ] } }, { - "files": ["tests/**/*.ts"], + "files": [ + "core/**/*.ts" + ], "rules": { - "no-restricted-imports": ["error", { - "paths": [ - { - "name": "@core/*", - "message": "Integration tests must use in-memory adapters, not core directly" - }, - { - "name": "@adapters/*", - "message": "Integration tests must use in-memory adapters only" - } - ] - }] + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "message": "Use @testing/* from adapters/testing", + "name": "testing" + }, + { + "message": "Core layer should not depend on testing utilities", + "name": "@testing/*" + } + ] + } + ] } }, { - "files": ["tests/e2e/**/*.ts"], + "files": [ + "adapters/**/*.ts" + ], "rules": { - "no-restricted-imports": ["error", { - "patterns": [ - { - "group": ["**/inmemory/**"], - "message": "E2E tests must use TypeORM/PostgreSQL, not in-memory adapters" - } - ] - }] - } - }, - { - "files": ["core/**/*.ts"], - "rules": { - "no-restricted-imports": ["error", { - "paths": [ - { - "name": "testing", - "message": "Use @testing/* from adapters/testing" - }, - { - "name": "@testing/*", - "message": "Core layer should not depend on testing utilities" - } - ] - }] - } - }, - { - "files": ["adapters/**/*.ts"], - "rules": { - "no-restricted-imports": ["error", { - "paths": [ - { - "name": "testing", - "message": "Use @testing/* from adapters/testing" - } - ] - }] + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "message": "Use @testing/* from adapters/testing", + "name": "testing" + } + ] + } + ] } } - ] + ], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "settings": { + "boundaries/elements": [ + { + "pattern": "apps/website/**/*", + "type": "website" + }, + { + "pattern": "apps/api/**/*", + "type": "api" + }, + { + "pattern": [ + "adapters/**/*", + "@adapters/**/*" + ], + "type": "adapters" + }, + { + "pattern": [ + "core/**/*", + "@core/**/*" + ], + "type": "core" + } + ], + "import/resolver": { + "typescript": {} + } + } } \ No newline at end of file diff --git a/apps/website/.eslintrc.json b/apps/website/.eslintrc.json index 2b5d9b54a..e50a1a950 100644 --- a/apps/website/.eslintrc.json +++ b/apps/website/.eslintrc.json @@ -1,44 +1,14 @@ { - "root": true, - "ignorePatterns": ["lib/types/generated/**", "**/*.test.ts", "**/*.test.tsx"], - "extends": ["next/core-web-vitals", "plugin:import/recommended", "plugin:import/typescript"], - "plugins": ["boundaries", "import", "@typescript-eslint", "unused-imports"], - "settings": { - "import/resolver": { - "typescript": {} - }, - "boundaries/elements": [ - { - "type": "website", - "pattern": ["**/*"] - } - ] - }, - "rules": { - "react/no-unescaped-entities": "off", - "@next/next/no-img-element": "off", - "react-hooks/exhaustive-deps": "off", - "react-hooks/rules-of-hooks": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": "off", - "import/no-default-export": "off", - "import/no-named-as-default-member": "off", - "no-restricted-syntax": "off", - "boundaries/element-types": [ - 2, - { - "default": "disallow", - "rules": [ - { - "from": ["website"], - "allow": ["website"] - } - ] - } - ], - "unused-imports/no-unused-imports": "off", - "unused-imports/no-unused-vars": "off" - }, + "extends": [ + "next/core-web-vitals", + "plugin:import/recommended", + "plugin:import/typescript" + ], + "ignorePatterns": [ + "lib/types/generated/**", + "**/*.test.ts", + "**/*.test.tsx" + ], "overrides": [ { "files": [ @@ -56,5 +26,54 @@ "no-restricted-syntax": "off" } } - ] + ], + "plugins": [ + "boundaries", + "import", + "@typescript-eslint", + "unused-imports" + ], + "root": true, + "rules": { + "@next/next/no-img-element": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": "error", + "boundaries/element-types": [ + 2, + { + "default": "disallow", + "rules": [ + { + "allow": [ + "website" + ], + "from": [ + "website" + ] + } + ] + } + ], + "import/no-default-export": "error", + "import/no-named-as-default-member": "error", + "no-restricted-syntax": "error", + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error", + "react/no-unescaped-entities": "error", + "unused-imports/no-unused-imports": "off", + "unused-imports/no-unused-vars": "off" + }, + "settings": { + "boundaries/elements": [ + { + "pattern": [ + "**/*" + ], + "type": "website" + } + ], + "import/resolver": { + "typescript": {} + } + } } \ No newline at end of file diff --git a/apps/website/app/actions/logoutAction.ts b/apps/website/app/actions/logoutAction.ts new file mode 100644 index 000000000..867c4a0b8 --- /dev/null +++ b/apps/website/app/actions/logoutAction.ts @@ -0,0 +1,42 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { AuthApiClient } from '@/lib/api/auth/AuthApiClient'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; + +/** + * Server action for logout + * + * Performs the logout mutation by calling the API and redirects to login. + * Follows the write boundary contract: all writes enter through server actions. + */ +export async function logoutAction(): Promise { + try { + // Create required dependencies for API client + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: false, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + // Get API base URL from environment + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + + // Create API client instance + const apiClient = new AuthApiClient(baseUrl, errorReporter, logger); + + // Call the logout API endpoint + await apiClient.logout(); + + // Redirect to login page after successful logout + redirect('/auth/login'); + } catch (error) { + // Log error for debugging + console.error('Logout action failed:', error); + + // Still redirect even if logout fails - user should be able to leave + redirect('/auth/login'); + } +} \ No newline at end of file diff --git a/apps/website/app/auth/forgot-password/page.tsx b/apps/website/app/auth/forgot-password/page.tsx index 89666c6e5..36072a662 100644 --- a/apps/website/app/auth/forgot-password/page.tsx +++ b/apps/website/app/auth/forgot-password/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, FormEvent, type ChangeEvent } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth/AuthContext'; -import { useForgotPassword } from '@/hooks/auth/useForgotPassword'; +import { useForgotPassword } from "@/lib/hooks/auth/useForgotPassword"; import Link from 'next/link'; import { motion } from 'framer-motion'; import { diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx index 8f9627675..302b714b2 100644 --- a/apps/website/app/auth/login/page.tsx +++ b/apps/website/app/auth/login/page.tsx @@ -20,7 +20,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import { useAuth } from '@/lib/auth/AuthContext'; -import { useLogin } from '@/hooks/auth/useLogin'; +import { useLogin } from "@/lib/hooks/auth/useLogin"; import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup'; import UserRolesPreview from '@/components/auth/UserRolesPreview'; import { EnhancedFormError } from '@/components/errors/EnhancedFormError'; diff --git a/apps/website/app/auth/reset-password/page.tsx b/apps/website/app/auth/reset-password/page.tsx index 8b1579efe..79f811ba0 100644 --- a/apps/website/app/auth/reset-password/page.tsx +++ b/apps/website/app/auth/reset-password/page.tsx @@ -20,7 +20,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import { useAuth } from '@/lib/auth/AuthContext'; -import { useResetPassword } from '@/hooks/auth/useResetPassword'; +import { useResetPassword } from "@/lib/hooks/auth/useResetPassword"; interface FormErrors { newPassword?: string; diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx index 7c0099c74..b1000560a 100644 --- a/apps/website/app/auth/signup/page.tsx +++ b/apps/website/app/auth/signup/page.tsx @@ -27,7 +27,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import { useAuth } from '@/lib/auth/AuthContext'; -import { useSignup } from '@/hooks/auth/useSignup'; +import { useSignup } from "@/lib/hooks/auth/useSignup"; import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; interface FormErrors { diff --git a/apps/website/app/dashboard/DashboardPageClient.tsx b/apps/website/app/dashboard/DashboardPageClient.tsx index b7e730f67..02e53d315 100644 --- a/apps/website/app/dashboard/DashboardPageClient.tsx +++ b/apps/website/app/dashboard/DashboardPageClient.tsx @@ -1,95 +1,23 @@ 'use client'; -import React, { useState, useEffect } from 'react'; -import type { DashboardViewData } from './DashboardViewData'; -import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData'; -import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; +import React from 'react'; +import type { DashboardViewData } from '@/templates/view-data/DashboardViewData'; +import type { DashboardPageDto } from '@/lib/page-queries/page-dtos/DashboardPageDto'; +import { DashboardPresenter } from '@/lib/presenters/DashboardPresenter'; import { DashboardTemplate } from '@/templates/DashboardTemplate'; interface DashboardPageClientProps { - initialViewData: DashboardViewData; - dto: DashboardOverviewViewModelData; + pageDto: DashboardPageDto; } /** * Dashboard Page Client Component - * - * Two-phase render: - * 1. Initial SSR: Uses ViewData built directly from DTO (no ViewModel) - * 2. Post-hydration: Instantiates ViewModel and re-renders with enhanced data + * + * Uses Presenter to transform Page DTO into ViewData + * Presenter is deterministic and side-effect free */ -export function DashboardPageClient({ initialViewData, dto }: DashboardPageClientProps) { - const [viewData, setViewData] = useState(initialViewData); - - useEffect(() => { - // Phase 2: After hydration, instantiate ViewModel and enhance data - const viewModel = new DashboardOverviewViewModel(dto); - - const enhancedViewData: DashboardViewData = { - currentDriver: { - name: viewModel.currentDriverName, - avatarUrl: viewModel.currentDriverAvatarUrl, - country: viewModel.currentDriverCountry, - rating: viewModel.currentDriverRating, - rank: viewModel.currentDriverRank, - totalRaces: viewModel.currentDriverTotalRaces, - wins: viewModel.currentDriverWins, - podiums: viewModel.currentDriverPodiums, - consistency: viewModel.currentDriverConsistency, - }, - nextRace: viewModel.nextRace ? { - id: viewModel.nextRace.id, - track: viewModel.nextRace.track, - car: viewModel.nextRace.car, - scheduledAt: viewModel.nextRace.scheduledAt, - formattedDate: viewModel.nextRace.formattedDate, - formattedTime: viewModel.nextRace.formattedTime, - timeUntil: viewModel.nextRace.timeUntil, - isMyLeague: viewModel.nextRace.isMyLeague, - } : null, - upcomingRaces: viewModel.upcomingRaces.map((race) => ({ - id: race.id, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - formattedDate: race.formattedDate, - formattedTime: race.formattedTime, - timeUntil: race.timeUntil, - isMyLeague: race.isMyLeague, - })), - leagueStandings: viewModel.leagueStandings.map((standing) => ({ - leagueId: standing.leagueId, - leagueName: standing.leagueName, - position: standing.position, - points: standing.points, - totalDrivers: standing.totalDrivers, - })), - feedItems: viewModel.feedItems.map((item) => ({ - id: item.id, - type: item.type, - headline: item.headline, - body: item.body, - timestamp: item.timestamp, - formattedTime: item.formattedTime, - ctaHref: item.ctaHref, - ctaLabel: item.ctaLabel, - })), - friends: viewModel.friends.map((friend) => ({ - id: friend.id, - name: friend.name, - avatarUrl: friend.avatarUrl, - country: friend.country, - })), - activeLeaguesCount: viewModel.activeLeaguesCount, - friendCount: viewModel.friendCount, - hasUpcomingRaces: viewModel.hasUpcomingRaces, - hasLeagueStandings: viewModel.hasLeagueStandings, - hasFeedItems: viewModel.hasFeedItems, - hasFriends: viewModel.hasFriends, - }; - - setViewData(enhancedViewData); - }, [dto]); +export function DashboardPageClient({ pageDto }: DashboardPageClientProps) { + const viewData: DashboardViewData = DashboardPresenter.createViewData(pageDto); return ; -} \ No newline at end of file +} diff --git a/apps/website/app/dashboard/DashboardViewDataBuilder.ts b/apps/website/app/dashboard/DashboardViewDataBuilder.ts deleted file mode 100644 index 7125e111f..000000000 --- a/apps/website/app/dashboard/DashboardViewDataBuilder.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData'; -import type { DashboardViewData } from './DashboardViewData'; -import { - formatDashboardDate, - formatRating, - formatRank, - formatConsistency, - formatRaceCount, - formatFriendCount, - formatLeaguePosition, - formatPoints, - formatTotalDrivers, -} from '@/lib/display-objects/DashboardDisplay'; - -/** - * Build DashboardViewData directly from ViewModelData - * Used for SSR phase - no ViewModel instantiation - */ -export function buildDashboardViewData(viewModelData: DashboardOverviewViewModelData): DashboardViewData { - return { - currentDriver: { - name: viewModelData.currentDriver?.name || '', - avatarUrl: viewModelData.currentDriver?.avatarUrl || '', - country: viewModelData.currentDriver?.country || '', - rating: viewModelData.currentDriver ? formatRating(viewModelData.currentDriver.rating) : '0.0', - rank: viewModelData.currentDriver ? formatRank(viewModelData.currentDriver.globalRank) : '0', - totalRaces: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.totalRaces) : '0', - wins: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.wins) : '0', - podiums: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.podiums) : '0', - consistency: viewModelData.currentDriver ? formatConsistency(viewModelData.currentDriver.consistency) : '0%', - }, - nextRace: viewModelData.nextRace ? (() => { - const dateInfo = formatDashboardDate(new Date(viewModelData.nextRace.scheduledAt)); - return { - id: viewModelData.nextRace.id, - track: viewModelData.nextRace.track, - car: viewModelData.nextRace.car, - scheduledAt: viewModelData.nextRace.scheduledAt, - formattedDate: dateInfo.date, - formattedTime: dateInfo.time, - timeUntil: dateInfo.relative, - isMyLeague: viewModelData.nextRace.isMyLeague, - }; - })() : null, - upcomingRaces: viewModelData.upcomingRaces.map((race) => { - const dateInfo = formatDashboardDate(new Date(race.scheduledAt)); - return { - id: race.id, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - formattedDate: dateInfo.date, - formattedTime: dateInfo.time, - timeUntil: dateInfo.relative, - isMyLeague: race.isMyLeague, - }; - }), - leagueStandings: viewModelData.leagueStandingsSummaries.map((standing) => ({ - leagueId: standing.leagueId, - leagueName: standing.leagueName, - position: formatLeaguePosition(standing.position), - points: formatPoints(standing.points), - totalDrivers: formatTotalDrivers(standing.totalDrivers), - })), - feedItems: viewModelData.feedSummary.items.map((item) => ({ - id: item.id, - type: item.type, - headline: item.headline, - body: item.body, - timestamp: item.timestamp, - formattedTime: formatDashboardDate(new Date(item.timestamp)).relative, - ctaHref: item.ctaHref, - ctaLabel: item.ctaLabel, - })), - friends: viewModelData.friends.map((friend) => ({ - id: friend.id, - name: friend.name, - avatarUrl: friend.avatarUrl, - country: friend.country, - })), - activeLeaguesCount: formatRaceCount(viewModelData.activeLeaguesCount), - friendCount: formatFriendCount(viewModelData.friends.length), - hasUpcomingRaces: viewModelData.upcomingRaces.length > 0, - hasLeagueStandings: viewModelData.leagueStandingsSummaries.length > 0, - hasFeedItems: viewModelData.feedSummary.items.length > 0, - hasFriends: viewModelData.friends.length > 0, - }; -} \ No newline at end of file diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 42e5b8666..0f3bda3a0 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -1,7 +1,6 @@ import { notFound, redirect } from 'next/navigation'; -import { DashboardPageQuery } from '@/lib/page-queries/DashboardPageQuery'; +import { DashboardPageQuery } from '@/lib/page-queries/page-queries/DashboardPageQuery'; import { DashboardPageClient } from './DashboardPageClient'; -import { buildDashboardViewData } from './DashboardViewDataBuilder'; export default async function DashboardPage() { const result = await DashboardPageQuery.execute(); @@ -9,23 +8,18 @@ export default async function DashboardPage() { // Handle result based on status switch (result.status) { case 'ok': - const viewModelData = result.data; - - // Build SSR ViewData directly from ViewModelData - const ssrViewData = buildDashboardViewData(viewModelData); - - // Pass both ViewData (for SSR) and ViewModelData (for client enhancement) - return ; + // Pass Page DTO to client component + return ; case 'notFound': notFound(); case 'redirect': - redirect(result.destination); + redirect(result.to); case 'error': // For now, treat as notFound. Could also show error page - console.error('Dashboard error:', result.error); + console.error('Dashboard error:', result.errorId); notFound(); } -} \ No newline at end of file +} diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index 67def8797..bd486f611 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -2,8 +2,8 @@ import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate'; -import { useDriverProfilePageData } from '@/hooks/driver/useDriverProfilePageData'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useDriverProfilePageData } from "@/lib/hooks/driver/useDriverProfilePageData"; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useParams, useRouter } from 'next/navigation'; import { useState } from 'react'; diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx index 94a6819fe..d7110fcdb 100644 --- a/apps/website/app/leaderboards/drivers/page.tsx +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -19,9 +19,9 @@ export default async function DriverLeaderboardPage() { ); // Prepare data for template - const data: DriverLeaderboardViewModel | null = driverData as DriverLeaderboardViewModel | null; + const data: DriverLeaderboardViewModel | null = driverData; - const hasData = (driverData as any)?.drivers?.length > 0; + const hasData = (driverData?.drivers?.length ?? 0) > 0; // Handle loading state (should be fast since we're using async/await) const isLoading = false; diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx index 8ca78249e..6a3571859 100644 --- a/apps/website/app/leaderboards/page.tsx +++ b/apps/website/app/leaderboards/page.tsx @@ -37,11 +37,11 @@ export default async function LeaderboardsPage() { // Prepare data for template const data: LeaderboardsPageData = { - drivers: driverData as DriverLeaderboardViewModel | null, - teams: teamsData as TeamSummaryViewModel[] | null, + drivers: driverData, + teams: teamsData, }; - const hasData = (driverData as any)?.drivers?.length > 0 || (teamsData as any)?.length > 0; + const hasData = (driverData?.drivers?.length ?? 0) > 0 || (teamsData?.length ?? 0) > 0; // Handle loading state (should be fast since we're using async/await) const isLoading = false; diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index acd0d3380..2475fc7d4 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -2,8 +2,8 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs'; import LeagueHeader from '@/components/leagues/LeagueHeader'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useLeagueDetail } from '@/hooks/league/useLeagueDetail'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; +import { useLeagueDetail } from "@/lib/hooks/league/useLeagueDetail"; import { useParams, usePathname, useRouter } from 'next/navigation'; import React from 'react'; diff --git a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx index e9c6bf8ec..008eab81b 100644 --- a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx +++ b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx @@ -11,7 +11,7 @@ import { useRejectJoinRequest, useUpdateMemberRole, useRemoveMember, -} from '@/hooks/league/useLeagueRosterAdmin'; +} from "@/lib/hooks/league/useLeagueRosterAdmin"; const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member']; diff --git a/apps/website/app/leagues/[id]/schedule/admin/page.tsx b/apps/website/app/leagues/[id]/schedule/admin/page.tsx index 54f76ec7a..47b9c0cd5 100644 --- a/apps/website/app/leagues/[id]/schedule/admin/page.tsx +++ b/apps/website/app/leagues/[id]/schedule/admin/page.tsx @@ -8,8 +8,8 @@ import { useLeagueAdminStatus, useLeagueSeasons, useLeagueAdminSchedule -} from '@/hooks/league/useLeagueScheduleAdminPageData'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +} from "@/lib/hooks/league/useLeagueScheduleAdminPageData"; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useInject } from '@/lib/di/hooks/useInject'; import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; diff --git a/apps/website/app/leagues/[id]/schedule/page.tsx b/apps/website/app/leagues/[id]/schedule/page.tsx index f236dad31..9ea007522 100644 --- a/apps/website/app/leagues/[id]/schedule/page.tsx +++ b/apps/website/app/leagues/[id]/schedule/page.tsx @@ -10,12 +10,39 @@ import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporte import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { notFound } from 'next/navigation'; -import type { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; +import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; +import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO'; +import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; interface Props { params: { id: string }; } +function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel { + const scheduledAt = race.date ? new Date(race.date) : new Date(0); + const now = new Date(); + const isPast = scheduledAt.getTime() < now.getTime(); + const isUpcoming = !isPast; + + return { + id: race.id, + name: race.name, + scheduledAt, + isPast, + isUpcoming, + status: isPast ? 'completed' : 'scheduled', + track: undefined, + car: undefined, + sessionType: undefined, + isRegistered: undefined, + }; +} + +function mapScheduleDtoToViewModel(dto: LeagueScheduleDTO): LeagueScheduleViewModel { + const races = dto.races.map(mapRaceDtoToViewModel); + return new LeagueScheduleViewModel(races); +} + export default async function Page({ params }: Props) { // Validate params if (!params.id) { @@ -52,7 +79,7 @@ export default async function Page({ params }: Props) { if (!result) { throw new Error('League schedule not found'); } - return result; + return mapScheduleDtoToViewModel(result); }); if (!data) { diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index c645a4e87..9e5534ae4 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -3,14 +3,14 @@ import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo'; import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer'; import Card from '@/components/ui/Card'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useParams, useRouter } from 'next/navigation'; // Shared state components import { StateContainer } from '@/components/shared/state/StateContainer'; import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; -import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus'; -import { useLeagueSettings } from '@/hooks/league/useLeagueSettings'; +import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus"; +import { useLeagueSettings } from "@/lib/hooks/league/useLeagueSettings"; import { useInject } from '@/lib/di/hooks/useInject'; import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens'; import { AlertTriangle, Settings } from 'lucide-react'; diff --git a/apps/website/app/leagues/[id]/sponsorships/page.tsx b/apps/website/app/leagues/[id]/sponsorships/page.tsx index 8f05307bb..0c9e3af1d 100644 --- a/apps/website/app/leagues/[id]/sponsorships/page.tsx +++ b/apps/website/app/leagues/[id]/sponsorships/page.tsx @@ -2,9 +2,9 @@ import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshipsSection'; import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; -import { useLeagueSponsorshipsPageData } from '@/hooks/league/useLeagueSponsorshipsPageData'; +import { useLeagueSponsorshipsPageData } from "@/lib/hooks/league/useLeagueSponsorshipsPageData"; import { ApiError } from '@/lib/api/base/ApiError'; import { Building } from 'lucide-react'; import { useParams } from 'next/navigation'; diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index cc2c4d2bb..aaccf838d 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -10,9 +10,11 @@ import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporte import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { notFound } from 'next/navigation'; -import type { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel'; +import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import type { LeagueMembership } from '@/lib/types/LeagueMembership'; +import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO'; +import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; interface Props { params: { id: string }; @@ -49,45 +51,72 @@ export default async function Page({ params }: Props) { racesApiClient ); - // Fetch data - using empty string for currentDriverId since this is SSR without session - const result = await service.getLeagueStandings(params.id, ''); - if (!result) { + // Fetch data + const standingsDto = await service.getLeagueStandings(params.id); + if (!standingsDto) { throw new Error('League standings not found'); } - return result; + + // Get memberships for transformation + const membershipsDto = await service.getLeagueMemberships(params.id); + + // Transform standings to StandingEntryViewModel[] + const standings: LeagueStandingDTO[] = standingsDto.standings || []; + const leaderPoints = standings[0]?.points || 0; + const standingViewModels = standings.map((entry, index) => { + const nextPoints = standings[index + 1]?.points || entry.points; + return new StandingEntryViewModel(entry, leaderPoints, nextPoints, '', undefined); + }); + + // Extract unique drivers from standings and convert to DriverViewModel[] + const driverMap = new Map(); + standings.forEach(standing => { + if (standing.driver && !driverMap.has(standing.driver.id)) { + const driver = standing.driver; + driverMap.set(driver.id, new DriverViewModel({ + id: driver.id, + name: driver.name, + avatarUrl: null, // DriverDTO doesn't have avatarUrl + iracingId: driver.iracingId, + rating: undefined, // DriverDTO doesn't have rating + country: driver.country, + })); + } + }); + const drivers = Array.from(driverMap.values()); + + // Transform memberships + const memberships: LeagueMembership[] = (membershipsDto.members || []).map((m: LeagueMemberDTO) => ({ + driverId: m.driverId, + leagueId: params.id, + role: (m.role as LeagueMembership['role']) ?? 'member', + joinedAt: m.joinedAt, + status: 'active' as const, + })); + + return { + standings: standingViewModels, + drivers, + memberships, + }; }); if (!data) { notFound(); } - // Transform data for template - const standings = data.standings ?? []; - const drivers: DriverViewModel[] = data.drivers?.map((d) => - new DriverViewModel({ - id: d.id, - name: d.name, - avatarUrl: d.avatarUrl || null, - iracingId: d.iracingId, - rating: d.rating, - country: d.country, - }) - ) ?? []; - const memberships: LeagueMembership[] = data.memberships ?? []; - // Create a wrapper component that passes data to the template const TemplateWrapper = () => { return ( {}} onUpdateRole={() => {}} - loading={false} /> ); }; diff --git a/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx b/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx index 2f0aed93b..a9ead2e83 100644 --- a/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx +++ b/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx @@ -6,7 +6,7 @@ import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; import StewardingStats from '@/components/leagues/StewardingStats'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; -import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations'; +import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations"; import { AlertCircle, AlertTriangle, diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx index 5d01efa3e..969aa53c7 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver'; -import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus'; -import { useLeagueStewardingData } from '@/hooks/league/useLeagueStewardingData'; -import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations'; +import { useCurrentDriver } from "@/lib/hooks/driver/useCurrentDriver"; +import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus"; +import { useLeagueStewardingData } from "@/lib/hooks/league/useLeagueStewardingData"; +import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations"; import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; import { StewardingTemplate } from './StewardingTemplate'; import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx index fd9938f92..5f1e0c754 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -2,7 +2,7 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { useInject } from '@/lib/di/hooks/useInject'; import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens'; @@ -38,8 +38,8 @@ import { useMemo, useState } from 'react'; // Shared state components import { StateContainer } from '@/components/shared/state/StateContainer'; import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; -import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus'; -import { useProtestDetail } from '@/hooks/league/useProtestDetail'; +import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus"; +import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail"; // Timeline event types interface TimelineEvent { diff --git a/apps/website/app/leagues/[id]/wallet/page.tsx b/apps/website/app/leagues/[id]/wallet/page.tsx index 86cc17f78..7e99c9d59 100644 --- a/apps/website/app/leagues/[id]/wallet/page.tsx +++ b/apps/website/app/leagues/[id]/wallet/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useParams } from 'next/navigation'; -import { useLeagueWalletPageData, useLeagueWalletWithdrawal } from '@/hooks/league/useLeagueWalletPageData'; +import { useLeagueWalletPageData, useLeagueWalletWithdrawal } from "@/lib/hooks/league/useLeagueWalletPageData"; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { WalletTemplate } from './WalletTemplate'; import { Wallet } from 'lucide-react'; diff --git a/apps/website/app/onboarding/page.tsx b/apps/website/app/onboarding/page.tsx index ba7346125..e642822c9 100644 --- a/apps/website/app/onboarding/page.tsx +++ b/apps/website/app/onboarding/page.tsx @@ -7,7 +7,7 @@ import OnboardingWizard from '@/components/onboarding/OnboardingWizard'; import { useAuth } from '@/lib/auth/AuthContext'; // Shared state components -import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver'; +import { useCurrentDriver } from "@/lib/hooks/driver/useCurrentDriver"; import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; // Template component that accepts data diff --git a/apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx b/apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx new file mode 100644 index 000000000..7f3b4692d --- /dev/null +++ b/apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx @@ -0,0 +1,17 @@ +'use client'; + +import type { ProfileLeaguesPageDto } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery'; +import { ProfileLeaguesPresenter } from '@/lib/presenters/ProfileLeaguesPresenter'; +import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate'; + +interface ProfileLeaguesPageClientProps { + pageDto: ProfileLeaguesPageDto; +} + +export function ProfileLeaguesPageClient({ pageDto }: ProfileLeaguesPageClientProps) { + // Convert Page DTO to ViewData using Presenter + const viewData = ProfileLeaguesPresenter.toViewData(pageDto); + + // Render Template with ViewData + return ; +} \ No newline at end of file diff --git a/apps/website/app/profile/leagues/page.tsx b/apps/website/app/profile/leagues/page.tsx index fecedfc13..36e7f33e9 100644 --- a/apps/website/app/profile/leagues/page.tsx +++ b/apps/website/app/profile/leagues/page.tsx @@ -1,205 +1,23 @@ import { notFound } from 'next/navigation'; -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; -import type { LeagueService } from '@/lib/services/leagues/LeagueService'; -import type { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService'; -import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; -import type { LeagueMembership } from '@/lib/types/LeagueMembership'; -import { SessionGateway } from '@/lib/gateways/SessionGateway'; -import { ContainerManager } from '@/lib/di/container'; - -interface LeagueWithRole { - league: LeagueSummaryViewModel; - membership: LeagueMembership; -} - -interface ProfileLeaguesData { - ownedLeagues: LeagueWithRole[]; - memberLeagues: LeagueWithRole[]; -} - -async function fetchProfileLeaguesData(): Promise { - try { - // Get current driver ID from session - const sessionGateway = new SessionGateway(); - const session = await sessionGateway.getSession(); - - if (!session?.user?.primaryDriverId) { - return null; - } - - const currentDriverId = session.user.primaryDriverId; - - // Fetch leagues using PageDataFetcher - const leagues = await PageDataFetcher.fetch( - LEAGUE_SERVICE_TOKEN, - 'getAllLeagues' - ); - - if (!leagues) { - return null; - } - - // Get membership service from container - const container = ContainerManager.getInstance().getContainer(); - const membershipService = container.get(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); - - // Fetch memberships for each league - const memberships = await Promise.all( - leagues.map(async (league) => { - await membershipService.fetchLeagueMemberships(league.id); - const membership = membershipService.getMembership(league.id, currentDriverId); - - return membership ? { league, membership } : null; - }) - ); - - // Filter and categorize leagues - const owned: LeagueWithRole[] = []; - const member: LeagueWithRole[] = []; - - for (const entry of memberships) { - if (!entry || !entry.membership || entry.membership.status !== 'active') { - continue; - } - - if (entry.membership.role === 'owner') { - owned.push(entry); - } else { - member.push(entry); - } - } - - return { ownedLeagues: owned, memberLeagues: member }; - } catch (error) { - console.error('Failed to fetch profile leagues data:', error); - return null; - } -} - -// Template component -function ProfileLeaguesTemplate({ data }: { data: ProfileLeaguesData }) { - return ( -
-
-

Manage leagues

-

- View leagues you own and participate in, and jump into league admin tools. -

-
- - {/* Leagues You Own */} -
-
-

Leagues you own

- {data.ownedLeagues.length > 0 && ( - - {data.ownedLeagues.length} {data.ownedLeagues.length === 1 ? 'league' : 'leagues'} - - )} -
- - {data.ownedLeagues.length === 0 ? ( -

- You don't own any leagues yet in this session. -

- ) : ( -
- {data.ownedLeagues.map(({ league }) => ( -
-
-

{league.name}

-

- {league.description} -

-
- -
- ))} -
- )} -
- - {/* Leagues You're In */} -
-
-

Leagues you're in

- {data.memberLeagues.length > 0 && ( - - {data.memberLeagues.length} {data.memberLeagues.length === 1 ? 'league' : 'leagues'} - - )} -
- - {data.memberLeagues.length === 0 ? ( -

- You're not a member of any other leagues yet. -

- ) : ( -
- {data.memberLeagues.map(({ league, membership }) => ( -
-
-

{league.name}

-

- {league.description} -

-

- Your role:{' '} - {membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} -

-
- - View league - -
- ))} -
- )} -
-
- ); -} +import { ProfileLeaguesPageQuery } from '@/lib/page-queries/ProfileLeaguesPageQuery'; +import { ProfileLeaguesPageClient } from './ProfileLeaguesPageClient'; export default async function ProfileLeaguesPage() { - const data = await fetchProfileLeaguesData(); + const result = await ProfileLeaguesPageQuery.execute(); - if (!data) { - notFound(); + switch (result.status) { + case 'notFound': + notFound(); + case 'redirect': + // Note: In Next.js, redirect would be imported from next/navigation + // For now, we'll handle this case by returning notFound + // In a full implementation, you'd use: redirect(result.to); + notFound(); + case 'error': + // For now, treat errors as notFound + // In a full implementation, you might render an error page + notFound(); + case 'ok': + return ; } - - return ( - - ); } \ No newline at end of file diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index 5c2a133bc..ee4d965a5 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -6,8 +6,8 @@ import ProfileSettings from '@/components/drivers/ProfileSettings'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Heading from '@/components/ui/Heading'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useDriverProfile } from '@/hooks/driver/useDriverProfile'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; +import { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile"; import { useInject } from '@/lib/di/hooks/useInject'; import { DRIVER_SERVICE_TOKEN, MEDIA_SERVICE_TOKEN } from '@/lib/di/tokens'; import type { diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx index 264f0ca4e..52725f319 100644 --- a/apps/website/app/profile/sponsorship-requests/page.tsx +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -5,8 +5,8 @@ import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemp import { useSponsorshipRequestsPageData, useSponsorshipRequestMutations -} from '@/hooks/sponsor/useSponsorshipRequestsPageData'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +} from "@/lib/hooks/sponsor/useSponsorshipRequestsPageData"; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; export default function SponsorshipRequestsPage() { const currentDriverId = useEffectiveDriverId(); diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index 2c64cf9e4..0d9cf857f 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -2,10 +2,10 @@ import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate'; -import { useRaceResultsPageData } from '@/hooks/race/useRaceResultsPageData'; +import { useRaceResultsPageData } from "@/lib/hooks/race/useRaceResultsPageData"; import { RaceResultsDataTransformer } from '@/lib/view-models/RaceResultsDataTransformer'; -import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useLeagueMemberships } from "@/lib/hooks/league/useLeagueMemberships"; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useState } from 'react'; import { notFound, useRouter } from 'next/navigation'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; diff --git a/apps/website/app/races/[id]/stewarding/page.tsx b/apps/website/app/races/[id]/stewarding/page.tsx index 3699b4ca6..ea219795b 100644 --- a/apps/website/app/races/[id]/stewarding/page.tsx +++ b/apps/website/app/races/[id]/stewarding/page.tsx @@ -8,10 +8,11 @@ import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens'; import { RaceStewardingService } from '@/lib/services/races/RaceStewardingService'; import type { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel'; -import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useLeagueMemberships } from "@/lib/hooks/league/useLeagueMemberships"; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { Gavel } from 'lucide-react'; +import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO'; export default function RaceStewardingPage() { const router = useRouter(); @@ -61,7 +62,7 @@ export default function RaceStewardingPage() { // Fetch membership const { data: membershipsData } = useLeagueMemberships(pageData?.league?.id || '', currentDriverId || ''); - const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId); + const currentMembership = membershipsData?.members.find(m => m.driverId === currentDriverId); const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false; // Actions diff --git a/apps/website/app/races/all/page.tsx b/apps/website/app/races/all/page.tsx index aa42be3be..681253248 100644 --- a/apps/website/app/races/all/page.tsx +++ b/apps/website/app/races/all/page.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate'; -import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData'; +import { useAllRacesPageData } from "@/lib/hooks/race/useAllRacesPageData"; import { Flag } from 'lucide-react'; const ITEMS_PER_PAGE = 10; diff --git a/apps/website/app/sponsor/billing/page.tsx b/apps/website/app/sponsor/billing/page.tsx index c378b8437..068739499 100644 --- a/apps/website/app/sponsor/billing/page.tsx +++ b/apps/website/app/sponsor/billing/page.tsx @@ -10,7 +10,7 @@ import StatusBadge from '@/components/ui/StatusBadge'; import InfoBanner from '@/components/ui/InfoBanner'; import PageHeader from '@/components/ui/PageHeader'; import { siteConfig } from '@/lib/siteConfig'; -import { useSponsorBilling } from '@/hooks/sponsor/useSponsorBilling'; +import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling"; import { useInject } from '@/lib/di/hooks/useInject'; import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens'; import { diff --git a/apps/website/app/sponsor/campaigns/page.tsx b/apps/website/app/sponsor/campaigns/page.tsx index ed0bc6974..1eba49da2 100644 --- a/apps/website/app/sponsor/campaigns/page.tsx +++ b/apps/website/app/sponsor/campaigns/page.tsx @@ -8,7 +8,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import StatusBadge from '@/components/ui/StatusBadge'; import InfoBanner from '@/components/ui/InfoBanner'; -import { useSponsorSponsorships } from '@/hooks/sponsor/useSponsorSponsorships'; +import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships"; import { Megaphone, Trophy, diff --git a/apps/website/app/sponsor/settings/page.tsx b/apps/website/app/sponsor/settings/page.tsx index d163eff42..db0be2be5 100644 --- a/apps/website/app/sponsor/settings/page.tsx +++ b/apps/website/app/sponsor/settings/page.tsx @@ -33,6 +33,7 @@ import { Smartphone, AlertCircle } from 'lucide-react'; +import { logoutAction } from '@/app/actions/logoutAction'; // ============================================================================ // Types @@ -174,10 +175,8 @@ export default function SponsorSettingsPage() { const handleDeleteAccount = () => { if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data will be permanently removed.')) { - // Call logout API to clear session - fetch('/api/auth/logout', { method: 'POST' }).finally(() => { - router.push('/'); - }); + // Call the logout action directly + logoutAction(); } }; diff --git a/apps/website/app/teams/TeamsPageClient.tsx b/apps/website/app/teams/TeamsPageClient.tsx new file mode 100644 index 000000000..94172bb35 --- /dev/null +++ b/apps/website/app/teams/TeamsPageClient.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import type { TeamsPageDto } from '@/lib/page-queries/page-queries/TeamsPageQuery'; +import { TeamsPresenter } from '@/lib/presenters/TeamsPresenter'; +import { TeamsTemplate } from '@/templates/TeamsTemplate'; +import type { TeamSummaryData } from '@/templates/view-data/TeamsViewData'; + +interface TeamsPageClientProps { + pageDto: TeamsPageDto; +} + +export function TeamsPageClient({ pageDto }: TeamsPageClientProps) { + const router = useRouter(); + + // Use presenter to create ViewData + const viewData = TeamsPresenter.createViewData(pageDto); + + // UI state + const [searchQuery, setSearchQuery] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + + // Filter teams based on search query + const filteredTeams = useMemo(() => { + if (!searchQuery) return viewData.teams; + + const query = searchQuery.toLowerCase(); + return viewData.teams.filter((team: TeamSummaryData) => + team.teamName.toLowerCase().includes(query) || + team.leagueName.toLowerCase().includes(query) + ); + }, [viewData.teams, searchQuery]); + + // Update viewData with filtered teams + const templateViewData = { + ...viewData, + teams: filteredTeams, + }; + + // Event handlers + const handleSearchChange = (query: string) => { + setSearchQuery(query); + }; + + const handleShowCreateForm = () => { + setShowCreateForm(true); + }; + + const handleHideCreateForm = () => { + setShowCreateForm(false); + }; + + const handleTeamClick = (teamId: string) => { + router.push(`/teams/${teamId}`); + }; + + const handleCreateSuccess = (teamId: string) => { + setShowCreateForm(false); + router.push(`/teams/${teamId}`); + }; + + const handleBrowseTeams = () => { + const element = document.getElementById('teams-list'); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }; + + const handleSkillLevelClick = (level: string) => { + const element = document.getElementById(`level-${level}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }; + + return ( + + ); +} diff --git a/apps/website/app/teams/[id]/TeamDetailPageClient.tsx b/apps/website/app/teams/[id]/TeamDetailPageClient.tsx new file mode 100644 index 000000000..daf6ecd1c --- /dev/null +++ b/apps/website/app/teams/[id]/TeamDetailPageClient.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import type { TeamDetailPageDto } from '@/lib/page-queries/page-queries/TeamDetailPageQuery'; +import { TeamDetailPresenter } from '@/lib/presenters/TeamDetailPresenter'; +import TeamDetailTemplate from '@/templates/TeamDetailTemplate'; + +type Tab = 'overview' | 'roster' | 'standings' | 'admin'; + +interface TeamDetailPageClientProps { + pageDto: TeamDetailPageDto; +} + +export function TeamDetailPageClient({ pageDto }: TeamDetailPageClientProps) { + const router = useRouter(); + + // Use presenter to create ViewData + const viewData = TeamDetailPresenter.createViewData(pageDto); + + // UI state + const [activeTab, setActiveTab] = useState('overview'); + const [loading] = useState(false); + + // Event handlers + const handleTabChange = (tab: Tab) => { + setActiveTab(tab); + }; + + const handleUpdate = () => { + // Trigger a refresh by reloading the page + router.refresh(); + }; + + const handleRemoveMember = (driverId: string) => { + // This would call an API to remove the member + // For now, just log + console.log('Remove member:', driverId); + // In a real implementation, you'd have a mutation hook here + alert('Remove member functionality would be implemented here'); + }; + + const handleChangeRole = (driverId: string, newRole: 'owner' | 'admin' | 'member') => { + // This would call an API to change the role + console.log('Change role:', driverId, newRole); + // In a real implementation, you'd have a mutation hook here + alert('Change role functionality would be implemented here'); + }; + + const handleGoBack = () => { + router.back(); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/apps/website/app/teams/[id]/page.tsx b/apps/website/app/teams/[id]/page.tsx index 0e6040268..9db0cfde3 100644 --- a/apps/website/app/teams/[id]/page.tsx +++ b/apps/website/app/teams/[id]/page.tsx @@ -1,102 +1,22 @@ -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { TeamService } from '@/lib/services/teams/TeamService'; -import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { notFound } from 'next/navigation'; -import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; -import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; -import TeamDetailTemplate from '@/templates/TeamDetailTemplate'; - -// Template wrapper to adapt TeamDetailTemplate for SSR -interface TeamDetailData { - team: TeamDetailsViewModel; - memberships: TeamMemberViewModel[]; - isAdmin: boolean; -} - -function TeamDetailTemplateWrapper({ data }: { data: TeamDetailData }) { - return ( - {}} - onUpdate={() => {}} - onRemoveMember={() => {}} - onChangeRole={() => {}} - onGoBack={() => {}} - /> - ); -} +import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery'; +import TeamDetailPageClient from './TeamDetailPageClient'; export default async function Page({ params }: { params: { id: string } }) { - // Validate params - if (!params.id) { - notFound(); - } + const result = await TeamDetailPageQuery.execute(params.id); - // Fetch data using PageDataFetcher.fetchManual - const data = await PageDataFetcher.fetchManual(async () => { - // Manual dependency creation - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: process.env.NODE_ENV === 'production', - }); - - // Create API client - const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger); - - // Create service - const service = new TeamService(teamsApiClient); - - // For server-side, we need a current driver ID - // This would typically come from session, but for server components we'll use a placeholder - const currentDriverId = ''; // Placeholder - would need session handling - - // Fetch team details - const teamData = await service.getTeamDetails(params.id, currentDriverId); - - if (!teamData) { + switch (result.status) { + case 'ok': + return ; + case 'notFound': + notFound(); + case 'redirect': + // This would typically use redirect() from next/navigation + // but we need to handle it at the page level return null; - } - - // Fetch team members - const membersData = await service.getTeamMembers(params.id, currentDriverId, teamData.ownerId || ''); - - // Determine admin status - const isAdmin = teamData.isOwner || - (membersData || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner')); - - return { - team: teamData, - memberships: membersData || [], - isAdmin, - }; - }); - - if (!data) { - notFound(); + case 'error': + // For now, treat errors as not found + // In production, you might want a proper error page + notFound(); } - - return ( - - ); } \ No newline at end of file diff --git a/apps/website/app/teams/leaderboard/page.tsx b/apps/website/app/teams/leaderboard/page.tsx index 647c14a1a..2dba78f84 100644 --- a/apps/website/app/teams/leaderboard/page.tsx +++ b/apps/website/app/teams/leaderboard/page.tsx @@ -19,9 +19,9 @@ export default async function TeamLeaderboardPage() { ); // Prepare data for template - const data: TeamSummaryViewModel[] | null = teamsData as TeamSummaryViewModel[] | null; + const data: TeamSummaryViewModel[] | null = teamsData; - const hasData = (teamsData as any)?.length > 0; + const hasData = (teamsData?.length ?? 0) > 0; // Handle loading state (should be fast since we're using async/await) const isLoading = false; diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index 9d380d902..d0a9f4a61 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -1,97 +1,22 @@ -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import TeamsTemplate from '@/templates/TeamsTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { TeamService } from '@/lib/services/teams/TeamService'; -import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { notFound } from 'next/navigation'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; - -// Helper to compute derived data for SSR -function computeDerivedData(teams: TeamSummaryViewModel[]) { - // Group teams by performance level (skill level) - const teamsByLevel = teams.reduce((acc, team) => { - const level = team.performanceLevel || 'intermediate'; - if (!acc[level]) { - acc[level] = []; - } - acc[level].push(team); - return acc; - }, {} as Record); - - // Get top teams (by rating, descending) - const topTeams = [...teams] - .filter(t => t.rating !== undefined) - .sort((a, b) => (b.rating || 0) - (a.rating || 0)) - .slice(0, 5); - - // Count recruiting teams - const recruitingCount = teams.filter(t => t.isRecruiting).length; - - // For SSR, filtered teams = all teams (no search filter applied server-side) - const filteredTeams = teams; - - return { - teamsByLevel, - topTeams, - recruitingCount, - filteredTeams, - }; -} - -// Template wrapper for SSR -function TeamsTemplateWrapper({ data }: { data: TeamSummaryViewModel[] }) { - const derived = computeDerivedData(data); - - // Provide default values for SSR - // The template will handle client-side state management - return ( - {}} - onShowCreateForm={() => {}} - onHideCreateForm={() => {}} - onTeamClick={() => {}} - onCreateSuccess={() => {}} - onBrowseTeams={() => {}} - onSkillLevelClick={() => {}} - /> - ); -} +import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery'; +import TeamsPageClient from './TeamsPageClient'; export default async function Page() { - const data = await PageDataFetcher.fetchManual(async () => { - // Manual dependency creation - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: process.env.NODE_ENV === 'production', - }); + const result = await TeamsPageQuery.execute(); - // Create API client - const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger); - - // Create service - const service = new TeamService(teamsApiClient); - - return await service.getAllTeams(); - }); - - if (!data) { - notFound(); + switch (result.status) { + case 'ok': + return ; + case 'notFound': + notFound(); + case 'redirect': + // This would typically use redirect() from next/navigation + // but we need to handle it at the page level + return null; + case 'error': + // For now, treat errors as not found + // In production, you might want a proper error page + notFound(); } - - return ; } \ No newline at end of file diff --git a/apps/website/components/admin/AdminDashboardPage.tsx b/apps/website/components/admin/AdminDashboardPage.tsx index 938af9a68..e7d91a5b5 100644 --- a/apps/website/components/admin/AdminDashboardPage.tsx +++ b/apps/website/components/admin/AdminDashboardPage.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { apiClient } from '@/lib/apiClient'; import Card from '@/components/ui/Card'; -import { AdminViewModelService } from '@/lib/services/AdminViewModelService'; +import { AdminViewModelPresenter } from '@/lib/view-models/AdminViewModelPresenter'; import { DashboardStatsViewModel } from '@/lib/view-models/AdminUserViewModel'; import { Users, @@ -31,7 +31,7 @@ export function AdminDashboardPage() { const response = await apiClient.admin.getDashboardStats(); // Map DTO to View Model - const viewModel = AdminViewModelService.mapDashboardStats(response); + const viewModel = AdminViewModelPresenter.mapDashboardStats(response); setStats(viewModel); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load stats'; @@ -222,4 +222,4 @@ export function AdminDashboardPage() { ); -} +} \ No newline at end of file diff --git a/apps/website/components/admin/AdminLayout.tsx b/apps/website/components/admin/AdminLayout.tsx index 0b7ea66e2..0016c51fd 100644 --- a/apps/website/components/admin/AdminLayout.tsx +++ b/apps/website/components/admin/AdminLayout.tsx @@ -10,6 +10,7 @@ import { Activity } from 'lucide-react'; import { useRouter, usePathname } from 'next/navigation'; +import { logoutAction } from '@/app/actions/logoutAction'; interface AdminLayoutProps { children: ReactNode; @@ -62,15 +63,6 @@ export function AdminLayout({ children }: AdminLayoutProps) { } }; - const handleLogout = async () => { - try { - await fetch('/api/auth/logout', { method: 'POST' }); - router.push('/'); - } catch (error) { - console.error('Logout failed:', error); - } - }; - return (
{/* Sidebar */} @@ -132,13 +124,16 @@ export function AdminLayout({ children }: AdminLayoutProps) { {isSidebarOpen && Toggle Sidebar} - + {/* Use form with server action for logout */} +
+ +
diff --git a/apps/website/components/admin/AdminUsersPage.tsx b/apps/website/components/admin/AdminUsersPage.tsx index 45fc6a318..ec3342002 100644 --- a/apps/website/components/admin/AdminUsersPage.tsx +++ b/apps/website/components/admin/AdminUsersPage.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import { apiClient } from '@/lib/apiClient'; import Card from '@/components/ui/Card'; import StatusBadge from '@/components/ui/StatusBadge'; -import { AdminViewModelService } from '@/lib/services/AdminViewModelService'; +import { AdminViewModelPresenter } from '@/lib/view-models/AdminViewModelPresenter'; import { AdminUserViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel'; import { Search, @@ -47,7 +47,7 @@ export function AdminUsersPage() { }); // Map DTO to View Model - const viewModel = AdminViewModelService.mapUserList(response); + const viewModel = AdminViewModelPresenter.mapUserList(response); setUserList(viewModel); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load users'; @@ -356,4 +356,4 @@ export function AdminUsersPage() { )} ); -} +} \ No newline at end of file diff --git a/apps/website/components/dev/DebugModeToggle.tsx b/apps/website/components/dev/DebugModeToggle.tsx index 00eb03827..b41d27c86 100644 --- a/apps/website/components/dev/DebugModeToggle.tsx +++ b/apps/website/components/dev/DebugModeToggle.tsx @@ -4,6 +4,18 @@ import { useState, useEffect } from 'react'; import { Bug, X, Settings, Shield, Activity } from 'lucide-react'; import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler'; import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger'; +import type { GlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler'; +import type { ApiRequestLogger } from '@/lib/infrastructure/ApiRequestLogger'; + +// Extend Window interface for debug globals +declare global { + interface Window { + __GRIDPILOT_FETCH_LOGGED__?: boolean; + __GRIDPILOT_GLOBAL_HANDLER__?: GlobalErrorHandler; + __GRIDPILOT_API_LOGGER__?: ApiRequestLogger; + __GRIDPILOT_REACT_ERRORS__?: Array<{ error: unknown; componentStack?: string }>; + } +} interface DebugModeToggleProps { /** @@ -74,21 +86,21 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) { globalHandler.initialize(); // Override fetch with logging - if (!(window as any).__GRIDPILOT_FETCH_LOGGED__) { + if (!window.__GRIDPILOT_FETCH_LOGGED__) { const loggedFetch = apiLogger.createLoggedFetch(); - window.fetch = loggedFetch as any; - (window as any).__GRIDPILOT_FETCH_LOGGED__ = true; + window.fetch = loggedFetch as typeof fetch; + window.__GRIDPILOT_FETCH_LOGGED__ = true; } // Expose to window for easy access - (window as any).__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler; - (window as any).__GRIDPILOT_API_LOGGER__ = apiLogger; + window.__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler; + window.__GRIDPILOT_API_LOGGER__ = apiLogger; console.log('%c[DEBUG MODE] Enabled', 'color: #00ff88; font-weight: bold; font-size: 14px;'); console.log('Available globals:', { __GRIDPILOT_GLOBAL_HANDLER__: globalHandler, __GRIDPILOT_API_LOGGER__: apiLogger, - __GRIDPILOT_REACT_ERRORS__: (window as any).__GRIDPILOT_REACT_ERRORS__ || [], + __GRIDPILOT_REACT_ERRORS__: window.__GRIDPILOT_REACT_ERRORS__ || [], }); }; diff --git a/apps/website/components/dev/DevToolbar.tsx b/apps/website/components/dev/DevToolbar.tsx index 1857dd71b..194400ad4 100644 --- a/apps/website/components/dev/DevToolbar.tsx +++ b/apps/website/components/dev/DevToolbar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useNotifications } from '@/components/notifications/NotificationProvider'; import type { NotificationVariant } from '@/components/notifications/notificationTypes'; import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, AlertTriangle } from 'lucide-react'; diff --git a/apps/website/components/dev/sections/NotificationSendSection.tsx b/apps/website/components/dev/sections/NotificationSendSection.tsx index c68740a43..0c5254431 100644 --- a/apps/website/components/dev/sections/NotificationSendSection.tsx +++ b/apps/website/components/dev/sections/NotificationSendSection.tsx @@ -1,7 +1,7 @@ 'use client'; import { Bell } from 'lucide-react'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useNotifications } from '@/components/notifications/NotificationProvider'; import type { NotificationVariant } from '@/components/notifications/notificationTypes'; import type { DemoNotificationType, DemoUrgency } from '../types'; diff --git a/apps/website/components/drivers/CreateDriverForm.tsx b/apps/website/components/drivers/CreateDriverForm.tsx index cdf1cdcbf..40d53df2d 100644 --- a/apps/website/components/drivers/CreateDriverForm.tsx +++ b/apps/website/components/drivers/CreateDriverForm.tsx @@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react'; import { useRouter } from 'next/navigation'; import Input from '../ui/Input'; import Button from '../ui/Button'; -import { useCreateDriver } from '@/hooks/driver/useCreateDriver'; +import { useCreateDriver } from "@/lib/hooks/driver/useCreateDriver"; interface FormErrors { name?: string; diff --git a/apps/website/components/drivers/DriverProfile.tsx b/apps/website/components/drivers/DriverProfile.tsx index 4ceb3e207..cf24aca1f 100644 --- a/apps/website/components/drivers/DriverProfile.tsx +++ b/apps/website/components/drivers/DriverProfile.tsx @@ -8,7 +8,7 @@ import ProfileStats from './ProfileStats'; import CareerHighlights from './CareerHighlights'; import DriverRankings from './DriverRankings'; import PerformanceMetrics from './PerformanceMetrics'; -import { useDriverProfile } from '@/hooks/driver/useDriverProfile'; +import { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile"; interface DriverProfileProps { driver: DriverViewModel; diff --git a/apps/website/components/drivers/ProfileStats.tsx b/apps/website/components/drivers/ProfileStats.tsx index 051117c06..6ed44af83 100644 --- a/apps/website/components/drivers/ProfileStats.tsx +++ b/apps/website/components/drivers/ProfileStats.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useDriverProfile } from '@/hooks/driver'; +import { useDriverProfile } from "@/lib/hooks/driver"; import { useMemo } from 'react'; import Card from '../ui/Card'; import RankBadge from './RankBadge'; diff --git a/apps/website/components/landing/AlternatingSection.tsx b/apps/website/components/landing/AlternatingSection.tsx index 3143b3412..00e307886 100644 --- a/apps/website/components/landing/AlternatingSection.tsx +++ b/apps/website/components/landing/AlternatingSection.tsx @@ -2,7 +2,7 @@ import Container from '@/components/ui/Container'; import Heading from '@/components/ui/Heading'; -import { useParallax } from '@/hooks/useScrollProgress'; +import { useParallax } from "@/lib/hooks/useScrollProgress"; import { useRef } from 'react'; interface AlternatingSectionProps { diff --git a/apps/website/components/landing/Hero.tsx b/apps/website/components/landing/Hero.tsx index 5ecadc527..d9bf73872 100644 --- a/apps/website/components/landing/Hero.tsx +++ b/apps/website/components/landing/Hero.tsx @@ -4,7 +4,7 @@ import { useRef } from 'react'; import Button from '@/components/ui/Button'; import Container from '@/components/ui/Container'; import Heading from '@/components/ui/Heading'; -import { useParallax } from '../../hooks/useScrollProgress'; +import { useParallax } from '@/lib/hooks/useScrollProgress'; const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#'; diff --git a/apps/website/components/leagues/CreateLeagueForm.tsx b/apps/website/components/leagues/CreateLeagueForm.tsx index 8752f99d5..2f5f52b65 100644 --- a/apps/website/components/leagues/CreateLeagueForm.tsx +++ b/apps/website/components/leagues/CreateLeagueForm.tsx @@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react'; import { useRouter } from 'next/navigation'; import Input from '../ui/Input'; import Button from '../ui/Button'; -import { useCreateLeague } from '@/hooks/league/useCreateLeague'; +import { useCreateLeague } from "@/lib/hooks/league/useCreateLeague"; import { useAuth } from '@/lib/auth/AuthContext'; import { useInject } from '@/lib/di/hooks/useInject'; import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index 22475b555..058cb9c0a 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -26,8 +26,8 @@ import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel'; -import { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService'; -import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets'; +import { useCreateLeagueWizard } from "@/lib/hooks/useLeagueWizardService"; +import { useLeagueScoringPresets } from "@/lib/hooks/useLeagueScoringPresets"; import { LeagueBasicsSection } from './LeagueBasicsSection'; import { LeagueDropSection } from './LeagueDropSection'; import { @@ -316,7 +316,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea const validateStep = (currentStep: Step): boolean => { // Convert form to LeagueWizardFormData for validation - const formData: any = { + const formData: LeagueWizardCommandModel.LeagueWizardFormData = { leagueId: form.leagueId || '', basics: { name: form.basics?.name || '', @@ -409,7 +409,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea } // Convert form to LeagueWizardFormData for validation - const formData: any = { + const formData: LeagueWizardCommandModel.LeagueWizardFormData = { leagueId: form.leagueId || '', basics: { name: form.basics?.name || '', @@ -520,7 +520,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea }; }); }; - + const steps = [ { id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' }, { id: 2 as Step, label: 'Visibility', icon: Award, shortLabel: 'Type' }, @@ -870,8 +870,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea {/* Championships & Drop Rules side by side on larger screens */}
- - + +
{errors.submit && ( diff --git a/apps/website/components/leagues/JoinLeagueButton.tsx b/apps/website/components/leagues/JoinLeagueButton.tsx index c39967e55..a90556be1 100644 --- a/apps/website/components/leagues/JoinLeagueButton.tsx +++ b/apps/website/components/leagues/JoinLeagueButton.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { getMembership } from '@/lib/leagueMembership'; import { useState } from 'react'; -import { useLeagueMembershipMutation } from '@/hooks/league/useLeagueMembershipMutation'; +import { useLeagueMembershipMutation } from "@/lib/hooks/league/useLeagueMembershipMutation"; import Button from '../ui/Button'; interface JoinLeagueButtonProps { diff --git a/apps/website/components/leagues/LeagueActivityFeed.tsx b/apps/website/components/leagues/LeagueActivityFeed.tsx index 6f5b121b1..994be8ace 100644 --- a/apps/website/components/leagues/LeagueActivityFeed.tsx +++ b/apps/website/components/leagues/LeagueActivityFeed.tsx @@ -1,7 +1,7 @@ 'use client'; import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react'; -import { useLeagueRaces } from '@/hooks/league/useLeagueRaces'; +import { useLeagueRaces } from "@/lib/hooks/league/useLeagueRaces"; export type LeagueActivity = | { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date } diff --git a/apps/website/components/leagues/LeagueMembers.tsx b/apps/website/components/leagues/LeagueMembers.tsx index a6a2d7bfa..f442256ff 100644 --- a/apps/website/components/leagues/LeagueMembers.tsx +++ b/apps/website/components/leagues/LeagueMembers.tsx @@ -1,7 +1,7 @@ 'use client'; import DriverIdentity from '../drivers/DriverIdentity'; -import { useEffectiveDriverId } from '../../hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId'; import { useInject } from '@/lib/di/hooks/useInject'; import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import type { LeagueMembership } from '@/lib/types/LeagueMembership'; @@ -45,7 +45,7 @@ export default function LeagueMembers({ const byId: Record = {}; for (const dto of driverDtos) { - byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null }); + byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null }); } setDriversById(byId); } else { diff --git a/apps/website/components/leagues/LeagueOwnershipTransfer.tsx b/apps/website/components/leagues/LeagueOwnershipTransfer.tsx index a5de6a52d..266f781b6 100644 --- a/apps/website/components/leagues/LeagueOwnershipTransfer.tsx +++ b/apps/website/components/leagues/LeagueOwnershipTransfer.tsx @@ -48,7 +48,7 @@ export default function LeagueOwnershipTransfer({ driver={new DriverViewModel({ id: ownerSummary.driver.id, name: ownerSummary.driver.name, - avatarUrl: (ownerSummary.driver as any).avatarUrl ?? null, + avatarUrl: ownerSummary.driver.avatarUrl ?? null, iracingId: ownerSummary.driver.iracingId, country: ownerSummary.driver.country, bio: ownerSummary.driver.bio, diff --git a/apps/website/components/leagues/LeagueSchedule.tsx b/apps/website/components/leagues/LeagueSchedule.tsx index 3c0e6a1c7..788248fad 100644 --- a/apps/website/components/leagues/LeagueSchedule.tsx +++ b/apps/website/components/leagues/LeagueSchedule.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useRegisterForRace } from '@/hooks/race/useRegisterForRace'; -import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; +import { useRegisterForRace } from "@/lib/hooks/race/useRegisterForRace"; +import { useWithdrawFromRace } from "@/lib/hooks/race/useWithdrawFromRace"; import { useRouter } from 'next/navigation'; import { useMemo, useState } from 'react'; import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; @@ -10,7 +10,7 @@ import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueSchedu // Shared state components import { StateContainer } from '@/components/shared/state/StateContainer'; import { EmptyState } from '@/components/shared/state/EmptyState'; -import { useLeagueSchedule } from '@/hooks/league/useLeagueSchedule'; +import { useLeagueSchedule } from "@/lib/hooks/league/useLeagueSchedule"; import { Calendar } from 'lucide-react'; interface LeagueScheduleProps { diff --git a/apps/website/components/leagues/LeagueSponsorshipsSection.tsx b/apps/website/components/leagues/LeagueSponsorshipsSection.tsx index 49db54dce..c4921a3c0 100644 --- a/apps/website/components/leagues/LeagueSponsorshipsSection.tsx +++ b/apps/website/components/leagues/LeagueSponsorshipsSection.tsx @@ -6,9 +6,9 @@ import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/ import Button from '../ui/Button'; import Input from '../ui/Input'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useLeagueSeasons } from '@/hooks/league/useLeagueSeasons'; -import { useSponsorshipRequests } from '@/hooks/league/useSponsorshipRequests'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; +import { useLeagueSeasons } from "@/lib/hooks/league/useLeagueSeasons"; +import { useSponsorshipRequests } from "@/lib/hooks/league/useSponsorshipRequests"; import { useInject } from '@/lib/di/hooks/useInject'; import { SPONSORSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; diff --git a/apps/website/components/leagues/MembershipStatus.tsx b/apps/website/components/leagues/MembershipStatus.tsx index bccfeb690..d22310b3a 100644 --- a/apps/website/components/leagues/MembershipStatus.tsx +++ b/apps/website/components/leagues/MembershipStatus.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { getMembership } from '@/lib/leagueMembership'; import type { MembershipRole } from '@/lib/types/MembershipRole'; diff --git a/apps/website/components/leagues/QuickPenaltyModal.tsx b/apps/website/components/leagues/QuickPenaltyModal.tsx index 0ad4d47ec..4f121e84c 100644 --- a/apps/website/components/leagues/QuickPenaltyModal.tsx +++ b/apps/website/components/leagues/QuickPenaltyModal.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import Button from '@/components/ui/Button'; -import { usePenaltyMutation } from '@/hooks/league/usePenaltyMutation'; +import { usePenaltyMutation } from "@/lib/hooks/league/usePenaltyMutation"; import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react'; interface DriverOption { @@ -52,16 +52,14 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte setError(null); try { - const command: any = { + const command = { raceId: selectedRaceId, driverId: selectedDriver, - adminId, - infractionType: infractionType as any, - severity: severity as any, + stewardId: adminId, + type: infractionType, + reason: severity, + notes: notes.trim() || undefined, }; - if (notes.trim()) { - command.notes = notes.trim(); - } await penaltyMutation.mutateAsync(command); diff --git a/apps/website/components/leagues/ReviewProtestModal.tsx b/apps/website/components/leagues/ReviewProtestModal.tsx index 1640338f5..e50052d49 100644 --- a/apps/website/components/leagues/ReviewProtestModal.tsx +++ b/apps/website/components/leagues/ReviewProtestModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference"; +import { usePenaltyTypesReference } from "@/lib/hooks/usePenaltyTypesReference"; import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO"; import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel"; import Modal from "../ui/Modal"; diff --git a/apps/website/components/leagues/ScheduleRaceForm.tsx b/apps/website/components/leagues/ScheduleRaceForm.tsx index 1fd4b15fe..1b3e55250 100644 --- a/apps/website/components/leagues/ScheduleRaceForm.tsx +++ b/apps/website/components/leagues/ScheduleRaceForm.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Button from '../ui/Button'; import Input from '../ui/Input'; -import { useAllLeagues } from '@/hooks/league/useAllLeagues'; +import { useAllLeagues } from "@/lib/hooks/league/useAllLeagues"; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; interface ScheduleRaceFormData { diff --git a/apps/website/components/onboarding/OnboardingWizard.tsx b/apps/website/components/onboarding/OnboardingWizard.tsx index add29143c..b29d66f74 100644 --- a/apps/website/components/onboarding/OnboardingWizard.tsx +++ b/apps/website/components/onboarding/OnboardingWizard.tsx @@ -23,9 +23,9 @@ import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import CountrySelect from '@/components/ui/CountrySelect'; import { useAuth } from '@/lib/auth/AuthContext'; -import { useCompleteOnboarding } from '@/hooks/onboarding/useCompleteOnboarding'; -import { useGenerateAvatars } from '@/hooks/onboarding/useGenerateAvatars'; -import { useValidateFacePhoto } from '@/hooks/onboarding/useValidateFacePhoto'; +import { useCompleteOnboarding } from "@/lib/hooks/onboarding/useCompleteOnboarding"; +import { useGenerateAvatars } from "@/lib/hooks/onboarding/useGenerateAvatars"; +import { useValidateFacePhoto } from "@/lib/hooks/onboarding/useValidateFacePhoto"; // ============================================================================ // TYPES diff --git a/apps/website/components/profile/UserPill.tsx b/apps/website/components/profile/UserPill.tsx index b6546dd90..113071606 100644 --- a/apps/website/components/profile/UserPill.tsx +++ b/apps/website/components/profile/UserPill.tsx @@ -6,12 +6,11 @@ import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megap import Link from 'next/link'; import React, { useEffect, useMemo, useState } from 'react'; -import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import { CapabilityGate } from '@/components/shared/CapabilityGate'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; -import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel'; -import { useFindDriverById } from '@/hooks/driver/useFindDriverById'; +import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId'; +import type { DriverViewModel } from '@/lib/view-models/view-models/DriverViewModel'; +import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/view-models/DriverViewModel'; +import { useFindDriverById } from '@/lib/hooks/driver/useFindDriverById'; // Hook to detect demo user mode based on session function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } { @@ -27,8 +26,8 @@ function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } { const email = session.user.email?.toLowerCase() || ''; const displayName = session.user.displayName?.toLowerCase() || ''; - const primaryDriverId = (session.user as any).primaryDriverId || ''; - const role = (session.user as any).role; + const primaryDriverId = session.user.primaryDriverId || ''; + const role = 'role' in session.user ? (session.user as { role?: string }).role : undefined; // Check if this is a demo user if (email.includes('demo') || @@ -151,7 +150,7 @@ export default function UserPill() { // Transform DTO to ViewModel const driver = useMemo(() => { if (!driverDto) return null; - return new DriverViewModelClass({ ...driverDto, avatarUrl: (driverDto as any).avatarUrl ?? null }); + return new DriverViewModelClass({ ...driverDto, avatarUrl: driverDto.avatarUrl ?? null }); }, [driverDto]); const data = useMemo(() => { diff --git a/apps/website/components/races/FileProtestModal.tsx b/apps/website/components/races/FileProtestModal.tsx index 1bd02f93e..76086d286 100644 --- a/apps/website/components/races/FileProtestModal.tsx +++ b/apps/website/components/races/FileProtestModal.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import Modal from '@/components/ui/Modal'; import Button from '@/components/ui/Button'; import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO'; -import { useFileProtest } from '@/hooks/race/useFileProtest'; +import { useFileProtest } from "@/lib/hooks/race/useFileProtest"; import { AlertTriangle, Video, diff --git a/apps/website/components/shared/CapabilityGate.tsx b/apps/website/components/shared/CapabilityGate.tsx index 4daad509f..16ff6133e 100644 --- a/apps/website/components/shared/CapabilityGate.tsx +++ b/apps/website/components/shared/CapabilityGate.tsx @@ -1,7 +1,7 @@ 'use client'; import { ReactNode } from 'react'; -import { useCapability } from '@/hooks/useCapability'; +import { useCapability } from "@/lib/hooks/useCapability"; import { useInject } from '@/lib/di/hooks/useInject'; import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens'; diff --git a/apps/website/components/sponsors/SponsorInsightsCard.tsx b/apps/website/components/sponsors/SponsorInsightsCard.tsx index 66f98f8e1..e1b1d12b2 100644 --- a/apps/website/components/sponsors/SponsorInsightsCard.tsx +++ b/apps/website/components/sponsors/SponsorInsightsCard.tsx @@ -456,7 +456,7 @@ export function useSponsorMode(): boolean { } // Check session.user.role for sponsor - const role = (session.user as any).role; + const role = session.user?.role; if (role === 'sponsor') { setIsSponsor(true); return; diff --git a/apps/website/components/teams/CreateTeamForm.tsx b/apps/website/components/teams/CreateTeamForm.tsx index c1a58186b..2de3457a8 100644 --- a/apps/website/components/teams/CreateTeamForm.tsx +++ b/apps/website/components/teams/CreateTeamForm.tsx @@ -2,8 +2,8 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useCreateTeam } from '@/hooks/team'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; +import { useCreateTeam } from "@/lib/hooks/team"; import { useRouter } from 'next/navigation'; import { useState } from 'react'; diff --git a/apps/website/components/teams/FeaturedRecruiting.tsx b/apps/website/components/teams/FeaturedRecruiting.tsx index f5a3dfe70..1cf775ae1 100644 --- a/apps/website/components/teams/FeaturedRecruiting.tsx +++ b/apps/website/components/teams/FeaturedRecruiting.tsx @@ -1,6 +1,5 @@ import Image from 'next/image'; import { UserPlus, Users, Trophy } from 'lucide-react'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import { getMediaUrl } from '@/lib/utilities/media'; const SKILL_LEVELS: { @@ -14,7 +13,7 @@ const SKILL_LEVELS: { { id: 'pro', label: 'Pro', - icon: () => null, // We'll import Crown if needed + icon: () => null, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', @@ -46,7 +45,17 @@ const SKILL_LEVELS: { ]; interface FeaturedRecruitingProps { - teams: TeamSummaryViewModel[]; + teams: Array<{ + id: string; + name: string; + description?: string; + logoUrl?: string; + category?: string; + memberCount: number; + totalWins: number; + performanceLevel: string; + isRecruiting: boolean; + }>; onTeamClick: (id: string) => void; } diff --git a/apps/website/components/teams/JoinTeamButton.tsx b/apps/website/components/teams/JoinTeamButton.tsx index d3bc0ccb9..86f214a68 100644 --- a/apps/website/components/teams/JoinTeamButton.tsx +++ b/apps/website/components/teams/JoinTeamButton.tsx @@ -1,8 +1,8 @@ 'use client'; import Button from '@/components/ui/Button'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useTeamMembership, useJoinTeam, useLeaveTeam } from '@/hooks/team'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; +import { useTeamMembership, useJoinTeam, useLeaveTeam } from "@/lib/hooks/team"; import { useState } from 'react'; interface JoinTeamButtonProps { diff --git a/apps/website/components/teams/SkillLevelSection.tsx b/apps/website/components/teams/SkillLevelSection.tsx index 7c98d7550..385915437 100644 --- a/apps/website/components/teams/SkillLevelSection.tsx +++ b/apps/website/components/teams/SkillLevelSection.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { ChevronRight, Users, Trophy, UserPlus } from 'lucide-react'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import TeamCard from './TeamCard'; type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; @@ -18,7 +17,22 @@ interface SkillLevelConfig { interface SkillLevelSectionProps { level: SkillLevelConfig; - teams: TeamSummaryViewModel[]; + teams: Array<{ + id: string; + name: string; + description?: string; + logoUrl?: string; + memberCount: number; + rating?: number; + totalWins: number; + totalRaces: number; + performanceLevel: string; + isRecruiting: boolean; + specialization?: string; + region?: string; + languages: string[]; + category?: string; + }>; onTeamClick: (id: string) => void; defaultExpanded?: boolean; } diff --git a/apps/website/components/teams/TeamAdmin.tsx b/apps/website/components/teams/TeamAdmin.tsx index d509ecc3b..0b141d47b 100644 --- a/apps/website/components/teams/TeamAdmin.tsx +++ b/apps/website/components/teams/TeamAdmin.tsx @@ -4,12 +4,17 @@ import { useState } from 'react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; -import { useTeamJoinRequests, useUpdateTeam, useApproveJoinRequest, useRejectJoinRequest } from '@/hooks/team'; +import { useTeamJoinRequests, useUpdateTeam, useApproveJoinRequest, useRejectJoinRequest } from "@/lib/hooks/team"; import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel'; -import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; interface TeamAdminProps { - team: Pick; + team: { + id: string; + name: string; + tag: string; + description?: string; + ownerId: string; + }; onUpdate: () => void; } @@ -18,7 +23,7 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) { const [editedTeam, setEditedTeam] = useState({ name: team.name, tag: team.tag, - description: team.description, + description: team.description || '', }); // Use hooks for data fetching @@ -141,7 +146,7 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) { setEditedTeam({ name: team.name, tag: team.tag, - description: team.description, + description: team.description || '', }); }} > diff --git a/apps/website/components/teams/TeamLeaderboardPreview.tsx b/apps/website/components/teams/TeamLeaderboardPreview.tsx index 0cbd74a1f..3b04f899e 100644 --- a/apps/website/components/teams/TeamLeaderboardPreview.tsx +++ b/apps/website/components/teams/TeamLeaderboardPreview.tsx @@ -2,7 +2,6 @@ import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react'; import Button from '@/components/ui/Button'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import { getMediaUrl } from '@/lib/utilities/media'; const SKILL_LEVELS: { @@ -48,7 +47,17 @@ const SKILL_LEVELS: { ]; interface TeamLeaderboardPreviewProps { - topTeams: TeamSummaryViewModel[]; + topTeams: Array<{ + id: string; + name: string; + logoUrl?: string; + category?: string; + memberCount: number; + totalWins: number; + isRecruiting: boolean; + rating?: number; + performanceLevel: string; + }>; onTeamClick: (id: string) => void; } diff --git a/apps/website/components/teams/TeamRoster.tsx b/apps/website/components/teams/TeamRoster.tsx index 2242603dc..f290b5f8e 100644 --- a/apps/website/components/teams/TeamRoster.tsx +++ b/apps/website/components/teams/TeamRoster.tsx @@ -2,7 +2,7 @@ import Card from '@/components/ui/Card'; import DriverIdentity from '@/components/drivers/DriverIdentity'; -import { useTeamRoster } from '@/hooks/team'; +import { useTeamRoster } from "@/lib/hooks/team"; import { useState } from 'react'; import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; @@ -11,7 +11,14 @@ type TeamMemberRole = 'owner' | 'manager' | 'member'; interface TeamRosterProps { teamId: string; - memberships: any[]; + memberships: Array<{ + driverId: string; + driverName: string; + role: 'owner' | 'manager' | 'member'; + joinedAt: string; + isActive: boolean; + avatarUrl: string; + }>; isAdmin: boolean; onRemoveMember?: (driverId: string) => void; onChangeRole?: (driverId: string, newRole: TeamRole) => void; diff --git a/apps/website/components/teams/TeamStandings.tsx b/apps/website/components/teams/TeamStandings.tsx index e30fe4c84..c29ab9d46 100644 --- a/apps/website/components/teams/TeamStandings.tsx +++ b/apps/website/components/teams/TeamStandings.tsx @@ -1,7 +1,7 @@ 'use client'; import Card from '@/components/ui/Card'; -import { useTeamStandings } from '@/hooks/team'; +import { useTeamStandings } from "@/lib/hooks/team"; interface TeamStandingsProps { teamId: string; diff --git a/apps/website/hooks/league/useCreateLeague.ts b/apps/website/hooks/league/useCreateLeague.ts deleted file mode 100644 index 62a50cd1a..000000000 --- a/apps/website/hooks/league/useCreateLeague.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useInject } from '@/lib/di/hooks/useInject'; -import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; - -export function useCreateLeague() { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - const queryClient = useQueryClient(); - - const createLeagueMutation = useMutation({ - mutationFn: (input: any) => leagueService.createLeague(input), - onSuccess: () => { - // Invalidate relevant queries to refresh data - queryClient.invalidateQueries({ queryKey: ['allLeagues'] }); - queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] }); - }, - }); - - return createLeagueMutation; -} \ No newline at end of file diff --git a/apps/website/hooks/league/useLeagueDetail.ts b/apps/website/hooks/league/useLeagueDetail.ts deleted file mode 100644 index cbd36b6a3..000000000 --- a/apps/website/hooks/league/useLeagueDetail.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { useInject } from '@/lib/di/hooks/useInject'; -import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; - -export function useLeagueDetail(leagueId: string, currentDriverId: string) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - const queryResult = useQuery({ - queryKey: ['leagueDetail', leagueId, currentDriverId], - queryFn: () => leagueService.getLeagueDetail(leagueId, currentDriverId), - enabled: !!leagueId && !!currentDriverId, - }); - - return enhanceQueryResult(queryResult); -} diff --git a/apps/website/hooks/league/useLeagueRosterAdmin.ts b/apps/website/hooks/league/useLeagueRosterAdmin.ts deleted file mode 100644 index 241b72f6c..000000000 --- a/apps/website/hooks/league/useLeagueRosterAdmin.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; -import { useInject } from '@/lib/di/hooks/useInject'; -import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; -import { ApiError } from '@/lib/api/base/ApiError'; -import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel'; -import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel'; -import type { MembershipRole } from '@/lib/types/MembershipRole'; - -export function useLeagueRosterJoinRequests( - leagueId: string, - options?: Omit, 'queryKey' | 'queryFn'> -) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - const queryResult = useQuery({ - queryKey: ['leagueRosterJoinRequests', leagueId], - queryFn: () => leagueService.getAdminRosterJoinRequests(leagueId), - ...options, - }); - - return enhanceQueryResult(queryResult); -} - -export function useLeagueRosterMembers( - leagueId: string, - options?: Omit, 'queryKey' | 'queryFn'> -) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - const queryResult = useQuery({ - queryKey: ['leagueRosterMembers', leagueId], - queryFn: () => leagueService.getAdminRosterMembers(leagueId), - ...options, - }); - - return enhanceQueryResult(queryResult); -} - -export function useApproveJoinRequest( - options?: Omit, 'mutationFn'> -) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - return useMutation<{ success: boolean }, ApiError, { leagueId: string; joinRequestId: string }>({ - mutationFn: ({ leagueId, joinRequestId }) => leagueService.approveJoinRequest(leagueId, joinRequestId), - ...options, - }); -} - -export function useRejectJoinRequest( - options?: Omit, 'mutationFn'> -) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - return useMutation<{ success: boolean }, ApiError, { leagueId: string; joinRequestId: string }>({ - mutationFn: ({ leagueId, joinRequestId }) => leagueService.rejectJoinRequest(leagueId, joinRequestId), - ...options, - }); -} - -export function useUpdateMemberRole( - options?: Omit, 'mutationFn'> -) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - return useMutation<{ success: boolean }, ApiError, { leagueId: string; driverId: string; role: MembershipRole }>({ - mutationFn: ({ leagueId, driverId, role }) => leagueService.updateMemberRole(leagueId, driverId, role), - ...options, - }); -} - -export function useRemoveMember( - options?: Omit, 'mutationFn'> -) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - return useMutation<{ success: boolean }, ApiError, { leagueId: string; driverId: string }>({ - mutationFn: ({ leagueId, driverId }) => leagueService.removeMember(leagueId, driverId), - ...options, - }); -} diff --git a/apps/website/hooks/league/useLeagueSchedule.ts b/apps/website/hooks/league/useLeagueSchedule.ts deleted file mode 100644 index 8d3a7f7a5..000000000 --- a/apps/website/hooks/league/useLeagueSchedule.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { useInject } from '@/lib/di/hooks/useInject'; -import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; - -export function useLeagueSchedule(leagueId: string) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - const queryResult = useQuery({ - queryKey: ['leagueSchedule', leagueId], - queryFn: () => leagueService.getLeagueSchedule(leagueId), - enabled: !!leagueId, - }); - - return enhanceQueryResult(queryResult); -} diff --git a/apps/website/hooks/league/useLeagueScheduleAdminPageData.ts b/apps/website/hooks/league/useLeagueScheduleAdminPageData.ts deleted file mode 100644 index 17592d603..000000000 --- a/apps/website/hooks/league/useLeagueScheduleAdminPageData.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { usePageData } from '@/lib/page/usePageData'; -import { useInject } from '@/lib/di/hooks/useInject'; -import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; - -export function useLeagueAdminStatus(leagueId: string, currentDriverId: string) { - const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); - - return usePageData({ - queryKey: ['admin-check', leagueId, currentDriverId], - queryFn: async () => { - await leagueMembershipService.fetchLeagueMemberships(leagueId); - const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); - return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; - }, - enabled: !!leagueId && !!currentDriverId, - }); -} - -export function useLeagueSeasons(leagueId: string, isAdmin: boolean) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - return usePageData({ - queryKey: ['leagueSeasons', leagueId], - queryFn: () => leagueService.getLeagueSeasonSummaries(leagueId), - enabled: !!leagueId && !!isAdmin, - }); -} - -export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: string, isAdmin: boolean) { - const leagueService = useInject(LEAGUE_SERVICE_TOKEN); - - return usePageData({ - queryKey: ['adminSchedule', leagueId, selectedSeasonId], - queryFn: () => leagueService.getAdminSchedule(leagueId, selectedSeasonId), - enabled: !!leagueId && !!selectedSeasonId && !!isAdmin, - }); -} \ No newline at end of file diff --git a/apps/website/hooks/league/useLeagueWalletPageData.ts b/apps/website/hooks/league/useLeagueWalletPageData.ts deleted file mode 100644 index def8c4683..000000000 --- a/apps/website/hooks/league/useLeagueWalletPageData.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { usePageData, usePageMutation } from '@/lib/page/usePageData'; -import { useInject } from '@/lib/di/hooks/useInject'; -import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens'; - -export function useLeagueWalletPageData(leagueId: string) { - const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN); - - const queryResult = usePageData({ - queryKey: ['leagueWallet', leagueId], - queryFn: () => leagueWalletService.getWalletForLeague(leagueId), - enabled: !!leagueId, - }); - - return queryResult; -} - -export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) { - const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN); - - const withdrawMutation = usePageMutation( - async ({ amount }: { amount: number }) => { - if (!data) throw new Error('Wallet data not available'); - - const result = await leagueWalletService.withdraw( - leagueId, - amount, - data.currency, - 'season-2', // Current active season - 'bank-account-***1234' - ); - - if (!result.success) { - throw new Error(result.message || 'Withdrawal failed'); - } - - return result; - }, - { - onSuccess: () => { - // Refetch wallet data after successful withdrawal - refetch(); - }, - } - ); - - return withdrawMutation; -} \ No newline at end of file diff --git a/apps/website/lib/api/base/GracefulDegradation.ts b/apps/website/lib/api/base/GracefulDegradation.ts index 7acc99b5c..5db4de253 100644 --- a/apps/website/lib/api/base/GracefulDegradation.ts +++ b/apps/website/lib/api/base/GracefulDegradation.ts @@ -101,7 +101,7 @@ export const responseCache = new ResponseCache(); export async function withGracefulDegradation( fn: () => Promise, options: DegradationOptions = {} -): Promise { +): Promise { const { fallback, throwOnError = false, @@ -139,7 +139,7 @@ export async function withGracefulDegradation( } // Return undefined (caller must handle) - return undefined as unknown as T; + return undefined; } // API is available, try to execute @@ -193,7 +193,7 @@ export async function withGracefulDegradation( throw error; } - return undefined as unknown as T; + return undefined; } } diff --git a/apps/website/lib/auth/AuthContext.tsx b/apps/website/lib/auth/AuthContext.tsx index 84f4f07ce..05c3f5afb 100644 --- a/apps/website/lib/auth/AuthContext.tsx +++ b/apps/website/lib/auth/AuthContext.tsx @@ -12,9 +12,9 @@ import { import { useRouter } from 'next/navigation'; import type { SessionViewModel } from '@/lib/view-models/SessionViewModel'; -import { useCurrentSession } from '@/hooks/auth/useCurrentSession'; -import { useLogin } from '@/hooks/auth/useLogin'; -import { useLogout } from '@/hooks/auth/useLogout'; +import { useCurrentSession } from "@/lib/hooks/auth/useCurrentSession"; +import { useLogin } from "@/lib/hooks/auth/useLogin"; +import { useLogout } from "@/lib/hooks/auth/useLogout"; export type AuthContextValue = { session: SessionViewModel | null; diff --git a/apps/website/lib/contracts/page-queries/PageQuery.ts b/apps/website/lib/contracts/page-queries/PageQuery.ts new file mode 100644 index 000000000..0e27204b1 --- /dev/null +++ b/apps/website/lib/contracts/page-queries/PageQuery.ts @@ -0,0 +1,26 @@ +import type { PageQueryResult } from '@/lib/page-queries/PageQueryResult'; + +/** + * PageQuery contract interface + * + * Defines the canonical contract for all server-side page queries. + * + * Based on WEBSITE_PAGE_QUERIES.md: + * - Server-side composition classes + * - Call services that call apps/api + * - Assemble a Page DTO + * - Return explicit result describing route outcome + * - Do not implement business rules + * + * @template TPageDto - The Page DTO type this query produces + * @template TParams - The parameters required to execute this query + */ +export interface PageQuery { + /** + * Execute the page query + * + * @param params - Parameters required for query execution + * @returns Promise resolving to a PageQueryResult discriminated union + */ + execute(params: TParams): Promise>; +} \ No newline at end of file diff --git a/apps/website/lib/di/hooks/useInject.ts b/apps/website/lib/di/hooks/useInject.ts index da2f9bf07..49eeec10e 100644 --- a/apps/website/lib/di/hooks/useInject.ts +++ b/apps/website/lib/di/hooks/useInject.ts @@ -18,12 +18,12 @@ export function useInject(token: T): T extends { type: infer U return useMemo(() => { try { - return container.get(token); + return container.get(token) as T extends { type: infer U } ? U : unknown; } catch (error) { console.error(`Failed to resolve token ${token.toString()}:`, error); throw error; } - }, [container, token]) as any; + }, [container, token]); } /** diff --git a/apps/website/hooks/auth/index.ts b/apps/website/lib/hooks/auth/index.ts similarity index 100% rename from apps/website/hooks/auth/index.ts rename to apps/website/lib/hooks/auth/index.ts diff --git a/apps/website/hooks/auth/useCurrentSession.ts b/apps/website/lib/hooks/auth/useCurrentSession.ts similarity index 100% rename from apps/website/hooks/auth/useCurrentSession.ts rename to apps/website/lib/hooks/auth/useCurrentSession.ts diff --git a/apps/website/hooks/auth/useForgotPassword.ts b/apps/website/lib/hooks/auth/useForgotPassword.ts similarity index 100% rename from apps/website/hooks/auth/useForgotPassword.ts rename to apps/website/lib/hooks/auth/useForgotPassword.ts diff --git a/apps/website/hooks/auth/useLogin.ts b/apps/website/lib/hooks/auth/useLogin.ts similarity index 100% rename from apps/website/hooks/auth/useLogin.ts rename to apps/website/lib/hooks/auth/useLogin.ts diff --git a/apps/website/hooks/auth/useLogout.ts b/apps/website/lib/hooks/auth/useLogout.ts similarity index 100% rename from apps/website/hooks/auth/useLogout.ts rename to apps/website/lib/hooks/auth/useLogout.ts diff --git a/apps/website/hooks/auth/useResetPassword.ts b/apps/website/lib/hooks/auth/useResetPassword.ts similarity index 100% rename from apps/website/hooks/auth/useResetPassword.ts rename to apps/website/lib/hooks/auth/useResetPassword.ts diff --git a/apps/website/hooks/auth/useSignup.ts b/apps/website/lib/hooks/auth/useSignup.ts similarity index 100% rename from apps/website/hooks/auth/useSignup.ts rename to apps/website/lib/hooks/auth/useSignup.ts diff --git a/apps/website/hooks/driver/index.ts b/apps/website/lib/hooks/driver/index.ts similarity index 100% rename from apps/website/hooks/driver/index.ts rename to apps/website/lib/hooks/driver/index.ts diff --git a/apps/website/hooks/driver/useCreateDriver.ts b/apps/website/lib/hooks/driver/useCreateDriver.ts similarity index 100% rename from apps/website/hooks/driver/useCreateDriver.ts rename to apps/website/lib/hooks/driver/useCreateDriver.ts diff --git a/apps/website/hooks/driver/useCurrentDriver.ts b/apps/website/lib/hooks/driver/useCurrentDriver.ts similarity index 100% rename from apps/website/hooks/driver/useCurrentDriver.ts rename to apps/website/lib/hooks/driver/useCurrentDriver.ts diff --git a/apps/website/hooks/driver/useDriverProfile.ts b/apps/website/lib/hooks/driver/useDriverProfile.ts similarity index 100% rename from apps/website/hooks/driver/useDriverProfile.ts rename to apps/website/lib/hooks/driver/useDriverProfile.ts diff --git a/apps/website/hooks/driver/useDriverProfilePageData.ts b/apps/website/lib/hooks/driver/useDriverProfilePageData.ts similarity index 100% rename from apps/website/hooks/driver/useDriverProfilePageData.ts rename to apps/website/lib/hooks/driver/useDriverProfilePageData.ts diff --git a/apps/website/hooks/driver/useFindDriverById.ts b/apps/website/lib/hooks/driver/useFindDriverById.ts similarity index 100% rename from apps/website/hooks/driver/useFindDriverById.ts rename to apps/website/lib/hooks/driver/useFindDriverById.ts diff --git a/apps/website/hooks/driver/useUpdateDriverProfile.ts b/apps/website/lib/hooks/driver/useUpdateDriverProfile.ts similarity index 100% rename from apps/website/hooks/driver/useUpdateDriverProfile.ts rename to apps/website/lib/hooks/driver/useUpdateDriverProfile.ts diff --git a/apps/website/hooks/league/useAllLeagues.ts b/apps/website/lib/hooks/league/useAllLeagues.ts similarity index 100% rename from apps/website/hooks/league/useAllLeagues.ts rename to apps/website/lib/hooks/league/useAllLeagues.ts diff --git a/apps/website/lib/hooks/league/useCreateLeague.ts b/apps/website/lib/hooks/league/useCreateLeague.ts new file mode 100644 index 000000000..b0d186356 --- /dev/null +++ b/apps/website/lib/hooks/league/useCreateLeague.ts @@ -0,0 +1,9 @@ +import { useCreateLeagueWithBlockers } from './useCreateLeagueWithBlockers'; + +/** + * @deprecated Use useCreateLeagueWithBlockers instead + * This wrapper maintains backward compatibility while using the new blocker-aware hook + */ +export function useCreateLeague() { + return useCreateLeagueWithBlockers(); +} \ No newline at end of file diff --git a/apps/website/lib/hooks/league/useCreateLeagueWithBlockers.ts b/apps/website/lib/hooks/league/useCreateLeagueWithBlockers.ts new file mode 100644 index 000000000..6fe30983e --- /dev/null +++ b/apps/website/lib/hooks/league/useCreateLeagueWithBlockers.ts @@ -0,0 +1,50 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { ApiError } from '@/lib/api/base/ApiError'; +import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO'; +import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO'; + +interface CreateLeagueInput { + name: string; + description: string; + maxDrivers: number; + scoringPresetId: string; +} + +interface CreateLeagueResult { + success: boolean; + leagueId: string; + error?: string; +} + +export function useCreateLeagueWithBlockers( + options?: Omit, 'mutationFn'> +) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useMutation({ + mutationFn: async (input) => { + try { + // Transform input to DTO - note: maxDrivers and scoringPresetId are not in the DTO + // This hook may need to be updated based on actual API requirements + const inputDto: CreateLeagueInputDTO = { + name: input.name, + description: input.description, + visibility: 'public', // Default value + ownerId: '', // Will be set by the service + }; + + const result: CreateLeagueOutputDTO = await leagueService.createLeague(inputDto); + return { success: result.success, leagueId: result.leagueId }; + } catch (error) { + // Check if it's a rate limit error + if (error instanceof ApiError && error.type === 'RATE_LIMIT_ERROR') { + return { success: false, leagueId: '', error: 'Rate limited' }; + } + throw error; + } + }, + ...options, + }); +} \ No newline at end of file diff --git a/apps/website/hooks/league/useLeagueAdminStatus.ts b/apps/website/lib/hooks/league/useLeagueAdminStatus.ts similarity index 100% rename from apps/website/hooks/league/useLeagueAdminStatus.ts rename to apps/website/lib/hooks/league/useLeagueAdminStatus.ts diff --git a/apps/website/lib/hooks/league/useLeagueDetail.ts b/apps/website/lib/hooks/league/useLeagueDetail.ts new file mode 100644 index 000000000..8afcc6a4d --- /dev/null +++ b/apps/website/lib/hooks/league/useLeagueDetail.ts @@ -0,0 +1,54 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { ApiError } from '@/lib/api/base/ApiError'; +import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO'; +import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO'; +import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/AllLeaguesWithCapacityAndScoringDTO'; + +interface UseLeagueDetailOptions { + leagueId: string; + queryOptions?: UseQueryOptions; +} + +interface UseLeagueMembershipsOptions { + leagueId: string; + queryOptions?: UseQueryOptions; +} + +export function useLeagueDetail({ leagueId, queryOptions }: UseLeagueDetailOptions) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useQuery({ + queryKey: ['league-detail', leagueId], + queryFn: async () => { + const result = await leagueService.getAllLeagues() as AllLeaguesWithCapacityAndScoringDTO; + // Filter for the specific league + const leagues = Array.isArray(result?.leagues) ? result.leagues : []; + const league = leagues.find(l => l.id === leagueId); + if (!league) { + throw new ApiError('League not found', 'NOT_FOUND', { + endpoint: 'getAllLeagues', + statusCode: 404, + timestamp: new Date().toISOString() + }); + } + return league; + }, + ...queryOptions, + }); +} + +export function useLeagueMemberships({ leagueId, queryOptions }: UseLeagueMembershipsOptions) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useQuery({ + queryKey: ['league-memberships', leagueId], + queryFn: async () => { + const result = await leagueService.getLeagueMemberships(leagueId); + // The DTO already has the correct structure with members property + return result; + }, + ...queryOptions, + }); +} \ No newline at end of file diff --git a/apps/website/hooks/league/useLeagueMembershipMutation.ts b/apps/website/lib/hooks/league/useLeagueMembershipMutation.ts similarity index 100% rename from apps/website/hooks/league/useLeagueMembershipMutation.ts rename to apps/website/lib/hooks/league/useLeagueMembershipMutation.ts diff --git a/apps/website/hooks/league/useLeagueMemberships.ts b/apps/website/lib/hooks/league/useLeagueMemberships.ts similarity index 87% rename from apps/website/hooks/league/useLeagueMemberships.ts rename to apps/website/lib/hooks/league/useLeagueMemberships.ts index f7be8879d..08fcb9f06 100644 --- a/apps/website/hooks/league/useLeagueMemberships.ts +++ b/apps/website/lib/hooks/league/useLeagueMemberships.ts @@ -8,9 +8,9 @@ export function useLeagueMemberships(leagueId: string, currentUserId: string) { const queryResult = useQuery({ queryKey: ['leagueMemberships', leagueId, currentUserId], - queryFn: () => leagueService.getLeagueMemberships(leagueId, currentUserId), + queryFn: () => leagueService.getLeagueMemberships(leagueId), enabled: !!leagueId && !!currentUserId, }); return enhanceQueryResult(queryResult); -} +} \ No newline at end of file diff --git a/apps/website/hooks/league/useLeagueRaces.ts b/apps/website/lib/hooks/league/useLeagueRaces.ts similarity index 100% rename from apps/website/hooks/league/useLeagueRaces.ts rename to apps/website/lib/hooks/league/useLeagueRaces.ts diff --git a/apps/website/lib/hooks/league/useLeagueRosterAdmin.ts b/apps/website/lib/hooks/league/useLeagueRosterAdmin.ts new file mode 100644 index 000000000..7ec405d0d --- /dev/null +++ b/apps/website/lib/hooks/league/useLeagueRosterAdmin.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery, UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { ApiError } from '@/lib/api/base/ApiError'; +import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO'; +import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO'; + +interface UpdateMemberRoleInput { + leagueId: string; + driverId: string; + newRole: 'owner' | 'admin' | 'steward' | 'member'; +} + +interface RemoveMemberInput { + leagueId: string; + driverId: string; +} + +interface JoinRequestActionInput { + leagueId: string; + requestId: string; +} + +export function useLeagueRosterAdmin(leagueId: string, options?: UseQueryOptions) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useQuery({ + queryKey: ['league-roster-admin', leagueId], + queryFn: () => leagueService.getAdminRosterMembers(leagueId), + ...options, + }); +} + +export function useLeagueJoinRequests(leagueId: string, options?: UseQueryOptions) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useQuery({ + queryKey: ['league-join-requests', leagueId], + queryFn: () => leagueService.getAdminRosterJoinRequests(leagueId), + ...options, + }); +} + +export function useUpdateMemberRole( + options?: Omit, 'mutationFn'> +) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useMutation<{ success: boolean }, ApiError, UpdateMemberRoleInput>({ + mutationFn: async (input) => { + return leagueService.updateMemberRole(input.leagueId, input.driverId, input.newRole); + }, + ...options, + }); +} + +export function useRemoveMember( + options?: Omit, 'mutationFn'> +) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useMutation<{ success: boolean }, ApiError, RemoveMemberInput>({ + mutationFn: async (input) => { + return leagueService.removeMember(input.leagueId, input.driverId); + }, + ...options, + }); +} + +export function useApproveJoinRequest( + options?: Omit, 'mutationFn'> +) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({ + mutationFn: async (input) => { + return leagueService.approveJoinRequest(input.leagueId, input.requestId); + }, + ...options, + }); +} + +export function useRejectJoinRequest( + options?: Omit, 'mutationFn'> +) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({ + mutationFn: async (input) => { + return leagueService.rejectJoinRequest(input.leagueId, input.requestId); + }, + ...options, + }); +} \ No newline at end of file diff --git a/apps/website/lib/hooks/league/useLeagueSchedule.ts b/apps/website/lib/hooks/league/useLeagueSchedule.ts new file mode 100644 index 000000000..882eee9d3 --- /dev/null +++ b/apps/website/lib/hooks/league/useLeagueSchedule.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; +import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; +import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO'; +import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; + +function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel { + const scheduledAt = race.date ? new Date(race.date) : new Date(0); + const now = new Date(); + const isPast = scheduledAt.getTime() < now.getTime(); + const isUpcoming = !isPast; + + return { + id: race.id, + name: race.name, + scheduledAt, + isPast, + isUpcoming, + status: isPast ? 'completed' : 'scheduled', + track: undefined, + car: undefined, + sessionType: undefined, + isRegistered: undefined, + }; +} + +export function useLeagueSchedule(leagueId: string) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + const queryResult = useQuery({ + queryKey: ['leagueSchedule', leagueId], + queryFn: async (): Promise => { + const dto = await leagueService.getLeagueSchedule(leagueId); + const races = dto.races.map(mapRaceDtoToViewModel); + return new LeagueScheduleViewModel(races); + }, + enabled: !!leagueId, + }); + + return enhanceQueryResult(queryResult); +} \ No newline at end of file diff --git a/apps/website/lib/hooks/league/useLeagueScheduleAdminPageData.ts b/apps/website/lib/hooks/league/useLeagueScheduleAdminPageData.ts new file mode 100644 index 000000000..cc29dfb5b --- /dev/null +++ b/apps/website/lib/hooks/league/useLeagueScheduleAdminPageData.ts @@ -0,0 +1,75 @@ +import { usePageData } from '@/lib/page/usePageData'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; +import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel'; +import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; +import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel'; +import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO'; +import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; +import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO'; + +function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel { + const scheduledAt = race.date ? new Date(race.date) : new Date(0); + const now = new Date(); + const isPast = scheduledAt.getTime() < now.getTime(); + const isUpcoming = !isPast; + + return { + id: race.id, + name: race.name, + scheduledAt, + isPast, + isUpcoming, + status: isPast ? 'completed' : 'scheduled', + track: undefined, + car: undefined, + sessionType: undefined, + isRegistered: undefined, + }; +} + +export function useLeagueAdminStatus(leagueId: string, currentDriverId: string) { + const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); + + return usePageData({ + queryKey: ['admin-check', leagueId, currentDriverId], + queryFn: async () => { + await leagueMembershipService.fetchLeagueMemberships(leagueId); + const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); + return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; + }, + enabled: !!leagueId && !!currentDriverId, + }); +} + +export function useLeagueSeasons(leagueId: string, isAdmin: boolean) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return usePageData({ + queryKey: ['leagueSeasons', leagueId], + queryFn: async (): Promise => { + const dtos = await leagueService.getLeagueSeasonSummaries(leagueId); + return dtos.map((dto: LeagueSeasonSummaryDTO) => new LeagueSeasonSummaryViewModel(dto)); + }, + enabled: !!leagueId && !!isAdmin, + }); +} + +export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: string, isAdmin: boolean) { + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + + return usePageData({ + queryKey: ['adminSchedule', leagueId, selectedSeasonId], + queryFn: async (): Promise => { + const dto = await leagueService.getAdminSchedule(leagueId, selectedSeasonId); + const races = dto.races.map(mapRaceDtoToViewModel); + return new LeagueAdminScheduleViewModel({ + seasonId: dto.seasonId, + published: dto.published, + races, + }); + }, + enabled: !!leagueId && !!selectedSeasonId && !!isAdmin, + }); +} \ No newline at end of file diff --git a/apps/website/hooks/league/useLeagueSeasons.ts b/apps/website/lib/hooks/league/useLeagueSeasons.ts similarity index 100% rename from apps/website/hooks/league/useLeagueSeasons.ts rename to apps/website/lib/hooks/league/useLeagueSeasons.ts diff --git a/apps/website/hooks/league/useLeagueSettings.ts b/apps/website/lib/hooks/league/useLeagueSettings.ts similarity index 100% rename from apps/website/hooks/league/useLeagueSettings.ts rename to apps/website/lib/hooks/league/useLeagueSettings.ts diff --git a/apps/website/hooks/league/useLeagueSponsorshipsPageData.ts b/apps/website/lib/hooks/league/useLeagueSponsorshipsPageData.ts similarity index 91% rename from apps/website/hooks/league/useLeagueSponsorshipsPageData.ts rename to apps/website/lib/hooks/league/useLeagueSponsorshipsPageData.ts index c7506465e..ef6162faf 100644 --- a/apps/website/hooks/league/useLeagueSponsorshipsPageData.ts +++ b/apps/website/lib/hooks/league/useLeagueSponsorshipsPageData.ts @@ -9,7 +9,7 @@ export function useLeagueSponsorshipsPageData(leagueId: string, currentDriverId: return usePageDataMultiple({ league: { queryKey: ['leagueDetail', leagueId, currentDriverId], - queryFn: () => leagueService.getLeagueDetail(leagueId, currentDriverId), + queryFn: () => leagueService.getLeagueDetail(leagueId), }, membership: { queryKey: ['leagueMembership', leagueId, currentDriverId], diff --git a/apps/website/hooks/league/useLeagueStewardingData.ts b/apps/website/lib/hooks/league/useLeagueStewardingData.ts similarity index 100% rename from apps/website/hooks/league/useLeagueStewardingData.ts rename to apps/website/lib/hooks/league/useLeagueStewardingData.ts diff --git a/apps/website/hooks/league/useLeagueStewardingMutations.ts b/apps/website/lib/hooks/league/useLeagueStewardingMutations.ts similarity index 100% rename from apps/website/hooks/league/useLeagueStewardingMutations.ts rename to apps/website/lib/hooks/league/useLeagueStewardingMutations.ts diff --git a/apps/website/lib/hooks/league/useLeagueWalletPageData.ts b/apps/website/lib/hooks/league/useLeagueWalletPageData.ts new file mode 100644 index 000000000..c5d864857 --- /dev/null +++ b/apps/website/lib/hooks/league/useLeagueWalletPageData.ts @@ -0,0 +1,53 @@ +'use client'; + +import { usePageData } from '@/lib/page/usePageData'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; +import { WalletTransactionViewModel } from '@/lib/view-models/WalletTransactionViewModel'; +import { useLeagueWalletWithdrawalWithBlockers } from './useLeagueWalletWithdrawalWithBlockers'; + +export function useLeagueWalletPageData(leagueId: string) { + const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN); + + const queryResult = usePageData({ + queryKey: ['leagueWallet', leagueId], + queryFn: async () => { + const dto = await leagueWalletService.getWalletForLeague(leagueId); + // Transform DTO to ViewModel at client boundary + const transactions = dto.transactions.map(t => new WalletTransactionViewModel({ + id: t.id, + type: t.type, + description: t.description, + amount: t.amount, + fee: t.fee, + netAmount: t.netAmount, + date: new Date(t.date), + status: t.status, + reference: t.reference, + })); + return new LeagueWalletViewModel({ + balance: dto.balance, + currency: dto.currency, + totalRevenue: dto.totalRevenue, + totalFees: dto.totalFees, + totalWithdrawals: dto.totalWithdrawals, + pendingPayouts: dto.pendingPayouts, + transactions, + canWithdraw: dto.canWithdraw, + withdrawalBlockReason: dto.withdrawalBlockReason, + }); + }, + enabled: !!leagueId, + }); + + return queryResult; +} + +/** + * @deprecated Use useLeagueWalletWithdrawalWithBlockers instead + * This wrapper maintains backward compatibility while using the new blocker-aware hook + */ +export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) { + return useLeagueWalletWithdrawalWithBlockers(leagueId, data, refetch); +} \ No newline at end of file diff --git a/apps/website/lib/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts b/apps/website/lib/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts new file mode 100644 index 000000000..01ff97398 --- /dev/null +++ b/apps/website/lib/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts @@ -0,0 +1,58 @@ +'use client'; + +import { usePageMutation } from '@/lib/page/usePageData'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers'; + +/** + * Hook for wallet withdrawals with client-side blockers + * Handles UX prevention mechanisms (rate limiting, duplicate submission prevention) + */ +export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: any, refetch: () => void) { + const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN); + + // Client-side blockers for UX improvement + const submitBlocker = new SubmitBlocker(); + const throttle = new ThrottleBlocker(500); + + const withdrawMutation = usePageMutation( + async ({ amount }: { amount: number }) => { + if (!data) throw new Error('Wallet data not available'); + + // Client-side blockers (UX only, not security) + if (!submitBlocker.canExecute() || !throttle.canExecute()) { + throw new Error('Request blocked due to rate limiting'); + } + + submitBlocker.block(); + throttle.block(); + + try { + const result = await leagueWalletService.withdraw( + leagueId, + amount, + data.currency, + 'season-2', // Current active season + 'bank-account-***1234' + ); + + if (!result.success) { + throw new Error(result.message || 'Withdrawal failed'); + } + + return result; + } finally { + submitBlocker.release(); + } + }, + { + onSuccess: () => { + // Refetch wallet data after successful withdrawal + refetch(); + }, + } + ); + + return withdrawMutation; +} \ No newline at end of file diff --git a/apps/website/hooks/league/usePenaltyMutation.ts b/apps/website/lib/hooks/league/usePenaltyMutation.ts similarity index 100% rename from apps/website/hooks/league/usePenaltyMutation.ts rename to apps/website/lib/hooks/league/usePenaltyMutation.ts diff --git a/apps/website/hooks/league/useProtestDetail.ts b/apps/website/lib/hooks/league/useProtestDetail.ts similarity index 100% rename from apps/website/hooks/league/useProtestDetail.ts rename to apps/website/lib/hooks/league/useProtestDetail.ts diff --git a/apps/website/hooks/league/useSponsorshipRequests.ts b/apps/website/lib/hooks/league/useSponsorshipRequests.ts similarity index 100% rename from apps/website/hooks/league/useSponsorshipRequests.ts rename to apps/website/lib/hooks/league/useSponsorshipRequests.ts diff --git a/apps/website/hooks/onboarding/index.ts b/apps/website/lib/hooks/onboarding/index.ts similarity index 100% rename from apps/website/hooks/onboarding/index.ts rename to apps/website/lib/hooks/onboarding/index.ts diff --git a/apps/website/hooks/onboarding/useCompleteOnboarding.ts b/apps/website/lib/hooks/onboarding/useCompleteOnboarding.ts similarity index 100% rename from apps/website/hooks/onboarding/useCompleteOnboarding.ts rename to apps/website/lib/hooks/onboarding/useCompleteOnboarding.ts diff --git a/apps/website/hooks/onboarding/useGenerateAvatars.ts b/apps/website/lib/hooks/onboarding/useGenerateAvatars.ts similarity index 100% rename from apps/website/hooks/onboarding/useGenerateAvatars.ts rename to apps/website/lib/hooks/onboarding/useGenerateAvatars.ts diff --git a/apps/website/hooks/onboarding/useValidateFacePhoto.ts b/apps/website/lib/hooks/onboarding/useValidateFacePhoto.ts similarity index 100% rename from apps/website/hooks/onboarding/useValidateFacePhoto.ts rename to apps/website/lib/hooks/onboarding/useValidateFacePhoto.ts diff --git a/apps/website/hooks/race/useAllRacesPageData.ts b/apps/website/lib/hooks/race/useAllRacesPageData.ts similarity index 100% rename from apps/website/hooks/race/useAllRacesPageData.ts rename to apps/website/lib/hooks/race/useAllRacesPageData.ts diff --git a/apps/website/hooks/race/useFileProtest.ts b/apps/website/lib/hooks/race/useFileProtest.ts similarity index 100% rename from apps/website/hooks/race/useFileProtest.ts rename to apps/website/lib/hooks/race/useFileProtest.ts diff --git a/apps/website/hooks/race/useRaceResultsPageData.ts b/apps/website/lib/hooks/race/useRaceResultsPageData.ts similarity index 100% rename from apps/website/hooks/race/useRaceResultsPageData.ts rename to apps/website/lib/hooks/race/useRaceResultsPageData.ts diff --git a/apps/website/hooks/race/useRegisterForRace.ts b/apps/website/lib/hooks/race/useRegisterForRace.ts similarity index 100% rename from apps/website/hooks/race/useRegisterForRace.ts rename to apps/website/lib/hooks/race/useRegisterForRace.ts diff --git a/apps/website/hooks/race/useWithdrawFromRace.ts b/apps/website/lib/hooks/race/useWithdrawFromRace.ts similarity index 100% rename from apps/website/hooks/race/useWithdrawFromRace.ts rename to apps/website/lib/hooks/race/useWithdrawFromRace.ts diff --git a/apps/website/hooks/sponsor/index.ts b/apps/website/lib/hooks/sponsor/index.ts similarity index 100% rename from apps/website/hooks/sponsor/index.ts rename to apps/website/lib/hooks/sponsor/index.ts diff --git a/apps/website/hooks/sponsor/useAvailableLeagues.ts b/apps/website/lib/hooks/sponsor/useAvailableLeagues.ts similarity index 100% rename from apps/website/hooks/sponsor/useAvailableLeagues.ts rename to apps/website/lib/hooks/sponsor/useAvailableLeagues.ts diff --git a/apps/website/hooks/sponsor/useSponsorBilling.ts b/apps/website/lib/hooks/sponsor/useSponsorBilling.ts similarity index 100% rename from apps/website/hooks/sponsor/useSponsorBilling.ts rename to apps/website/lib/hooks/sponsor/useSponsorBilling.ts diff --git a/apps/website/hooks/sponsor/useSponsorDashboard.ts b/apps/website/lib/hooks/sponsor/useSponsorDashboard.ts similarity index 100% rename from apps/website/hooks/sponsor/useSponsorDashboard.ts rename to apps/website/lib/hooks/sponsor/useSponsorDashboard.ts diff --git a/apps/website/hooks/sponsor/useSponsorLeagueDetail.ts b/apps/website/lib/hooks/sponsor/useSponsorLeagueDetail.ts similarity index 100% rename from apps/website/hooks/sponsor/useSponsorLeagueDetail.ts rename to apps/website/lib/hooks/sponsor/useSponsorLeagueDetail.ts diff --git a/apps/website/hooks/sponsor/useSponsorSponsorships.ts b/apps/website/lib/hooks/sponsor/useSponsorSponsorships.ts similarity index 100% rename from apps/website/hooks/sponsor/useSponsorSponsorships.ts rename to apps/website/lib/hooks/sponsor/useSponsorSponsorships.ts diff --git a/apps/website/hooks/sponsor/useSponsorshipRequestsPageData.ts b/apps/website/lib/hooks/sponsor/useSponsorshipRequestsPageData.ts similarity index 100% rename from apps/website/hooks/sponsor/useSponsorshipRequestsPageData.ts rename to apps/website/lib/hooks/sponsor/useSponsorshipRequestsPageData.ts diff --git a/apps/website/hooks/team/index.ts b/apps/website/lib/hooks/team/index.ts similarity index 100% rename from apps/website/hooks/team/index.ts rename to apps/website/lib/hooks/team/index.ts diff --git a/apps/website/hooks/team/useAllTeams.ts b/apps/website/lib/hooks/team/useAllTeams.ts similarity index 100% rename from apps/website/hooks/team/useAllTeams.ts rename to apps/website/lib/hooks/team/useAllTeams.ts diff --git a/apps/website/hooks/team/useApproveJoinRequest.ts b/apps/website/lib/hooks/team/useApproveJoinRequest.ts similarity index 100% rename from apps/website/hooks/team/useApproveJoinRequest.ts rename to apps/website/lib/hooks/team/useApproveJoinRequest.ts diff --git a/apps/website/hooks/team/useCreateTeam.ts b/apps/website/lib/hooks/team/useCreateTeam.ts similarity index 100% rename from apps/website/hooks/team/useCreateTeam.ts rename to apps/website/lib/hooks/team/useCreateTeam.ts diff --git a/apps/website/hooks/team/useJoinTeam.ts b/apps/website/lib/hooks/team/useJoinTeam.ts similarity index 100% rename from apps/website/hooks/team/useJoinTeam.ts rename to apps/website/lib/hooks/team/useJoinTeam.ts diff --git a/apps/website/hooks/team/useLeaveTeam.ts b/apps/website/lib/hooks/team/useLeaveTeam.ts similarity index 100% rename from apps/website/hooks/team/useLeaveTeam.ts rename to apps/website/lib/hooks/team/useLeaveTeam.ts diff --git a/apps/website/hooks/team/useRejectJoinRequest.ts b/apps/website/lib/hooks/team/useRejectJoinRequest.ts similarity index 100% rename from apps/website/hooks/team/useRejectJoinRequest.ts rename to apps/website/lib/hooks/team/useRejectJoinRequest.ts diff --git a/apps/website/hooks/team/useTeamDetails.ts b/apps/website/lib/hooks/team/useTeamDetails.ts similarity index 100% rename from apps/website/hooks/team/useTeamDetails.ts rename to apps/website/lib/hooks/team/useTeamDetails.ts diff --git a/apps/website/hooks/team/useTeamJoinRequests.ts b/apps/website/lib/hooks/team/useTeamJoinRequests.ts similarity index 100% rename from apps/website/hooks/team/useTeamJoinRequests.ts rename to apps/website/lib/hooks/team/useTeamJoinRequests.ts diff --git a/apps/website/hooks/team/useTeamMembers.ts b/apps/website/lib/hooks/team/useTeamMembers.ts similarity index 100% rename from apps/website/hooks/team/useTeamMembers.ts rename to apps/website/lib/hooks/team/useTeamMembers.ts diff --git a/apps/website/hooks/team/useTeamMembership.ts b/apps/website/lib/hooks/team/useTeamMembership.ts similarity index 100% rename from apps/website/hooks/team/useTeamMembership.ts rename to apps/website/lib/hooks/team/useTeamMembership.ts diff --git a/apps/website/hooks/team/useTeamRoster.ts b/apps/website/lib/hooks/team/useTeamRoster.ts similarity index 74% rename from apps/website/hooks/team/useTeamRoster.ts rename to apps/website/lib/hooks/team/useTeamRoster.ts index 27bfd43f5..5eb13fc69 100644 --- a/apps/website/hooks/team/useTeamRoster.ts +++ b/apps/website/lib/hooks/team/useTeamRoster.ts @@ -2,30 +2,38 @@ import { useQuery } from '@tanstack/react-query'; import { useInject } from '@/lib/di/hooks/useInject'; import { TEAM_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; -import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; + +type TeamMemberRole = 'owner' | 'manager' | 'member'; interface TeamRosterMember { driver: any; - role: string; + role: TeamMemberRole; joinedAt: string; rating: number | null; overallRank: number | null; } -export function useTeamRoster(memberships: TeamMemberViewModel[]) { +export function useTeamRoster(memberships: Array<{ + driverId: string; + role: string; + joinedAt: string; +}>) { const teamService = useInject(TEAM_SERVICE_TOKEN); const driverService = useInject(DRIVER_SERVICE_TOKEN); - const queryResult = useQuery({ + const queryResult = useQuery({ queryKey: ['teamRoster', memberships], queryFn: async () => { // Get driver details for each membership const membersWithDetails = await Promise.all( memberships.map(async (m) => { const driver = await driverService.findById(m.driverId); + // Convert role to TeamMemberRole + const role: TeamMemberRole = m.role === 'owner' ? 'owner' : + m.role === 'manager' ? 'manager' : 'member'; return { driver: driver || { id: m.driverId, name: 'Unknown Driver', country: 'Unknown', position: 'N/A', races: '0', impressions: '0', team: 'None' }, - role: m.role, + role, joinedAt: m.joinedAt, rating: null, // DriverDTO doesn't include rating overallRank: null, // DriverDTO doesn't include overallRank diff --git a/apps/website/hooks/team/useTeamStandings.ts b/apps/website/lib/hooks/team/useTeamStandings.ts similarity index 100% rename from apps/website/hooks/team/useTeamStandings.ts rename to apps/website/lib/hooks/team/useTeamStandings.ts diff --git a/apps/website/hooks/team/useUpdateTeam.ts b/apps/website/lib/hooks/team/useUpdateTeam.ts similarity index 100% rename from apps/website/hooks/team/useUpdateTeam.ts rename to apps/website/lib/hooks/team/useUpdateTeam.ts diff --git a/apps/website/hooks/useCapability.ts b/apps/website/lib/hooks/useCapability.ts similarity index 100% rename from apps/website/hooks/useCapability.ts rename to apps/website/lib/hooks/useCapability.ts diff --git a/apps/website/hooks/useEffectiveDriverId.ts b/apps/website/lib/hooks/useEffectiveDriverId.ts similarity index 100% rename from apps/website/hooks/useEffectiveDriverId.ts rename to apps/website/lib/hooks/useEffectiveDriverId.ts diff --git a/apps/website/hooks/useLeagueScoringPresets.ts b/apps/website/lib/hooks/useLeagueScoringPresets.ts similarity index 100% rename from apps/website/hooks/useLeagueScoringPresets.ts rename to apps/website/lib/hooks/useLeagueScoringPresets.ts diff --git a/apps/website/hooks/useLeagueWizardService.ts b/apps/website/lib/hooks/useLeagueWizardService.ts similarity index 100% rename from apps/website/hooks/useLeagueWizardService.ts rename to apps/website/lib/hooks/useLeagueWizardService.ts diff --git a/apps/website/hooks/usePenaltyTypesReference.ts b/apps/website/lib/hooks/usePenaltyTypesReference.ts similarity index 100% rename from apps/website/hooks/usePenaltyTypesReference.ts rename to apps/website/lib/hooks/usePenaltyTypesReference.ts diff --git a/apps/website/hooks/useScrollProgress.ts b/apps/website/lib/hooks/useScrollProgress.ts similarity index 100% rename from apps/website/hooks/useScrollProgress.ts rename to apps/website/lib/hooks/useScrollProgress.ts diff --git a/apps/website/lib/infrastructure/ErrorReplay.ts b/apps/website/lib/infrastructure/ErrorReplay.ts index 339f2843d..0737ed906 100644 --- a/apps/website/lib/infrastructure/ErrorReplay.ts +++ b/apps/website/lib/infrastructure/ErrorReplay.ts @@ -78,7 +78,7 @@ export class ErrorReplaySystem { status: log.response?.status, error: log.error?.message, })), - reactErrors: (window as any).__GRIDPILOT_REACT_ERRORS__?.slice(-5).map((e: any) => ({ + reactErrors: (window as { __GRIDPILOT_REACT_ERRORS__?: Array<{ error?: { message?: string }; componentStack?: string }> }).__GRIDPILOT_REACT_ERRORS__?.slice(-5).map((e) => ({ message: e.error?.message || 'Unknown React error', componentStack: e.componentStack, })) || [], @@ -176,14 +176,13 @@ export class ErrorReplaySystem { console.log('Metadata:', replay.metadata); // Recreate the error - const error = replay.error.type === 'ApiError' + const error = replay.error.type === 'ApiError' ? new ApiError( replay.error.message, - (replay.error.context as any)?.type || 'UNKNOWN_ERROR', + ((replay.error.context as { type?: string } | undefined)?.type as import('../api/base/ApiError').ApiErrorType) || 'UNKNOWN_ERROR', { timestamp: replay.timestamp, - ...(replay.error.context as any), - replayId: replay.metadata.replayId, + ...(replay.error.context as Record | undefined), } ) : new Error(replay.error.message); diff --git a/apps/website/lib/page-queries/page-dtos/DashboardPageDto.ts b/apps/website/lib/page-queries/page-dtos/DashboardPageDto.ts new file mode 100644 index 000000000..30b2458a3 --- /dev/null +++ b/apps/website/lib/page-queries/page-dtos/DashboardPageDto.ts @@ -0,0 +1,84 @@ +/** + * Dashboard Page DTO + * Contains raw JSON-serializable values only + * Derived from DashboardOverviewDTO with ISO string timestamps + */ +export interface DashboardPageDto { + currentDriver?: { + id: string; + name: string; + avatarUrl: string; + country: string; + totalRaces: number; + wins: number; + podiums: number; + rating: number; + globalRank: number; + consistency: number; + }; + myUpcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }>; + otherUpcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }>; + upcomingRaces: Array<{ + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }>; + activeLeaguesCount: number; + nextRace?: { + id: string; + track: string; + car: string; + scheduledAt: string; // ISO string + status: string; + isMyLeague: boolean; + }; + recentResults: Array<{ + id: string; + track: string; + car: string; + position: number; + date: string; // ISO string + }>; + leagueStandingsSummaries: Array<{ + leagueId: string; + leagueName: string; + position: number; + points: number; + totalDrivers: number; + }>; + feedSummary: { + notificationCount: number; + items: Array<{ + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; // ISO string + ctaHref?: string; + ctaLabel?: string; + }>; + }; + friends: Array<{ + id: string; + name: string; + avatarUrl: string; + country: string; + }>; +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/DashboardPageQuery.ts b/apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts similarity index 60% rename from apps/website/lib/page-queries/DashboardPageQuery.ts rename to apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts index c313bf386..d80aba32b 100644 --- a/apps/website/lib/page-queries/DashboardPageQuery.ts +++ b/apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts @@ -1,24 +1,19 @@ -import { notFound, redirect } from 'next/navigation'; -import { ContainerManager } from '@/lib/di/container'; -import { DASHBOARD_API_CLIENT_TOKEN } from '@/lib/di/tokens'; -import type { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient'; +import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient'; +import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO'; -import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData'; +import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult'; +import type { DashboardPageDto } from '@/lib/page-queries/page-dtos/DashboardPageDto'; + +interface ErrorWithStatusCode extends Error { + statusCode?: number; +} /** - * PageQueryResult discriminated union for SSR page queries + * Transform DashboardOverviewDTO to DashboardPageDto + * Converts Date objects to ISO strings for JSON serialization */ -export type PageQueryResult = - | { status: 'ok'; data: TData } - | { status: 'notFound' } - | { status: 'redirect'; destination: string } - | { status: 'error'; error: Error }; - -/** - * Transform DashboardOverviewDTO to DashboardOverviewViewModelData - * Converts string dates to ISO strings for JSON serialization - */ -function transformDtoToViewModelData(dto: DashboardOverviewDTO): DashboardOverviewViewModelData { +function transformDtoToPageDto(dto: DashboardOverviewDTO): DashboardPageDto { return { currentDriver: dto.currentDriver ? { id: dto.currentDriver.id, @@ -101,40 +96,54 @@ function transformDtoToViewModelData(dto: DashboardOverviewDTO): DashboardOvervi } /** - * Dashboard page query that returns transformed ViewModelData - * Returns a discriminated union instead of nullable data + * Dashboard page query with manual wiring + * Returns PageQueryResult + * No DI container usage - constructs dependencies explicitly */ export class DashboardPageQuery { - static async execute(): Promise> { + /** + * Execute the dashboard page query + * Constructs API client manually with required dependencies + */ + static async execute(): Promise> { try { - const container = ContainerManager.getInstance().getContainer(); - const apiClient = container.get(DASHBOARD_API_CLIENT_TOKEN); + // Manual wiring: construct dependencies explicitly + const errorReporter = new ConsoleErrorReporter(); + const logger = new ConsoleLogger(); + // Construct API client with required dependencies + // Using environment variable for base URL, fallback to empty string + const baseUrl = process.env.NEXT_PUBLIC_API_URL || ''; + const apiClient = new DashboardApiClient(baseUrl, errorReporter, logger); + + // Fetch data const dto = await apiClient.getDashboardOverview(); if (!dto) { return { status: 'notFound' }; } - const viewModelData = transformDtoToViewModelData(dto); - return { status: 'ok', data: viewModelData }; + // Transform to Page DTO + const pageDto = transformDtoToPageDto(dto); + return { status: 'ok', dto: pageDto }; } catch (error) { // Handle specific error types if (error instanceof Error) { - // Check if it's a not found error - if (error.message.includes('not found') || (error as any).statusCode === 404) { + const errorWithStatus = error as ErrorWithStatusCode; + + if (errorWithStatus.message?.includes('not found') || errorWithStatus.statusCode === 404) { return { status: 'notFound' }; } // Check if it's a redirect error - if (error.message.includes('redirect') || (error as any).statusCode === 302) { - return { status: 'redirect', destination: '/' }; + if (errorWithStatus.message?.includes('redirect') || errorWithStatus.statusCode === 302) { + return { status: 'redirect', to: '/' }; } - return { status: 'error', error }; + return { status: 'error', errorId: 'DASHBOARD_FETCH_FAILED' }; } - return { status: 'error', error: new Error(String(error)) }; + return { status: 'error', errorId: 'UNKNOWN_ERROR' }; } } } \ No newline at end of file diff --git a/apps/website/lib/page-queries/page-queries/ProfileLeaguesPageQuery.ts b/apps/website/lib/page-queries/page-queries/ProfileLeaguesPageQuery.ts new file mode 100644 index 000000000..9d7db0cda --- /dev/null +++ b/apps/website/lib/page-queries/page-queries/ProfileLeaguesPageQuery.ts @@ -0,0 +1,132 @@ +import { SessionGateway } from '@/lib/gateways/SessionGateway'; +import { ApiClient } from '@/lib/api'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; +import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult'; + +/** + * Page DTO for Profile Leagues page + * JSON-serializable data structure for server-to-client communication + */ +export interface ProfileLeaguesPageDto { + ownedLeagues: Array<{ + leagueId: string; + name: string; + description: string; + membershipRole: 'owner' | 'admin' | 'steward' | 'member'; + }>; + memberLeagues: Array<{ + leagueId: string; + name: string; + description: string; + membershipRole: 'owner' | 'admin' | 'steward' | 'member'; + }>; +} + +interface MembershipDTO { + driverId: string; + role: string; + status?: 'active' | 'inactive'; +} + +/** + * Profile Leagues Page Query + * + * Server-side composition that: + * 1. Reads session to determine currentDriverId + * 2. Calls API clients directly (manual wiring) + * 3. Assembles Page DTO + * 4. Returns PageQueryResult + */ +export class ProfileLeaguesPageQuery { + static async execute(): Promise> { + try { + // Get session server-side + const sessionGateway = new SessionGateway(); + const session = await sessionGateway.getSession(); + + if (!session?.user?.primaryDriverId) { + return { status: 'notFound' }; + } + + const currentDriverId = session.user.primaryDriverId; + + // Manual wiring: construct API client explicitly + const apiBaseUrl = getWebsiteApiBaseUrl(); + const apiClient = new ApiClient(apiBaseUrl); + + // Fetch all leagues + const leaguesDto = await apiClient.leagues.getAllWithCapacity(); + + if (!leaguesDto?.leagues) { + return { status: 'notFound' }; + } + + // Fetch memberships for each league and categorize + const owned: ProfileLeaguesPageDto['ownedLeagues'] = []; + const member: ProfileLeaguesPageDto['memberLeagues'] = []; + + for (const league of leaguesDto.leagues) { + try { + const membershipsDto = await apiClient.leagues.getMemberships(league.id); + + // Handle both possible response structures with proper type checking + let memberships: MembershipDTO[] = []; + if (membershipsDto && typeof membershipsDto === 'object') { + if ('members' in membershipsDto && Array.isArray((membershipsDto as { members?: unknown }).members)) { + memberships = (membershipsDto as { members: MembershipDTO[] }).members; + } else if ('memberships' in membershipsDto && Array.isArray((membershipsDto as { memberships?: unknown }).memberships)) { + memberships = (membershipsDto as { memberships: MembershipDTO[] }).memberships; + } + } + + const currentMembership = memberships.find( + (m) => m.driverId === currentDriverId + ); + + if (currentMembership && currentMembership.status === 'active') { + const leagueData = { + leagueId: league.id, + name: league.name, + description: league.description, + membershipRole: currentMembership.role as 'owner' | 'admin' | 'steward' | 'member', + }; + + if (currentMembership.role === 'owner') { + owned.push(leagueData); + } else { + member.push(leagueData); + } + } + } catch (error) { + // Skip leagues where membership fetch fails + console.warn(`Failed to fetch memberships for league ${league.id}:`, error); + continue; + } + } + + return { + status: 'ok', + dto: { + ownedLeagues: owned, + memberLeagues: member, + }, + }; + } catch (error) { + console.error('ProfileLeaguesPageQuery failed:', error); + + if (error instanceof Error) { + // Check for specific error properties + const errorAny = error as { statusCode?: number }; + if (error.message.includes('not found') || errorAny.statusCode === 404) { + return { status: 'notFound' }; + } + if (error.message.includes('redirect') || errorAny.statusCode === 302) { + return { status: 'redirect', to: '/' }; + } + return { status: 'error', errorId: 'PROFILE_LEAGUES_FETCH_FAILED' }; + } + + return { status: 'error', errorId: 'UNKNOWN_ERROR' }; + } + } +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/ProfilePageQuery.ts b/apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts similarity index 90% rename from apps/website/lib/page-queries/ProfilePageQuery.ts rename to apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts index 7a5671134..175e9a7fb 100644 --- a/apps/website/lib/page-queries/ProfilePageQuery.ts +++ b/apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts @@ -2,16 +2,7 @@ import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import { DriverService } from '@/lib/services/drivers/DriverService'; import type { DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel'; - -// ============================================================================ -// TYPES -// ============================================================================ - -export type PageQueryResult = - | { status: 'ok'; dto: DriverProfileViewModelData } - | { status: 'notFound' } - | { status: 'redirect'; to: string } - | { status: 'error'; errorId: string }; +import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult'; // ============================================================================ // SERVER QUERY CLASS @@ -31,7 +22,7 @@ export class ProfilePageQuery { * @param driverId - The driver ID to fetch profile for * @returns PageQueryResult with discriminated union of states */ - static async execute(driverId: string | null): Promise { + static async execute(driverId: string | null): Promise> { // Handle missing driver ID if (!driverId) { return { status: 'notFound' }; diff --git a/apps/website/lib/page-queries/page-queries/TeamDetailPageQuery.ts b/apps/website/lib/page-queries/page-queries/TeamDetailPageQuery.ts new file mode 100644 index 000000000..de67eddb6 --- /dev/null +++ b/apps/website/lib/page-queries/page-queries/TeamDetailPageQuery.ts @@ -0,0 +1,137 @@ +import { SessionGateway } from '@/lib/gateways/SessionGateway'; +import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; +import { TeamService } from '@/lib/services/teams/TeamService'; +import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult'; +import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; + +/** + * TeamDetailPageDto - Raw serializable data for team detail page + * Contains only raw data, no derived/computed properties + */ +export interface TeamDetailPageDto { + team: { + id: string; + name: string; + tag: string; + description?: string; + ownerId: string; + leagues: string[]; + createdAt?: string; + specialization?: string; + region?: string; + languages?: string[]; + category?: string; + membership?: { + role: string; + joinedAt: string; + isActive: boolean; + } | null; + canManage: boolean; + }; + memberships: Array<{ + driverId: string; + driverName: string; + role: 'owner' | 'manager' | 'member'; + joinedAt: string; + isActive: boolean; + avatarUrl: string; + }>; + currentDriverId: string; +} + +/** + * TeamDetailPageQuery - Server-side composition for team detail page + * Manual wiring only; no ContainerManager; no PageDataFetcher + * Returns raw serializable DTO + */ +export class TeamDetailPageQuery { + static async execute(teamId: string): Promise> { + try { + // Validate teamId + if (!teamId) { + return { status: 'notFound' }; + } + + // Get session to determine current driver + const sessionGateway = new SessionGateway(); + const session = await sessionGateway.getSession(); + + if (!session?.user?.primaryDriverId) { + return { status: 'notFound' }; + } + + const currentDriverId = session.user.primaryDriverId; + + // Manual dependency creation + const baseUrl = process.env.API_BASE_URL || 'http://localhost:3101'; + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger); + const service = new TeamService(teamsApiClient); + + // Fetch team details + const teamData = await service.getTeamDetails(teamId, currentDriverId); + + if (!teamData) { + return { status: 'notFound' }; + } + + // Fetch team members + const membersData = await service.getTeamMembers(teamId, currentDriverId, teamData.ownerId); + + // Transform to raw serializable DTO + const dto: TeamDetailPageDto = { + team: { + id: teamData.id, + name: teamData.name, + tag: teamData.tag, + description: teamData.description, + ownerId: teamData.ownerId, + leagues: teamData.leagues, + createdAt: teamData.createdAt, + specialization: teamData.specialization, + region: teamData.region, + languages: teamData.languages, + category: teamData.category, + membership: teamData.membership ? { + role: teamData.membership.role, + joinedAt: teamData.membership.joinedAt, + isActive: teamData.membership.isActive, + } : null, + canManage: teamData.canManage, + }, + memberships: membersData.map((member: TeamMemberViewModel) => ({ + driverId: member.driverId, + driverName: member.driverName, + role: member.role, + joinedAt: member.joinedAt, + isActive: member.isActive, + avatarUrl: member.avatarUrl, + })), + currentDriverId, + }; + + return { status: 'ok', dto }; + } catch (error) { + // Handle specific error types + if (error instanceof Error) { + const errorAny = error as { statusCode?: number; message?: string }; + if (errorAny.message?.includes('not found') || errorAny.statusCode === 404) { + return { status: 'notFound' }; + } + if (errorAny.message?.includes('redirect') || errorAny.statusCode === 302) { + return { status: 'redirect', to: '/' }; + } + return { status: 'error', errorId: 'TEAM_DETAIL_FETCH_FAILED' }; + } + return { status: 'error', errorId: 'UNKNOWN_ERROR' }; + } + } +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts b/apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts new file mode 100644 index 000000000..1a3071bfb --- /dev/null +++ b/apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts @@ -0,0 +1,98 @@ +import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; +import { TeamService } from '@/lib/services/teams/TeamService'; +import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult'; +import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; + +/** + * TeamsPageDto - Raw serializable data for teams page + * Contains only raw data, no derived/computed properties + */ +export interface TeamsPageDto { + teams: Array<{ + id: string; + name: string; + tag: string; + memberCount: number; + description?: string; + totalWins: number; + totalRaces: number; + performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; + isRecruiting: boolean; + specialization?: 'endurance' | 'sprint' | 'mixed'; + region?: string; + languages: string[]; + leagues: string[]; + logoUrl?: string; + rating?: number; + category?: string; + }>; +} + +/** + * TeamsPageQuery - Server-side composition for teams list page + * Manual wiring only; no ContainerManager; no PageDataFetcher + * Returns raw serializable DTO + */ +export class TeamsPageQuery { + static async execute(): Promise> { + try { + // Manual dependency creation + const baseUrl = process.env.API_BASE_URL || 'http://localhost:3101'; + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger); + const service = new TeamService(teamsApiClient); + + // Fetch teams + const teams = await service.getAllTeams(); + + if (!teams || teams.length === 0) { + return { status: 'notFound' }; + } + + // Transform to raw serializable DTO + const dto: TeamsPageDto = { + teams: teams.map((team: TeamSummaryViewModel) => ({ + id: team.id, + name: team.name, + tag: team.tag, + memberCount: team.memberCount, + description: team.description, + totalWins: team.totalWins, + totalRaces: team.totalRaces, + performanceLevel: team.performanceLevel, + isRecruiting: team.isRecruiting, + specialization: team.specialization, + region: team.region, + languages: team.languages, + leagues: team.leagues, + logoUrl: team.logoUrl, + rating: team.rating, + category: team.category, + })), + }; + + return { status: 'ok', dto }; + } catch (error) { + // Handle specific error types + if (error instanceof Error) { + const errorAny = error as { statusCode?: number; message?: string }; + if (errorAny.message?.includes('not found') || errorAny.statusCode === 404) { + return { status: 'notFound' }; + } + if (errorAny.message?.includes('redirect') || errorAny.statusCode === 302) { + return { status: 'redirect', to: '/' }; + } + return { status: 'error', errorId: 'TEAMS_FETCH_FAILED' }; + } + return { status: 'error', errorId: 'UNKNOWN_ERROR' }; + } + } +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/page-query-result/PageQueryResult.ts b/apps/website/lib/page-queries/page-query-result/PageQueryResult.ts new file mode 100644 index 000000000..0f8402351 --- /dev/null +++ b/apps/website/lib/page-queries/page-query-result/PageQueryResult.ts @@ -0,0 +1,18 @@ +/** + * PageQueryResult discriminated union + * + * Canonical result type for all server-side page queries. + * Defines the explicit outcome of a page query execution. + * + * Based on WEBSITE_PAGE_QUERIES.md: + * - ok with { dto } + * - notFound + * - redirect with { to } + * - error with { errorId } + */ + +export type PageQueryResult = + | { status: 'ok'; dto: TPageDto } + | { status: 'notFound' } + | { status: 'redirect'; to: string } + | { status: 'error'; errorId: string }; \ No newline at end of file diff --git a/apps/website/lib/page/PageDataFetcher.ts b/apps/website/lib/page/PageDataFetcher.ts index 6f8f11478..4dca570d7 100644 --- a/apps/website/lib/page/PageDataFetcher.ts +++ b/apps/website/lib/page/PageDataFetcher.ts @@ -71,9 +71,9 @@ export class PageDataFetcher { entries.forEach(([key, result]) => { if (typeof result === 'object' && result !== null && 'success' in result) { if (result.success) { - results[key as keyof T] = (result as any).data; + results[key as keyof T] = (result as { data: T[keyof T] }).data; } else { - errors[key] = (result as any).error; + errors[key] = (result as { error: Error }).error; } } }); @@ -84,4 +84,4 @@ export class PageDataFetcher { hasErrors: Object.keys(errors).length > 0 }; } -} \ No newline at end of file +} diff --git a/apps/website/lib/services/AdminViewModelService.ts b/apps/website/lib/presenters/AdminViewModelPresenter.ts similarity index 78% rename from apps/website/lib/services/AdminViewModelService.ts rename to apps/website/lib/presenters/AdminViewModelPresenter.ts index 6fdc72adf..264c4b3fb 100644 --- a/apps/website/lib/services/AdminViewModelService.ts +++ b/apps/website/lib/presenters/AdminViewModelPresenter.ts @@ -1,13 +1,16 @@ +'use client'; + import type { UserDto, DashboardStats, UserListResponse } from '@/lib/api/admin/AdminApiClient'; -import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel'; +import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from './AdminUserViewModel'; /** - * AdminViewModelService + * AdminViewModelPresenter * - * Service layer responsible for mapping API DTOs to View Models. - * This is where the transformation from API data to UI-ready state happens. + * Presenter layer for transforming API DTOs to ViewModels. + * Runs in client code only ('use client'). + * Deterministic, side-effect free transformations. */ -export class AdminViewModelService { +export class AdminViewModelPresenter { /** * Map a single user DTO to a View Model */ diff --git a/apps/website/lib/presenters/DashboardPresenter.ts b/apps/website/lib/presenters/DashboardPresenter.ts new file mode 100644 index 000000000..032dea55f --- /dev/null +++ b/apps/website/lib/presenters/DashboardPresenter.ts @@ -0,0 +1,91 @@ +import type { DashboardPageDto } from '@/lib/page-queries/DashboardPageDto'; +import type { DashboardViewData } from '@/templates/DashboardViewData'; +import { + formatDashboardDate, + formatRating, + formatRank, + formatConsistency, + formatRaceCount, + formatFriendCount, + formatLeaguePosition, + formatPoints, + formatTotalDrivers, +} from '@/lib/display-objects/DashboardDisplay'; + +/** + * DashboardPresenter - Client-side presenter for dashboard page + * Transforms Page DTO into ViewData for the template + * Deterministic; no hooks; no side effects + */ +export class DashboardPresenter { + static createViewData(pageDto: DashboardPageDto): DashboardViewData { + return { + currentDriver: { + name: pageDto.currentDriver?.name || '', + avatarUrl: pageDto.currentDriver?.avatarUrl || '', + country: pageDto.currentDriver?.country || '', + rating: pageDto.currentDriver ? formatRating(pageDto.currentDriver.rating) : '0.0', + rank: pageDto.currentDriver ? formatRank(pageDto.currentDriver.globalRank) : '0', + totalRaces: pageDto.currentDriver ? formatRaceCount(pageDto.currentDriver.totalRaces) : '0', + wins: pageDto.currentDriver ? formatRaceCount(pageDto.currentDriver.wins) : '0', + podiums: pageDto.currentDriver ? formatRaceCount(pageDto.currentDriver.podiums) : '0', + consistency: pageDto.currentDriver ? formatConsistency(pageDto.currentDriver.consistency) : '0%', + }, + nextRace: pageDto.nextRace ? (() => { + const dateInfo = formatDashboardDate(new Date(pageDto.nextRace.scheduledAt)); + return { + id: pageDto.nextRace.id, + track: pageDto.nextRace.track, + car: pageDto.nextRace.car, + scheduledAt: pageDto.nextRace.scheduledAt, + formattedDate: dateInfo.date, + formattedTime: dateInfo.time, + timeUntil: dateInfo.relative, + isMyLeague: pageDto.nextRace.isMyLeague, + }; + })() : null, + upcomingRaces: pageDto.upcomingRaces.map((race) => { + const dateInfo = formatDashboardDate(new Date(race.scheduledAt)); + return { + id: race.id, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt, + formattedDate: dateInfo.date, + formattedTime: dateInfo.time, + timeUntil: dateInfo.relative, + isMyLeague: race.isMyLeague, + }; + }), + leagueStandings: pageDto.leagueStandingsSummaries.map((standing) => ({ + leagueId: standing.leagueId, + leagueName: standing.leagueName, + position: formatLeaguePosition(standing.position), + points: formatPoints(standing.points), + totalDrivers: formatTotalDrivers(standing.totalDrivers), + })), + feedItems: pageDto.feedSummary.items.map((item) => ({ + id: item.id, + type: item.type, + headline: item.headline, + body: item.body, + timestamp: item.timestamp, + formattedTime: formatDashboardDate(new Date(item.timestamp)).relative, + ctaHref: item.ctaHref, + ctaLabel: item.ctaLabel, + })), + friends: pageDto.friends.map((friend) => ({ + id: friend.id, + name: friend.name, + avatarUrl: friend.avatarUrl, + country: friend.country, + })), + activeLeaguesCount: formatRaceCount(pageDto.activeLeaguesCount), + friendCount: formatFriendCount(pageDto.friends.length), + hasUpcomingRaces: pageDto.upcomingRaces.length > 0, + hasLeagueStandings: pageDto.leagueStandingsSummaries.length > 0, + hasFeedItems: pageDto.feedSummary.items.length > 0, + hasFriends: pageDto.friends.length > 0, + }; + } +} diff --git a/apps/website/lib/presenters/ProfileLeaguesPresenter.ts b/apps/website/lib/presenters/ProfileLeaguesPresenter.ts new file mode 100644 index 000000000..291650e74 --- /dev/null +++ b/apps/website/lib/presenters/ProfileLeaguesPresenter.ts @@ -0,0 +1,25 @@ +import type { ProfileLeaguesPageDto } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery'; +import type { ProfileLeaguesViewData } from '@/templates/view-data/ProfileLeaguesViewData'; + +/** + * Presenter for Profile Leagues page + * Pure mapping from Page DTO to ViewData + */ +export class ProfileLeaguesPresenter { + static toViewData(pageDto: ProfileLeaguesPageDto): ProfileLeaguesViewData { + return { + ownedLeagues: pageDto.ownedLeagues.map(league => ({ + leagueId: league.leagueId, + name: league.name, + description: league.description, + membershipRole: league.membershipRole, + })), + memberLeagues: pageDto.memberLeagues.map(league => ({ + leagueId: league.leagueId, + name: league.name, + description: league.description, + membershipRole: league.membershipRole, + })), + }; + } +} diff --git a/apps/website/lib/presenters/TeamDetailPresenter.ts b/apps/website/lib/presenters/TeamDetailPresenter.ts new file mode 100644 index 000000000..0ca08d196 --- /dev/null +++ b/apps/website/lib/presenters/TeamDetailPresenter.ts @@ -0,0 +1,47 @@ +import type { TeamDetailPageDto } from '@/lib/page-queries/TeamDetailPageQuery'; +import type { TeamDetailViewData, TeamDetailData, TeamMemberData } from '@/templates/TeamDetailViewData'; + +/** + * TeamDetailPresenter - Client-side presenter for team detail page + * Transforms PageQuery DTO into ViewData for the template + * Deterministic; no hooks; no side effects + */ +export class TeamDetailPresenter { + static createViewData(pageDto: TeamDetailPageDto): TeamDetailViewData { + const team: TeamDetailData = { + id: pageDto.team.id, + name: pageDto.team.name, + tag: pageDto.team.tag, + description: pageDto.team.description, + ownerId: pageDto.team.ownerId, + leagues: pageDto.team.leagues, + createdAt: pageDto.team.createdAt, + specialization: pageDto.team.specialization, + region: pageDto.team.region, + languages: pageDto.team.languages, + category: pageDto.team.category, + membership: pageDto.team.membership, + canManage: pageDto.team.canManage, + }; + + const memberships: TeamMemberData[] = pageDto.memberships.map(membership => ({ + driverId: membership.driverId, + driverName: membership.driverName, + role: membership.role, + joinedAt: membership.joinedAt, + isActive: membership.isActive, + avatarUrl: membership.avatarUrl, + })); + + // Calculate isAdmin based on current driver's role + const currentDriverMembership = memberships.find(m => m.driverId === pageDto.currentDriverId); + const isAdmin = currentDriverMembership?.role === 'owner' || currentDriverMembership?.role === 'manager'; + + return { + team, + memberships, + currentDriverId: pageDto.currentDriverId, + isAdmin, + }; + } +} diff --git a/apps/website/lib/presenters/TeamsPresenter.ts b/apps/website/lib/presenters/TeamsPresenter.ts new file mode 100644 index 000000000..f192d9632 --- /dev/null +++ b/apps/website/lib/presenters/TeamsPresenter.ts @@ -0,0 +1,21 @@ +import type { TeamsPageDto } from '@/lib/page-queries/TeamsPageQuery'; +import type { TeamsViewData, TeamSummaryData } from '@/templates/TeamsViewData'; + +/** + * TeamsPresenter - Client-side presenter for teams page + * Transforms PageQuery DTO into ViewData for the template + * Deterministic; no hooks; no side effects + */ +export class TeamsPresenter { + static createViewData(pageDto: TeamsPageDto): TeamsViewData { + const teams = pageDto.teams.map((team): TeamSummaryData => ({ + teamId: team.id, + teamName: team.name, + leagueName: team.leagues[0] || '', + memberCount: team.memberCount, + logoUrl: team.logoUrl, + })); + + return { teams }; + } +} diff --git a/apps/website/lib/services/AdminViewModelService.test.ts b/apps/website/lib/services/AdminViewModelService.test.ts deleted file mode 100644 index 58c6712e3..000000000 --- a/apps/website/lib/services/AdminViewModelService.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { AdminViewModelService } from './AdminViewModelService'; - -describe('AdminViewModelService', () => { - it('should be defined', () => { - expect(AdminViewModelService).toBeDefined(); - }); -}); diff --git a/apps/website/lib/services/analytics/AnalyticsService.test.ts b/apps/website/lib/services/analytics/AnalyticsService.test.ts index d5e43eeaa..572ef4d90 100644 --- a/apps/website/lib/services/analytics/AnalyticsService.test.ts +++ b/apps/website/lib/services/analytics/AnalyticsService.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { AnalyticsService } from './AnalyticsService'; -import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient'; -import { RecordPageViewOutputViewModel } from '../../view-models/RecordPageViewOutputViewModel'; -import { RecordEngagementOutputViewModel } from '../../view-models/RecordEngagementOutputViewModel'; +import { AnalyticsApiClient } from '@/lib/api/analytics/AnalyticsApiClient'; +import { RecordPageViewOutputViewModel } from '@/lib/view-models/RecordPageViewOutputViewModel'; +import { RecordEngagementOutputViewModel } from '@/lib/view-models/RecordEngagementOutputViewModel'; describe('AnalyticsService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/analytics/AnalyticsService.ts b/apps/website/lib/services/analytics/AnalyticsService.ts deleted file mode 100644 index 7ee1b8174..000000000 --- a/apps/website/lib/services/analytics/AnalyticsService.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient'; -import { RecordPageViewOutputViewModel } from '../../view-models/RecordPageViewOutputViewModel'; -import { RecordEngagementOutputViewModel } from '../../view-models/RecordEngagementOutputViewModel'; -import { RecordPageViewInputDTO } from '../../types/generated/RecordPageViewInputDTO'; -import { RecordEngagementInputDTO } from '../../types/generated/RecordEngagementInputDTO'; - -/** - * Analytics Service - * - * Orchestrates analytics operations by coordinating API calls. - * All dependencies are injected via constructor. - */ -export class AnalyticsService { - constructor( - private readonly apiClient: AnalyticsApiClient - ) {} - - /** - * Record a page view - */ - async recordPageView(input: RecordPageViewInputDTO): Promise { - const result = await this.apiClient.recordPageView(input); - return new RecordPageViewOutputViewModel(result); - } - - /** - * Record an engagement event - */ - async recordEngagement(input: RecordEngagementInputDTO): Promise { - const result = await this.apiClient.recordEngagement(input); - return new RecordEngagementOutputViewModel(result); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/analytics/DashboardService.test.ts b/apps/website/lib/services/analytics/DashboardService.test.ts index 674edcddd..4e6524424 100644 --- a/apps/website/lib/services/analytics/DashboardService.test.ts +++ b/apps/website/lib/services/analytics/DashboardService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { DashboardService } from './DashboardService'; -import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient'; +import { AnalyticsApiClient } from '@/lib/api/analytics/AnalyticsApiClient'; import { AnalyticsDashboardViewModel, AnalyticsMetricsViewModel } from '../../view-models'; describe('DashboardService', () => { diff --git a/apps/website/lib/services/analytics/DashboardService.ts b/apps/website/lib/services/analytics/DashboardService.ts deleted file mode 100644 index 5a4914221..000000000 --- a/apps/website/lib/services/analytics/DashboardService.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { AnalyticsDashboardViewModel } from '@/lib/view-models/AnalyticsDashboardViewModel'; -import { AnalyticsMetricsViewModel } from '@/lib/view-models/AnalyticsMetricsViewModel'; -import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient'; - -/** - * Dashboard Service - * - * Orchestrates dashboard operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class DashboardService { - constructor( - private readonly apiClient: AnalyticsApiClient - ) {} - - /** - * Get dashboard data with view model transformation - */ - async getDashboardData(): Promise { - const dto = await this.apiClient.getDashboardData(); - return new AnalyticsDashboardViewModel(dto); - } - - /** - * Get analytics metrics with view model transformation - */ - async getAnalyticsMetrics(): Promise { - const dto = await this.apiClient.getAnalyticsMetrics(); - return new AnalyticsMetricsViewModel(dto); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/auth/AuthService.test.ts b/apps/website/lib/services/auth/AuthService.test.ts index 519ac1c8f..0af68174f 100644 --- a/apps/website/lib/services/auth/AuthService.test.ts +++ b/apps/website/lib/services/auth/AuthService.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { AuthService } from './AuthService'; -import { AuthApiClient } from '../../api/auth/AuthApiClient'; -import { SessionViewModel } from '../../view-models/SessionViewModel'; +import { AuthApiClient } from '@/lib/api/auth/AuthApiClient'; +import { SessionViewModel } from '@/lib/view-models/SessionViewModel'; describe('AuthService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/auth/AuthService.ts b/apps/website/lib/services/auth/AuthService.ts deleted file mode 100644 index 3075dfcf8..000000000 --- a/apps/website/lib/services/auth/AuthService.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { AuthApiClient } from '../../api/auth/AuthApiClient'; -import { SessionViewModel } from '../../view-models/SessionViewModel'; -import type { LoginParamsDTO } from '../../types/generated/LoginParamsDTO'; -import type { SignupParamsDTO } from '../../types/generated/SignupParamsDTO'; -import type { ForgotPasswordDTO } from '../../types/generated/ForgotPasswordDTO'; -import type { ResetPasswordDTO } from '../../types/generated/ResetPasswordDTO'; - -/** - * Auth Service - * - * Orchestrates authentication operations by coordinating API calls. - * All dependencies are injected via constructor. - */ -export class AuthService { - constructor( - private readonly apiClient: AuthApiClient - ) {} - - /** - * Sign up a new user - */ - async signup(params: SignupParamsDTO): Promise { - try { - const dto = await this.apiClient.signup(params); - return new SessionViewModel(dto.user); - } catch (error) { - throw error; - } - } - - /** - * Log in an existing user - */ - async login(params: LoginParamsDTO): Promise { - try { - const dto = await this.apiClient.login(params); - return new SessionViewModel(dto.user); - } catch (error) { - throw error; - } - } - - /** - * Log out the current user - */ - async logout(): Promise { - try { - await this.apiClient.logout(); - } catch (error) { - throw error; - } - } - - /** - * Forgot password - send reset link - */ - async forgotPassword(params: ForgotPasswordDTO): Promise<{ message: string; magicLink?: string }> { - try { - return await this.apiClient.forgotPassword(params); - } catch (error) { - throw error; - } - } - - /** - * Reset password with token - */ - async resetPassword(params: ResetPasswordDTO): Promise<{ message: string }> { - try { - return await this.apiClient.resetPassword(params); - } catch (error) { - throw error; - } - } -} \ No newline at end of file diff --git a/apps/website/lib/services/auth/SessionService.test.ts b/apps/website/lib/services/auth/SessionService.test.ts index c380d3740..a0bf07ff5 100644 --- a/apps/website/lib/services/auth/SessionService.test.ts +++ b/apps/website/lib/services/auth/SessionService.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { SessionService } from './SessionService'; -import { AuthApiClient } from '../../api/auth/AuthApiClient'; -import { SessionViewModel } from '../../view-models/SessionViewModel'; +import { AuthApiClient } from '@/lib/api/auth/AuthApiClient'; +import { SessionViewModel } from '@/lib/view-models/SessionViewModel'; describe('SessionService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/auth/SessionService.ts b/apps/website/lib/services/auth/SessionService.ts index 5690f09cc..08766aa49 100644 --- a/apps/website/lib/services/auth/SessionService.ts +++ b/apps/website/lib/services/auth/SessionService.ts @@ -1,5 +1,5 @@ import { SessionViewModel } from '@/lib/view-models/SessionViewModel'; -import { AuthApiClient } from '../../api/auth/AuthApiClient'; +import { AuthApiClient } from '@/lib/api/auth/AuthApiClient'; /** * Session Service diff --git a/apps/website/lib/services/dashboard/DashboardService.test.ts b/apps/website/lib/services/dashboard/DashboardService.test.ts index cca28c1f3..4fcf7f38d 100644 --- a/apps/website/lib/services/dashboard/DashboardService.test.ts +++ b/apps/website/lib/services/dashboard/DashboardService.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { DashboardService } from './DashboardService'; -import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient'; -import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel'; +import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient'; +import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; describe('DashboardService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/dashboard/DashboardService.ts b/apps/website/lib/services/dashboard/DashboardService.ts deleted file mode 100644 index ea21f3727..000000000 --- a/apps/website/lib/services/dashboard/DashboardService.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel'; -import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient'; -import type { DashboardOverviewDTO } from '../../types/generated/DashboardOverviewDTO'; -import type { DashboardOverviewViewModelData } from '../../view-models/DashboardOverviewViewModelData'; - -/** - * Dashboard Service - * - * Orchestrates dashboard operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class DashboardService { - constructor( - private readonly apiClient: DashboardApiClient - ) {} - - /** - * Get dashboard overview data with view model transformation - * Returns the ViewModel for backward compatibility - */ - async getDashboardOverview(): Promise { - const dto = await this.apiClient.getDashboardOverview(); - // Convert DTO to ViewModelData format for the ViewModel - const viewModelData: DashboardOverviewViewModelData = { - currentDriver: dto.currentDriver ? { - id: dto.currentDriver.id, - name: dto.currentDriver.name, - avatarUrl: dto.currentDriver.avatarUrl || '', - country: dto.currentDriver.country, - totalRaces: dto.currentDriver.totalRaces, - wins: dto.currentDriver.wins, - podiums: dto.currentDriver.podiums, - rating: dto.currentDriver.rating ?? 0, - globalRank: dto.currentDriver.globalRank ?? 0, - consistency: dto.currentDriver.consistency ?? 0, - } : undefined, - myUpcomingRaces: dto.myUpcomingRaces.map(race => ({ - id: race.id, - track: race.track, - car: race.car, - scheduledAt: new Date(race.scheduledAt).toISOString(), - status: race.status, - isMyLeague: race.isMyLeague, - })), - otherUpcomingRaces: dto.otherUpcomingRaces.map(race => ({ - id: race.id, - track: race.track, - car: race.car, - scheduledAt: new Date(race.scheduledAt).toISOString(), - status: race.status, - isMyLeague: race.isMyLeague, - })), - upcomingRaces: dto.upcomingRaces.map(race => ({ - id: race.id, - track: race.track, - car: race.car, - scheduledAt: new Date(race.scheduledAt).toISOString(), - status: race.status, - isMyLeague: race.isMyLeague, - })), - activeLeaguesCount: dto.activeLeaguesCount, - nextRace: dto.nextRace ? { - id: dto.nextRace.id, - track: dto.nextRace.track, - car: dto.nextRace.car, - scheduledAt: new Date(dto.nextRace.scheduledAt).toISOString(), - status: dto.nextRace.status, - isMyLeague: dto.nextRace.isMyLeague, - } : undefined, - recentResults: dto.recentResults.map(result => ({ - id: result.raceId, - track: result.raceName, - car: '', - position: result.position, - date: new Date(result.finishedAt).toISOString(), - })), - leagueStandingsSummaries: dto.leagueStandingsSummaries.map(standing => ({ - leagueId: standing.leagueId, - leagueName: standing.leagueName, - position: standing.position, - points: standing.points, - totalDrivers: standing.totalDrivers, - })), - feedSummary: { - notificationCount: dto.feedSummary.notificationCount, - items: dto.feedSummary.items.map(item => ({ - id: item.id, - type: item.type, - headline: item.headline, - body: item.body, - timestamp: new Date(item.timestamp).toISOString(), - ctaHref: item.ctaHref, - ctaLabel: item.ctaLabel, - })), - }, - friends: dto.friends.map(friend => ({ - id: friend.id, - name: friend.name, - avatarUrl: friend.avatarUrl || '', - country: friend.country, - })), - }; - - return new DashboardOverviewViewModel(viewModelData); - } - - /** - * Get raw DTO for page queries - */ - async getDashboardOverviewDTO(): Promise { - return await this.apiClient.getDashboardOverview(); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverRegistrationService.test.ts b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts index 77bfb0426..d264a5539 100644 --- a/apps/website/lib/services/drivers/DriverRegistrationService.test.ts +++ b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { DriverRegistrationService } from './DriverRegistrationService'; -import { DriversApiClient } from '../../api/drivers/DriversApiClient'; -import { DriverRegistrationStatusViewModel } from '../../view-models/DriverRegistrationStatusViewModel'; +import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; +import { DriverRegistrationStatusViewModel } from '@/lib/view-models/DriverRegistrationStatusViewModel'; describe('DriverRegistrationService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/drivers/DriverRegistrationService.ts b/apps/website/lib/services/drivers/DriverRegistrationService.ts deleted file mode 100644 index d06522f80..000000000 --- a/apps/website/lib/services/drivers/DriverRegistrationService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { DriversApiClient } from '../../api/drivers/DriversApiClient'; -import { DriverRegistrationStatusViewModel } from '../../view-models/DriverRegistrationStatusViewModel'; - -/** - * Driver Registration Service - * - * Orchestrates driver registration status operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class DriverRegistrationService { - constructor( - private readonly apiClient: DriversApiClient - ) {} - - /** - * Get driver registration status for a specific race - */ - async getDriverRegistrationStatus( - driverId: string, - raceId: string - ): Promise { - const dto = await this.apiClient.getRegistrationStatus(driverId, raceId); - return new DriverRegistrationStatusViewModel(dto); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverService.test.ts b/apps/website/lib/services/drivers/DriverService.test.ts index dbeadb18b..e778ecd1c 100644 --- a/apps/website/lib/services/drivers/DriverService.test.ts +++ b/apps/website/lib/services/drivers/DriverService.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { DriverService } from './DriverService'; -import { DriversApiClient } from '../../api/drivers/DriversApiClient'; -import { DriverLeaderboardViewModel } from '../../view-models/DriverLeaderboardViewModel'; -import { DriverViewModel } from '../../view-models/DriverViewModel'; -import { CompleteOnboardingViewModel } from '../../view-models/CompleteOnboardingViewModel'; +import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; +import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; +import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; +import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel'; describe('DriverService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/drivers/DriverService.ts b/apps/website/lib/services/drivers/DriverService.ts index e6358209f..180e90215 100644 --- a/apps/website/lib/services/drivers/DriverService.ts +++ b/apps/website/lib/services/drivers/DriverService.ts @@ -41,7 +41,7 @@ export class DriverService { if (!dto) { return null; } - return new DriverViewModel({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null }); + return new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null }); } /** @@ -113,7 +113,7 @@ export class DriverService { extendedProfile: dto.extendedProfile ? { socialHandles: dto.extendedProfile.socialHandles.map((h) => ({ - platform: h.platform as any, + platform: h.platform as 'twitter' | 'youtube' | 'twitch' | 'discord', handle: h.handle, url: h.url, })), @@ -121,8 +121,8 @@ export class DriverService { id: a.id, title: a.title, description: a.description, - icon: a.icon as any, - rarity: a.rarity as any, + icon: a.icon as 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap', + rarity: a.rarity as 'common' | 'rare' | 'epic' | 'legendary', earnedAt: a.earnedAt, })), racingStyle: dto.extendedProfile.racingStyle, diff --git a/apps/website/lib/services/landing/LandingService.test.ts b/apps/website/lib/services/landing/LandingService.test.ts index ef088f88c..e3cd52486 100644 --- a/apps/website/lib/services/landing/LandingService.test.ts +++ b/apps/website/lib/services/landing/LandingService.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { LandingService } from './LandingService'; -import { RacesApiClient } from '../../api/races/RacesApiClient'; -import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient'; -import { TeamsApiClient } from '../../api/teams/TeamsApiClient'; +import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel'; describe('LandingService', () => { diff --git a/apps/website/lib/services/landing/LandingService.ts b/apps/website/lib/services/landing/LandingService.ts deleted file mode 100644 index 14303db8c..000000000 --- a/apps/website/lib/services/landing/LandingService.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; -import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; -import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; -import { AuthApiClient } from '@/lib/api/auth/AuthApiClient'; -import type { AllLeaguesWithCapacityDTO } from '@/lib/types/generated/AllLeaguesWithCapacityDTO'; -import type { GetAllTeamsOutputDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO'; -import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO'; -import type { LeagueWithCapacityDTO } from '@/lib/types/generated/LeagueWithCapacityDTO'; -import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO'; -import type { SignupParamsDTO } from '@/lib/types/generated/SignupParamsDTO'; -import type { AuthSessionDTO } from '@/lib/types/generated/AuthSessionDTO'; -import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel'; -import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel'; -import { LeagueCardViewModel } from '@/lib/view-models/LeagueCardViewModel'; -import { TeamCardViewModel } from '@/lib/view-models/TeamCardViewModel'; -import { UpcomingRaceCardViewModel } from '@/lib/view-models/UpcomingRaceCardViewModel'; -import { EmailSignupViewModel } from '@/lib/view-models/EmailSignupViewModel'; - -export class LandingService { - constructor( - private readonly racesApi: RacesApiClient, - private readonly leaguesApi: LeaguesApiClient, - private readonly teamsApi: TeamsApiClient, - private readonly authApi: AuthApiClient, - ) {} - - async getHomeDiscovery(): Promise { - const [racesDto, leaguesDto, teamsDto] = await Promise.all([ - this.racesApi.getPageData() as Promise, - this.leaguesApi.getAllWithCapacity() as Promise, - this.teamsApi.getAll() as Promise, - ]); - - const racesVm = new RacesPageViewModel(racesDto); - - const topLeagues = (leaguesDto?.leagues || []).slice(0, 4).map( - (league: LeagueWithCapacityDTO) => new LeagueCardViewModel({ - id: league.id, - name: league.name, - description: league.description ?? 'Competitive iRacing league', - }), - ); - - const teams = (teamsDto?.teams || []).slice(0, 4).map( - (team: TeamListItemDTO) => - new TeamCardViewModel({ - id: team.id, - name: team.name, - tag: team.tag, - description: team.description, - logoUrl: team.logoUrl, - }), - ); - - const upcomingRaces = racesVm.upcomingRaces.slice(0, 4).map( - race => - new UpcomingRaceCardViewModel({ - id: race.id, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - }), - ); - - return new HomeDiscoveryViewModel({ - topLeagues, - teams, - upcomingRaces, - }); - } - - /** - * Sign up for early access with email - * Uses the auth signup endpoint - */ - async signup(email: string): Promise { - try { - // Create signup params with default values for early access - const signupParams: SignupParamsDTO = { - email, - password: 'temp_password_' + Math.random().toString(36).substring(7), // Temporary password - displayName: email.split('@')[0] || 'user', // Use email prefix as display name, fallback to 'user' - }; - - const session: AuthSessionDTO = await this.authApi.signup(signupParams); - - if (session?.user?.userId) { - return new EmailSignupViewModel(email, 'Welcome to GridPilot! Check your email to confirm.', 'success'); - } else { - return new EmailSignupViewModel(email, 'Signup successful but session not created.', 'error'); - } - } catch (error: any) { - // Handle specific error cases - if (error?.status === 429) { - return new EmailSignupViewModel(email, 'Too many requests. Please try again later.', 'error'); - } - if (error?.status === 409) { - return new EmailSignupViewModel(email, 'This email is already registered.', 'info'); - } - return new EmailSignupViewModel(email, 'Something broke. Try again?', 'error'); - } - } -} \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.test.ts b/apps/website/lib/services/leagues/LeagueMembershipService.test.ts index e0fcb5023..e4cde80bb 100644 --- a/apps/website/lib/services/leagues/LeagueMembershipService.test.ts +++ b/apps/website/lib/services/leagues/LeagueMembershipService.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { LeagueMembershipService } from './LeagueMembershipService'; -import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient'; -import { LeagueMemberViewModel } from '../../view-models/LeagueMemberViewModel'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel'; describe('LeagueMembershipService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.ts b/apps/website/lib/services/leagues/LeagueMembershipService.ts index f32882f8d..6f537d654 100644 --- a/apps/website/lib/services/leagues/LeagueMembershipService.ts +++ b/apps/website/lib/services/leagues/LeagueMembershipService.ts @@ -11,7 +11,7 @@ function getDefaultLeaguesApiClient(): LeaguesApiClient { if (cachedLeaguesApiClient) return cachedLeaguesApiClient; const api = new ApiClient(getWebsiteApiBaseUrl()); - cachedLeaguesApiClient = (api as any).leagues as LeaguesApiClient; + cachedLeaguesApiClient = api.leagues; return cachedLeaguesApiClient; } @@ -27,12 +27,12 @@ export class LeagueMembershipService { async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { const dto = await this.getClient().getMemberships(leagueId); - const members: LeagueMemberDTO[] = ((dto as any)?.members ?? (dto as any)?.memberships ?? []) as LeagueMemberDTO[]; + const members: LeagueMemberDTO[] = dto.members ?? []; return members.map((m) => new LeagueMemberViewModel(m, currentUserId)); } async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> { - return this.getClient().removeMember(leagueId, performerDriverId, targetDriverId) as unknown as { success: boolean }; + return this.getClient().removeMember(leagueId, performerDriverId, targetDriverId); } /** @@ -57,11 +57,11 @@ export class LeagueMembershipService { static async fetchLeagueMemberships(leagueId: string): Promise { try { const result = await getDefaultLeaguesApiClient().getMemberships(leagueId); - const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({ + const memberships: LeagueMembership[] = (result.members ?? []).map((member) => ({ id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it leagueId, driverId: member.driverId, - role: member.role, + role: member.role as 'owner' | 'admin' | 'steward' | 'member', status: 'active', // Assume active since API returns current members joinedAt: member.joinedAt, })); diff --git a/apps/website/lib/services/leagues/LeagueService.test.ts b/apps/website/lib/services/leagues/LeagueService.test.ts index 5d44156c7..bc26cb0de 100644 --- a/apps/website/lib/services/leagues/LeagueService.test.ts +++ b/apps/website/lib/services/leagues/LeagueService.test.ts @@ -1,15 +1,15 @@ -import { describe, it, expect, vi, Mocked, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest'; import { LeagueService } from './LeagueService'; -import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient'; -import { LeagueStandingsViewModel } from '../../view-models/LeagueStandingsViewModel'; -import { LeagueStatsViewModel } from '../../view-models/LeagueStatsViewModel'; -import { LeagueScheduleViewModel } from '../../view-models/LeagueScheduleViewModel'; -import { LeagueMembershipsViewModel } from '../../view-models/LeagueMembershipsViewModel'; -import { RemoveMemberViewModel } from '../../view-models/RemoveMemberViewModel'; -import { LeagueMemberViewModel } from '../../view-models/LeagueMemberViewModel'; -import type { CreateLeagueInputDTO } from '../../types/generated/CreateLeagueInputDTO'; -import type { CreateLeagueOutputDTO } from '../../types/generated/CreateLeagueOutputDTO'; -import type { RemoveLeagueMemberOutputDTO } from '../../types/generated/RemoveLeagueMemberOutputDTO'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel'; +import { LeagueStatsViewModel } from '@/lib/view-models/LeagueStatsViewModel'; +import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; +import { LeagueMembershipsViewModel } from '@/lib/view-models/LeagueMembershipsViewModel'; +import { RemoveMemberViewModel } from '@/lib/view-models/RemoveMemberViewModel'; +import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel'; +import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO'; +import type { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO'; +import type { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueMemberOutputDTO'; describe('LeagueService', () => { let mockApiClient: Mocked; @@ -114,14 +114,7 @@ describe('LeagueService', () => { }); describe('getLeagueSchedule', () => { - afterEach(() => { - vi.useRealTimers(); - }); - it('should call apiClient.getSchedule and return LeagueScheduleViewModel with Date parsing', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); - const leagueId = 'league-123'; const mockDto = { races: [ @@ -136,44 +129,7 @@ describe('LeagueService', () => { expect(mockApiClient.getSchedule).toHaveBeenCalledWith(leagueId); expect(result).toBeInstanceOf(LeagueScheduleViewModel); - expect(result.raceCount).toBe(2); - expect(result.races[0]!.scheduledAt).toBeInstanceOf(Date); - expect(result.races[0]!.isPast).toBe(true); - expect(result.races[1]!.isUpcoming).toBe(true); - }); - - it('should prefer scheduledAt over date and map optional fields/status', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); - - const leagueId = 'league-123'; - const mockDto = { - races: [ - { - id: 'race-1', - name: 'Round 1', - date: '2025-01-02T20:00:00Z', - scheduledAt: '2025-01-03T20:00:00Z', - track: 'Monza', - car: 'GT3', - sessionType: 'race', - isRegistered: true, - status: 'scheduled', - }, - ], - } as any; - - mockApiClient.getSchedule.mockResolvedValue(mockDto); - - const result = await service.getLeagueSchedule(leagueId); - - expect(result.races[0]!.scheduledAt.toISOString()).toBe('2025-01-03T20:00:00.000Z'); - expect(result.races[0]!.track).toBe('Monza'); - expect(result.races[0]!.car).toBe('GT3'); - expect(result.races[0]!.sessionType).toBe('race'); - expect(result.races[0]!.isRegistered).toBe(true); - expect(result.races[0]!.status).toBe('scheduled'); }); it('should handle empty races array', async () => { @@ -279,56 +235,6 @@ describe('LeagueService', () => { await expect(service.createLeague(input)).rejects.toThrow('API call failed'); }); - - it('should not call apiClient.create when submitBlocker is blocked', async () => { - const input: CreateLeagueInputDTO = { - name: 'New League', - description: 'A new league', - visibility: 'public', - ownerId: 'owner-1', - }; - - // First call should succeed - const mockDto: CreateLeagueOutputDTO = { - leagueId: 'new-league-id', - success: true, - }; - mockApiClient.create.mockResolvedValue(mockDto); - - await service.createLeague(input); // This should block the submitBlocker - - // Reset mock to check calls - mockApiClient.create.mockClear(); - - // Second call should not call API - await service.createLeague(input); - expect(mockApiClient.create).not.toHaveBeenCalled(); - }); - - it('should not call apiClient.create when throttle is active', async () => { - const input: CreateLeagueInputDTO = { - name: 'New League', - description: 'A new league', - visibility: 'public', - ownerId: 'owner-1', - }; - - // First call - const mockDto: CreateLeagueOutputDTO = { - leagueId: 'new-league-id', - success: true, - }; - mockApiClient.create.mockResolvedValue(mockDto); - - await service.createLeague(input); // This blocks throttle for 500ms - - // Reset mock - mockApiClient.create.mockClear(); - - // Immediate second call should not call API due to throttle - await service.createLeague(input); - expect(mockApiClient.create).not.toHaveBeenCalled(); - }); }); describe('removeMember', () => { diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index 29c07d5e2..241d0ff0e 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -4,26 +4,11 @@ import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient"; import { RacesApiClient } from "@/lib/api/races/RacesApiClient"; import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO"; import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO"; -import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel"; -import { LeagueAdminScheduleViewModel } from "@/lib/view-models/LeagueAdminScheduleViewModel"; -import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel"; -import { LeagueScheduleViewModel, type LeagueScheduleRaceViewModel } from "@/lib/view-models/LeagueScheduleViewModel"; -import { LeagueSeasonSummaryViewModel } from "@/lib/view-models/LeagueSeasonSummaryViewModel"; -import { LeagueStandingsViewModel } from "@/lib/view-models/LeagueStandingsViewModel"; -import { LeagueStatsViewModel } from "@/lib/view-models/LeagueStatsViewModel"; -import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel"; -import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel"; -import { LeaguePageDetailViewModel } from "@/lib/view-models/LeaguePageDetailViewModel"; -import { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel"; -import { RaceViewModel } from "@/lib/view-models/RaceViewModel"; -import type { LeagueAdminRosterJoinRequestViewModel } from "@/lib/view-models/LeagueAdminRosterJoinRequestViewModel"; -import type { LeagueAdminRosterMemberViewModel } from "@/lib/view-models/LeagueAdminRosterMemberViewModel"; import type { MembershipRole } from "@/lib/types/MembershipRole"; import type { LeagueRosterJoinRequestDTO } from "@/lib/types/generated/LeagueRosterJoinRequestDTO"; -import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers"; import type { RaceDTO } from "@/lib/types/generated/RaceDTO"; -import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO"; -import { LeagueScoringConfigDTO } from "@/lib/types/generated/LeagueScoringConfigDTO"; +import type { TotalLeaguesDTO } from '@/lib/types/generated/TotalLeaguesDTO'; +import type { LeagueScoringConfigDTO } from "@/lib/types/generated/LeagueScoringConfigDTO"; import type { LeagueMembership } from "@/lib/types/LeagueMembership"; import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO'; import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO'; @@ -32,70 +17,16 @@ import type { CreateLeagueScheduleRaceOutputDTO } from '@/lib/types/generated/Cr import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/UpdateLeagueScheduleRaceInputDTO'; import type { LeagueScheduleRaceMutationSuccessDTO } from '@/lib/types/generated/LeagueScheduleRaceMutationSuccessDTO'; import type { LeagueSeasonSchedulePublishOutputDTO } from '@/lib/types/generated/LeagueSeasonSchedulePublishOutputDTO'; - +import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO'; +import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO'; /** - * League Service + * League Service - DTO Only * - * Orchestrates league operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. + * Returns raw API DTOs. No ViewModels or UX logic. + * All client-side presentation logic must be handled by hooks/components. */ -function parseIsoDate(value: string, fallback: Date): Date { - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return fallback; - return parsed; -} - -function getBestEffortIsoDate(race: RaceDTO): string | undefined { - const anyRace = race as unknown as { scheduledAt?: unknown; date?: unknown }; - - if (typeof anyRace.scheduledAt === 'string') return anyRace.scheduledAt; - if (typeof anyRace.date === 'string') return anyRace.date; - - return undefined; -} - -function getOptionalStringField(race: RaceDTO, key: string): string | undefined { - const anyRace = race as unknown as Record; - const value = anyRace[key]; - return typeof value === 'string' ? value : undefined; -} - -function getOptionalBooleanField(race: RaceDTO, key: string): boolean | undefined { - const anyRace = race as unknown as Record; - const value = anyRace[key]; - return typeof value === 'boolean' ? value : undefined; -} - -function mapLeagueScheduleDtoToRaceViewModels(dto: LeagueScheduleDTO, now: Date = new Date()): LeagueScheduleRaceViewModel[] { - return dto.races.map((race) => { - const iso = getBestEffortIsoDate(race); - const scheduledAt = iso ? parseIsoDate(iso, new Date(0)) : new Date(0); - - const isPast = scheduledAt.getTime() < now.getTime(); - const isUpcoming = !isPast; - - const status = getOptionalStringField(race, 'status') ?? (isPast ? 'completed' : 'scheduled'); - - return { - id: race.id, - name: race.name, - scheduledAt, - isPast, - isUpcoming, - status, - track: getOptionalStringField(race, 'track'), - car: getOptionalStringField(race, 'car'), - sessionType: getOptionalStringField(race, 'sessionType'), - isRegistered: getOptionalBooleanField(race, 'isRegistered'), - }; - }); -} - export class LeagueService { - private readonly submitBlocker = new SubmitBlocker(); - private readonly throttle = new ThrottleBlocker(500); - constructor( private readonly apiClient: LeaguesApiClient, private readonly driversApiClient?: DriversApiClient, @@ -103,117 +34,49 @@ export class LeagueService { private readonly racesApiClient?: RacesApiClient ) {} - /** - * Get all leagues with view model transformation - */ - async getAllLeagues(): Promise { - const dto = await this.apiClient.getAllWithCapacityAndScoring(); - const leagues = Array.isArray((dto as any)?.leagues) ? ((dto as any).leagues as any[]) : []; - - return leagues.map((league) => ({ - id: league.id, - name: league.name, - description: league.description, - logoUrl: league.logoUrl ?? null, // Use API-provided logo URL - ownerId: league.ownerId, - createdAt: league.createdAt, - maxDrivers: league.settings?.maxDrivers ?? 0, - usedDriverSlots: league.usedSlots ?? 0, - structureSummary: league.scoring?.scoringPresetName ?? 'Custom rules', - scoringPatternSummary: league.scoring?.scoringPatternSummary, - timingSummary: league.timingSummary ?? '', - ...(league.category ? { category: league.category } : {}), - ...(league.scoring ? { scoring: league.scoring } : {}), - })); + async getAllLeagues(): Promise { + return this.apiClient.getAllWithCapacityAndScoring(); } - /** - * Get league standings with view model transformation - */ - async getLeagueStandings(leagueId: string, currentUserId: string): Promise { - // Core standings (positions, points, driverIds) - const dto = await this.apiClient.getStandings(leagueId); - const standings = ((dto as any)?.standings ?? []) as any[]; - - // League memberships (roles, statuses) - const membershipsDto = await this.apiClient.getMemberships(leagueId); - const membershipEntries = ((membershipsDto as any)?.members ?? (membershipsDto as any)?.memberships ?? []) as any[]; - - const memberships: LeagueMembership[] = membershipEntries.map((m) => ({ - driverId: m.driverId, - leagueId, - role: (m.role as LeagueMembership['role']) ?? 'member', - joinedAt: m.joinedAt, - status: 'active', - })); - - // Resolve unique drivers that appear in standings - const driverIds: string[] = Array.from(new Set(standings.map((entry: any) => entry.driverId))); - const driverDtos = this.driversApiClient - ? await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id))) - : []; - const drivers = driverDtos.filter((d): d is NonNullable => d !== null); - - const dtoWithExtras = { standings, drivers, memberships }; - - return new LeagueStandingsViewModel(dtoWithExtras, currentUserId); + async getLeagueStandings(leagueId: string): Promise { + return this.apiClient.getStandings(leagueId); } - /** - * Get league statistics - */ - async getLeagueStats(): Promise { - const dto = await this.apiClient.getTotal(); - return new LeagueStatsViewModel(dto); + async getLeagueStats(): Promise { + return this.apiClient.getTotal(); } - /** - * Get league schedule - * - * Service boundary: returns ViewModels only (no DTOs / mappers in UI). - */ - async getLeagueSchedule(leagueId: string): Promise { - const dto = await this.apiClient.getSchedule(leagueId); - const races = mapLeagueScheduleDtoToRaceViewModels(dto); - return new LeagueScheduleViewModel(races); + async getLeagueSchedule(leagueId: string): Promise { + return this.apiClient.getSchedule(leagueId); } - /** - * Admin schedule editor API (ViewModel boundary) - */ - async getLeagueSeasonSummaries(leagueId: string): Promise { - const dtos = await this.apiClient.getSeasons(leagueId); - return dtos.map((dto) => new LeagueSeasonSummaryViewModel(dto)); + async getLeagueSeasons(leagueId: string): Promise { + return this.apiClient.getSeasons(leagueId); } - async getAdminSchedule(leagueId: string, seasonId: string): Promise { - const dto = await this.apiClient.getSchedule(leagueId, seasonId); - const races = mapLeagueScheduleDtoToRaceViewModels(dto); - return new LeagueAdminScheduleViewModel({ - seasonId: dto.seasonId, - published: dto.published, - races, - }); + async getLeagueSeasonSummaries(leagueId: string): Promise { + return this.apiClient.getSeasons(leagueId); } - async publishAdminSchedule(leagueId: string, seasonId: string): Promise { - await this.apiClient.publishSeasonSchedule(leagueId, seasonId); - return this.getAdminSchedule(leagueId, seasonId); + async getAdminSchedule(leagueId: string, seasonId: string): Promise { + return this.apiClient.getSchedule(leagueId, seasonId); } - async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise { - await this.apiClient.unpublishSeasonSchedule(leagueId, seasonId); - return this.getAdminSchedule(leagueId, seasonId); + async publishAdminSchedule(leagueId: string, seasonId: string): Promise { + return this.apiClient.publishSeasonSchedule(leagueId, seasonId); + } + + async unpublishAdminSchedule(leagueId: string, seasonId: string): Promise { + return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId); } async createAdminScheduleRace( leagueId: string, seasonId: string, input: { track: string; car: string; scheduledAtIso: string }, - ): Promise { + ): Promise { const payload: CreateLeagueScheduleRaceInputDTO = { ...input, example: '' }; - await this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload); - return this.getAdminSchedule(leagueId, seasonId); + return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, payload); } async updateAdminScheduleRace( @@ -221,47 +84,27 @@ export class LeagueService { seasonId: string, raceId: string, input: Partial<{ track: string; car: string; scheduledAtIso: string }>, - ): Promise { + ): Promise { const payload: UpdateLeagueScheduleRaceInputDTO = { ...input, example: '' }; - await this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload); - return this.getAdminSchedule(leagueId, seasonId); + return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, payload); } - async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise { - await this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId); - return this.getAdminSchedule(leagueId, seasonId); + async deleteAdminScheduleRace(leagueId: string, seasonId: string, raceId: string): Promise { + return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId); } - /** - * Legacy DTO methods (kept for existing callers) - */ - - /** - * Get league schedule DTO (season-scoped) - * - * Admin UI uses the raw DTO so it can render `published` and do CRUD refreshes. - */ async getLeagueScheduleDto(leagueId: string, seasonId: string): Promise { return this.apiClient.getSchedule(leagueId, seasonId); } - /** - * Publish a league season schedule - */ async publishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise { return this.apiClient.publishSeasonSchedule(leagueId, seasonId); } - /** - * Unpublish a league season schedule - */ async unpublishLeagueSeasonSchedule(leagueId: string, seasonId: string): Promise { return this.apiClient.unpublishSeasonSchedule(leagueId, seasonId); } - /** - * Create a schedule race for a league season - */ async createLeagueSeasonScheduleRace( leagueId: string, seasonId: string, @@ -270,9 +113,6 @@ export class LeagueService { return this.apiClient.createSeasonScheduleRace(leagueId, seasonId, input); } - /** - * Update a schedule race for a league season - */ async updateLeagueSeasonScheduleRace( leagueId: string, seasonId: string, @@ -282,9 +122,6 @@ export class LeagueService { return this.apiClient.updateSeasonScheduleRace(leagueId, seasonId, raceId, input); } - /** - * Delete a schedule race for a league season - */ async deleteLeagueSeasonScheduleRace( leagueId: string, seasonId: string, @@ -293,101 +130,30 @@ export class LeagueService { return this.apiClient.deleteSeasonScheduleRace(leagueId, seasonId, raceId); } - /** - * Get seasons for a league - */ - async getLeagueSeasons(leagueId: string): Promise { - return this.apiClient.getSeasons(leagueId); + async getLeagueMemberships(leagueId: string): Promise { + return this.apiClient.getMemberships(leagueId); } - /** - * Get league memberships - */ - async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { - const dto = await this.apiClient.getMemberships(leagueId); - return new LeagueMembershipsViewModel(dto, currentUserId); + async createLeague(input: CreateLeagueInputDTO): Promise { + return this.apiClient.create(input); } - /** - * Create a new league - */ - async createLeague(input: CreateLeagueInputDTO): Promise { - if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) { - return { success: false, leagueId: '' } as CreateLeagueOutputDTO; - } - - this.submitBlocker.block(); - this.throttle.block(); - try { - return await this.apiClient.create(input); - } finally { - this.submitBlocker.release(); - } - } - - /** - * Remove a member from league - * - * Overload: - * - Legacy: removeMember(leagueId, performerDriverId, targetDriverId) - * - Admin roster: removeMember(leagueId, targetDriverId) (actor derived from session) - */ - async removeMember(leagueId: string, targetDriverId: string): Promise<{ success: boolean }>; - async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise; - async removeMember(leagueId: string, arg1: string, arg2?: string): Promise<{ success: boolean } | RemoveMemberViewModel> { - if (arg2 === undefined) { - const dto = await this.apiClient.removeRosterMember(leagueId, arg1); - return { success: dto.success }; - } - - const dto = await this.apiClient.removeMember(leagueId, arg1, arg2); - return new RemoveMemberViewModel(dto as any); + async removeMember(leagueId: string, targetDriverId: string): Promise<{ success: boolean }> { + const dto = await this.apiClient.removeRosterMember(leagueId, targetDriverId); + return { success: dto.success }; } - /** - * Update a member's role in league - * - * Overload: - * - Legacy: updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole) - * - Admin roster: updateMemberRole(leagueId, targetDriverId, newRole) (actor derived from session) - */ - async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise<{ success: boolean }>; - async updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }>; - async updateMemberRole(leagueId: string, arg1: string, arg2: string, arg3?: string): Promise<{ success: boolean }> { - if (arg3 === undefined) { - const dto = await this.apiClient.updateRosterMemberRole(leagueId, arg1, arg2); - return { success: dto.success }; - } - - return this.apiClient.updateMemberRole(leagueId, arg1, arg2, arg3); + async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise<{ success: boolean }> { + const dto = await this.apiClient.updateRosterMemberRole(leagueId, targetDriverId, newRole); + return { success: dto.success }; } - /** - * Admin roster: members list as ViewModels - */ - async getAdminRosterMembers(leagueId: string): Promise { - const dtos = await this.apiClient.getAdminRosterMembers(leagueId); - return dtos.map((dto) => ({ - driverId: dto.driverId, - driverName: dto.driver?.name ?? dto.driverId, - role: (dto.role as MembershipRole) ?? 'member', - joinedAtIso: dto.joinedAt, - })); + async getAdminRosterMembers(leagueId: string): Promise { + return this.apiClient.getAdminRosterMembers(leagueId); } - /** - * Admin roster: join requests list as ViewModels - */ - async getAdminRosterJoinRequests(leagueId: string): Promise { - const dtos = await this.apiClient.getAdminRosterJoinRequests(leagueId); - return dtos.map((dto) => ({ - id: dto.id, - leagueId: dto.leagueId, - driverId: dto.driverId, - driverName: this.resolveJoinRequestDriverName(dto), - requestedAtIso: dto.requestedAt, - message: dto.message, - })); + async getAdminRosterJoinRequests(leagueId: string): Promise { + return this.apiClient.getAdminRosterJoinRequests(leagueId); } async approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }> { @@ -400,214 +166,14 @@ export class LeagueService { return { success: dto.success }; } - private resolveJoinRequestDriverName(dto: LeagueRosterJoinRequestDTO): string { - const driver = dto.driver as any; - const name = driver && typeof driver === 'object' ? (driver.name as string | undefined) : undefined; - return name ?? dto.driverId; + async getLeagueDetail(leagueId: string): Promise { + return this.apiClient.getAllWithCapacityAndScoring(); } - /** - * Get league detail with owner, membership, and sponsor info - */ - async getLeagueDetail(leagueId: string, currentDriverId: string): Promise { - if (!this.driversApiClient) return null; - - // For now, assume league data comes from getAllWithCapacity or a new endpoint - // Since API may not have detailed league, we'll mock or assume - // In real implementation, add getLeagueDetail to API - const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); - const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : []; - const leagueDto = leagues.find((l) => l?.id === leagueId); - if (!leagueDto) return null; - - // LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided - const league = { - id: leagueDto.id, - name: leagueDto.name, - description: leagueDto.description ?? 'Description not available', - ownerId: leagueDto.ownerId ?? 'owner-id', - }; - - // Get owner - const owner = await this.driversApiClient.getDriver(league.ownerId); - const ownerName = owner ? (owner as any).name : `${league.ownerId.slice(0, 8)}...`; - - // Get membership - const membershipsDto = await this.apiClient.getMemberships(leagueId); - const members = Array.isArray((membershipsDto as any)?.members) ? ((membershipsDto as any).members as any[]) : []; - const membership = members.find((m: any) => m?.driverId === currentDriverId); - const isAdmin = membership ? ['admin', 'owner'].includes((membership as any).role) : false; - - // Get main sponsor - let mainSponsor = null; - if (this.sponsorsApiClient) { - try { - const seasons = await this.apiClient.getSeasons(leagueId); - const seasonList = Array.isArray(seasons) ? (seasons as any[]) : []; - const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0]; - if (activeSeason) { - const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId); - const sponsorships = Array.isArray((sponsorshipsDto as any)?.sponsorships) - ? ((sponsorshipsDto as any).sponsorships as any[]) - : []; - const mainSponsorship = sponsorships.find((s: any) => s?.tier === 'main' && s?.status === 'active'); - if (mainSponsorship) { - const sponsorId = (mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id; - if (sponsorId) { - const sponsorResult = await this.sponsorsApiClient.getSponsor(sponsorId); - const sponsor = (sponsorResult as any)?.sponsor ?? null; - if (sponsor) { - mainSponsor = { - name: sponsor.name, - logoUrl: sponsor.logoUrl ?? '', - websiteUrl: sponsor.websiteUrl ?? '', - }; - } - } - } - } - } catch (error) { - console.warn('Failed to load main sponsor:', error); - } - } - - return new LeaguePageDetailViewModel({ - league: { - id: league.id, - name: league.name, - game: 'iRacing', - tier: 'standard', - season: 'Season 1', - description: league.description, - drivers: 0, - races: 0, - completedRaces: 0, - totalImpressions: 0, - avgViewsPerRace: 0, - engagement: 0, - rating: 0, - seasonStatus: 'active', - seasonDates: { start: new Date().toISOString(), end: new Date().toISOString() }, - sponsorSlots: { - main: { available: true, price: 800, benefits: [] }, - secondary: { available: 2, total: 2, price: 250, benefits: [] } - } - }, - drivers: [], - races: [] - }); + async getLeagueDetailPageData(leagueId: string): Promise { + return this.apiClient.getAllWithCapacityAndScoring(); } - /** - * Get comprehensive league detail page data - */ - async getLeagueDetailPageData(leagueId: string): Promise { - if (!this.driversApiClient || !this.sponsorsApiClient) return null; - - try { - // Get league basic info - const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); - const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : []; - const league = leagues.find((l) => l?.id === leagueId); - if (!league) return null; - - // Get owner - const owner = await this.driversApiClient.getDriver(league.ownerId); - - // League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI - const scoringConfig: LeagueScoringConfigDTO | null = null; - - // Drivers list is limited to those present in memberships until a dedicated league-drivers endpoint exists - const memberships = await this.apiClient.getMemberships(leagueId); - const membershipMembers = Array.isArray((memberships as any)?.members) ? ((memberships as any).members as any[]) : []; - const driverIds = membershipMembers.map((m: any) => m?.driverId).filter((id: any): id is string => typeof id === 'string'); - const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id))); - const drivers = driverDtos.filter((d: any): d is NonNullable => d !== null); - - // Get all races for this league via the leagues API helper - // Service boundary hardening: tolerate `null/undefined` arrays from API. - const leagueRaces = await this.apiClient.getRaces(leagueId); - const allRaces = (leagueRaces.races ?? []).map((race) => new RaceViewModel(race)); - - // League stats endpoint currently returns global league statistics rather than per-league values - const leagueStats: LeagueStatsDTO = { - totalMembers: league.usedSlots, - totalRaces: allRaces.length, - averageRating: 0, - }; - - // Get sponsors - const sponsors = await this.getLeagueSponsors(leagueId); - - return new LeagueDetailPageViewModel( - league, - owner, - scoringConfig, - drivers, - memberships, - allRaces, - leagueStats, - sponsors - ); - } catch (error) { - console.error('Failed to load league detail page data:', error); - return null; - } - } - - /** - * Get sponsors for a league - */ - private async getLeagueSponsors(leagueId: string): Promise { - if (!this.sponsorsApiClient) return []; - - try { - const seasons = await this.apiClient.getSeasons(leagueId); - const seasonList = Array.isArray(seasons) ? (seasons as any[]) : []; - const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0]; - - if (!activeSeason) return []; - - const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId); - const sponsorshipList = Array.isArray((sponsorships as any)?.sponsorships) - ? ((sponsorships as any).sponsorships as any[]) - : []; - const activeSponsorships = sponsorshipList.filter((s: any) => s?.status === 'active'); - - const sponsorInfos: SponsorInfo[] = []; - for (const sponsorship of activeSponsorships) { - const sponsorResult = await this.sponsorsApiClient.getSponsor((sponsorship as any).sponsorId ?? (sponsorship as any).sponsor?.id); - const sponsor = (sponsorResult as any)?.sponsor ?? null; - if (sponsor) { - // Tagline is not supplied by the sponsor API in this build; callers may derive one from marketing content if needed - sponsorInfos.push({ - id: sponsor.id, - name: sponsor.name, - logoUrl: sponsor.logoUrl ?? '', - websiteUrl: sponsor.websiteUrl ?? '', - tier: ((sponsorship as any).tier as 'main' | 'secondary') ?? 'secondary', - tagline: '', - }); - } - } - - // Sort: main sponsors first, then secondary - sponsorInfos.sort((a, b) => { - if (a.tier === 'main' && b.tier !== 'main') return -1; - if (a.tier !== 'main' && b.tier === 'main') return 1; - return 0; - }); - - return sponsorInfos; - } catch (error) { - console.warn('Failed to load sponsors:', error); - return []; - } - } - - /** - * Get league scoring presets - */ async getScoringPresets(): Promise { const result = await this.apiClient.getScoringPresets(); return result.presets; diff --git a/apps/website/lib/services/leagues/LeagueSettingsService.test.ts b/apps/website/lib/services/leagues/LeagueSettingsService.test.ts index 38bf0c3ff..9bdc63f33 100644 --- a/apps/website/lib/services/leagues/LeagueSettingsService.test.ts +++ b/apps/website/lib/services/leagues/LeagueSettingsService.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { LeagueSettingsService } from './LeagueSettingsService'; -import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient'; -import { DriversApiClient } from '../../api/drivers/DriversApiClient'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; describe('LeagueSettingsService', () => { diff --git a/apps/website/lib/services/leagues/LeagueSettingsService.ts b/apps/website/lib/services/leagues/LeagueSettingsService.ts deleted file mode 100644 index 91448f5fc..000000000 --- a/apps/website/lib/services/leagues/LeagueSettingsService.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient"; -import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient"; -import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel"; -import type { LeagueScoringPresetDTO } from "@/lib/types/generated/LeagueScoringPresetDTO"; -import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel"; -import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel"; -import type { LeagueScoringPresetViewModel } from "@/lib/view-models/LeagueScoringPresetViewModel"; -import type { CustomPointsConfig } from "@/lib/view-models/ScoringConfigurationViewModel"; - -/** - * League Settings Service - * - * Orchestrates league settings operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class LeagueSettingsService { - constructor( - private readonly leaguesApiClient: LeaguesApiClient, - private readonly driversApiClient: DriversApiClient - ) {} - - /** - * Get league settings with view model transformation - */ - async getLeagueSettings(leagueId: string): Promise { - try { - // Get league basic info (includes ownerId in DTO) - const allLeagues = await this.leaguesApiClient.getAllWithCapacity(); - const leagueDto = allLeagues.leagues.find(l => l.id === leagueId); - if (!leagueDto) return null; - - const league = { - id: leagueDto.id, - name: leagueDto.name, - ownerId: leagueDto.ownerId, - createdAt: leagueDto.createdAt || new Date().toISOString(), - }; - - // Get config - const configDto = await this.leaguesApiClient.getLeagueConfig(leagueId); - const config: LeagueConfigFormModel = (configDto.form ?? undefined) as unknown as LeagueConfigFormModel; - - // Get presets - const presetsDto = await this.leaguesApiClient.getScoringPresets(); - const presets: LeagueScoringPresetDTO[] = presetsDto.presets; - - // Get leaderboard once so we can hydrate rating / rank for owner + members - const leaderboardDto = await this.driversApiClient.getLeaderboard(); - const leaderboardByDriverId = new Map( - leaderboardDto.drivers.map(driver => [driver.id, driver]) - ); - - // Get owner - const ownerDriver = await this.driversApiClient.getDriver(league.ownerId); - let owner: DriverSummaryViewModel | null = null; - if (ownerDriver) { - const ownerStats = leaderboardByDriverId.get(ownerDriver.id); - owner = new DriverSummaryViewModel({ - driver: ownerDriver, - rating: ownerStats?.rating ?? null, - rank: ownerStats?.rank ?? null, - }); - } - - // Get members - const membershipsDto = await this.leaguesApiClient.getMemberships(leagueId); - const members: DriverSummaryViewModel[] = []; - for (const member of membershipsDto.members) { - if (member.driverId !== league.ownerId && member.role !== 'owner') { - const driver = await this.driversApiClient.getDriver(member.driverId); - if (driver) { - const memberStats = leaderboardByDriverId.get(driver.id); - members.push(new DriverSummaryViewModel({ - driver, - rating: memberStats?.rating ?? null, - rank: memberStats?.rank ?? null, - })); - } - } - } - - return new LeagueSettingsViewModel({ - league, - config, - presets, - owner, - members, - }); - } catch (error) { - console.error('Failed to load league settings:', error); - return null; - } - } - - /** - * Transfer league ownership - */ - async transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise { - try { - const result = await this.leaguesApiClient.transferOwnership(leagueId, currentOwnerId, newOwnerId); - return result.success; - } catch (error) { - console.error('Failed to transfer ownership:', error); - throw error; - } - } - - /** - * Select a scoring preset - */ - selectScoringPreset( - currentForm: LeagueConfigFormModel, - presetId: string - ): LeagueConfigFormModel { - return { - ...currentForm, - scoring: { - ...currentForm.scoring, - patternId: presetId, - customScoringEnabled: false, - }, - }; - } - - /** - * Toggle custom scoring - */ - toggleCustomScoring(currentForm: LeagueConfigFormModel): LeagueConfigFormModel { - return { - ...currentForm, - scoring: { - ...currentForm.scoring, - customScoringEnabled: !currentForm.scoring.customScoringEnabled, - }, - }; - } - - /** - * Update championship settings - */ - updateChampionship( - currentForm: LeagueConfigFormModel, - key: keyof LeagueConfigFormModel['championships'], - value: boolean - ): LeagueConfigFormModel { - return { - ...currentForm, - championships: { - ...currentForm.championships, - [key]: value, - }, - }; - } - - /** - * Get preset emoji based on name - */ - getPresetEmoji(preset: LeagueScoringPresetViewModel): string { - const name = preset.name.toLowerCase(); - if (name.includes('sprint') || name.includes('double')) return '⚡'; - if (name.includes('endurance') || name.includes('long')) return '🏆'; - if (name.includes('club') || name.includes('casual')) return '🏅'; - return '🏁'; - } - - /** - * Get preset description based on name - */ - getPresetDescription(preset: LeagueScoringPresetViewModel): string { - const name = preset.name.toLowerCase(); - if (name.includes('sprint')) return 'Sprint + Feature race'; - if (name.includes('endurance')) return 'Long-form endurance'; - if (name.includes('club')) return 'Casual league format'; - return preset.sessionSummary; - } - - /** - * Get preset info content for flyout - */ - getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } { - const name = presetName.toLowerCase(); - if (name.includes('sprint')) { - return { - title: 'Sprint + Feature Format', - description: 'A two-race weekend format with a shorter sprint race and a longer feature race.', - details: [ - 'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)', - 'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)', - 'Grid for feature often based on sprint results', - 'Great for competitive leagues with time for multiple races', - ], - }; - } - if (name.includes('endurance') || name.includes('long')) { - return { - title: 'Endurance Format', - description: 'Long-form racing focused on consistency and strategy over raw pace.', - details: [ - 'Single race per weekend, longer duration (60-90+ minutes)', - 'Higher points for finishing (rewards reliability)', - 'Often includes mandatory pit stops', - 'Best for serious leagues with dedicated racers', - ], - }; - } - if (name.includes('club') || name.includes('casual')) { - return { - title: 'Club/Casual Format', - description: 'Relaxed format perfect for community leagues and casual racing.', - details: [ - 'Simple points structure, easy to understand', - 'Typically single race per weekend', - 'Lower stakes, focus on participation', - 'Great for beginners or mixed-skill leagues', - ], - }; - } - return { - title: 'Standard Race Format', - description: 'Traditional single-race weekend with standard F1-style points.', - details: [ - 'Points: 25-18-15-12-10-8-6-4-2-1 for top 10', - 'Bonus points for pole position and fastest lap', - 'One race per weekend', - 'The most common format used in sim racing', - ], - }; - } - - /** - * Get championship info content for flyout - */ - getChampionshipInfoContent(key: string): { title: string; description: string; details: string[] } { - const info: Record = { - enableDriverChampionship: { - title: 'Driver Championship', - description: 'Track individual driver performance across all races in the season.', - details: [ - 'Each driver accumulates points based on race finishes', - 'The driver with most points at season end wins', - 'Standard in all racing leagues', - 'Shows overall driver skill and consistency', - ], - }, - enableTeamChampionship: { - title: 'Team Championship', - description: 'Combine points from all drivers within a team for team standings.', - details: [ - 'All drivers\' points count toward team total', - 'Rewards having consistent performers across the roster', - 'Creates team strategy opportunities', - 'Only available in Teams mode leagues', - ], - }, - enableNationsChampionship: { - title: 'Nations Cup', - description: 'Group drivers by nationality for international competition.', - details: [ - 'Drivers represent their country automatically', - 'Points pooled by nationality', - 'Adds international rivalry element', - 'Great for diverse, international leagues', - ], - }, - enableTrophyChampionship: { - title: 'Trophy Championship', - description: 'A special category championship for specific classes or groups.', - details: [ - 'Custom category you define (e.g., Am drivers, rookies)', - 'Separate standings from main championship', - 'Encourages participation from all skill levels', - 'Can be used for gentleman drivers, newcomers, etc.', - ], - }, - }; - - return info[key] || { - title: 'Championship', - description: 'A championship standings category.', - details: ['Enable to track this type of championship.'], - }; - } -} \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueStewardingService.test.ts b/apps/website/lib/services/leagues/LeagueStewardingService.test.ts index c6f250322..f1580acc6 100644 --- a/apps/website/lib/services/leagues/LeagueStewardingService.test.ts +++ b/apps/website/lib/services/leagues/LeagueStewardingService.test.ts @@ -5,7 +5,7 @@ import { ProtestService } from '../protests/ProtestService'; import { PenaltyService } from '../penalties/PenaltyService'; import { DriverService } from '../drivers/DriverService'; import { LeagueMembershipService } from './LeagueMembershipService'; -import { LeagueStewardingViewModel } from '../../view-models/LeagueStewardingViewModel'; +import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel'; describe('LeagueStewardingService', () => { let mockRaceService: Mocked; diff --git a/apps/website/lib/services/leagues/LeagueStewardingService.ts b/apps/website/lib/services/leagues/LeagueStewardingService.ts deleted file mode 100644 index b4d398707..000000000 --- a/apps/website/lib/services/leagues/LeagueStewardingService.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { RaceService } from '../races/RaceService'; -import { ProtestService } from '../protests/ProtestService'; -import { PenaltyService } from '../penalties/PenaltyService'; -import { DriverService } from '../drivers/DriverService'; -import { LeagueMembershipService } from './LeagueMembershipService'; -import { LeagueStewardingViewModel, RaceWithProtests } from '../../view-models/LeagueStewardingViewModel'; -import type { ProtestDetailViewModel } from '../../view-models/ProtestDetailViewModel'; - -/** - * League Stewarding Service - * - * Orchestrates league stewarding operations by coordinating calls to race, protest, penalty, driver, and membership services. - * All dependencies are injected via constructor. - */ -export class LeagueStewardingService { - private getPenaltyValueLabel(valueKind: string): string { - switch (valueKind) { - case 'seconds': - return 'seconds'; - case 'grid_positions': - return 'positions'; - case 'points': - return 'points'; - case 'races': - return 'races'; - case 'none': - return ''; - default: - return ''; - } - } - - private getFallbackDefaultPenaltyValue(valueKind: string): number { - switch (valueKind) { - case 'seconds': - return 5; - case 'grid_positions': - return 3; - case 'points': - return 5; - case 'races': - return 1; - case 'none': - return 0; - default: - return 0; - } - } - constructor( - private readonly raceService: RaceService, - private readonly protestService: ProtestService, - private readonly penaltyService: PenaltyService, - private readonly driverService: DriverService, - private readonly leagueMembershipService: LeagueMembershipService - ) {} - - /** - * Get league stewarding data for all races in a league - */ - async getLeagueStewardingData(leagueId: string): Promise { - // Get all races for this league - const leagueRaces = await this.raceService.findByLeagueId(leagueId); - - // Get protests and penalties for each race - const protestsMap: Record = {}; - const penaltiesMap: Record = {}; - const driverIds = new Set(); - - for (const race of leagueRaces) { - const raceProtests = await this.protestService.findByRaceId(race.id); - const racePenalties = await this.penaltyService.findByRaceId(race.id); - - protestsMap[race.id] = raceProtests; - penaltiesMap[race.id] = racePenalties; - - // Collect driver IDs - raceProtests.forEach((p: any) => { - driverIds.add(p.protestingDriverId); - driverIds.add(p.accusedDriverId); - }); - racePenalties.forEach((p: any) => { - driverIds.add(p.driverId); - }); - } - - // Load driver info - const driverEntities = await this.driverService.findByIds(Array.from(driverIds)); - const driverMap: Record = {}; - driverEntities.forEach((driver) => { - if (driver) { - driverMap[driver.id] = driver; - } - }); - - // Compute race data with protest/penalty info - const racesWithData: RaceWithProtests[] = leagueRaces.map(race => { - const protests = protestsMap[race.id] || []; - const penalties = penaltiesMap[race.id] || []; - return { - race: { - id: race.id, - track: race.track, - scheduledAt: new Date(race.scheduledAt), - }, - pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'), - resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'), - penalties - }; - }).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime()); - - return new LeagueStewardingViewModel(racesWithData, driverMap); - } - - /** - * Get protest review details as a page-ready view model - */ - async getProtestDetailViewModel(leagueId: string, protestId: string): Promise { - const [protestData, penaltyTypesReference] = await Promise.all([ - this.protestService.getProtestById(leagueId, protestId), - this.penaltyService.getPenaltyTypesReference(), - ]); - - if (!protestData) { - throw new Error('Protest not found'); - } - - const penaltyUiDefaults: Record = { - time_penalty: { label: 'Time Penalty', description: 'Add seconds to race result', defaultValue: 5 }, - grid_penalty: { label: 'Grid Penalty', description: 'Grid positions for next race', defaultValue: 3 }, - points_deduction: { label: 'Points Deduction', description: 'Deduct championship points', defaultValue: 5 }, - disqualification: { label: 'Disqualification', description: 'Disqualify from race', defaultValue: 0 }, - warning: { label: 'Warning', description: 'Official warning only', defaultValue: 0 }, - license_points: { label: 'License Points', description: 'Safety rating penalty', defaultValue: 2 }, - }; - - const penaltyTypes = (penaltyTypesReference?.penaltyTypes ?? []).map((ref: any) => { - const ui = penaltyUiDefaults[ref.type]; - const valueLabel = this.getPenaltyValueLabel(String(ref.valueKind ?? 'none')); - const defaultValue = ui?.defaultValue ?? this.getFallbackDefaultPenaltyValue(String(ref.valueKind ?? 'none')); - - return { - type: String(ref.type), - label: ui?.label ?? String(ref.type).replaceAll('_', ' '), - description: ui?.description ?? '', - requiresValue: Boolean(ref.requiresValue), - valueLabel, - defaultValue, - }; - }); - - const timePenalty = penaltyTypes.find((p) => p.type === 'time_penalty'); - const initial = timePenalty ?? penaltyTypes[0]; - - return { - protest: protestData.protest, - race: protestData.race, - protestingDriver: protestData.protestingDriver, - accusedDriver: protestData.accusedDriver, - penaltyTypes, - defaultReasons: penaltyTypesReference?.defaultReasons ?? { upheld: '', dismissed: '' }, - initialPenaltyType: initial?.type ?? null, - initialPenaltyValue: initial?.defaultValue ?? 0, - }; - } - - /** - * Review a protest - */ - async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise { - await this.protestService.reviewProtest(input); - } - - /** - * Apply a penalty - */ - async applyPenalty(input: any): Promise { - await this.penaltyService.applyPenalty(input); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueWalletService.test.ts b/apps/website/lib/services/leagues/LeagueWalletService.test.ts index 59a97afba..48caf30c3 100644 --- a/apps/website/lib/services/leagues/LeagueWalletService.test.ts +++ b/apps/website/lib/services/leagues/LeagueWalletService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { LeagueWalletService } from './LeagueWalletService'; -import { WalletsApiClient } from '../../api/wallets/WalletsApiClient'; +import { WalletsApiClient } from '@/lib/api/wallets/WalletsApiClient'; import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; describe('LeagueWalletService', () => { @@ -97,27 +97,5 @@ describe('LeagueWalletService', () => { await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Withdrawal failed'); }); - - it('should block multiple rapid calls due to throttle', async () => { - const leagueId = 'league-123'; - const amount = 500; - const currency = 'USD'; - const seasonId = 'season-456'; - const destinationAccount = 'account-789'; - - const mockResponse = { success: true }; - mockApiClient.withdrawFromLeagueWallet.mockResolvedValue(mockResponse); - - // First call should succeed - await service.withdraw(leagueId, amount, currency, seasonId, destinationAccount); - - // Reset mock - mockApiClient.withdrawFromLeagueWallet.mockClear(); - - // Immediate second call should be blocked by throttle and throw error - await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Request blocked due to rate limiting'); - - expect(mockApiClient.withdrawFromLeagueWallet).not.toHaveBeenCalled(); - }); }); }); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueWalletService.ts b/apps/website/lib/services/leagues/LeagueWalletService.ts deleted file mode 100644 index 79e9e8266..000000000 --- a/apps/website/lib/services/leagues/LeagueWalletService.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { WalletsApiClient, LeagueWalletDTO, WithdrawRequestDTO, WithdrawResponseDTO } from '@/lib/api/wallets/WalletsApiClient'; -import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; -import { WalletTransactionViewModel } from '@/lib/view-models/WalletTransactionViewModel'; -import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers'; - -/** - * League Wallet Service - * - * Orchestrates league wallet operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class LeagueWalletService { - private readonly submitBlocker = new SubmitBlocker(); - private readonly throttle = new ThrottleBlocker(500); - - constructor( - private readonly apiClient: WalletsApiClient - ) {} - - /** - * Get wallet for a league - */ - async getWalletForLeague(leagueId: string): Promise { - const dto = await this.apiClient.getLeagueWallet(leagueId); - const transactions = dto.transactions.map(t => new WalletTransactionViewModel({ - id: t.id, - type: t.type, - description: t.description, - amount: t.amount, - fee: t.fee, - netAmount: t.netAmount, - date: new Date(t.date), - status: t.status, - reference: t.reference, - })); - return new LeagueWalletViewModel({ - balance: dto.balance, - currency: dto.currency, - totalRevenue: dto.totalRevenue, - totalFees: dto.totalFees, - totalWithdrawals: dto.totalWithdrawals, - pendingPayouts: dto.pendingPayouts, - transactions, - canWithdraw: dto.canWithdraw, - withdrawalBlockReason: dto.withdrawalBlockReason, - }); - } - - /** - * Withdraw from league wallet - */ - async withdraw(leagueId: string, amount: number, currency: string, seasonId: string, destinationAccount: string): Promise { - if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) { - throw new Error('Request blocked due to rate limiting'); - } - - this.submitBlocker.block(); - this.throttle.block(); - try { - const request: WithdrawRequestDTO = { - amount, - currency, - seasonId, - destinationAccount, - }; - return await this.apiClient.withdrawFromLeagueWallet(leagueId, request); - } finally { - this.submitBlocker.release(); - } - } -} \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueWizardService.ts b/apps/website/lib/services/leagues/LeagueWizardService.ts deleted file mode 100644 index 0cdb0a9cc..000000000 --- a/apps/website/lib/services/leagues/LeagueWizardService.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { apiClient } from '@/lib/apiClient'; -import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel'; -import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO'; - -export class LeagueWizardService { - static async createLeague( - form: LeagueWizardCommandModel, - ownerId: string, - ): Promise { - const command = form.toCreateLeagueCommand(ownerId); - const result = await apiClient.leagues.create(command); - - return result; - } - - // Static method for backward compatibility - static async createLeagueFromConfig( - form: LeagueWizardCommandModel, - ownerId: string, - ): Promise { - return this.createLeague(form, ownerId); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/media/AvatarService.test.ts b/apps/website/lib/services/media/AvatarService.test.ts index 5979e3456..8ae5b688c 100644 --- a/apps/website/lib/services/media/AvatarService.test.ts +++ b/apps/website/lib/services/media/AvatarService.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { AvatarService } from './AvatarService'; -import { MediaApiClient } from '../../api/media/MediaApiClient'; -import { RequestAvatarGenerationViewModel } from '../../view-models/RequestAvatarGenerationViewModel'; -import { AvatarViewModel } from '../../view-models/AvatarViewModel'; -import { UpdateAvatarViewModel } from '../../view-models/UpdateAvatarViewModel'; +import { MediaApiClient } from '@/lib/api/media/MediaApiClient'; +import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel'; +import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel'; +import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel'; describe('AvatarService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/media/AvatarService.ts b/apps/website/lib/services/media/AvatarService.ts deleted file mode 100644 index d9aab8ddc..000000000 --- a/apps/website/lib/services/media/AvatarService.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO'; -import { UpdateAvatarInputDTO } from '@/lib/types/generated/UpdateAvatarInputDTO'; -import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel'; -import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel'; -import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel'; -import type { MediaApiClient } from '../../api/media/MediaApiClient'; - -/** - * Avatar Service - * - * Orchestrates avatar operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class AvatarService { - constructor( - private readonly apiClient: MediaApiClient - ) {} - - /** - * Request avatar generation with view model transformation - */ - async requestAvatarGeneration(input: RequestAvatarGenerationInputDTO): Promise { - const dto = await this.apiClient.requestAvatarGeneration(input); - return new RequestAvatarGenerationViewModel(dto); - } - - /** - * Get avatar for driver with view model transformation - */ - async getAvatar(driverId: string): Promise { - const dto = await this.apiClient.getAvatar(driverId); - // Convert GetAvatarOutputDTO to AvatarDTO format - const avatarDto = { - driverId: driverId, - avatarUrl: dto.avatarUrl - }; - return new AvatarViewModel(avatarDto); - } - - /** - * Update avatar for driver with view model transformation - */ - async updateAvatar(input: UpdateAvatarInputDTO): Promise { - const dto = await this.apiClient.updateAvatar(input); - return new UpdateAvatarViewModel(dto); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/media/MediaService.test.ts b/apps/website/lib/services/media/MediaService.test.ts index 69d0bc335..850c44551 100644 --- a/apps/website/lib/services/media/MediaService.test.ts +++ b/apps/website/lib/services/media/MediaService.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { MediaService } from './MediaService'; -import { MediaApiClient } from '../../api/media/MediaApiClient'; -import { MediaViewModel } from '../../view-models/MediaViewModel'; -import { UploadMediaViewModel } from '../../view-models/UploadMediaViewModel'; -import { DeleteMediaViewModel } from '../../view-models/DeleteMediaViewModel'; +import { MediaApiClient } from '@/lib/api/media/MediaApiClient'; +import { MediaViewModel } from '@/lib/view-models/MediaViewModel'; +import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel'; +import { DeleteMediaViewModel } from '@/lib/view-models/DeleteMediaViewModel'; describe('MediaService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/media/MediaService.ts b/apps/website/lib/services/media/MediaService.ts deleted file mode 100644 index 864804577..000000000 --- a/apps/website/lib/services/media/MediaService.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { DeleteMediaViewModel } from '@/lib/view-models/DeleteMediaViewModel'; -import { MediaViewModel } from '@/lib/view-models/MediaViewModel'; -import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel'; -import type { MediaApiClient } from '../../api/media/MediaApiClient'; - -// Local request shape mirroring the media upload API contract until a generated type is available -type UploadMediaRequest = { file: File; type: string; category?: string }; - -/** - * Media Service - * - * Orchestrates media operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class MediaService { - constructor( - private readonly apiClient: MediaApiClient - ) {} - - /** - * Upload media file with view model transformation - */ - async uploadMedia(input: UploadMediaRequest): Promise { - const dto = await this.apiClient.uploadMedia(input); - return new UploadMediaViewModel(dto); - } - - /** - * Get media by ID with view model transformation - */ - async getMedia(mediaId: string): Promise { - const dto = await this.apiClient.getMedia(mediaId); - return new MediaViewModel(dto); - } - - /** - * Delete media by ID with view model transformation - */ - async deleteMedia(mediaId: string): Promise { - const dto = await this.apiClient.deleteMedia(mediaId); - return new DeleteMediaViewModel(dto); - } - - } \ No newline at end of file diff --git a/apps/website/lib/services/onboarding/OnboardingService.ts b/apps/website/lib/services/onboarding/OnboardingService.ts deleted file mode 100644 index 15beb7fa9..000000000 --- a/apps/website/lib/services/onboarding/OnboardingService.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { MediaApiClient } from '@/lib/api/media/MediaApiClient'; -import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; -import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO'; -import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO'; -import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO'; -import { ValidateFaceInputDTO } from '@/lib/types/generated/ValidateFaceInputDTO'; -import { ValidateFaceOutputDTO } from '@/lib/types/generated/ValidateFaceOutputDTO'; -import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel'; -import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel'; -import { AvatarGenerationViewModel } from '@/lib/view-models/AvatarGenerationViewModel'; - -/** - * Onboarding Service - * - * Handles the complete onboarding flow including avatar generation and profile creation. - */ -export class OnboardingService { - constructor( - private readonly mediaApiClient: MediaApiClient, - private readonly driversApiClient: DriversApiClient - ) {} - - /** - * Validate face photo using the API - */ - async validateFacePhoto(photoData: string): Promise<{ isValid: boolean; errorMessage?: string }> { - const input: ValidateFaceInputDTO = { imageData: photoData }; - const dto: ValidateFaceOutputDTO = await this.mediaApiClient.validateFacePhoto(input); - return { isValid: dto.isValid, errorMessage: dto.errorMessage }; - } - - /** - * Generate avatars based on face photo and suit color - * This method wraps the API call and returns a ViewModel - */ - async generateAvatars( - userId: string, - facePhotoData: string, - suitColor: string - ): Promise { - const input: RequestAvatarGenerationInputDTO = { - userId, - facePhotoData, - suitColor, - }; - - const dto: RequestAvatarGenerationOutputDTO = await this.mediaApiClient.requestAvatarGeneration(input); - return new AvatarGenerationViewModel(dto); - } - - /** - * Complete onboarding process - */ - async completeOnboarding( - input: CompleteOnboardingInputDTO - ): Promise { - const dto = await this.driversApiClient.completeOnboarding(input); - return new CompleteOnboardingViewModel(dto); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/payments/MembershipFeeService.test.ts b/apps/website/lib/services/payments/MembershipFeeService.test.ts index 598b20ebf..c473b14ac 100644 --- a/apps/website/lib/services/payments/MembershipFeeService.test.ts +++ b/apps/website/lib/services/payments/MembershipFeeService.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { MembershipFeeService } from './MembershipFeeService'; -import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; +import { PaymentsApiClient } from '@/lib/api/payments/PaymentsApiClient'; import { MembershipFeeViewModel } from '../../view-models'; -import type { MembershipFeeDto } from '../../types/generated'; +import type { MembershipFeeDto } from '@/lib/types/generated'; describe('MembershipFeeService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/payments/MembershipFeeService.ts b/apps/website/lib/services/payments/MembershipFeeService.ts deleted file mode 100644 index 5c4439318..000000000 --- a/apps/website/lib/services/payments/MembershipFeeService.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { MembershipFeeDTO } from '@/lib/types/generated/MembershipFeeDTO'; -import type { MemberPaymentDTO } from '@/lib/types/generated/MemberPaymentDTO'; -import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel'; -import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; - -// Response shape as returned by the membership-fees payments endpoint; mirrors the API contract until a generated type is introduced -export interface GetMembershipFeesOutputDto { - fee: MembershipFeeDTO | null; - payments: MemberPaymentDTO[]; -} - -/** - * Membership Fee Service - * - * Orchestrates membership fee operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class MembershipFeeService { - constructor( - private readonly apiClient: PaymentsApiClient - ) {} - - /** - * Get membership fees by league ID with view model transformation - */ - async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: MemberPaymentDTO[] }> { - const dto: GetMembershipFeesOutputDto = await this.apiClient.getMembershipFees({ leagueId }); - return { - fee: dto.fee ? new MembershipFeeViewModel(dto.fee) : null, - // Expose raw member payment DTOs; callers may map these into UI-specific view models if needed - payments: dto.payments, - }; - } -} \ No newline at end of file diff --git a/apps/website/lib/services/payments/PaymentService.test.ts b/apps/website/lib/services/payments/PaymentService.test.ts index 6d7c2e71c..97ca9c548 100644 --- a/apps/website/lib/services/payments/PaymentService.test.ts +++ b/apps/website/lib/services/payments/PaymentService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { PaymentService } from './PaymentService'; -import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; +import { PaymentsApiClient } from '@/lib/api/payments/PaymentsApiClient'; import { PaymentViewModel, MembershipFeeViewModel, PrizeViewModel, WalletViewModel } from '../../view-models'; describe('PaymentService', () => { diff --git a/apps/website/lib/services/payments/PaymentService.ts b/apps/website/lib/services/payments/PaymentService.ts deleted file mode 100644 index 944bf0511..000000000 --- a/apps/website/lib/services/payments/PaymentService.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel'; -import { PaymentViewModel } from '@/lib/view-models/PaymentViewModel'; -import { PrizeViewModel } from '@/lib/view-models/PrizeViewModel'; -import { WalletViewModel } from '@/lib/view-models/WalletViewModel'; -import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; -import type { PaymentDTO } from '../../types/generated/PaymentDTO'; -import type { PrizeDTO } from '../../types/generated/PrizeDTO'; - -// Local payment creation request matching the Payments API contract until a shared generated type is introduced -type CreatePaymentRequest = { - type: 'sponsorship' | 'membership_fee'; - amount: number; - payerId: string; - payerType: 'sponsor' | 'driver'; - leagueId: string; - seasonId?: string; -}; - - -/** - * Payment Service - * - * Orchestrates payment operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class PaymentService { - constructor( - private readonly apiClient: PaymentsApiClient - ) {} - - /** - * Get all payments with optional filters - */ - async getPayments(leagueId?: string, payerId?: string): Promise { - const query = (leagueId || payerId) ? { ...(leagueId && { leagueId }), ...(payerId && { payerId }) } : undefined; - const dto = await this.apiClient.getPayments(query); - return (dto?.payments || []).map((payment: PaymentDTO) => new PaymentViewModel(payment)); - } - - /** - * Get single payment by ID - */ - async getPayment(paymentId: string): Promise { - // Note: Assuming the API returns a single payment from the list - const dto = await this.apiClient.getPayments(); - const payment = (dto?.payments || []).find((p: PaymentDTO) => p.id === paymentId); - if (!payment) { - throw new Error(`Payment with ID ${paymentId} not found`); - } - return new PaymentViewModel(payment); - } - - /** - * Create a new payment - */ - async createPayment(input: CreatePaymentRequest): Promise { - const dto = await this.apiClient.createPayment(input); - return new PaymentViewModel(dto.payment); - } - - /** - * Get membership fees for a league - */ - async getMembershipFees(leagueId: string, driverId?: string): Promise { - const dto = await this.apiClient.getMembershipFees({ leagueId, ...(driverId && { driverId }) }); - return dto.fee ? new MembershipFeeViewModel(dto.fee) : null; - } - - /** - * Get prizes with optional filters - */ - async getPrizes(leagueId?: string, seasonId?: string): Promise { - const query = (leagueId || seasonId) ? { ...(leagueId && { leagueId }), ...(seasonId && { seasonId }) } : undefined; - const dto = await this.apiClient.getPrizes(query); - return (dto?.prizes || []).map((prize: PrizeDTO) => new PrizeViewModel(prize)); - } - - /** - * Get wallet for a league - */ - async getWallet(leagueId: string): Promise { - const dto = await this.apiClient.getWallet({ leagueId }); - return new WalletViewModel({ ...dto.wallet, transactions: dto.transactions }); - } - - /** - * Get payment history for a user (driver) - */ - async getPaymentHistory(payerId: string): Promise { - return await this.getPayments(undefined, payerId); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/payments/WalletService.test.ts b/apps/website/lib/services/payments/WalletService.test.ts index d39ab7ef2..0da59e5d0 100644 --- a/apps/website/lib/services/payments/WalletService.test.ts +++ b/apps/website/lib/services/payments/WalletService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { WalletService } from './WalletService'; -import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; +import { PaymentsApiClient } from '@/lib/api/payments/PaymentsApiClient'; import { WalletViewModel } from '../../view-models'; describe('WalletService', () => { diff --git a/apps/website/lib/services/payments/WalletService.ts b/apps/website/lib/services/payments/WalletService.ts deleted file mode 100644 index 8b557b27e..000000000 --- a/apps/website/lib/services/payments/WalletService.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { WalletViewModel } from '@/lib/view-models/WalletViewModel'; -import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; -import { FullTransactionDto } from '../../view-models/WalletTransactionViewModel'; - -/** - * Wallet Service - * - * Orchestrates wallet operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class WalletService { - constructor( - private readonly apiClient: PaymentsApiClient - ) {} - - /** - * Get wallet by driver ID with view model transformation - */ - async getWallet(leagueId?: string): Promise { - const { wallet, transactions } = await this.apiClient.getWallet({ leagueId }); - - // Convert TransactionDTO to FullTransactionDto format - const convertedTransactions: FullTransactionDto[] = transactions.map(t => ({ - id: t.id, - type: t.type as 'sponsorship' | 'membership' | 'withdrawal' | 'prize', - description: t.description, - amount: t.amount, - fee: t.amount * 0.05, // Calculate fee (5%) - netAmount: t.amount * 0.95, // Calculate net amount - date: new Date(t.createdAt), - status: 'completed', - referenceId: t.referenceId - })); - - return new WalletViewModel({ ...wallet, transactions: convertedTransactions }); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/penalties/PenaltyService.test.ts b/apps/website/lib/services/penalties/PenaltyService.test.ts index 19482894f..2505a1dff 100644 --- a/apps/website/lib/services/penalties/PenaltyService.test.ts +++ b/apps/website/lib/services/penalties/PenaltyService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { PenaltyService } from './PenaltyService'; -import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; +import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient'; describe('PenaltyService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/penalties/PenaltyService.ts b/apps/website/lib/services/penalties/PenaltyService.ts index f397f5c82..1bb9a1f3c 100644 --- a/apps/website/lib/services/penalties/PenaltyService.ts +++ b/apps/website/lib/services/penalties/PenaltyService.ts @@ -1,5 +1,5 @@ -import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; -import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO'; +import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient'; +import type { PenaltyTypesReferenceDTO } from '@/lib/types/PenaltyTypesReferenceDTO'; /** * Penalty Service diff --git a/apps/website/lib/services/policy/PolicyService.ts b/apps/website/lib/services/policy/PolicyService.ts index d32a445d3..5fb5ab92a 100644 --- a/apps/website/lib/services/policy/PolicyService.ts +++ b/apps/website/lib/services/policy/PolicyService.ts @@ -1,4 +1,4 @@ -import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '../../api/policy/PolicyApiClient'; +import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '@/lib/api/policy/PolicyApiClient'; export interface CapabilityEvaluationResult { isLoading: boolean; diff --git a/apps/website/lib/services/protests/ProtestService.test.ts b/apps/website/lib/services/protests/ProtestService.test.ts index 26d102e83..57f8ed844 100644 --- a/apps/website/lib/services/protests/ProtestService.test.ts +++ b/apps/website/lib/services/protests/ProtestService.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { ProtestService } from './ProtestService'; -import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; -import { ProtestViewModel } from '../../view-models/ProtestViewModel'; -import { RaceViewModel } from '../../view-models/RaceViewModel'; -import { ProtestDriverViewModel } from '../../view-models/ProtestDriverViewModel'; +import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient'; +import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel'; +import { RaceViewModel } from '@/lib/view-models/RaceViewModel'; +import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel'; describe('ProtestService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/protests/ProtestService.ts b/apps/website/lib/services/protests/ProtestService.ts deleted file mode 100644 index b091fa032..000000000 --- a/apps/website/lib/services/protests/ProtestService.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; -import { ProtestViewModel } from '../../view-models/ProtestViewModel'; -import { RaceViewModel } from '../../view-models/RaceViewModel'; -import { ProtestDriverViewModel } from '../../view-models/ProtestDriverViewModel'; -import type { LeagueAdminProtestsDTO } from '../../types/generated/LeagueAdminProtestsDTO'; -import type { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO'; -import type { RequestProtestDefenseCommandDTO } from '../../types/generated/RequestProtestDefenseCommandDTO'; -import type { ReviewProtestCommandDTO } from '../../types/generated/ReviewProtestCommandDTO'; -import type { DriverDTO } from '../../types/generated/DriverDTO'; -import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO'; -import type { ProtestIncidentDTO } from '../../types/generated/ProtestIncidentDTO'; - -export interface ProtestParticipant { - id: string; - name: string; -} - -export interface FileProtestInput { - raceId: string; - leagueId?: string; - protestingDriverId: string; - accusedDriverId: string; - lap: string; - timeInRace?: string; - description: string; - comment?: string; - proofVideoUrl?: string; -} - -/** - * Protest Service - * - * Orchestrates protest operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class ProtestService { - constructor( - private readonly apiClient: ProtestsApiClient - ) {} - - /** - * Get protests for a league with view model transformation - */ - async getLeagueProtests(leagueId: string): Promise<{ - protests: ProtestViewModel[]; - racesById: LeagueAdminProtestsDTO['racesById']; - driversById: LeagueAdminProtestsDTO['driversById']; - }> { - const dto = await this.apiClient.getLeagueProtests(leagueId); - return { - protests: dto.protests.map(protest => new ProtestViewModel(protest)), - racesById: dto.racesById, - driversById: dto.driversById, - }; - } - - /** - * Get a single protest by ID from league protests - */ - async getProtestById(leagueId: string, protestId: string): Promise<{ - protest: ProtestViewModel; - race: RaceViewModel; - protestingDriver: ProtestDriverViewModel; - accusedDriver: ProtestDriverViewModel; - } | null> { - const dto = await this.apiClient.getLeagueProtest(leagueId, protestId); - const protest = dto.protests[0]; - if (!protest) return null; - - const race = Object.values(dto.racesById)[0]; - if (!race) return null; - - // Cast to the correct type for indexing - const driversById = dto.driversById as unknown as Record; - const protestingDriver = driversById[protest.protestingDriverId]; - const accusedDriver = driversById[protest.accusedDriverId]; - - if (!protestingDriver || !accusedDriver) return null; - - return { - protest: new ProtestViewModel(protest), - race: new RaceViewModel(race), - protestingDriver: new ProtestDriverViewModel(protestingDriver), - accusedDriver: new ProtestDriverViewModel(accusedDriver), - }; - } - - /** - * Apply a penalty - */ - async applyPenalty(input: ApplyPenaltyCommandDTO): Promise { - await this.apiClient.applyPenalty(input); - } - - /** - * Request protest defense - */ - async requestDefense(input: RequestProtestDefenseCommandDTO): Promise { - await this.apiClient.requestDefense(input); - } - - /** - * Review protest - */ - async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise { - const normalizedDecision = - input.decision.toLowerCase() === 'upheld' ? 'uphold' : input.decision.toLowerCase(); - - const command: ReviewProtestCommandDTO = { - protestId: input.protestId, - stewardId: input.stewardId, - decision: normalizedDecision, - decisionNotes: input.decisionNotes, - }; - - await this.apiClient.reviewProtest(command); - } - - /** - * Find protests by race ID - */ - async findByRaceId(raceId: string): Promise { - const dto = await this.apiClient.getRaceProtests(raceId); - return dto.protests; - } - - /** - * Validate file protest input - * @throws Error with descriptive message if validation fails - */ - validateFileProtestInput(input: FileProtestInput): void { - if (!input.accusedDriverId) { - throw new Error('Please select the driver you are protesting against.'); - } - if (!input.lap || parseInt(input.lap, 10) < 0) { - throw new Error('Please enter a valid lap number.'); - } - if (!input.description.trim()) { - throw new Error('Please describe what happened.'); - } - } - - /** - * Construct file protest command from input - */ - constructFileProtestCommand(input: FileProtestInput): FileProtestCommandDTO { - this.validateFileProtestInput(input); - - const incident: ProtestIncidentDTO = { - lap: parseInt(input.lap, 10), - description: input.description.trim(), - ...(input.timeInRace ? { timeInRace: parseInt(input.timeInRace, 10) } : {}), - }; - - const command: FileProtestCommandDTO = { - raceId: input.raceId, - protestingDriverId: input.protestingDriverId, - accusedDriverId: input.accusedDriverId, - incident, - ...(input.comment?.trim() ? { comment: input.comment.trim() } : {}), - ...(input.proofVideoUrl?.trim() ? { proofVideoUrl: input.proofVideoUrl.trim() } : {}), - }; - - return command; - } -} \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceResultsService.test.ts b/apps/website/lib/services/races/RaceResultsService.test.ts index eaaba13d6..eb87e3e87 100644 --- a/apps/website/lib/services/races/RaceResultsService.test.ts +++ b/apps/website/lib/services/races/RaceResultsService.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { RaceResultsService } from './RaceResultsService'; -import { RacesApiClient } from '../../api/races/RacesApiClient'; -import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel'; -import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel'; -import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel'; -import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '../../types/generated'; +import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; +import { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel'; +import { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel'; +import { ImportRaceResultsSummaryViewModel } from '@/lib/view-models/ImportRaceResultsSummaryViewModel'; +import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '@/lib/types/generated'; describe('RaceResultsService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/races/RaceResultsService.ts b/apps/website/lib/services/races/RaceResultsService.ts deleted file mode 100644 index a8ba4a5d2..000000000 --- a/apps/website/lib/services/races/RaceResultsService.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { RacesApiClient } from '../../api/races/RacesApiClient'; -import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel'; -import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel'; -import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel'; -import type { ImportRaceResultsDTO } from '../../types/generated/ImportRaceResultsDTO'; -import { v4 as uuidv4 } from 'uuid'; - -// Define types -type ImportRaceResultsInputDto = ImportRaceResultsDTO; -type ImportRaceResultsSummaryDto = { - success: boolean; - raceId: string; - driversProcessed: number; - resultsRecorded: number; - errors?: string[]; -}; - -export interface ImportResultRowDTO { - id: string; - raceId: string; - driverId: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; -} - -export interface CSVRow { - driverId: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; -} - -/** - * Race Results Service - * - * Orchestrates race results operations including viewing, importing, and SOF calculations. - * All dependencies are injected via constructor. - */ -export class RaceResultsService { - constructor( - private readonly apiClient: RacesApiClient - ) {} - - /** - * Get race results detail with view model transformation - */ - async getResultsDetail(raceId: string, currentUserId?: string): Promise { - const dto = await this.apiClient.getResultsDetail(raceId); - return new RaceResultsDetailViewModel(dto, currentUserId || ''); - } - - /** - * Get race with strength of field calculation - */ - async getWithSOF(raceId: string): Promise { - const dto = await this.apiClient.getWithSOF(raceId); - return new RaceWithSOFViewModel(dto); - } - - /** - * Import race results and get summary - */ - async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise { - const dto = await this.apiClient.importResults(raceId, input); - return new ImportRaceResultsSummaryViewModel(dto); - } - - /** - * Parse CSV content and validate results - * @throws Error with descriptive message if validation fails - */ - parseCSV(content: string): CSVRow[] { - const lines = content.trim().split('\n'); - - if (lines.length < 2) { - throw new Error('CSV file is empty or invalid'); - } - - const headerLine = lines[0]!; - const header = headerLine.toLowerCase().split(',').map((h) => h.trim()); - const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition']; - - for (const field of requiredFields) { - if (!header.includes(field)) { - throw new Error(`Missing required field: ${field}`); - } - } - - const rows: CSVRow[] = []; - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - if (!line) { - continue; - } - const values = line.split(',').map((v) => v.trim()); - - if (values.length !== header.length) { - throw new Error( - `Invalid row ${i}: expected ${header.length} columns, got ${values.length}`, - ); - } - - const row: Record = {}; - header.forEach((field, index) => { - row[field] = values[index] ?? ''; - }); - - const driverId = row['driverid'] ?? ''; - const position = parseInt(row['position'] ?? '', 10); - const fastestLap = parseFloat(row['fastestlap'] ?? ''); - const incidents = parseInt(row['incidents'] ?? '', 10); - const startPosition = parseInt(row['startposition'] ?? '', 10); - - if (!driverId || driverId.length === 0) { - throw new Error(`Row ${i}: driverId is required`); - } - - if (Number.isNaN(position) || position < 1) { - throw new Error(`Row ${i}: position must be a positive integer`); - } - - if (Number.isNaN(fastestLap) || fastestLap < 0) { - throw new Error(`Row ${i}: fastestLap must be a non-negative number`); - } - - if (Number.isNaN(incidents) || incidents < 0) { - throw new Error(`Row ${i}: incidents must be a non-negative integer`); - } - - if (Number.isNaN(startPosition) || startPosition < 1) { - throw new Error(`Row ${i}: startPosition must be a positive integer`); - } - - rows.push({ driverId, position, fastestLap, incidents, startPosition }); - } - - const positions = rows.map((r) => r.position); - const uniquePositions = new Set(positions); - if (positions.length !== uniquePositions.size) { - throw new Error('Duplicate positions found in CSV'); - } - - const driverIds = rows.map((r) => r.driverId); - const uniqueDrivers = new Set(driverIds); - if (driverIds.length !== uniqueDrivers.size) { - throw new Error('Duplicate driver IDs found in CSV'); - } - - return rows; - } - - /** - * Transform parsed CSV rows into ImportResultRowDTO array - */ - transformToImportResults(rows: CSVRow[], raceId: string): ImportResultRowDTO[] { - return rows.map((row) => ({ - id: uuidv4(), - raceId, - driverId: row.driverId, - position: row.position, - fastestLap: row.fastestLap, - incidents: row.incidents, - startPosition: row.startPosition, - })); - } - - /** - * Parse CSV file content and transform to import results - * @throws Error with descriptive message if parsing or validation fails - */ - parseAndTransformCSV(content: string, raceId: string): ImportResultRowDTO[] { - const rows = this.parseCSV(content); - return this.transformToImportResults(rows, raceId); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.ts b/apps/website/lib/services/races/RaceService.ts deleted file mode 100644 index a0b4f63ee..000000000 --- a/apps/website/lib/services/races/RaceService.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { RacesApiClient } from '../../api/races/RacesApiClient'; -import { RaceDetailEntryViewModel } from '../../view-models/RaceDetailEntryViewModel'; -import { RaceDetailUserResultViewModel } from '../../view-models/RaceDetailUserResultViewModel'; -import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel'; -import { RacesPageViewModel } from '../../view-models/RacesPageViewModel'; -import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel'; -import type { RaceDetailsViewModel } from '../../view-models/RaceDetailsViewModel'; -import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO'; -import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO'; -/** - * Race Service - * - * Orchestrates race operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class RaceService { - constructor( - private readonly apiClient: RacesApiClient - ) {} - - /** - * Get race detail with view model transformation - */ - async getRaceDetail( - raceId: string, - driverId: string - ): Promise { - const dto = await this.apiClient.getDetail(raceId, driverId); - return new RaceDetailViewModel(dto, driverId); - } - - /** - * Get race details for pages/components (DTO-free shape) - */ - async getRaceDetails( - raceId: string, - driverId: string - ): Promise { - const dto: any = await this.apiClient.getDetail(raceId, driverId); - - const raceDto: any = dto?.race ?? null; - const leagueDto: any = dto?.league ?? null; - - const registrationDto: any = dto?.registration ?? {}; - const isUserRegistered = Boolean(registrationDto.isUserRegistered ?? registrationDto.isRegistered ?? false); - const canRegister = Boolean(registrationDto.canRegister); - - const status = String(raceDto?.status ?? ''); - const canReopenRace = status === 'completed' || status === 'cancelled'; - - return { - race: raceDto - ? { - id: String(raceDto.id ?? ''), - track: String(raceDto.track ?? ''), - car: String(raceDto.car ?? ''), - scheduledAt: String(raceDto.scheduledAt ?? ''), - status, - sessionType: String(raceDto.sessionType ?? ''), - } - : null, - league: leagueDto - ? { - id: String(leagueDto.id ?? ''), - name: String(leagueDto.name ?? ''), - description: leagueDto.description ?? null, - settings: leagueDto.settings, - } - : null, - entryList: (dto?.entryList ?? []).map((entry: any) => new RaceDetailEntryViewModel(entry, driverId)), - registration: { - canRegister, - isUserRegistered, - }, - userResult: dto?.userResult ? new RaceDetailUserResultViewModel(dto.userResult) : null, - canReopenRace, - error: dto?.error, - }; - } - - /** - * Get races page data with view model transformation - */ - async getRacesPageData(): Promise { - const dto = await this.apiClient.getPageData(); - return new RacesPageViewModel(dto); - } - - /** - * Get races page data filtered by league - */ - async getLeagueRacesPageData(leagueId: string): Promise { - const dto = await this.apiClient.getPageData(leagueId); - return new RacesPageViewModel(dto); - } - - /** - * Get all races page data with view model transformation - * Currently same as getRacesPageData, but can be extended for different filtering - */ - async getAllRacesPageData(): Promise { - const dto = await this.apiClient.getPageData(); - return new RacesPageViewModel(dto); - } - - /** - * Get total races statistics with view model transformation - */ - async getRacesTotal(): Promise { - const dto: RaceStatsDTO = await this.apiClient.getTotal(); - return new RaceStatsViewModel(dto); - } - - /** - * Register for a race - */ - async registerForRace(raceId: string, leagueId: string, driverId: string): Promise { - await this.apiClient.register(raceId, { raceId, leagueId, driverId }); - } - - /** - * Withdraw from a race - */ - async withdrawFromRace(raceId: string, driverId: string): Promise { - await this.apiClient.withdraw(raceId, { raceId, driverId }); - } - - /** - * Cancel a race - */ - async cancelRace(raceId: string): Promise { - await this.apiClient.cancel(raceId); - } - - /** - * Complete a race - */ - async completeRace(raceId: string): Promise { - await this.apiClient.complete(raceId); - } - - /** - * Re-open a race - */ - async reopenRace(raceId: string): Promise { - await this.apiClient.reopen(raceId); - } - - /** - * File a protest - */ - async fileProtest(input: FileProtestCommandDTO): Promise { - await this.apiClient.fileProtest(input); - } - - /** - * Find races by league ID - * - * The races API does not currently expose a league-filtered listing endpoint in this build, - * so this method deliberately signals that the operation is unavailable instead of making - * assumptions about URL structure. - */ - async findByLeagueId(leagueId: string): Promise { - const page = await this.getLeagueRacesPageData(leagueId); - return page.races; - } -} diff --git a/apps/website/lib/services/races/RaceStewardingService.test.ts b/apps/website/lib/services/races/RaceStewardingService.test.ts index 16ee7a456..3e9f121c9 100644 --- a/apps/website/lib/services/races/RaceStewardingService.test.ts +++ b/apps/website/lib/services/races/RaceStewardingService.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { RaceStewardingService } from './RaceStewardingService'; -import { RacesApiClient } from '../../api/races/RacesApiClient'; -import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; -import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; -import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel'; +import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; +import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient'; +import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient'; +import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel'; describe('RaceStewardingService', () => { let mockRacesApiClient: Mocked; diff --git a/apps/website/lib/services/races/RaceStewardingService.ts b/apps/website/lib/services/races/RaceStewardingService.ts deleted file mode 100644 index ead19be3b..000000000 --- a/apps/website/lib/services/races/RaceStewardingService.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { RacesApiClient } from '../../api/races/RacesApiClient'; -import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; -import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; -import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel'; - -/** - * Race Stewarding Service - * - * Orchestrates race stewarding operations by coordinating API calls for race details, - * protests, and penalties, and returning a unified view model. - */ -export class RaceStewardingService { - constructor( - private readonly racesApiClient: RacesApiClient, - private readonly protestsApiClient: ProtestsApiClient, - private readonly penaltiesApiClient: PenaltiesApiClient - ) {} - - /** - * Get race stewarding data with view model transformation - */ - async getRaceStewardingData(raceId: string, driverId: string): Promise { - // Fetch all data in parallel - const [raceDetail, protests, penalties] = await Promise.all([ - this.racesApiClient.getDetail(raceId, driverId), - this.protestsApiClient.getRaceProtests(raceId), - this.penaltiesApiClient.getRacePenalties(raceId), - ]); - - // Convert API responses to match RaceStewardingViewModel expectations - const convertedProtests = { - protests: protests.protests.map(p => ({ - id: p.id, - protestingDriverId: p.protestingDriverId, - accusedDriverId: p.accusedDriverId, - incident: { - lap: p.lap, - description: p.description - }, - filedAt: p.filedAt, - status: p.status - })), - driverMap: Object.entries(protests.driverMap).reduce((acc, [id, name]) => { - acc[id] = { id, name: name as string }; - return acc; - }, {} as Record) - }; - - const convertedPenalties = { - penalties: penalties.penalties.map(p => ({ - id: p.id, - driverId: p.driverId, - type: p.type, - value: p.value, - reason: p.reason, - notes: p.notes - })), - driverMap: Object.entries(penalties.driverMap).reduce((acc, [id, name]) => { - acc[id] = { id, name: name as string }; - return acc; - }, {} as Record) - }; - - return new RaceStewardingViewModel({ - raceDetail, - protests: convertedProtests, - penalties: convertedPenalties, - }); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorService.test.ts b/apps/website/lib/services/sponsors/SponsorService.test.ts index d40ed6c67..c51efdef2 100644 --- a/apps/website/lib/services/sponsors/SponsorService.test.ts +++ b/apps/website/lib/services/sponsors/SponsorService.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { SponsorService } from './SponsorService'; -import { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient'; -import { SponsorViewModel } from '../../view-models/SponsorViewModel'; -import { SponsorDashboardViewModel } from '../../view-models/SponsorDashboardViewModel'; -import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel'; +import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient'; +import { SponsorViewModel } from '@/lib/view-models/SponsorViewModel'; +import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel'; +import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel'; describe('SponsorService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/sponsors/SponsorService.ts b/apps/website/lib/services/sponsors/SponsorService.ts deleted file mode 100644 index 31b4aa9be..000000000 --- a/apps/website/lib/services/sponsors/SponsorService.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient'; -import { SponsorViewModel } from '../../view-models/SponsorViewModel'; -import { SponsorDashboardViewModel } from '../../view-models/SponsorDashboardViewModel'; -import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel'; -import type { CreateSponsorInputDTO } from '../../types/generated/CreateSponsorInputDTO'; -import type { SponsorDTO } from '../../types/generated/SponsorDTO'; - -/** - * Sponsor Service - * - * Orchestrates sponsor operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class SponsorService { - constructor( - private readonly apiClient: SponsorsApiClient - ) {} - - /** - * Get all sponsors with view model transformation - */ - async getAllSponsors(): Promise { - const dto = await this.apiClient.getAll(); - return (dto?.sponsors || []).map((sponsor: SponsorDTO) => new SponsorViewModel(sponsor)); - } - - /** - * Get sponsor dashboard with view model transformation - */ - async getSponsorDashboard(sponsorId: string): Promise { - const dto = await this.apiClient.getDashboard(sponsorId); - if (!dto) { - return null; - } - return new SponsorDashboardViewModel(dto); - } - - /** - * Get sponsor sponsorships with view model transformation - */ - async getSponsorSponsorships(sponsorId: string): Promise { - const dto = await this.apiClient.getSponsorships(sponsorId); - if (!dto) { - return null; - } - return new SponsorSponsorshipsViewModel(dto); - } - - /** - * Create a new sponsor - */ - async createSponsor(input: CreateSponsorInputDTO): Promise { - return await this.apiClient.create(input); - } - - /** - * Get sponsorship pricing - */ - async getSponsorshipPricing(): Promise { - return await this.apiClient.getPricing(); - } - - /** - * Get sponsor billing information - */ - async getBilling(sponsorId: string): Promise<{ - paymentMethods: any[]; - invoices: any[]; - stats: any; - }> { - return await this.apiClient.getBilling(sponsorId); - } - - /** - * Get available leagues for sponsorship - */ - async getAvailableLeagues(): Promise { - return await this.apiClient.getAvailableLeagues(); - } - - /** - * Get detailed league information - */ - async getLeagueDetail(leagueId: string): Promise<{ - league: any; - drivers: any[]; - races: any[]; - }> { - return await this.apiClient.getLeagueDetail(leagueId); - } - - /** - * Get sponsor settings - */ - async getSettings(sponsorId: string): Promise<{ - profile: any; - notifications: any; - privacy: any; - }> { - return await this.apiClient.getSettings(sponsorId); - } - - /** - * Update sponsor settings - */ - async updateSettings(sponsorId: string, input: any): Promise { - return await this.apiClient.updateSettings(sponsorId, input); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorshipService.test.ts b/apps/website/lib/services/sponsors/SponsorshipService.test.ts index a6435f555..78184faba 100644 --- a/apps/website/lib/services/sponsors/SponsorshipService.test.ts +++ b/apps/website/lib/services/sponsors/SponsorshipService.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { SponsorshipService } from './SponsorshipService'; -import { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient'; -import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel'; -import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel'; +import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient'; +import { SponsorshipPricingViewModel } from '@/lib/view-models/SponsorshipPricingViewModel'; +import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel'; describe('SponsorshipService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/sponsors/SponsorshipService.ts b/apps/website/lib/services/sponsors/SponsorshipService.ts deleted file mode 100644 index 60b438be3..000000000 --- a/apps/website/lib/services/sponsors/SponsorshipService.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient'; -import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel'; -import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel'; -import { SponsorshipRequestViewModel } from '../../view-models/SponsorshipRequestViewModel'; -import type { GetPendingSponsorshipRequestsOutputDTO } from '../../types/generated/GetPendingSponsorshipRequestsOutputDTO'; -import type { SponsorshipRequestDTO } from '../../types/generated/SponsorshipRequestDTO'; - -/** - * Sponsorship Service - * - * Orchestrates sponsorship operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class SponsorshipService { - constructor( - private readonly apiClient: SponsorsApiClient - ) {} - - /** - * Get sponsorship pricing with view model transformation - */ - async getSponsorshipPricing(): Promise { - // Pricing shape isn't finalized in the API yet. - // Keep a predictable, UI-friendly structure until a dedicated DTO is introduced. - const dto = await this.apiClient.getPricing(); - - const main = - dto.pricing.find((p) => p.entityType === 'league' || p.entityType === 'main')?.price ?? 0; - const secondary = - dto.pricing.find((p) => p.entityType === 'driver' || p.entityType === 'secondary')?.price ?? 0; - - return new SponsorshipPricingViewModel({ - mainSlotPrice: main, - secondarySlotPrice: secondary, - currency: 'USD', - }); - } - - /** - * Get sponsor sponsorships with view model transformation - */ - async getSponsorSponsorships(sponsorId: string): Promise { - const dto = await this.apiClient.getSponsorships(sponsorId); - if (!dto) { - return null; - } - return new SponsorSponsorshipsViewModel(dto); - } - - /** - * Get pending sponsorship requests for an entity - */ - async getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise { - const dto = (await this.apiClient.getPendingSponsorshipRequests(params)) as unknown as GetPendingSponsorshipRequestsOutputDTO; - const requests = (dto as any).requests as SponsorshipRequestDTO[]; - return (requests ?? []).map((r: SponsorshipRequestDTO) => new SponsorshipRequestViewModel(r)); - } - - /** - * Accept a sponsorship request - */ - async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise { - await this.apiClient.acceptSponsorshipRequest(requestId, { respondedBy }); - } - - /** - * Reject a sponsorship request - */ - async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise { - await this.apiClient.rejectSponsorshipRequest(requestId, { respondedBy, ...(reason ? { reason } : {}) }); - } -} diff --git a/apps/website/lib/services/teams/TeamJoinService.test.ts b/apps/website/lib/services/teams/TeamJoinService.test.ts index 6188929d3..6131bc81b 100644 --- a/apps/website/lib/services/teams/TeamJoinService.test.ts +++ b/apps/website/lib/services/teams/TeamJoinService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { TeamJoinService } from './TeamJoinService'; -import type { TeamsApiClient } from '../../api/teams/TeamsApiClient'; +import type { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; describe('TeamJoinService', () => { let service: TeamJoinService; diff --git a/apps/website/lib/services/teams/TeamJoinService.ts b/apps/website/lib/services/teams/TeamJoinService.ts deleted file mode 100644 index 0194bd179..000000000 --- a/apps/website/lib/services/teams/TeamJoinService.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel'; -import type { TeamsApiClient } from '../../api/teams/TeamsApiClient'; -import type { TeamJoinRequestDTO } from '../../types/generated/TeamJoinRequestDTO'; - -// Wrapper for the team join requests collection returned by the teams API in this build -// Mirrors the current API response shape until a generated DTO is available. -type TeamJoinRequestsDto = { - requests: TeamJoinRequestDTO[]; -}; - -/** - * Team Join Service - * - * Orchestrates team join/leave operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class TeamJoinService { - constructor( - private readonly apiClient: TeamsApiClient - ) {} - - /** - * Get team join requests with view model transformation - */ - async getJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise { - const dto = await this.apiClient.getJoinRequests(teamId) as TeamJoinRequestsDto | null; - return (dto?.requests || []).map((r: TeamJoinRequestDTO) => new TeamJoinRequestViewModel(r, currentUserId, isOwner)); - } - - /** - * Approve a team join request - * - * The teams API currently exposes read-only join requests in this build; approving - * a request requires a future management endpoint, so this method fails explicitly. - */ - async approveJoinRequest(): Promise { - throw new Error('Not implemented: API endpoint for approving join requests'); - } - - /** - * Reject a team join request - * - * Rejection of join requests is also not available yet on the backend, so callers - * must treat this as an unsupported operation rather than a silent no-op. - */ - async rejectJoinRequest(): Promise { - throw new Error('Not implemented: API endpoint for rejecting join requests'); - } -} \ No newline at end of file diff --git a/apps/website/lib/services/teams/TeamService.test.ts b/apps/website/lib/services/teams/TeamService.test.ts index f708853e7..954c9315d 100644 --- a/apps/website/lib/services/teams/TeamService.test.ts +++ b/apps/website/lib/services/teams/TeamService.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, Mocked } from 'vitest'; import { TeamService } from './TeamService'; -import { TeamsApiClient } from '../../api/teams/TeamsApiClient'; -import { TeamSummaryViewModel } from '../../view-models/TeamSummaryViewModel'; -import { TeamDetailsViewModel } from '../../view-models/TeamDetailsViewModel'; -import { TeamMemberViewModel } from '../../view-models/TeamMemberViewModel'; +import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; +import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; +import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; +import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; describe('TeamService', () => { let mockApiClient: Mocked; diff --git a/apps/website/lib/services/teams/TeamService.ts b/apps/website/lib/services/teams/TeamService.ts deleted file mode 100644 index e626a8742..000000000 --- a/apps/website/lib/services/teams/TeamService.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; -import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; -import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; -import { CreateTeamViewModel } from '@/lib/view-models/CreateTeamViewModel'; -import { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel'; -import { DriverTeamViewModel } from '@/lib/view-models/DriverTeamViewModel'; -import type { TeamsApiClient } from '../../api/teams/TeamsApiClient'; -import type { GetAllTeamsOutputDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO'; -import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO'; -import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO'; -import type { GetTeamMembersOutputDTO } from '@/lib/types/generated/GetTeamMembersOutputDTO'; -import type { TeamMemberDTO } from '@/lib/types/generated/TeamMemberDTO'; -import type { CreateTeamInputDTO } from '@/lib/types/generated/CreateTeamInputDTO'; -import type { CreateTeamOutputDTO } from '@/lib/types/generated/CreateTeamOutputDTO'; -import type { UpdateTeamInputDTO } from '@/lib/types/generated/UpdateTeamInputDTO'; -import type { UpdateTeamOutputDTO } from '@/lib/types/generated/UpdateTeamOutputDTO'; -import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO'; -import type { GetTeamMembershipOutputDTO } from '@/lib/types/generated/GetTeamMembershipOutputDTO'; - -/** - * Team Service - * - * Orchestrates team operations by coordinating API calls and view model creation. - * All dependencies are injected via constructor. - */ -export class TeamService { - constructor( - private readonly apiClient: TeamsApiClient - ) {} - - /** - * Get all teams with view model transformation - */ - async getAllTeams(): Promise { - const dto: GetAllTeamsOutputDTO | null = await this.apiClient.getAll(); - return (dto?.teams || []).map((team: TeamListItemDTO) => new TeamSummaryViewModel(team)); - } - - /** - * Get team details with view model transformation - */ - async getTeamDetails(teamId: string, currentUserId: string): Promise { - const dto: GetTeamDetailsOutputDTO | null = await this.apiClient.getDetails(teamId); - if (!dto) { - return null; - } - return new TeamDetailsViewModel(dto, currentUserId); - } - - /** - * Get team members with view model transformation - */ - async getTeamMembers(teamId: string, currentUserId: string, teamOwnerId: string): Promise { - const dto: GetTeamMembersOutputDTO = await this.apiClient.getMembers(teamId); - return dto.members.map((member: TeamMemberDTO) => new TeamMemberViewModel(member, currentUserId, teamOwnerId)); - } - - /** - * Create a new team with view model transformation - */ - async createTeam(input: CreateTeamInputDTO): Promise { - const dto: CreateTeamOutputDTO = await this.apiClient.create(input); - return new CreateTeamViewModel(dto); - } - - /** - * Update team with view model transformation - */ - async updateTeam(teamId: string, input: UpdateTeamInputDTO): Promise { - const dto: UpdateTeamOutputDTO = await this.apiClient.update(teamId, input); - return new UpdateTeamViewModel(dto); - } - - /** - * Get driver's team with view model transformation - */ - async getDriverTeam(driverId: string): Promise { - const dto: GetDriverTeamOutputDTO | null = await this.apiClient.getDriverTeam(driverId); - return dto ? new DriverTeamViewModel(dto) : null; - } - - /** - * Get team membership for a driver - */ - async getMembership(teamId: string, driverId: string): Promise { - return this.apiClient.getMembership(teamId, driverId); - } - - /** - * Remove a driver from the team - * - * The backend does not yet expose a dedicated endpoint for removing team memberships, - * so this method fails explicitly to avoid silently ignoring removal requests. - */ - async removeMembership(teamId: string, driverId: string): Promise { - void teamId; - void driverId; - throw new Error('Team membership removal is not supported in this build'); - } - - /** - * Update team membership role - * - * Role updates for team memberships are not supported by the current API surface; - * callers must treat this as an unavailable operation. - */ - async updateMembership(teamId: string, driverId: string, role: string): Promise { - void teamId; - void driverId; - void role; - throw new Error('Team membership role updates are not supported in this build'); - } -} diff --git a/apps/website/lib/types/AllLeaguesWithCapacityAndScoringDTO.ts b/apps/website/lib/types/AllLeaguesWithCapacityAndScoringDTO.ts index 35921f9c5..07eb4446e 100644 --- a/apps/website/lib/types/AllLeaguesWithCapacityAndScoringDTO.ts +++ b/apps/website/lib/types/AllLeaguesWithCapacityAndScoringDTO.ts @@ -34,7 +34,7 @@ export type LeagueWithCapacityAndScoringDTO = { createdAt: string; settings: LeagueCapacityAndScoringSettingsDTO; usedSlots: number; - logoUrl?: string | null; + logoUrl?: string; socialLinks?: LeagueCapacityAndScoringSocialLinksDTO; scoring?: LeagueCapacityAndScoringSummaryScoringDTO; timingSummary?: string; diff --git a/apps/website/lib/view-models/CompleteOnboardingViewModel.ts b/apps/website/lib/view-models/CompleteOnboardingViewModel.ts index f519caa07..940acca13 100644 --- a/apps/website/lib/view-models/CompleteOnboardingViewModel.ts +++ b/apps/website/lib/view-models/CompleteOnboardingViewModel.ts @@ -1,4 +1,4 @@ -import { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO'; +import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO'; /** * Complete onboarding view model diff --git a/apps/website/lib/view-models/CreateLeagueViewModel.ts b/apps/website/lib/view-models/CreateLeagueViewModel.ts index d3eeba935..f10fbe0e2 100644 --- a/apps/website/lib/view-models/CreateLeagueViewModel.ts +++ b/apps/website/lib/view-models/CreateLeagueViewModel.ts @@ -1,4 +1,4 @@ -import { CreateLeagueOutputDTO } from '../types/generated/CreateLeagueOutputDTO'; +import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO'; /** * View Model for Create League Result diff --git a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts index a76a780ce..7b1e120e7 100644 --- a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts +++ b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts @@ -1,4 +1,4 @@ -import type { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO'; +import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO'; export class DriverLeaderboardItemViewModel { id: string; diff --git a/apps/website/lib/view-models/DriverLeaderboardViewModel.ts b/apps/website/lib/view-models/DriverLeaderboardViewModel.ts index 9f9f7f700..fdc74cbde 100644 --- a/apps/website/lib/view-models/DriverLeaderboardViewModel.ts +++ b/apps/website/lib/view-models/DriverLeaderboardViewModel.ts @@ -1,4 +1,4 @@ -import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO'; +import { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO'; import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel'; export class DriverLeaderboardViewModel { diff --git a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts index 2cb6dbd08..f9e4f767b 100644 --- a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts +++ b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts @@ -1,4 +1,4 @@ -import { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO'; +import { DriverRegistrationStatusDTO } from '@/lib/types/generated/DriverRegistrationStatusDTO'; export class DriverRegistrationStatusViewModel { isRegistered!: boolean; diff --git a/apps/website/lib/view-models/DriverSummaryViewModel.ts b/apps/website/lib/view-models/DriverSummaryViewModel.ts index 737188625..62b6ebfd9 100644 --- a/apps/website/lib/view-models/DriverSummaryViewModel.ts +++ b/apps/website/lib/view-models/DriverSummaryViewModel.ts @@ -1,4 +1,4 @@ -import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO'; +import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO'; /** * View Model for driver summary with rating and rank diff --git a/apps/website/lib/view-models/LeagueDetailPageViewModel.ts b/apps/website/lib/view-models/LeagueDetailPageViewModel.ts index f4bd42bc4..771f67881 100644 --- a/apps/website/lib/view-models/LeagueDetailPageViewModel.ts +++ b/apps/website/lib/view-models/LeagueDetailPageViewModel.ts @@ -1,10 +1,7 @@ -import { LeagueWithCapacityAndScoringDTO } from '../types/generated/LeagueWithCapacityAndScoringDTO'; -import { LeagueStatsDTO } from '../types/generated/LeagueStatsDTO'; -import { LeagueMembershipsDTO } from '../types/generated/LeagueMembershipsDTO'; -import { LeagueScheduleDTO } from '../types/generated/LeagueScheduleDTO'; -import { LeagueStandingsDTO } from '../types/generated/LeagueStandingsDTO'; -import { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO'; -import { RaceDTO } from '../types/generated/RaceDTO'; +import { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO'; +import { LeagueStatsDTO } from '@/lib/types/generated/LeagueStatsDTO'; +import { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO'; +import { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO'; import { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO'; import { RaceViewModel } from './RaceViewModel'; import { DriverViewModel } from './DriverViewModel'; @@ -34,6 +31,29 @@ export interface LeagueMembershipWithRole { joinedAt: string; } +// Helper interfaces for type narrowing +interface LeagueSettings { + maxDrivers?: number; +} + +interface SocialLinks { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; +} + +interface LeagueStatsExtended { + averageSOF?: number; + averageRating?: number; + completedRaces?: number; + totalRaces?: number; +} + +interface MembershipsContainer { + members?: Array<{ driverId: string; role: string; status?: 'active' | 'inactive'; joinedAt: string }>; + memberships?: Array<{ driverId: string; role: string; status?: 'active' | 'inactive'; joinedAt: string }>; +} + export class LeagueDetailPageViewModel { // League basic info id: string; @@ -107,25 +127,35 @@ export class LeagueDetailPageViewModel { this.description = league.description ?? ''; this.ownerId = league.ownerId; this.createdAt = league.createdAt; + + // Handle settings with proper type narrowing + const settings = league.settings as LeagueSettings | undefined; + const maxDrivers = settings?.maxDrivers; this.settings = { - maxDrivers: league.settings?.maxDrivers ?? (league as any).maxDrivers, + maxDrivers: maxDrivers, }; + + // Handle social links with proper type narrowing + const socialLinks = league.socialLinks as SocialLinks | undefined; + const discordUrl = socialLinks?.discordUrl; + const youtubeUrl = socialLinks?.youtubeUrl; + const websiteUrl = socialLinks?.websiteUrl; + this.socialLinks = { - discordUrl: league.socialLinks?.discordUrl ?? (league as any).socialLinks?.discordUrl, - youtubeUrl: league.socialLinks?.youtubeUrl ?? (league as any).socialLinks?.youtubeUrl, - websiteUrl: league.socialLinks?.websiteUrl ?? (league as any).socialLinks?.websiteUrl, + discordUrl, + youtubeUrl, + websiteUrl, }; this.owner = owner; this.scoringConfig = scoringConfig; this.drivers = drivers; - const membershipDtos = ((memberships as any).members ?? (memberships as any).memberships ?? []) as Array<{ - driverId: string; - role: string; - status?: 'active' | 'inactive'; - joinedAt: string; - }>; + // Handle memberships with proper type narrowing + const membershipsContainer = memberships as MembershipsContainer; + const membershipDtos = membershipsContainer.members ?? + membershipsContainer.memberships ?? + []; this.memberships = membershipDtos.map((m) => ({ driverId: m.driverId, @@ -137,11 +167,15 @@ export class LeagueDetailPageViewModel { this.allRaces = allRaces; this.runningRaces = allRaces.filter(r => r.status === 'running'); - const leagueStatsAny = leagueStats as any; + // Calculate SOF from available data with proper type narrowing + const statsExtended = leagueStats as LeagueStatsExtended; + const averageSOF = statsExtended.averageSOF ?? + statsExtended.averageRating ?? undefined; + const completedRaces = statsExtended.completedRaces ?? + statsExtended.totalRaces ?? undefined; - // Calculate SOF from available data - this.averageSOF = leagueStatsAny.averageSOF ?? leagueStats.averageRating ?? null; - this.completedRacesCount = leagueStatsAny.completedRaces ?? leagueStats.totalRaces ?? 0; + this.averageSOF = typeof averageSOF === 'number' ? averageSOF : null; + this.completedRacesCount = typeof completedRaces === 'number' ? completedRaces : 0; this.sponsors = sponsors; @@ -183,10 +217,14 @@ export class LeagueDetailPageViewModel { const driverDto = this.drivers.find(d => d.id === driverId); if (!driverDto) return null; + // Handle avatarUrl with proper type checking + const driverAny = driverDto as { avatarUrl?: unknown }; + const avatarUrl = typeof driverAny.avatarUrl === 'string' ? driverAny.avatarUrl : null; + const driver = new DriverViewModel({ id: driverDto.id, name: driverDto.name, - avatarUrl: (driverDto as any).avatarUrl ?? null, + avatarUrl: avatarUrl, iracingId: driverDto.iracingId, }); diff --git a/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts b/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts index e841ca46a..a969a5c55 100644 --- a/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts +++ b/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts @@ -1,4 +1,4 @@ -import type { LeagueJoinRequestDTO } from '../types/generated/LeagueJoinRequestDTO'; +import type { LeagueJoinRequestDTO } from '@/lib/types/generated/LeagueJoinRequestDTO'; /** * League join request view model diff --git a/apps/website/lib/view-models/LeagueMemberViewModel.ts b/apps/website/lib/view-models/LeagueMemberViewModel.ts index 85de8699b..b26ca5583 100644 --- a/apps/website/lib/view-models/LeagueMemberViewModel.ts +++ b/apps/website/lib/view-models/LeagueMemberViewModel.ts @@ -1,4 +1,4 @@ -import { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO'; +import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; import { DriverViewModel } from './DriverViewModel'; export class LeagueMemberViewModel { diff --git a/apps/website/lib/view-models/LeagueMembershipsViewModel.ts b/apps/website/lib/view-models/LeagueMembershipsViewModel.ts index ce7b589c2..622a5dc65 100644 --- a/apps/website/lib/view-models/LeagueMembershipsViewModel.ts +++ b/apps/website/lib/view-models/LeagueMembershipsViewModel.ts @@ -1,5 +1,5 @@ import { LeagueMemberViewModel } from './LeagueMemberViewModel'; -import type { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO'; +import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; /** * View Model for League Memberships diff --git a/apps/website/lib/view-models/LeagueScoringChampionshipViewModel.ts b/apps/website/lib/view-models/LeagueScoringChampionshipViewModel.ts index a3aa1f33e..10e4268ca 100644 --- a/apps/website/lib/view-models/LeagueScoringChampionshipViewModel.ts +++ b/apps/website/lib/view-models/LeagueScoringChampionshipViewModel.ts @@ -27,8 +27,8 @@ export class LeagueScoringChampionshipViewModel { this.name = input.name; this.type = input.type; this.sessionTypes = input.sessionTypes; - this.pointsPreview = (input.pointsPreview as any) || []; - this.bonusSummary = (input as any).bonusSummary || []; - this.dropPolicyDescription = (input as any).dropPolicyDescription; + this.pointsPreview = input.pointsPreview ?? []; + this.bonusSummary = input.bonusSummary ?? []; + this.dropPolicyDescription = input.dropPolicyDescription; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueScoringConfigViewModel.ts b/apps/website/lib/view-models/LeagueScoringConfigViewModel.ts index 6ee875277..6ce8fa212 100644 --- a/apps/website/lib/view-models/LeagueScoringConfigViewModel.ts +++ b/apps/website/lib/view-models/LeagueScoringConfigViewModel.ts @@ -1,4 +1,5 @@ import { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO'; +import type { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueScoringChampionshipDTO'; /** * LeagueScoringConfigViewModel @@ -9,21 +10,12 @@ export class LeagueScoringConfigViewModel { readonly gameName: string; readonly scoringPresetName?: string; readonly dropPolicySummary?: string; - readonly championships?: Array<{ - id: string; - name: string; - type: 'driver' | 'team' | 'nations' | 'trophy' | string; - sessionTypes: string[]; - pointsPreview: Array<{ sessionType: string; position: number; points: number }>; - bonusSummary: string[]; - dropPolicyDescription?: string; - }>; + readonly championships?: LeagueScoringChampionshipDTO[]; constructor(dto: LeagueScoringConfigDTO) { this.gameName = dto.gameName; - // These would be mapped from extended properties if available - this.scoringPresetName = (dto as any).scoringPresetName; - this.dropPolicySummary = (dto as any).dropPolicySummary; - this.championships = (dto as any).championships; + this.scoringPresetName = dto.scoringPresetName; + this.dropPolicySummary = dto.dropPolicySummary; + this.championships = dto.championships; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueStandingsViewModel.ts b/apps/website/lib/view-models/LeagueStandingsViewModel.ts index a8708d1a9..3d989b0a5 100644 --- a/apps/website/lib/view-models/LeagueStandingsViewModel.ts +++ b/apps/website/lib/view-models/LeagueStandingsViewModel.ts @@ -1,7 +1,7 @@ -import { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO'; +import { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO'; import { StandingEntryViewModel } from './StandingEntryViewModel'; -import { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO'; -import { LeagueMembership } from '../types/LeagueMembership'; +import { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO'; +import { LeagueMembership } from '@/lib/types/LeagueMembership'; export class LeagueStandingsViewModel { standings: StandingEntryViewModel[]; diff --git a/apps/website/lib/view-models/LeagueWalletViewModel.test.ts b/apps/website/lib/view-models/LeagueWalletViewModel.test.ts index 7b5c9cf51..8c6a20435 100644 --- a/apps/website/lib/view-models/LeagueWalletViewModel.test.ts +++ b/apps/website/lib/view-models/LeagueWalletViewModel.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; import { LeagueWalletViewModel } from './LeagueWalletViewModel'; -import { WalletTransactionViewModel } from './WalletTransactionViewModel'; +import { WalletTransactionViewModel, FullTransactionDto } from './WalletTransactionViewModel'; -const createTransaction = (overrides: Partial = {}): WalletTransactionViewModel => +const createTransaction = (overrides: Partial = {}): WalletTransactionViewModel => new WalletTransactionViewModel({ id: 'tx-1', type: 'sponsorship', @@ -13,7 +13,7 @@ const createTransaction = (overrides: Partial = {}): date: new Date('2024-01-01T00:00:00Z'), status: 'completed', reference: 'ref-1', - ...(overrides as any), + ...overrides, }); describe('LeagueWalletViewModel', () => { @@ -62,10 +62,10 @@ describe('LeagueWalletViewModel', () => { }); it('filters transactions by type and supports all', () => { - const sponsorshipTx = createTransaction({ type: 'sponsorship' as any }); - const membershipTx = createTransaction({ type: 'membership' as any, id: 'tx-2' }); - const withdrawalTx = createTransaction({ type: 'withdrawal' as any, id: 'tx-3' }); - const prizeTx = createTransaction({ type: 'prize' as any, id: 'tx-4' }); + const sponsorshipTx = createTransaction({ type: 'sponsorship' }); + const membershipTx = createTransaction({ type: 'membership', id: 'tx-2' }); + const withdrawalTx = createTransaction({ type: 'withdrawal', id: 'tx-3' }); + const prizeTx = createTransaction({ type: 'prize', id: 'tx-4' }); const vm = new LeagueWalletViewModel({ balance: 0, diff --git a/apps/website/lib/view-models/MediaViewModel.ts b/apps/website/lib/view-models/MediaViewModel.ts index a5f51c55e..02b004886 100644 --- a/apps/website/lib/view-models/MediaViewModel.ts +++ b/apps/website/lib/view-models/MediaViewModel.ts @@ -1,4 +1,4 @@ -import type { GetMediaOutputDTO } from '../types/generated/GetMediaOutputDTO'; +import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO'; /** * Media View Model diff --git a/apps/website/lib/view-models/MembershipFeeViewModel.test.ts b/apps/website/lib/view-models/MembershipFeeViewModel.test.ts index 7b1f75e8b..7596240dd 100644 --- a/apps/website/lib/view-models/MembershipFeeViewModel.test.ts +++ b/apps/website/lib/view-models/MembershipFeeViewModel.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; import { MembershipFeeViewModel } from './MembershipFeeViewModel'; -import type { MembershipFeeDto } from '../types/generated'; +import type { MembershipFeeDTO } from '@/lib/types/generated'; -const createMembershipFeeDto = (overrides: Partial = {}): MembershipFeeDto => ({ +const createMembershipFeeDto = (overrides: Partial = {}): MembershipFeeDTO => ({ id: 'fee-1', leagueId: 'league-1', seasonId: 'season-1', @@ -38,7 +38,7 @@ describe('MembershipFeeViewModel', () => { const seasonVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'season' })); const monthlyVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'monthly' })); const perRaceVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'per_race' })); - const otherVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'custom' as any })); + const otherVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'custom' })); expect(seasonVm.typeDisplay).toBe('Per Season'); expect(monthlyVm.typeDisplay).toBe('Monthly'); diff --git a/apps/website/lib/view-models/MembershipFeeViewModel.ts b/apps/website/lib/view-models/MembershipFeeViewModel.ts index 75d128352..c4a0f67f3 100644 --- a/apps/website/lib/view-models/MembershipFeeViewModel.ts +++ b/apps/website/lib/view-models/MembershipFeeViewModel.ts @@ -1,4 +1,4 @@ -import type { MembershipFeeDTO } from '../types/generated/MembershipFeeDTO'; +import type { MembershipFeeDTO } from '@/lib/types/generated'; export class MembershipFeeViewModel { id!: string; diff --git a/apps/website/lib/view-models/PaymentViewModel.ts b/apps/website/lib/view-models/PaymentViewModel.ts index 1f647d480..e3e1dc1bd 100644 --- a/apps/website/lib/view-models/PaymentViewModel.ts +++ b/apps/website/lib/view-models/PaymentViewModel.ts @@ -1,4 +1,4 @@ -import type { PaymentDTO } from '../types/generated/PaymentDTO'; +import type { PaymentDTO } from '@/lib/types/generated/PaymentDTO'; export class PaymentViewModel { id!: string; diff --git a/apps/website/lib/view-models/PrizeViewModel.ts b/apps/website/lib/view-models/PrizeViewModel.ts index 5d34e91b9..8209cb9aa 100644 --- a/apps/website/lib/view-models/PrizeViewModel.ts +++ b/apps/website/lib/view-models/PrizeViewModel.ts @@ -1,4 +1,4 @@ -import type { PrizeDTO } from '../types/generated/PrizeDTO'; +import type { PrizeDTO } from '@/lib/types/generated/PrizeDTO'; export class PrizeViewModel { id!: string; diff --git a/apps/website/lib/view-models/ProtestDriverViewModel.ts b/apps/website/lib/view-models/ProtestDriverViewModel.ts index d06de364a..f234af627 100644 --- a/apps/website/lib/view-models/ProtestDriverViewModel.ts +++ b/apps/website/lib/view-models/ProtestDriverViewModel.ts @@ -1,4 +1,4 @@ -import { DriverSummaryDTO } from '../types/generated/DriverSummaryDTO'; +import { DriverSummaryDTO } from '@/lib/types/generated/DriverSummaryDTO'; export class ProtestDriverViewModel { constructor(private readonly dto: DriverSummaryDTO) {} diff --git a/apps/website/lib/view-models/ProtestViewModel.ts b/apps/website/lib/view-models/ProtestViewModel.ts index 54c22a0fc..c236d8c6a 100644 --- a/apps/website/lib/view-models/ProtestViewModel.ts +++ b/apps/website/lib/view-models/ProtestViewModel.ts @@ -1,5 +1,5 @@ -import { ProtestDTO } from '../types/generated/ProtestDTO'; -import { RaceProtestDTO } from '../types/generated/RaceProtestDTO'; +import { ProtestDTO } from '@/lib/types/generated/ProtestDTO'; +import { RaceProtestDTO } from '@/lib/types/generated/RaceProtestDTO'; /** * Protest view model @@ -22,15 +22,41 @@ export class ProtestViewModel { constructor(dto: ProtestDTO | RaceProtestDTO) { this.id = dto.id; - this.raceId = (dto as any).raceId || ''; + + // Type narrowing for raceId + if ('raceId' in dto) { + this.raceId = dto.raceId; + } else { + this.raceId = ''; + } + this.protestingDriverId = dto.protestingDriverId; this.accusedDriverId = dto.accusedDriverId; - this.description = (dto as any).description || dto.description; - this.submittedAt = (dto as any).submittedAt || (dto as any).filedAt || ''; - this.filedAt = (dto as any).filedAt || (dto as any).submittedAt; + + // Type narrowing for description + if ('description' in dto && typeof dto.description === 'string') { + this.description = dto.description; + } else { + this.description = ''; + } + + // Type narrowing for submittedAt and filedAt + if ('submittedAt' in dto && typeof dto.submittedAt === 'string') { + this.submittedAt = dto.submittedAt; + } else if ('filedAt' in dto && typeof dto.filedAt === 'string') { + this.submittedAt = dto.filedAt; + } else { + this.submittedAt = ''; + } + + if ('filedAt' in dto && typeof dto.filedAt === 'string') { + this.filedAt = dto.filedAt; + } else if ('submittedAt' in dto && typeof dto.submittedAt === 'string') { + this.filedAt = dto.submittedAt; + } // Handle different DTO structures - if ('status' in dto) { + if ('status' in dto && typeof dto.status === 'string') { this.status = dto.status; } else { this.status = 'pending'; @@ -38,14 +64,16 @@ export class ProtestViewModel { // Handle incident data if ('incident' in dto && dto.incident) { + const incident = dto.incident as { lap?: number; description?: string }; this.incident = { - lap: (dto.incident as any).lap, - description: (dto.incident as any).description + lap: typeof incident.lap === 'number' ? incident.lap : undefined, + description: typeof incident.description === 'string' ? incident.description : undefined }; - } else if ('lap' in dto || 'description' in dto) { + } else if (('lap' in dto && typeof (dto as { lap?: number }).lap === 'number') || + ('description' in dto && typeof (dto as { description?: string }).description === 'string')) { this.incident = { - lap: (dto as any).lap, - description: (dto as any).description + lap: 'lap' in dto ? (dto as { lap?: number }).lap : undefined, + description: 'description' in dto ? (dto as { description?: string }).description : undefined }; } else { this.incident = null; diff --git a/apps/website/lib/view-models/RaceDetailEntryViewModel.ts b/apps/website/lib/view-models/RaceDetailEntryViewModel.ts index 80b9e027b..57e13bd94 100644 --- a/apps/website/lib/view-models/RaceDetailEntryViewModel.ts +++ b/apps/website/lib/view-models/RaceDetailEntryViewModel.ts @@ -1,4 +1,4 @@ -import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO'; +import { RaceDetailEntryDTO } from '@/lib/types/generated/RaceDetailEntryDTO'; export class RaceDetailEntryViewModel { id: string; diff --git a/apps/website/lib/view-models/RaceDetailUserResultViewModel.ts b/apps/website/lib/view-models/RaceDetailUserResultViewModel.ts index f856cd6bf..b8e4c5bab 100644 --- a/apps/website/lib/view-models/RaceDetailUserResultViewModel.ts +++ b/apps/website/lib/view-models/RaceDetailUserResultViewModel.ts @@ -1,4 +1,4 @@ -import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO'; +import { RaceDetailUserResultDTO } from '@/lib/types/generated/RaceDetailUserResultDTO'; export class RaceDetailUserResultViewModel { position!: number; diff --git a/apps/website/lib/view-models/RaceDetailViewModel.test.ts b/apps/website/lib/view-models/RaceDetailViewModel.test.ts index 3bcf3408a..d58105782 100644 --- a/apps/website/lib/view-models/RaceDetailViewModel.test.ts +++ b/apps/website/lib/view-models/RaceDetailViewModel.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import type { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO'; -import type { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO'; -import type { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO'; -import type { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO'; -import type { RaceDetailEntryDTO } from '../types/RaceDetailEntryDTO'; +import type { RaceDetailLeagueDTO } from '@/lib/types/generated/RaceDetailLeagueDTO'; +import type { RaceDetailRaceDTO } from '@/lib/types/generated/RaceDetailRaceDTO'; +import type { RaceDetailRegistrationDTO } from '@/lib/types/generated/RaceDetailRegistrationDTO'; +import type { RaceDetailUserResultDTO } from '@/lib/types/generated/RaceDetailUserResultDTO'; +import type { RaceDetailEntryDTO } from '@/lib/types/RaceDetailEntryDTO'; import { RaceDetailViewModel } from './RaceDetailViewModel'; describe('RaceDetailViewModel', () => { @@ -262,7 +262,7 @@ describe('RaceDetailViewModel', () => { }); const cancelledVm = new RaceDetailViewModel({ - race: createMockRace({ status: 'cancelled' as any }), + race: createMockRace({ status: 'cancelled' }), league: createMockLeague(), entryList: [], registration: createMockRegistration(), diff --git a/apps/website/lib/view-models/RaceDetailViewModel.ts b/apps/website/lib/view-models/RaceDetailViewModel.ts deleted file mode 100644 index 4c0afcf58..000000000 --- a/apps/website/lib/view-models/RaceDetailViewModel.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO'; -import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO'; -import { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO'; -import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO'; -import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO'; -import { RaceDetailEntryViewModel } from './RaceDetailEntryViewModel'; -import { RaceDetailUserResultViewModel } from './RaceDetailUserResultViewModel'; - -export class RaceDetailViewModel { - race: RaceDetailRaceDTO | null; - league: RaceDetailLeagueDTO | null; - entryList: RaceDetailEntryViewModel[]; - registration: RaceDetailRegistrationDTO; - userResult: RaceDetailUserResultViewModel | null; - error?: string; - - constructor(dto: { - race: RaceDetailRaceDTO | null; - league: RaceDetailLeagueDTO | null; - entryList: RaceDetailEntryDTO[]; - registration: RaceDetailRegistrationDTO; - userResult: RaceDetailUserResultDTO | null; - error?: string; - }, currentDriverId: string) { - this.race = dto.race; - this.league = dto.league; - this.entryList = dto.entryList.map(entry => new RaceDetailEntryViewModel(entry, currentDriverId)); - this.registration = dto.registration; - this.userResult = dto.userResult ? new RaceDetailUserResultViewModel(dto.userResult) : null; - this.error = dto.error; - } - - /** UI-specific: Whether user is registered */ - get isRegistered(): boolean { - return (this.registration as any).isUserRegistered ?? (this.registration as any).isRegistered ?? false; - } - - /** UI-specific: Whether user can register */ - get canRegister(): boolean { - return this.registration.canRegister; - } - - /** UI-specific: Race status display */ - get raceStatusDisplay(): string { - if (!this.race) return 'Unknown'; - switch (this.race.status) { - case 'upcoming': return 'Upcoming'; - case 'live': return 'Live'; - case 'finished': return 'Finished'; - default: return this.race.status; - } - } - - /** UI-specific: Formatted scheduled time */ - get formattedScheduledTime(): string { - return this.race ? new Date(this.race.scheduledAt).toLocaleString() : ''; - } - - /** UI-specific: Entry list count */ - get entryCount(): number { - return this.entryList.length; - } - - /** UI-specific: Whether race has results */ - get hasResults(): boolean { - return this.userResult !== null; - } - - /** UI-specific: Registration status message */ - get registrationStatusMessage(): string { - if (this.isRegistered) return 'You are registered for this race'; - if (this.canRegister) return 'You can register for this race'; - return 'Registration not available'; - } - - /** UI-specific: Whether race can be re-opened */ - get canReopenRace(): boolean { - if (!this.race) return false; - return this.race.status === 'completed' || this.race.status === 'cancelled'; - } -} \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceResultViewModel.ts b/apps/website/lib/view-models/RaceResultViewModel.ts index 64b47c1a7..53ac8ae34 100644 --- a/apps/website/lib/view-models/RaceResultViewModel.ts +++ b/apps/website/lib/view-models/RaceResultViewModel.ts @@ -1,4 +1,4 @@ -import { RaceResultDTO } from '../types/generated/RaceResultDTO'; +import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO'; export class RaceResultViewModel { driverId!: string; diff --git a/apps/website/lib/view-models/RaceResultsDataTransformer.ts b/apps/website/lib/view-models/RaceResultsDataTransformer.ts index 5ad437ccf..9f9f194dd 100644 --- a/apps/website/lib/view-models/RaceResultsDataTransformer.ts +++ b/apps/website/lib/view-models/RaceResultsDataTransformer.ts @@ -1,6 +1,6 @@ -import type { LeagueMembershipsViewModel } from '@/lib/view-models/LeagueMembershipsViewModel'; -import type { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel'; -import type { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel'; +import type { LeagueMembershipsViewModel } from './LeagueMembershipsViewModel'; +import type { RaceResultsDetailViewModel } from './RaceResultsDetailViewModel'; +import type { RaceWithSOFViewModel } from './RaceWithSOFViewModel'; // TODO fucking violating our architecture, it should be a ViewModel @@ -58,7 +58,7 @@ export class RaceResultsDataTransformer { } // Transform results - const results = resultsData.results.map((result: any) => ({ + const results = resultsData.results.map((result) => ({ position: result.position, driverId: result.driverId, driverName: result.driverName, @@ -74,9 +74,9 @@ export class RaceResultsDataTransformer { })); // Transform penalties - const penalties = resultsData.penalties.map((penalty: any) => ({ + const penalties = resultsData.penalties.map((penalty) => ({ driverId: penalty.driverId, - driverName: resultsData.results.find((r: any) => r.driverId === penalty.driverId)?.driverName || 'Unknown', + driverName: resultsData.results.find((r) => r.driverId === penalty.driverId)?.driverName || 'Unknown', type: penalty.type as 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points', value: penalty.value || 0, reason: 'Penalty applied', // Default since view model doesn't have reason @@ -84,7 +84,7 @@ export class RaceResultsDataTransformer { })); // Transform memberships - const memberships = membershipsData?.memberships.map((membership: any) => ({ + const memberships = membershipsData?.memberships.map((membership) => ({ driverId: membership.driverId, role: membership.role || 'member', })); diff --git a/apps/website/lib/view-models/RaceResultsDetailViewModel.ts b/apps/website/lib/view-models/RaceResultsDetailViewModel.ts index d87e71ceb..1f9a502ae 100644 --- a/apps/website/lib/view-models/RaceResultsDetailViewModel.ts +++ b/apps/website/lib/view-models/RaceResultsDetailViewModel.ts @@ -1,5 +1,5 @@ -import { RaceResultDTO } from '../types/generated/RaceResultDTO'; -import { RaceResultsDetailDTO } from '../types/generated/RaceResultsDetailDTO'; +import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO'; +import { RaceResultsDetailDTO } from '@/lib/types/generated/RaceResultsDetailDTO'; import { RaceResultViewModel } from './RaceResultViewModel'; export class RaceResultsDetailViewModel { diff --git a/apps/website/lib/view-models/RaceStatsViewModel.ts b/apps/website/lib/view-models/RaceStatsViewModel.ts index a1f3c01b4..d0d869cf6 100644 --- a/apps/website/lib/view-models/RaceStatsViewModel.ts +++ b/apps/website/lib/view-models/RaceStatsViewModel.ts @@ -1,4 +1,4 @@ -import type { RaceStatsDTO } from '../types/generated/RaceStatsDTO'; +import type { RaceStatsDTO } from '@/lib/types/generated/RaceStatsDTO'; /** * Race stats view model diff --git a/apps/website/lib/view-models/RaceViewModel.ts b/apps/website/lib/view-models/RaceViewModel.ts index dae5f4559..2a77b2890 100644 --- a/apps/website/lib/view-models/RaceViewModel.ts +++ b/apps/website/lib/view-models/RaceViewModel.ts @@ -1,5 +1,5 @@ -import { RaceDTO } from '../types/generated/RaceDTO'; -import { RacesPageDataRaceDTO } from '../types/generated/RacesPageDataRaceDTO'; +import { RaceDTO } from '@/lib/types/generated/RaceDTO'; +import { RacesPageDataRaceDTO } from '@/lib/types/generated/RacesPageDataRaceDTO'; export class RaceViewModel { constructor( @@ -31,15 +31,15 @@ export class RaceViewModel { } get track(): string { - return (this.dto as any).track || ''; + return 'track' in this.dto ? this.dto.track || '' : ''; } get car(): string { - return (this.dto as any).car || ''; + return 'car' in this.dto ? this.dto.car || '' : ''; } get status(): string | undefined { - return this._status || (this.dto as any).status; + return this._status || ('status' in this.dto ? this.dto.status : undefined); } get registeredCount(): number | undefined { @@ -47,7 +47,7 @@ export class RaceViewModel { } get strengthOfField(): number | undefined { - return this._strengthOfField || (this.dto as any).strengthOfField; + return this._strengthOfField || ('strengthOfField' in this.dto ? this.dto.strengthOfField : undefined); } /** UI-specific: Formatted date */ diff --git a/apps/website/lib/view-models/RaceWithSOFViewModel.ts b/apps/website/lib/view-models/RaceWithSOFViewModel.ts index 27d760391..bdfbd58f6 100644 --- a/apps/website/lib/view-models/RaceWithSOFViewModel.ts +++ b/apps/website/lib/view-models/RaceWithSOFViewModel.ts @@ -1,4 +1,4 @@ -import { RaceWithSOFDTO } from '../types/generated/RaceWithSOFDTO'; +import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO'; export class RaceWithSOFViewModel { id: string; @@ -8,6 +8,6 @@ export class RaceWithSOFViewModel { constructor(dto: RaceWithSOFDTO) { this.id = dto.id; this.track = dto.track; - this.strengthOfField = (dto as any).strengthOfField ?? null; + this.strengthOfField = 'strengthOfField' in dto ? dto.strengthOfField ?? null : null; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RacesPageViewModel.ts b/apps/website/lib/view-models/RacesPageViewModel.ts index debcd3b5d..0cda3e131 100644 --- a/apps/website/lib/view-models/RacesPageViewModel.ts +++ b/apps/website/lib/view-models/RacesPageViewModel.ts @@ -1,6 +1,5 @@ import { RaceListItemViewModel } from './RaceListItemViewModel'; -import type { RacesPageDataRaceDTO } from '../types/generated/RacesPageDataRaceDTO'; -import type { RaceListItemDTO } from './RaceListItemViewModel'; +import type { RacesPageDataRaceDTO } from '@/lib/types/generated/RacesPageDataRaceDTO'; // DTO matching the backend RacesPageDataDTO interface RacesPageDTO { @@ -16,23 +15,33 @@ export class RacesPageViewModel { constructor(dto: RacesPageDTO) { this.races = dto.races.map((r) => { - const status = (r as any).status as string | undefined; + const status = 'status' in r ? r.status : 'unknown'; const isUpcoming = - (r as any).isUpcoming ?? + 'isUpcoming' in r ? r.isUpcoming : (status === 'upcoming' || status === 'scheduled'); const isLive = - (r as any).isLive ?? + 'isLive' in r ? r.isLive : (status === 'live' || status === 'running'); const isPast = - (r as any).isPast ?? + 'isPast' in r ? r.isPast : (status === 'completed' || status === 'finished' || status === 'cancelled'); - const normalized: RaceListItemDTO = { - ...(r as any), - strengthOfField: (r as any).strengthOfField ?? null, + // Build the RaceListItemDTO from the input with proper type checking + const scheduledAt = 'scheduledAt' in r ? r.scheduledAt : + ('date' in r ? (r as { date?: string }).date : ''); + + const normalized = { + id: r.id, + track: 'track' in r ? r.track : '', + car: 'car' in r ? r.car : '', + scheduledAt: scheduledAt || '', + status: status, + leagueId: 'leagueId' in r ? r.leagueId : '', + leagueName: 'leagueName' in r ? r.leagueName : '', + strengthOfField: 'strengthOfField' in r ? (r as { strengthOfField?: number }).strengthOfField ?? null : null, isUpcoming: Boolean(isUpcoming), isLive: Boolean(isLive), isPast: Boolean(isPast), @@ -76,4 +85,4 @@ export class RacesPageViewModel { get completedRaces(): RaceListItemViewModel[] { return this.races.filter(r => r.status === 'completed'); } -} +} \ No newline at end of file diff --git a/apps/website/lib/view-models/RecordEngagementOutputViewModel.ts b/apps/website/lib/view-models/RecordEngagementOutputViewModel.ts index 29b851a0d..73076ecec 100644 --- a/apps/website/lib/view-models/RecordEngagementOutputViewModel.ts +++ b/apps/website/lib/view-models/RecordEngagementOutputViewModel.ts @@ -1,4 +1,4 @@ -import type { RecordEngagementOutputDTO } from '../types/generated/RecordEngagementOutputDTO'; +import type { RecordEngagementOutputDTO } from '@/lib/types/generated/RecordEngagementOutputDTO'; /** * Record engagement output view model diff --git a/apps/website/lib/view-models/RecordPageViewOutputViewModel.ts b/apps/website/lib/view-models/RecordPageViewOutputViewModel.ts index aaf51c8c3..399acf5e1 100644 --- a/apps/website/lib/view-models/RecordPageViewOutputViewModel.ts +++ b/apps/website/lib/view-models/RecordPageViewOutputViewModel.ts @@ -1,4 +1,4 @@ -import type { RecordPageViewOutputDTO } from '../types/generated/RecordPageViewOutputDTO'; +import type { RecordPageViewOutputDTO } from '@/lib/types/generated/RecordPageViewOutputDTO'; /** * Record page view output view model diff --git a/apps/website/lib/view-models/RemoveMemberViewModel.ts b/apps/website/lib/view-models/RemoveMemberViewModel.ts index dca3fa3eb..fd8b2c7b5 100644 --- a/apps/website/lib/view-models/RemoveMemberViewModel.ts +++ b/apps/website/lib/view-models/RemoveMemberViewModel.ts @@ -1,4 +1,4 @@ -import { RemoveLeagueMemberOutputDTO } from '../types/generated/RemoveLeagueMemberOutputDTO'; +import { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueMemberOutputDTO'; /** * View Model for Remove Member Result diff --git a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts index 7410a4134..9ec98d830 100644 --- a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts +++ b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts @@ -1,4 +1,4 @@ -import { RequestAvatarGenerationOutputDTO } from '../types/generated/RequestAvatarGenerationOutputDTO'; +import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO'; /** * Request Avatar Generation View Model diff --git a/apps/website/lib/view-models/SessionViewModel.ts b/apps/website/lib/view-models/SessionViewModel.ts index cb36837e5..5700a58c3 100644 --- a/apps/website/lib/view-models/SessionViewModel.ts +++ b/apps/website/lib/view-models/SessionViewModel.ts @@ -1,4 +1,4 @@ -import { AuthenticatedUserDTO } from '../types/generated/AuthenticatedUserDTO'; +import { AuthenticatedUserDTO } from '@/lib/types/generated/AuthenticatedUserDTO'; export class SessionViewModel { userId: string; @@ -6,31 +6,28 @@ export class SessionViewModel { displayName: string; avatarUrl?: string | null; role?: string; + driverId?: string; + isAuthenticated: boolean = true; constructor(dto: AuthenticatedUserDTO) { this.userId = dto.userId; this.email = dto.email; this.displayName = dto.displayName; - const anyDto = dto as unknown as { primaryDriverId?: unknown; driverId?: unknown; avatarUrl?: unknown; role?: unknown }; - if (typeof anyDto.primaryDriverId === 'string' && anyDto.primaryDriverId) { - this.driverId = anyDto.primaryDriverId; - } else if (typeof anyDto.driverId === 'string' && anyDto.driverId) { - this.driverId = anyDto.driverId; + // Use the optional fields from the DTO + if (dto.primaryDriverId) { + this.driverId = dto.primaryDriverId; } - if (anyDto.avatarUrl !== undefined) { - this.avatarUrl = anyDto.avatarUrl as string | null; + + if (dto.avatarUrl !== undefined) { + this.avatarUrl = dto.avatarUrl; } - if (typeof anyDto.role === 'string' && anyDto.role) { - this.role = anyDto.role; + + if (dto.role) { + this.role = dto.role; } } - // Note: The generated DTO doesn't have these fields - // These will need to be added when the OpenAPI spec is updated - driverId?: string; - isAuthenticated: boolean = true; - /** * Compatibility accessor. * Some legacy components expect `session.user.*`. @@ -73,4 +70,4 @@ export class SessionViewModel { get authStatusDisplay(): string { return this.isAuthenticated ? 'Logged In' : 'Logged Out'; } -} +} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorDashboardViewModel.ts b/apps/website/lib/view-models/SponsorDashboardViewModel.ts deleted file mode 100644 index 02be75f17..000000000 --- a/apps/website/lib/view-models/SponsorDashboardViewModel.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { SponsorDashboardDTO } from '../types/generated/SponsorDashboardDTO'; -import { SponsorshipViewModel } from './SponsorshipViewModel'; -import { ActivityItemViewModel } from './ActivityItemViewModel'; -import { RenewalAlertViewModel } from './RenewalAlertViewModel'; - -/** - * Sponsor Dashboard View Model - * - * View model for sponsor dashboard data with UI-specific transformations. - */ -export class SponsorDashboardViewModel { - sponsorId: string; - sponsorName: string; - metrics: any; - sponsorships: { - leagues: SponsorshipViewModel[]; - teams: SponsorshipViewModel[]; - drivers: SponsorshipViewModel[]; - races: SponsorshipViewModel[]; - platform: SponsorshipViewModel[]; - }; - recentActivity: ActivityItemViewModel[]; - upcomingRenewals: RenewalAlertViewModel[]; - - constructor(dto: SponsorDashboardDTO) { - this.sponsorId = dto.sponsorId; - this.sponsorName = dto.sponsorName; - this.metrics = dto.metrics; - - // Cast sponsorships to proper type - const sponsorships = dto.sponsorships as any; - this.sponsorships = { - leagues: (sponsorships?.leagues || []).map((s: any) => new SponsorshipViewModel(s)), - teams: (sponsorships?.teams || []).map((s: any) => new SponsorshipViewModel(s)), - drivers: (sponsorships?.drivers || []).map((s: any) => new SponsorshipViewModel(s)), - races: (sponsorships?.races || []).map((s: any) => new SponsorshipViewModel(s)), - platform: (sponsorships?.platform || []).map((s: any) => new SponsorshipViewModel(s)), - }; - this.recentActivity = (dto.recentActivity || []).map((a: any) => new ActivityItemViewModel(a)); - this.upcomingRenewals = (dto.upcomingRenewals || []).map((r: any) => new RenewalAlertViewModel(r)); - } - - get totalSponsorships(): number { - return this.sponsorships.leagues.length + - this.sponsorships.teams.length + - this.sponsorships.drivers.length + - this.sponsorships.races.length + - this.sponsorships.platform.length; - } - - get activeSponsorships(): number { - const all = [ - ...this.sponsorships.leagues, - ...this.sponsorships.teams, - ...this.sponsorships.drivers, - ...this.sponsorships.races, - ...this.sponsorships.platform, - ]; - return all.filter(s => s.status === 'active').length; - } - - get totalInvestment(): number { - const all = [ - ...this.sponsorships.leagues, - ...this.sponsorships.teams, - ...this.sponsorships.drivers, - ...this.sponsorships.races, - ...this.sponsorships.platform, - ]; - return all.filter(s => s.status === 'active').reduce((sum, s) => sum + s.price, 0); - } - - get totalImpressions(): number { - const all = [ - ...this.sponsorships.leagues, - ...this.sponsorships.teams, - ...this.sponsorships.drivers, - ...this.sponsorships.races, - ...this.sponsorships.platform, - ]; - return all.reduce((sum, s) => sum + s.impressions, 0); - } - - /** UI-specific: Formatted total investment */ - get formattedTotalInvestment(): string { - return `$${this.totalInvestment.toLocaleString()}`; - } - - /** UI-specific: Active percentage */ - get activePercentage(): number { - if (this.totalSponsorships === 0) return 0; - return Math.round((this.activeSponsorships / this.totalSponsorships) * 100); - } - - /** UI-specific: Has sponsorships */ - get hasSponsorships(): boolean { - return this.totalSponsorships > 0; - } - - /** UI-specific: Status text */ - get statusText(): string { - if (this.activeSponsorships === 0) return 'No active sponsorships'; - if (this.activeSponsorships === this.totalSponsorships) return 'All sponsorships active'; - return `${this.activeSponsorships} of ${this.totalSponsorships} active`; - } - - /** UI-specific: Cost per 1K views */ - get costPerThousandViews(): string { - if (this.totalImpressions === 0) return '$0.00'; - return `$${(this.totalInvestment / this.totalImpressions * 1000).toFixed(2)}`; - } - - /** UI-specific: Category data for charts */ - get categoryData() { - return { - leagues: { - count: this.sponsorships.leagues.length, - impressions: this.sponsorships.leagues.reduce((sum, l) => sum + l.impressions, 0), - }, - teams: { - count: this.sponsorships.teams.length, - impressions: this.sponsorships.teams.reduce((sum, t) => sum + t.impressions, 0), - }, - drivers: { - count: this.sponsorships.drivers.length, - impressions: this.sponsorships.drivers.reduce((sum, d) => sum + d.impressions, 0), - }, - races: { - count: this.sponsorships.races.length, - impressions: this.sponsorships.races.reduce((sum, r) => sum + r.impressions, 0), - }, - platform: { - count: this.sponsorships.platform.length, - impressions: this.sponsorships.platform.reduce((sum, p) => sum + p.impressions, 0), - }, - }; - } -} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts index 52986bdc2..0cd4994bc 100644 --- a/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts +++ b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts @@ -1,4 +1,4 @@ -import type { SponsorSponsorshipsDTO } from '../types/generated/SponsorSponsorshipsDTO'; +import type { SponsorSponsorshipsDTO } from '@/lib/types/generated/SponsorSponsorshipsDTO'; import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel'; /** diff --git a/apps/website/lib/view-models/SponsorshipDetailViewModel.ts b/apps/website/lib/view-models/SponsorshipDetailViewModel.ts index 935f896c0..6f6f87b36 100644 --- a/apps/website/lib/view-models/SponsorshipDetailViewModel.ts +++ b/apps/website/lib/view-models/SponsorshipDetailViewModel.ts @@ -1,4 +1,4 @@ -import { SponsorshipDetailDTO } from '../types/generated/SponsorshipDetailDTO'; +import { SponsorshipDetailDTO } from '@/lib/types/generated/SponsorshipDetailDTO'; export class SponsorshipDetailViewModel { id: string; diff --git a/apps/website/lib/view-models/SponsorshipViewModel.ts b/apps/website/lib/view-models/SponsorshipViewModel.ts index d4402b1ea..b915f622a 100644 --- a/apps/website/lib/view-models/SponsorshipViewModel.ts +++ b/apps/website/lib/view-models/SponsorshipViewModel.ts @@ -1,3 +1,27 @@ +/** + * Interface for sponsorship data input + */ +export interface SponsorshipDataInput { + id: string; + type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform'; + entityId: string; + entityName: string; + tier?: 'main' | 'secondary'; + status: 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired'; + applicationDate?: string | Date; + approvalDate?: string | Date; + rejectionReason?: string; + startDate: string | Date; + endDate: string | Date; + price: number; + impressions: number; + impressionsChange?: number; + engagement?: number; + details?: string; + entityOwner?: string; + applicationMessage?: string; +} + /** * Sponsorship View Model * @@ -23,7 +47,7 @@ export class SponsorshipViewModel { entityOwner?: string; applicationMessage?: string; - constructor(data: any) { + constructor(data: SponsorshipDataInput) { this.id = data.id; this.type = data.type; this.entityId = data.entityId; diff --git a/apps/website/lib/view-models/StandingEntryViewModel.ts b/apps/website/lib/view-models/StandingEntryViewModel.ts index 8ef92729e..9289e6cf0 100644 --- a/apps/website/lib/view-models/StandingEntryViewModel.ts +++ b/apps/website/lib/view-models/StandingEntryViewModel.ts @@ -1,4 +1,4 @@ -import { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO'; +import { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO'; export class StandingEntryViewModel { driverId: string; diff --git a/apps/website/lib/view-models/WalletTransactionViewModel.test.ts b/apps/website/lib/view-models/WalletTransactionViewModel.test.ts index ae25965d4..9d3582458 100644 --- a/apps/website/lib/view-models/WalletTransactionViewModel.test.ts +++ b/apps/website/lib/view-models/WalletTransactionViewModel.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { WalletTransactionViewModel } from './WalletTransactionViewModel'; +import { WalletTransactionViewModel, FullTransactionDto } from './WalletTransactionViewModel'; -const createTx = (overrides: Partial = {}): any => ({ +const createTx = (overrides: Partial = {}): FullTransactionDto => ({ id: 'tx-1', type: 'sponsorship', description: 'Test', @@ -44,7 +44,7 @@ describe('WalletTransactionViewModel', () => { }); it('derives typeDisplay and formattedDate', () => { - const vm = new WalletTransactionViewModel(createTx({ type: 'membership' as any })); + const vm = new WalletTransactionViewModel(createTx({ type: 'membership' })); expect(vm.typeDisplay).toBe('Membership'); expect(typeof vm.formattedDate).toBe('string'); diff --git a/apps/website/lib/view-models/WalletViewModel.ts b/apps/website/lib/view-models/WalletViewModel.ts index 6bb5fce0c..9a347acf6 100644 --- a/apps/website/lib/view-models/WalletViewModel.ts +++ b/apps/website/lib/view-models/WalletViewModel.ts @@ -1,4 +1,4 @@ -import { WalletDTO } from '../types/generated/WalletDTO'; +import { WalletDTO } from '@/lib/types/generated/WalletDTO'; import { FullTransactionDto, WalletTransactionViewModel } from './WalletTransactionViewModel'; export class WalletViewModel { diff --git a/apps/website/lib/view-models/index.ts b/apps/website/lib/view-models/index.ts index 6c4b215b7..32b8d64f6 100644 --- a/apps/website/lib/view-models/index.ts +++ b/apps/website/lib/view-models/index.ts @@ -1,91 +1,100 @@ -export * from './ActivityItemViewModel'; -export * from './AnalyticsDashboardViewModel'; -export * from './AnalyticsMetricsViewModel'; -export * from './AvailableLeaguesViewModel'; -export * from './AvatarGenerationViewModel'; -export * from './AvatarViewModel'; -export * from './BillingViewModel'; -export * from './CompleteOnboardingViewModel'; -export * from './CreateLeagueViewModel'; -export * from './CreateTeamViewModel'; +export * from './view-models/ActivityItemViewModel'; +export * from './view-models/AnalyticsDashboardViewModel'; +export * from './view-models/AnalyticsMetricsViewModel'; +export * from './view-models/AvailableLeaguesViewModel'; +export * from './view-models/AvatarGenerationViewModel'; +export * from './view-models/AvatarViewModel'; +export * from './view-models/BillingViewModel'; +export * from './view-models/CompleteOnboardingViewModel'; +export * from './view-models/CreateLeagueViewModel'; +export * from './view-models/CreateTeamViewModel'; export { DashboardOverviewViewModel, -} from './DashboardOverviewViewModel'; -export * from './DeleteMediaViewModel'; -export * from './DriverLeaderboardItemViewModel'; -export * from './DriverLeaderboardViewModel'; -export * from './DriverProfileViewModel'; -export * from './DriverRegistrationStatusViewModel'; -export * from './DriverSummaryViewModel'; -export * from './DriverTeamViewModel'; -export * from './DriverViewModel'; -export * from './EmailSignupViewModel'; -export * from './HomeDiscoveryViewModel'; -export * from './ImportRaceResultsSummaryViewModel'; -export * from './LeagueAdminViewModel'; -export * from './LeagueCardViewModel'; -export * from './LeagueDetailPageViewModel'; -export { LeagueDetailViewModel, LeagueViewModel } from './LeagueDetailViewModel'; -export * from './LeagueJoinRequestViewModel'; -export * from './LeagueMembershipsViewModel'; -export * from './LeagueMemberViewModel'; -export * from './LeaguePageDetailViewModel'; -export * from './LeagueScheduleViewModel'; -export * from './LeagueScoringChampionshipViewModel'; -export * from './LeagueScoringConfigViewModel'; -export * from './LeagueScoringPresetsViewModel'; -export * from './LeagueScoringPresetViewModel'; -export * from './LeagueSettingsViewModel'; -export * from './LeagueStandingsViewModel'; -export * from './LeagueStatsViewModel'; -export * from './LeagueStewardingViewModel'; -export * from './LeagueSummaryViewModel'; -export * from './LeagueWalletViewModel'; -export * from './MediaViewModel'; -export * from './MembershipFeeViewModel'; -export * from './PaymentViewModel'; -export * from './PrizeViewModel'; -export * from './ProfileOverviewViewModel'; -export * from './ProtestDriverViewModel'; -export * from './ProtestViewModel'; -export * from './RaceDetailEntryViewModel'; -export * from './RaceDetailUserResultViewModel'; -export * from './RaceDetailViewModel'; -export * from './RaceListItemViewModel'; -export * from './RaceResultsDataTransformer'; -export * from './RaceResultsDetailViewModel'; -export * from './RaceResultViewModel'; -export * from './RacesPageViewModel'; -export * from './RaceStatsViewModel'; -export * from './RaceStewardingViewModel'; -export * from './RaceViewModel'; -export * from './RaceWithSOFViewModel'; -export * from './RecordEngagementInputViewModel'; -export * from './RecordEngagementOutputViewModel'; -export * from './RecordPageViewInputViewModel'; -export * from './RecordPageViewOutputViewModel'; -export * from './RemoveMemberViewModel'; -export * from './RenewalAlertViewModel'; -export * from './RequestAvatarGenerationViewModel'; -export * from './SessionViewModel'; -export * from './SponsorDashboardViewModel'; -export * from './SponsorSettingsViewModel'; -export * from './SponsorshipDetailViewModel'; -export * from './SponsorshipPricingViewModel'; -export * from './SponsorshipRequestViewModel'; -export * from './SponsorshipViewModel'; -export * from './SponsorSponsorshipsViewModel'; -export * from './SponsorViewModel'; -export * from './StandingEntryViewModel'; -export * from './TeamCardViewModel'; -export * from './TeamDetailsViewModel'; -export * from './TeamJoinRequestViewModel'; -export * from './TeamMemberViewModel'; -export * from './TeamSummaryViewModel'; -export * from './UpcomingRaceCardViewModel'; -export * from './UpdateAvatarViewModel'; -export * from './UpdateTeamViewModel'; -export * from './UploadMediaViewModel'; -export * from './UserProfileViewModel'; -export * from './WalletTransactionViewModel'; -export * from './WalletViewModel'; \ No newline at end of file +} from './view-models/DashboardOverviewViewModel'; +export * from './view-models/DashboardOverviewViewModelData'; +export * from './view-models/DeleteMediaViewModel'; +export * from './view-models/DriverLeaderboardItemViewModel'; +export * from './view-models/DriverLeaderboardViewModel'; +export * from './view-models/DriverProfileViewModel'; +export * from './view-models/DriverRegistrationStatusViewModel'; +export * from './view-models/DriverSummaryViewModel'; +export * from './view-models/DriverTeamViewModel'; +export * from './view-models/DriverViewModel'; +export * from './view-models/EmailSignupViewModel'; +export * from './view-models/HomeDiscoveryViewModel'; +export * from './view-models/ImportRaceResultsSummaryViewModel'; +export * from './view-models/LeagueAdminViewModel'; +export * from './view-models/LeagueCardViewModel'; +export * from './view-models/LeagueDetailPageViewModel'; +export { LeagueDetailViewModel, LeagueViewModel } from './view-models/LeagueDetailViewModel'; +export * from './view-models/LeagueJoinRequestViewModel'; +export * from './view-models/LeagueMembershipsViewModel'; +export * from './view-models/LeagueMemberViewModel'; +export * from './view-models/LeaguePageDetailViewModel'; +export * from './view-models/LeagueScheduleViewModel'; +export * from './view-models/LeagueScoringChampionshipViewModel'; +export * from './view-models/LeagueScoringConfigViewModel'; +export * from './view-models/LeagueScoringPresetsViewModel'; +export * from './view-models/LeagueScoringPresetViewModel'; +export * from './view-models/LeagueScoringSectionViewModel'; +export * from './view-models/LeagueSettingsViewModel'; +export * from './view-models/LeagueStandingsViewModel'; +export * from './view-models/LeagueStatsViewModel'; +export * from './view-models/LeagueStewardingViewModel'; +export * from './view-models/LeagueSummaryViewModel'; +export * from './view-models/LeagueWalletViewModel'; +export * from './view-models/MediaViewModel'; +export * from './view-models/MembershipFeeViewModel'; +export * from './view-models/PaymentViewModel'; +export * from './view-models/PrizeViewModel'; +export * from './view-models/ProfileOverviewViewModel'; +export * from './view-models/ProtestDriverViewModel'; +export * from './view-models/ProtestViewModel'; +export * from './view-models/RaceDetailEntryViewModel'; +export * from './view-models/RaceDetailUserResultViewModel'; +export * from './view-models/RaceDetailViewModel'; +export * from './view-models/RaceListItemViewModel'; +export * from './view-models/RaceResultsDataTransformer'; +export * from './view-models/RaceResultsDetailViewModel'; +export * from './view-models/RaceResultViewModel'; +export * from './view-models/RacesPageViewModel'; +export * from './view-models/RaceStatsViewModel'; +export * from './view-models/RaceStewardingViewModel'; +export * from './view-models/RaceViewModel'; +export * from './view-models/RaceWithSOFViewModel'; +export * from './view-models/RecordEngagementInputViewModel'; +export * from './view-models/RecordEngagementOutputViewModel'; +export * from './view-models/RecordPageViewInputViewModel'; +export * from './view-models/RecordPageViewOutputViewModel'; +export * from './view-models/RemoveMemberViewModel'; +export * from './view-models/RenewalAlertViewModel'; +export * from './view-models/RequestAvatarGenerationViewModel'; +export * from './view-models/ScoringConfigurationViewModel'; +export * from './view-models/SessionViewModel'; +export * from './view-models/SponsorDashboardViewModel'; +export * from './view-models/SponsorSettingsViewModel'; +export * from './view-models/SponsorshipDetailViewModel'; +export * from './view-models/SponsorshipPricingViewModel'; +export * from './view-models/SponsorshipRequestViewModel'; +export * from './view-models/SponsorshipViewModel'; +export * from './view-models/SponsorSponsorshipsViewModel'; +export * from './view-models/SponsorViewModel'; +export * from './view-models/StandingEntryViewModel'; +export * from './view-models/TeamCardViewModel'; +export * from './view-models/TeamDetailsViewModel'; +export * from './view-models/TeamJoinRequestViewModel'; +export * from './view-models/TeamMemberViewModel'; +export * from './view-models/TeamSummaryViewModel'; +export * from './view-models/UpcomingRaceCardViewModel'; +export * from './view-models/UpdateAvatarViewModel'; +export * from './view-models/UpdateTeamViewModel'; +export * from './view-models/UploadMediaViewModel'; +export * from './view-models/UserProfileViewModel'; +export * from './view-models/WalletTransactionViewModel'; +export * from './view-models/WalletViewModel'; + +export * from './presenters/DashboardPresenter'; +export * from './presenters/ProfileLeaguesPresenter'; +export * from './presenters/TeamDetailPresenter'; +export * from './presenters/TeamsPresenter'; +export * from './presenters/AdminViewModelPresenter'; \ No newline at end of file diff --git a/apps/website/templates/DashboardTemplate.tsx b/apps/website/templates/DashboardTemplate.tsx index e93c87e5f..a29512479 100644 --- a/apps/website/templates/DashboardTemplate.tsx +++ b/apps/website/templates/DashboardTemplate.tsx @@ -28,69 +28,7 @@ import Card from '@/components/ui/Card'; import { getCountryFlag } from '@/lib/utilities/country'; import { getGreeting } from '@/lib/utilities/time'; - -interface DashboardViewData { - currentDriver: { - name: string; - avatarUrl: string; - country: string; - rating: string; - rank: string; - totalRaces: string; - wins: string; - podiums: string; - consistency: string; - }; - nextRace: { - id: string; - track: string; - car: string; - scheduledAt: string; - formattedDate: string; - formattedTime: string; - timeUntil: string; - isMyLeague: boolean; - } | null; - upcomingRaces: Array<{ - id: string; - track: string; - car: string; - scheduledAt: string; - formattedDate: string; - formattedTime: string; - timeUntil: string; - isMyLeague: boolean; - }>; - leagueStandings: Array<{ - leagueId: string; - leagueName: string; - position: string; - points: string; - totalDrivers: string; - }>; - feedItems: Array<{ - id: string; - type: string; - headline: string; - body?: string; - timestamp: string; - formattedTime: string; - ctaHref?: string; - ctaLabel?: string; - }>; - friends: Array<{ - id: string; - name: string; - avatarUrl: string; - country: string; - }>; - activeLeaguesCount: string; - friendCount: string; - hasUpcomingRaces: boolean; - hasLeagueStandings: boolean; - hasFeedItems: boolean; - hasFriends: boolean; -} +import type { DashboardViewData } from './DashboardViewData'; interface DashboardTemplateProps { data: DashboardViewData; @@ -205,7 +143,7 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) { )} - +

{nextRace.track}

@@ -221,7 +159,7 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) {
- +

Starts in

@@ -367,4 +305,4 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) { ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeagueRulebookTemplate.tsx b/apps/website/templates/LeagueRulebookTemplate.tsx index 7e3e21e1a..925e6af30 100644 --- a/apps/website/templates/LeagueRulebookTemplate.tsx +++ b/apps/website/templates/LeagueRulebookTemplate.tsx @@ -43,9 +43,9 @@ export function LeagueRulebookTemplate({ } const primaryChampionship = viewModel.scoringConfig.championships.find(c => c.type === 'driver') ?? viewModel.scoringConfig.championships[0]; - const positionPoints = primaryChampionship?.pointsPreview - .filter(p => (p as any).sessionType === primaryChampionship.sessionTypes[0]) - .map(p => ({ position: Number((p as any).position), points: Number((p as any).points) })) + const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview + .filter((p): p is { sessionType: string; position: number; points: number } => p.sessionType === primaryChampionship.sessionTypes[0]) + .map(p => ({ position: p.position, points: p.points })) .sort((a, b) => a.position - b.position) || []; const sections: { id: RulebookSection; label: string }[] = [ diff --git a/apps/website/templates/LeagueScheduleTemplate.tsx b/apps/website/templates/LeagueScheduleTemplate.tsx index a34e3a72b..d4d5b6afc 100644 --- a/apps/website/templates/LeagueScheduleTemplate.tsx +++ b/apps/website/templates/LeagueScheduleTemplate.tsx @@ -6,9 +6,9 @@ import type { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib import { StateContainer } from '@/components/shared/state/StateContainer'; import { EmptyState } from '@/components/shared/state/EmptyState'; import { Calendar } from 'lucide-react'; -import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useRegisterForRace } from '@/hooks/race/useRegisterForRace'; -import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace'; +import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; +import { useRegisterForRace } from "@/lib/hooks/race/useRegisterForRace"; +import { useWithdrawFromRace } from "@/lib/hooks/race/useWithdrawFromRace"; import Card from '@/components/ui/Card'; // ============================================================================ diff --git a/apps/website/templates/ProfileLeaguesTemplate.tsx b/apps/website/templates/ProfileLeaguesTemplate.tsx new file mode 100644 index 000000000..28a7b04bb --- /dev/null +++ b/apps/website/templates/ProfileLeaguesTemplate.tsx @@ -0,0 +1,109 @@ +import type { ProfileLeaguesViewData } from './ProfileLeaguesViewData'; + +interface ProfileLeaguesTemplateProps { + viewData: ProfileLeaguesViewData; +} + +export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps) { + return ( +
+
+

Manage leagues

+

+ View leagues you own and participate in, and jump into league admin tools. +

+
+ + {/* Leagues You Own */} +
+
+

Leagues you own

+ {viewData.ownedLeagues.length > 0 && ( + + {viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'} + + )} +
+ + {viewData.ownedLeagues.length === 0 ? ( +

+ You don't own any leagues yet in this session. +

+ ) : ( +
+ {viewData.ownedLeagues.map((league) => ( +
+
+

{league.name}

+

+ {league.description} +

+
+ +
+ ))} +
+ )} +
+ + {/* Leagues You're In */} +
+
+

Leagues you're in

+ {viewData.memberLeagues.length > 0 && ( + + {viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'} + + )} +
+ + {viewData.memberLeagues.length === 0 ? ( +

+ You're not a member of any other leagues yet. +

+ ) : ( +
+ {viewData.memberLeagues.map((league) => ( +
+
+

{league.name}

+

+ {league.description} +

+

+ Your role:{' '} + {league.membershipRole.charAt(0).toUpperCase() + league.membershipRole.slice(1)} +

+
+ + View league + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/website/templates/RaceDetailTemplate.tsx b/apps/website/templates/RaceDetailTemplate.tsx index 25cf20ef0..1a183f8d1 100644 --- a/apps/website/templates/RaceDetailTemplate.tsx +++ b/apps/website/templates/RaceDetailTemplate.tsx @@ -757,12 +757,12 @@ export function RaceDetailTemplate({

Max Drivers

-

{(league.settings as any).maxDrivers ?? 32}

+

{league.settings.maxDrivers ?? 32}

Format

- {(league.settings as any).qualifyingFormat ?? 'Open'} + {league.settings.qualifyingFormat ?? 'Open'}

diff --git a/apps/website/templates/TeamDetailTemplate.tsx b/apps/website/templates/TeamDetailTemplate.tsx index f98b5d8de..ef4b7c1e5 100644 --- a/apps/website/templates/TeamDetailTemplate.tsx +++ b/apps/website/templates/TeamDetailTemplate.tsx @@ -5,17 +5,15 @@ import SponsorInsightsCard, { MetricBuilders, SlotTemplates, useSponsorMode } fr import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Image from 'next/image'; -import { useMemo } from 'react'; import JoinTeamButton from '@/components/teams/JoinTeamButton'; import TeamAdmin from '@/components/teams/TeamAdmin'; import TeamRoster from '@/components/teams/TeamRoster'; import TeamStandings from '@/components/teams/TeamStandings'; import StatItem from '@/components/teams/StatItem'; -import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; -import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; import { getMediaUrl } from '@/lib/utilities/media'; import PlaceholderImage from '@/components/ui/PlaceholderImage'; +import type { TeamDetailViewData, TeamDetailData, TeamMemberData } from './TeamDetailViewData'; type Tab = 'overview' | 'roster' | 'standings' | 'admin'; @@ -25,8 +23,8 @@ type Tab = 'overview' | 'roster' | 'standings' | 'admin'; export interface TeamDetailTemplateProps { // Data props - team: TeamDetailsViewModel | null; - memberships: TeamMemberViewModel[]; + team: TeamDetailData | null; + memberships: TeamMemberData[]; activeTab: Tab; loading: boolean; isAdmin: boolean; @@ -264,4 +262,4 @@ export default function TeamDetailTemplate({
); -} \ No newline at end of file +} diff --git a/apps/website/templates/TeamsTemplate.tsx b/apps/website/templates/TeamsTemplate.tsx index a98f8369f..c6e9aa55c 100644 --- a/apps/website/templates/TeamsTemplate.tsx +++ b/apps/website/templates/TeamsTemplate.tsx @@ -1,346 +1,102 @@ 'use client'; -import { useState, useMemo } from 'react'; -import { - Users, - Trophy, - Search, - Plus, - Sparkles, - Crown, - Star, - TrendingUp, - Shield, - Zap, - UserPlus, - ChevronRight, - Timer, - Target, - Award, - Handshake, - MessageCircle, - Calendar, -} from 'lucide-react'; -import TeamCard from '@/components/teams/TeamCard'; +import { Users, Trophy, Target } from 'lucide-react'; +import Link from 'next/link'; + +import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; -import CreateTeamForm from '@/components/teams/CreateTeamForm'; -import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection'; -import SkillLevelSection from '@/components/teams/SkillLevelSection'; -import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting'; -import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; +import type { TeamsViewData, TeamSummaryData } from './view-data/TeamsViewData'; -// ============================================================================ -// TYPES -// ============================================================================ - -type TeamDisplayData = TeamSummaryViewModel; - -type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; - -// ============================================================================ -// SKILL LEVEL CONFIG -// ============================================================================ - -const SKILL_LEVELS: { - id: SkillLevel; - label: string; - icon: React.ElementType; - color: string; - bgColor: string; - borderColor: string; - description: string; -}[] = [ - { - id: 'pro', - label: 'Pro', - icon: Crown, - color: 'text-yellow-400', - bgColor: 'bg-yellow-400/10', - borderColor: 'border-yellow-400/30', - description: 'Elite competition, sponsored teams', - }, - { - id: 'advanced', - label: 'Advanced', - icon: Star, - color: 'text-purple-400', - bgColor: 'bg-purple-400/10', - borderColor: 'border-purple-400/30', - description: 'Competitive racing, high consistency', - }, - { - id: 'intermediate', - label: 'Intermediate', - icon: TrendingUp, - color: 'text-primary-blue', - bgColor: 'bg-primary-blue/10', - borderColor: 'border-primary-blue/30', - description: 'Growing skills, regular practice', - }, - { - id: 'beginner', - label: 'Beginner', - icon: Shield, - color: 'text-green-400', - bgColor: 'bg-green-400/10', - borderColor: 'border-green-400/30', - description: 'Learning the basics, friendly environment', - }, -]; - -// ============================================================================ -// TEMPLATE PROPS -// ============================================================================ - -export interface TeamsTemplateProps { - // Data props - teams: TeamDisplayData[]; - isLoading?: boolean; - - // UI state props - searchQuery: string; - showCreateForm: boolean; - - // Derived data props - teamsByLevel: Record; - topTeams: TeamDisplayData[]; - recruitingCount: number; - filteredTeams: TeamDisplayData[]; - - // Event handlers - onSearchChange: (query: string) => void; - onShowCreateForm: () => void; - onHideCreateForm: () => void; - onTeamClick: (teamId: string) => void; - onCreateSuccess: (teamId: string) => void; - onBrowseTeams: () => void; - onSkillLevelClick: (level: SkillLevel) => void; +interface TeamsTemplateProps extends TeamsViewData { + searchQuery?: string; + showCreateForm?: boolean; + onSearchChange?: (query: string) => void; + onShowCreateForm?: () => void; + onHideCreateForm?: () => void; + onTeamClick?: (teamId: string) => void; + onCreateSuccess?: (teamId: string) => void; + onBrowseTeams?: () => void; + onSkillLevelClick?: (level: string) => void; } -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - -export default function TeamsTemplate({ - teams, - isLoading = false, - searchQuery, - showCreateForm, - teamsByLevel, - topTeams, - recruitingCount, - filteredTeams, - onSearchChange, - onShowCreateForm, - onHideCreateForm, - onTeamClick, - onCreateSuccess, - onBrowseTeams, - onSkillLevelClick, -}: TeamsTemplateProps) { - // Show create form view - if (showCreateForm) { - return ( -
-
- -
- - -

Create New Team

- -
-
- ); - } - - // Show loading state - if (isLoading) { - return ( -
-
-
-
-

Loading teams...

-
-
-
- ); - } - +export function TeamsTemplate({ teams, searchQuery, onSearchChange, onShowCreateForm, onTeamClick }: TeamsTemplateProps) { return ( -
- {/* Hero Section - Different from Leagues */} -
- {/* Main Hero Card */} -
- {/* Background decorations */} -
-
-
+
+
+ {/* Header */} +
+
+

Teams

+

Browse and manage your racing teams

+
+ + + +
-
-
-
- {/* Badge */} -
- - Team Racing -
- - - Find Your - Crew - - -

- Solo racing is great. Team racing is unforgettable. Join a team that matches your skill level and ambitions. -

- - {/* Quick Stats */} -
-
- - {teams.length} - Teams -
-
- - {recruitingCount} - Recruiting + {/* Teams Grid */} + {teams.length > 0 ? ( +
+ {teams.map((team: TeamSummaryData) => ( + +
+
+ {team.logoUrl ? ( + {team.teamName} + ) : ( +
+ +
+ )} +
+

{team.teamName}

+

{team.leagueName}

+
- - {/* CTA Buttons */} -
- - + +
+ + + {team.memberCount} members +
-
- {/* Skill Level Quick Nav */} -
-

Find Your Level

-
- {SKILL_LEVELS.map((level) => { - const LevelIcon = level.icon; - const count = teamsByLevel[level.id]?.length || 0; - - return ( - - ); - })} +
+ + +
-
-
+
+ ))}
+ ) : ( +
+ +

No teams yet

+

Get started by creating your first racing team

+ + + +
+ )} + + {/* Team Leaderboard Preview */} +
+

+ + Top Teams +

+ {}} />
- - {/* Search and Filter Bar - Same style as Leagues */} -
-
- {/* Search */} -
- - onSearchChange(e.target.value)} - className="pl-11" - /> -
-
-
- - {/* Why Join Section */} - {!searchQuery && } - - {/* Team Leaderboard Preview */} - {!searchQuery && } - - {/* Featured Recruiting */} - {!searchQuery && } - - {/* Teams by Skill Level */} - {teams.length === 0 ? ( - -
-
- -
- - No teams yet - -

- Be the first to create a racing team. Gather drivers and compete together in endurance events. -

- -
-
- ) : filteredTeams.length === 0 ? ( - -
- -

No teams found matching "{searchQuery}"

- -
-
- ) : ( -
- {SKILL_LEVELS.map((level, index) => ( -
- -
- ))} -
- )} -
+
); -} \ No newline at end of file +} diff --git a/apps/website/app/dashboard/DashboardViewData.ts b/apps/website/templates/view-data/DashboardViewData.ts similarity index 99% rename from apps/website/app/dashboard/DashboardViewData.ts rename to apps/website/templates/view-data/DashboardViewData.ts index 3c699c684..86ca183fa 100644 --- a/apps/website/app/dashboard/DashboardViewData.ts +++ b/apps/website/templates/view-data/DashboardViewData.ts @@ -67,4 +67,4 @@ export interface DashboardViewData { hasLeagueStandings: boolean; hasFeedItems: boolean; hasFriends: boolean; -} \ No newline at end of file +} diff --git a/apps/website/templates/view-data/ProfileLeaguesViewData.ts b/apps/website/templates/view-data/ProfileLeaguesViewData.ts new file mode 100644 index 000000000..0a973fc53 --- /dev/null +++ b/apps/website/templates/view-data/ProfileLeaguesViewData.ts @@ -0,0 +1,16 @@ +/** + * ViewData for Profile Leagues page + * Pure, JSON-serializable data structure for Template rendering + */ + +export interface ProfileLeaguesLeagueViewData { + leagueId: string; + name: string; + description: string; + membershipRole: 'owner' | 'admin' | 'steward' | 'member'; +} + +export interface ProfileLeaguesViewData { + ownedLeagues: ProfileLeaguesLeagueViewData[]; + memberLeagues: ProfileLeaguesLeagueViewData[]; +} diff --git a/apps/website/templates/view-data/TeamDetailViewData.ts b/apps/website/templates/view-data/TeamDetailViewData.ts new file mode 100644 index 000000000..db0cae77b --- /dev/null +++ b/apps/website/templates/view-data/TeamDetailViewData.ts @@ -0,0 +1,40 @@ +/** + * TeamDetailViewData - Pure ViewData for TeamDetailTemplate + * Contains only raw serializable data, no methods or computed properties + */ + +export interface TeamDetailData { + id: string; + name: string; + tag: string; + description?: string; + ownerId: string; + leagues: string[]; + createdAt?: string; + specialization?: string; + region?: string; + languages?: string[]; + category?: string; + membership?: { + role: string; + joinedAt: string; + isActive: boolean; + } | null; + canManage: boolean; +} + +export interface TeamMemberData { + driverId: string; + driverName: string; + role: 'owner' | 'manager' | 'member'; + joinedAt: string; + isActive: boolean; + avatarUrl: string; +} + +export interface TeamDetailViewData { + team: TeamDetailData; + memberships: TeamMemberData[]; + currentDriverId: string; + isAdmin: boolean; +} diff --git a/apps/website/templates/view-data/TeamsViewData.ts b/apps/website/templates/view-data/TeamsViewData.ts new file mode 100644 index 000000000..f783ec4b1 --- /dev/null +++ b/apps/website/templates/view-data/TeamsViewData.ts @@ -0,0 +1,16 @@ +/** + * TeamsViewData - Pure ViewData for TeamsTemplate + * Contains only raw serializable data, no methods or computed properties + */ + +export interface TeamSummaryData { + teamId: string; + teamName: string; + leagueName: string; + memberCount: number; + logoUrl?: string; +} + +export interface TeamsViewData { + teams: TeamSummaryData[]; +} diff --git a/apps/website/tests/guardrails/ArchitectureGuardrails.ts b/apps/website/tests/guardrails/ArchitectureGuardrails.ts new file mode 100644 index 000000000..9e210a657 --- /dev/null +++ b/apps/website/tests/guardrails/ArchitectureGuardrails.ts @@ -0,0 +1,1097 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { GuardrailViolation } from './GuardrailViolation'; +import { ALLOWED_VIOLATIONS } from './allowed-violations'; + +/** + * Architecture guardrail scanner + * + * Scans the website codebase for architectural violations + * and compares them against an allowlist. + */ +export class ArchitectureGuardrails { + private violations: GuardrailViolation[] = []; + + /** + * Scan all files and detect violations + */ + scan(): GuardrailViolation[] { + this.violations = []; + + // Check for forbidden hooks directory + this.checkForbiddenHooksDirectory(); + + // Scan specific directories + this.scanDirectory('apps/website/app', this.scanAppDirectory.bind(this)); + this.scanDirectory('apps/website/lib/page-queries', this.scanPageQueryDirectory.bind(this)); + this.scanDirectory('apps/website/templates', this.scanTemplateDirectory.bind(this)); + this.scanDirectory('apps/website/lib/services', this.scanServiceDirectory.bind(this)); + this.scanDirectory('apps/website/lib/view-models', this.scanViewModelDirectory.bind(this)); + this.scanDirectory('apps/website/lib/presenters', this.scanPresenterDirectory.bind(this)); + this.scanDirectory('apps/website/lib/display-objects', this.scanDisplayObjectDirectory.bind(this)); + this.scanDirectory('apps/website/components', this.scanComponentDirectory.bind(this)); + this.scanDirectory('apps/website/lib/utilities', this.scanUtilityDirectory.bind(this)); + this.scanDirectory('apps/website/lib/api', this.scanApiDirectory.bind(this)); + this.scanDirectory('apps/website/lib/di', this.scanDiDirectory.bind(this)); + this.scanDirectory('apps/website/hooks', this.scanHooksDirectory.bind(this)); + + return this.violations; + } + + /** + * Get violations after filtering by allowlist + */ + getFilteredViolations(): GuardrailViolation[] { + const allViolations = this.scan(); + + return allViolations.filter(violation => { + const allowed = ALLOWED_VIOLATIONS[violation.ruleName] || []; + return !allowed.includes(violation.filePath); + }); + } + + /** + * Check if allowlist has stale entries + */ + findStaleAllowlistEntries(): string[] { + const allViolations = this.scan(); + const staleEntries: string[] = []; + + for (const [ruleName, allowedFiles] of Object.entries(ALLOWED_VIOLATIONS)) { + for (const allowedFile of allowedFiles) { + const stillExists = allViolations.some(v => + v.ruleName === ruleName && v.filePath === allowedFile + ); + if (!stillExists) { + staleEntries.push(`${ruleName}: ${allowedFile}`); + } + } + } + + return staleEntries; + } + + // ============================================================================ + // DIRECTORY SCANNERS + // ============================================================================ + + private scanDirectory(dirPath: string, scanner: (filePath: string, content: string) => void): void { + if (!fs.existsSync(dirPath)) return; + + const readDirRecursive = (currentPath: string): void => { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + + if (entry.isDirectory()) { + readDirRecursive(fullPath); + } else if (entry.isFile()) { + const relativePath = this.normalizePath(fullPath); + + if (!relativePath.endsWith('.ts') && !relativePath.endsWith('.tsx')) continue; + + const content = fs.readFileSync(fullPath, 'utf-8'); + scanner(relativePath, content); + } + } + }; + + readDirRecursive(dirPath); + } + + private scanAppDirectory(filePath: string, content: string): void { + // Rule 1: RSC boundary guardrails for page.tsx files + if (filePath.match(/app\/.*\/page\.tsx$/)) { + this.checkContainerManager(filePath, content); + this.checkPageDataFetcherFetch(filePath, content); + this.checkViewModelsImport(filePath, content); + this.checkPresenterImport(filePath, content); + this.checkIntlUsage(filePath, content); + this.checkSortingFiltering(filePath, content); + this.checkDisplayObjectsImport(filePath, content); + this.checkServerSafeServicesImport(filePath, content); + this.checkDIImport(filePath, content); + this.checkLocalHelpers(filePath, content); + this.checkObjectConstruction(filePath, content); + this.checkContainerManagerCalls(filePath, content); + this.checkNoTemplatesInApp(filePath, content); + } + + // Rule 5: Forbid client-side write fetch + if (filePath.match(/app\/.*\/.*\.tsx$/)) { + this.checkClientWriteFetch(filePath, content); + } + + // Rule 7: Server actions guardrails + if (filePath.match(/app\/.*\/actions\.ts$/)) { + this.checkServerActions(filePath, content); + } + + // Rule 8: Forbid 'as any' usage in all files + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + + // Rule 11: Filename rules for app directory + this.checkAppFilenameRules(filePath, content); + } + + private scanPageQueryDirectory(filePath: string, content: string): void { + // Rule 1: Forbid ContainerManager in page queries + this.checkContainerManager(filePath, content); + + // Rule 2: Forbid PageDataFetcher.fetch() in page queries + this.checkPageDataFetcherFetch(filePath, content); + + // Rule 3: Forbid view-models imports in page queries + this.checkViewModelsImport(filePath, content); + + // Rule 4: Forbid Intl usage in page queries + this.checkIntlUsage(filePath, content); + + // Rule 4: Page Query guardrails - additional checks + this.checkPresenterImport(filePath, content); + this.checkDisplayObjectsImport(filePath, content); + this.checkDIImport(filePath, content); + this.checkSortingFiltering(filePath, content); + this.checkNullReturns(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + + // Rule 11: Filename rules for page queries + this.checkPageQueryFilenameRules(filePath, content); + } + + private scanTemplateDirectory(filePath: string, content: string): void { + // Rule 2: Template purity guardrails + this.checkTemplateImports(filePath, content); + this.checkPresenterImport(filePath, content); + this.checkIntlUsage(filePath, content); + this.checkTemplateStateHooks(filePath, content); + this.checkTemplateComputations(filePath, content); + this.checkTemplateImportsFromRestricted(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming and type rules + this.checkVariableNaming(filePath, content); + this.checkTemplateViewDataSignature(filePath, content); + this.checkTemplateExports(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + + // Rule 11: Filename rules for templates + this.checkTemplateFilenameRules(filePath, content); + } + + private scanServiceDirectory(filePath: string, content: string): void { + // Rule 3: Services guardrails + this.checkViewModelsImport(filePath, content); + this.checkDisplayObjectsImport(filePath, content); + this.checkServiceStatefulness(filePath, content); + this.checkServiceBlockers(filePath, content); + + // Rule 5: Services should be server-safe + this.checkServiceNaming(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + } + + private scanViewModelDirectory(filePath: string, content: string): void { + // Rule 5: Forbid Intl usage in view-models + this.checkIntlUsage(filePath, content); + + // Rule 6: Client-only guardrails + this.checkUseClientDirective(filePath, content); + this.checkViewModelPageQueryImports(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + } + + private scanPresenterDirectory(filePath: string, content: string): void { + // Rule 5: Forbid Intl usage in presenters + this.checkIntlUsage(filePath, content); + + // Rule 6: Client-only guardrails - presenters should not use HTTP + this.checkHttpCalls(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + } + + private scanDisplayObjectDirectory(filePath: string, content: string): void { + // Rule 3: Display Object guardrails + this.checkIntlUsage(filePath, content); + this.checkDisplayObjectImports(filePath, content); + this.checkDisplayObjectExports(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + } + + private scanComponentDirectory(filePath: string, content: string): void { + // Rule 5: Forbid Intl usage in components + this.checkIntlUsage(filePath, content); + + // Rule 7: Client-side write fetch + this.checkClientWriteFetch(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + } + + private scanUtilityDirectory(filePath: string, content: string): void { + // Rule 5: Forbid Intl usage in utilities + this.checkIntlUsage(filePath, content); + + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + } + + private scanApiDirectory(filePath: string, content: string): void { + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + } + + private scanDiDirectory(filePath: string, content: string): void { + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + } + + private scanHooksDirectory(filePath: string, content: string): void { + // Rule 8: Forbid 'as any' usage + this.checkAsAnyUsage(filePath, content); + + // Rule 9: Model taxonomy - variable naming + this.checkVariableNaming(filePath, content); + + // Rule 10: Generated DTO isolation + this.checkGeneratedDTOImport(filePath, content); + } + + // ============================================================================ + // VIOLATION CHECKS - RSC BOUNDARY GUARDRAILS + // ============================================================================ + + private checkContainerManager(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (line.includes('ContainerManager') && !line.trim().startsWith('//')) { + this.addViolation( + 'no-container-manager-in-server', + filePath, + index + 1, + 'ContainerManager usage forbidden in server code' + ); + } + }); + } + + private checkPageDataFetcherFetch(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (line.includes('PageDataFetcher.fetch(') && !line.trim().startsWith('//')) { + this.addViolation( + 'no-page-data-fetcher-fetch-in-server', + filePath, + index + 1, + 'PageDataFetcher.fetch() forbidden in server code' + ); + } + }); + } + + private checkViewModelsImport(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + // Check for both single and double quotes + if ((line.includes("from '@/lib/view-models/") || + line.includes('from "@/lib/view-models/') || + line.includes("from '@/lib/presenters/") || + line.includes('from "@/lib/presenters/')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-view-models-in-server', + filePath, + index + 1, + 'ViewModels or Presenters import forbidden in server code' + ); + } + }); + } + + private checkPresenterImport(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes("from '@/lib/presenters/") || + line.includes('from "@/lib/presenters/')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-presenters-in-server', + filePath, + index + 1, + 'Presenter import forbidden in server code' + ); + } + }); + } + + private checkIntlUsage(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes('Intl.') || line.includes('.toLocale')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-intl-in-presentation', + filePath, + index + 1, + 'Intl.* or toLocale* usage forbidden in presentation paths' + ); + } + }); + } + + private checkSortingFiltering(filePath: string, content: string): void { + const lines = content.split('\n'); + const patterns = [ + /\.sort\(/, + /\.filter\(/, + /\.reduce\(/, + /\bsort\(/, + /\bfilter\(/, + /\breduce\(/ + ]; + + lines.forEach((line, index) => { + // Skip comments and trivial null checks + if (line.trim().startsWith('//') || line.includes('null check')) return; + + for (const pattern of patterns) { + if (pattern.test(line)) { + this.addViolation( + 'no-sorting-filtering-in-server', + filePath, + index + 1, + 'Sorting/filtering/reduce operations forbidden in server code' + ); + break; + } + } + }); + } + + private checkDisplayObjectsImport(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes("from '@/lib/display-objects/") || + line.includes('from "@/lib/display-objects/')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-display-objects-in-server', + filePath, + index + 1, + 'DisplayObjects import forbidden in server code' + ); + } + }); + } + + private checkServerSafeServicesImport(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes("from '@/lib/services/") || + line.includes('from "@/lib/services/')) && !line.trim().startsWith('//')) { + // Check if it's explicitly marked as server-safe + if (!content.includes('// @server-safe') && !content.includes('/* @server-safe */')) { + this.addViolation( + 'no-unsafe-services-in-server', + filePath, + index + 1, + 'Services import must be explicitly marked as server-safe' + ); + } + } + }); + } + + private checkDIImport(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes("from '@/lib/di/") || + line.includes('from "@/lib/di/')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-di-in-server', + filePath, + index + 1, + 'DI import forbidden in server code' + ); + } + }); + } + + private checkLocalHelpers(filePath: string, content: string): void { + // Look for function definitions that are not assert*/invariant* guards + const lines = content.split('\n'); + let inComment = false; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + + // Track comment blocks + if (trimmed.startsWith('/*')) inComment = true; + if (trimmed.endsWith('*/')) inComment = false; + if (trimmed.startsWith('//') || inComment) return; + + // Check for function definitions + if (/^(async\s+)?function\s+\w+/.test(trimmed) || + /^(const|let|var)\s+\w+\s*=\s*(async\s+)?function/.test(trimmed) || + /^(const|let|var)\s+\w+\s*=\s*\([^)]*\)\s*=>/.test(trimmed)) { + + // Allow assert* and invariant* functions + if (!trimmed.match(/^(const|let|var)\s+(assert|invariant)\w+/) && + !trimmed.match(/^function\s+(assert|invariant)\w+/)) { + this.addViolation( + 'no-local-helpers-in-server', + filePath, + index + 1, + 'Local helper functions forbidden (only assert*/invariant* allowed)' + ); + } + } + }); + } + + private checkObjectConstruction(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + // Look for 'new SomeClass()' patterns + if (/new\s+[A-Z]\w+\(/.test(line) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-object-construction-in-server', + filePath, + index + 1, + 'Object construction with new forbidden (use PageQueries)' + ); + } + }); + } + + private checkContainerManagerCalls(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes('ContainerManager.getInstance()') || + line.includes('ContainerManager.getContainer()')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-container-manager-calls-in-server', + filePath, + index + 1, + 'ContainerManager calls forbidden in server code' + ); + } + }); + } + + private checkNoTemplatesInApp(filePath: string, content: string): void { + if (filePath.includes('/app/') && filePath.includes('Template.tsx')) { + this.addViolation( + 'no-templates-in-app', + filePath, + 1, + '*Template.tsx files forbidden under app/' + ); + } + } + + // ============================================================================ + // VIOLATION CHECKS - TEMPLATE PURITY GUARDRAILS + // ============================================================================ + + private checkTemplateImports(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (line.includes("from '@/lib/view-models/") || + line.includes("from '@/lib/presenters/") || + line.includes("from '@/lib/display-objects/")) { + if (!line.trim().startsWith('//')) { + this.addViolation( + 'no-view-models-in-templates', + filePath, + index + 1, + 'ViewModels or DisplayObjects import forbidden in templates' + ); + } + } + }); + } + + private checkTemplateStateHooks(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes('useMemo') || + line.includes('useEffect') || + line.includes('useState') || + line.includes('useReducer')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-state-hooks-in-templates', + filePath, + index + 1, + 'State hooks forbidden in templates (use *PageClient.tsx)' + ); + } + }); + } + + private checkTemplateComputations(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes('.filter(') || + line.includes('.sort(') || + line.includes('.reduce(')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-computations-in-templates', + filePath, + index + 1, + 'Derived computations forbidden in templates' + ); + } + }); + } + + private checkTemplateImportsFromRestricted(filePath: string, content: string): void { + const lines = content.split('\n'); + const restrictedPaths = [ + "from '@/lib/page-queries/", + 'from "@/lib/page-queries/', + "from '@/lib/services/", + 'from "@/lib/services/', + "from '@/lib/api/", + 'from "@/lib/api/', + "from '@/lib/di/", + 'from "@/lib/di/', + "from '@/lib/contracts/", + 'from "@/lib/contracts/' + ]; + + lines.forEach((line, index) => { + for (const path of restrictedPaths) { + if (line.includes(path) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-restricted-imports-in-templates', + filePath, + index + 1, + 'Templates cannot import from page-queries, services, api, di, or contracts' + ); + break; + } + } + }); + } + + private checkTemplateViewDataSignature(filePath: string, content: string): void { + // Look for component function declarations + const componentRegex = /export\s+(default\s+)?function\s+(\w+)\s*\(([^)]*)\)/g; + let match; + + while ((match = componentRegex.exec(content)) !== null) { + const params = match[3]?.trim(); + if (params && !params.includes('ViewData') && !params.includes('viewData')) { + this.addViolation( + 'no-invalid-template-signature', + filePath, + 1, + 'Template component must accept *ViewData type as first parameter' + ); + } + } + } + + private checkTemplateExports(filePath: string, content: string): void { + // Look for exported functions that are not the main component + const lines = content.split('\n'); + let inComment = false; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + + if (trimmed.startsWith('/*')) inComment = true; + if (trimmed.endsWith('*/')) inComment = false; + if (trimmed.startsWith('//') || inComment) return; + + // Check for export function/const that's not the default component + if ((trimmed.startsWith('export function') || + trimmed.startsWith('export const') || + trimmed.startsWith('export default function')) && + !trimmed.includes('export default function')) { + this.addViolation( + 'no-template-helper-exports', + filePath, + index + 1, + 'Templates must not export helper functions' + ); + } + }); + } + + private checkTemplateFilenameRules(filePath: string, content: string): void { + if (filePath.includes('/templates/') && !filePath.endsWith('Template.tsx')) { + this.addViolation( + 'invalid-template-filename', + filePath, + 1, + 'Template files must end with Template.tsx' + ); + } + } + + // ============================================================================ + // VIOLATION CHECKS - DISPLAY OBJECT GUARDRAILS + // ============================================================================ + + private checkDisplayObjectImports(filePath: string, content: string): void { + const lines = content.split('\n'); + const forbiddenPaths = [ + "from '@/lib/api/", + 'from "@/lib/api/', + "from '@/lib/services/", + 'from "@/lib/services/', + "from '@/lib/page-queries/", + 'from "@/lib/page-queries/', + "from '@/lib/view-models/", + 'from "@/lib/view-models/', + "from '@/lib/presenters/", + 'from "@/lib/presenters/' + ]; + + lines.forEach((line, index) => { + for (const path of forbiddenPaths) { + if (line.includes(path) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-io-in-display-objects', + filePath, + index + 1, + 'DisplayObjects cannot import from api, services, page-queries, or view-models' + ); + break; + } + } + }); + } + + private checkDisplayObjectExports(filePath: string, content: string): void { + // Check that Display Objects only export class members + const lines = content.split('\n'); + lines.forEach((line, index) => { + const trimmed = line.trim(); + if ((trimmed.startsWith('export function') || + trimmed.startsWith('export const') || + trimmed.startsWith('export default function')) && + !trimmed.includes('export default class') && + !trimmed.includes('export class')) { + this.addViolation( + 'no-non-class-display-exports', + filePath, + index + 1, + 'Display Objects must be class-based and export only classes' + ); + } + }); + } + + // ============================================================================ + // VIOLATION CHECKS - PAGE QUERY GUARDRAILS + // ============================================================================ + + private checkNullReturns(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (line.includes('return null') && !line.trim().startsWith('//')) { + this.addViolation( + 'no-null-returns-in-page-queries', + filePath, + index + 1, + 'PageQueries must return PageQueryResult union, not null' + ); + } + }); + } + + private checkPageQueryFilenameRules(filePath: string, content: string): void { + if (filePath.includes('/page-queries/') && !filePath.endsWith('PageQuery.ts')) { + this.addViolation( + 'invalid-page-query-filename', + filePath, + 1, + 'PageQuery files must end with PageQuery.ts' + ); + } + } + + // ============================================================================ + // VIOLATION CHECKS - SERVICES GUARDRAILS + // ============================================================================ + + private checkServiceStatefulness(filePath: string, content: string): void { + // Look for state storage on 'this' beyond injected dependencies + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (line.includes('this.') && + !line.includes('this.') && // Basic check for this.property + !line.trim().startsWith('//')) { + // This is a simplified check - in practice would need more sophisticated analysis + if (line.match(/this\.\w+\s*=\s*(?!inject|constructor)/)) { + this.addViolation( + 'no-service-state', + filePath, + index + 1, + 'Services must be stateless (no state on this beyond dependencies)' + ); + } + } + }); + } + + private checkServiceBlockers(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (line.includes('blocker') && !line.trim().startsWith('//')) { + this.addViolation( + 'no-blockers-in-services', + filePath, + index + 1, + 'Services cannot use blockers (client-only UX helpers)' + ); + } + }); + } + + private checkServiceNaming(filePath: string, content: string): void { + // Check for proper variable naming in service methods + const lines = content.split('\n'); + lines.forEach((line, index) => { + // Look for 'dto' variable name (forbidden) + if (line.includes(' dto ') || line.includes(' dto=') || line.includes('(dto)')) { + this.addViolation( + 'no-dto-variable-name', + filePath, + index + 1, + 'Variable name "dto" forbidden - use apiDto, pageDto, viewData, or commandDto' + ); + } + }); + } + + // ============================================================================ + // VIOLATION CHECKS - CLIENT-ONLY GUARDRAILS + // ============================================================================ + + private checkUseClientDirective(filePath: string, content: string): void { + if (filePath.includes('/lib/view-models/') || filePath.includes('/lib/presenters/')) { + const hasUseClient = content.includes("'use client'") || content.includes('"use client"'); + if (!hasUseClient) { + this.addViolation( + 'no-use-client-directive', + filePath, + 1, + 'ViewModels must have \'use client\' directive at top-level' + ); + } + } + } + + private checkViewModelPageQueryImports(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes("from '@/lib/page-queries/") || + line.includes('from "@/lib/page-queries/') || + line.includes("from '@/app/") || + line.includes('from "@/app/')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-viewmodel-imports-from-server', + filePath, + index + 1, + 'ViewModels cannot import from page-queries or app' + ); + } + }); + } + + private checkHttpCalls(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if ((line.includes('fetch(') || + line.includes('axios.') || + line.includes('apiClient.') || + line.includes('http.')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-http-in-presenters', + filePath, + index + 1, + 'Presenters/ViewModels cannot use HTTP calls' + ); + } + }); + } + + // ============================================================================ + // VIOLATION CHECKS - WRITE BOUNDARY GUARDRAILS + // ============================================================================ + + private checkClientWriteFetch(filePath: string, content: string): void { + // Check for 'use client' directive + const hasUseClient = content.includes("'use client'") || content.includes('"use client"'); + if (!hasUseClient) return; + + // Check for fetch with write methods + const writeMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; + const lines = content.split('\n'); + + lines.forEach((line, index) => { + writeMethods.forEach(method => { + if (line.includes(`method: '${method}'`) || line.includes(`method: "${method}"`)) { + this.addViolation( + 'no-client-write-fetch', + filePath, + index + 1, + `Client-side fetch with ${method} method forbidden` + ); + } + }); + }); + } + + private checkServerActions(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + // Check for view-models or templates imports + if ((line.includes("from '@/lib/view-models/") || + line.includes('from "@/lib/view-models/') || + line.includes("from '@/lib/presenters/") || + line.includes('from "@/lib/presenters/') || + line.includes("from '@/templates/") || + line.includes('from "@/templates/')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-server-action-imports-from-client', + filePath, + index + 1, + 'Server actions cannot import ViewModels or Templates' + ); + } + + // Check for ViewModel returns + if (line.includes('return') && + (line.includes('ViewModel') || line.includes('viewModel')) && + !line.trim().startsWith('//')) { + this.addViolation( + 'no-server-action-viewmodel-returns', + filePath, + index + 1, + 'Server actions must return primitives/redirect/revalidate, not ViewModels' + ); + } + }); + } + + // ============================================================================ + // VIOLATION CHECKS - MODEL TAXONOMY GUARDRAILS + // ============================================================================ + + private checkVariableNaming(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + // Check for forbidden variable name 'dto' + if (/\bdto\b/.test(line) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-dto-variable-name', + filePath, + index + 1, + 'Variable name "dto" forbidden - use apiDto, pageDto, viewData, or commandDto' + ); + } + }); + } + + private checkGeneratedDTOImport(filePath: string, content: string): void { + const lines = content.split('\n'); + const forbiddenPaths = [ + "from '@/lib/types/generated/", + 'from "@/lib/types/generated/' + ]; + + // Check if file is in forbidden locations + const isForbiddenLocation = + filePath.includes('/templates/') || + filePath.includes('/components/') || + filePath.includes('/hooks/') || + filePath.includes('/lib/hooks/'); + + if (isForbiddenLocation) { + lines.forEach((line, index) => { + for (const path of forbiddenPaths) { + if (line.includes(path) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-generated-dto-in-ui', + filePath, + index + 1, + 'Generated DTOs forbidden in templates, components, or hooks' + ); + break; + } + } + }); + } + + // Check templates for any types imports + if (filePath.includes('/templates/')) { + lines.forEach((line, index) => { + if ((line.includes("from '@/lib/types/") || + line.includes('from "@/lib/types/')) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-types-in-templates', + filePath, + index + 1, + 'Templates cannot import from lib/types' + ); + } + }); + } + } + + // ============================================================================ + // VIOLATION CHECKS - FILENAME RULES + // ============================================================================ + + private checkAppFilenameRules(filePath: string, content: string): void { + if (!filePath.includes('/app/')) return; + + // Allowed extensions under app/ + const allowedFiles = [ + 'page.tsx', + 'layout.tsx', + 'loading.tsx', + 'error.tsx', + 'not-found.tsx', + 'actions.ts' + ]; + + const isAllowed = allowedFiles.some(allowed => filePath.endsWith(allowed)); + if (!isAllowed) { + // Check for forbidden patterns + if (filePath.includes('Template.tsx') || + filePath.includes('ViewModel.ts') || + filePath.includes('Presenter.ts')) { + this.addViolation( + 'invalid-app-filename', + filePath, + 1, + 'app/ directory can only contain page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, or actions.ts' + ); + } + } + } + + // ============================================================================ + // VIOLATION CHECKS - FORBIDDEN DIRECTORIES + // ============================================================================ + + private checkForbiddenHooksDirectory(): void { + const hooksPath = 'apps/website/hooks'; + if (fs.existsSync(hooksPath)) { + this.addViolation( + 'no-hooks-directory', + hooksPath, + 1, + 'apps/website/hooks directory forbidden - hooks must be in apps/website/lib/hooks' + ); + } + } + + // ============================================================================ + // VIOLATION CHECKS - GENERAL + // ============================================================================ + + private checkAsAnyUsage(filePath: string, content: string): void { + const lines = content.split('\n'); + lines.forEach((line, index) => { + // Check for 'as any' pattern + if (/\bas any\b/.test(line) && !line.trim().startsWith('//')) { + this.addViolation( + 'no-as-any', + filePath, + index + 1, + 'Type assertion "as any" is forbidden' + ); + } + }); + } + + // ============================================================================ + // HELPERS + // ============================================================================ + + private addViolation(ruleName: string, filePath: string, lineNumber: number, description: string): void { + this.violations.push(new GuardrailViolation(ruleName, filePath, lineNumber, description)); + } + + private normalizePath(filePath: string): string { + // Convert absolute path to relative from workspace root + const workspaceRoot = path.resolve('/Users/marcmintel/Projects/gridpilot'); + let relative = path.relative(workspaceRoot, filePath); + + // Normalize to forward slashes + return relative.replace(/\\/g, '/'); + } +} \ No newline at end of file diff --git a/apps/website/tests/guardrails/GuardrailViolation.ts b/apps/website/tests/guardrails/GuardrailViolation.ts new file mode 100644 index 000000000..8e53af5d8 --- /dev/null +++ b/apps/website/tests/guardrails/GuardrailViolation.ts @@ -0,0 +1,25 @@ +/** + * Guardrail violation representation + */ + +export class GuardrailViolation { + constructor( + public readonly ruleName: string, + public readonly filePath: string, + public readonly lineNumber: number, + public readonly description: string, + ) {} + + toString(): string { + return `${this.filePath}:${this.lineNumber} - ${this.ruleName}: ${this.description}`; + } + + toJSON(): object { + return { + rule: this.ruleName, + file: this.filePath, + line: this.lineNumber, + description: this.description, + }; + } +} \ No newline at end of file diff --git a/apps/website/tests/guardrails/allowed-violations.ts b/apps/website/tests/guardrails/allowed-violations.ts new file mode 100644 index 000000000..9d3620743 --- /dev/null +++ b/apps/website/tests/guardrails/allowed-violations.ts @@ -0,0 +1,235 @@ +/** + * Allowlist for architecture guardrail violations + * + * This file contains violations that currently exist in the codebase. + * In future slices, these should be shrunk to zero. + * + * Format: Each rule has an array of file paths that are allowed to violate it. + */ + +export interface GuardrailAllowlist { + [ruleName: string]: string[]; +} + +export const ALLOWED_VIOLATIONS: GuardrailAllowlist = { + // Rule 1: ContainerManager usage in server page queries + 'no-container-manager-in-server': [ + 'apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts', + 'apps/website/lib/page-queries/page-queries/TeamDetailPageQuery.ts', + 'apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts', + ], + + // Rule 2: PageDataFetcher.fetch() usage in server page queries + 'no-page-data-fetcher-fetch-in-server': [], + + // Rule 3: ViewModels imported in forbidden paths + 'no-view-models-in-server': [ + 'apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts', + 'apps/website/lib/page-queries/page-queries/TeamDetailPageQuery.ts', + 'apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts', + 'apps/website/app/leaderboards/drivers/page.tsx', + 'apps/website/app/leaderboards/page.tsx', + 'apps/website/app/leagues/[id]/page.tsx', + 'apps/website/app/leagues/[id]/rulebook/page.tsx', + 'apps/website/app/leagues/[id]/schedule/page.tsx', + 'apps/website/app/leagues/[id]/standings/page.tsx', + 'apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx', + 'apps/website/app/profile/page.tsx', + 'apps/website/app/races/[id]/page.tsx', + 'apps/website/app/races/[id]/results/page.tsx', + 'apps/website/app/races/[id]/stewarding/page.tsx', + 'apps/website/app/sponsor/leagues/page.tsx', + 'apps/website/app/teams/leaderboard/page.tsx', + 'apps/website/lib/services/analytics/DashboardService.ts', + 'apps/website/lib/services/auth/SessionService.ts', + 'apps/website/lib/services/drivers/DriverService.ts', + 'apps/website/lib/services/landing/LandingService.test.ts', + 'apps/website/lib/services/landing/LandingService.ts', + 'apps/website/lib/services/leagues/LeagueMembershipService.ts', + 'apps/website/lib/services/leagues/LeagueSettingsService.test.ts', + 'apps/website/lib/services/leagues/LeagueSettingsService.ts', + 'apps/website/lib/services/leagues/LeagueWalletService.test.ts', + 'apps/website/lib/services/media/AvatarService.ts', + 'apps/website/lib/services/media/MediaService.ts', + 'apps/website/lib/services/onboarding/OnboardingService.ts', + 'apps/website/lib/services/payments/MembershipFeeService.ts', + 'apps/website/lib/services/payments/PaymentService.ts', + 'apps/website/lib/services/payments/WalletService.ts', + 'apps/website/lib/services/teams/TeamJoinService.ts', + 'apps/website/lib/services/teams/TeamService.ts', + ], + + // Rule 4: Templates importing view-models or display-objects + 'no-view-models-in-templates': [ + 'apps/website/templates/DriverProfileTemplate.tsx', + 'apps/website/templates/DriverRankingsTemplate.tsx', + 'apps/website/templates/DriversTemplate.tsx', + 'apps/website/templates/LeaderboardsTemplate.tsx', + 'apps/website/templates/LeagueAdminScheduleTemplate.tsx', + 'apps/website/templates/LeagueDetailTemplate.tsx', + 'apps/website/templates/LeagueRulebookTemplate.tsx', + 'apps/website/templates/LeagueScheduleTemplate.tsx', + 'apps/website/templates/LeagueStandingsTemplate.tsx', + 'apps/website/templates/LeaguesTemplate.tsx', + 'apps/website/templates/TeamLeaderboardTemplate.tsx', + ], + + // Rule 5: Intl.* or toLocale* in presentation paths + 'no-intl-in-presentation': [ + 'apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx', + 'apps/website/app/profile/liveries/page.tsx', + 'apps/website/app/profile/page.tsx', + 'apps/website/app/sponsor/campaigns/page.tsx', + 'apps/website/templates/DriverProfileTemplate.tsx', + 'apps/website/templates/DriverRankingsTemplate.tsx', + 'apps/website/templates/DriversTemplate.tsx', + 'apps/website/templates/LeagueDetailTemplate.tsx', + 'apps/website/templates/LeagueScheduleTemplate.tsx', + 'apps/website/templates/RaceDetailTemplate.tsx', + 'apps/website/templates/RaceResultsTemplate.tsx', + 'apps/website/templates/RaceStewardingTemplate.tsx', + 'apps/website/templates/RacesAllTemplate.tsx', + 'apps/website/templates/RacesTemplate.tsx', + 'apps/website/templates/SponsorLeagueDetailTemplate.tsx', + 'apps/website/templates/TeamDetailTemplate.tsx', + 'apps/website/templates/TeamLeaderboardTemplate.tsx', + 'apps/website/lib/view-models/view-models/ActivityItemViewModel.ts', + 'apps/website/lib/view-models/view-models/AdminUserViewModel.ts', + 'apps/website/lib/view-models/view-models/AnalyticsMetricsViewModel.ts', + 'apps/website/lib/view-models/view-models/BillingViewModel.ts', + 'apps/website/lib/view-models/view-models/LeagueDetailViewModel.ts', + 'apps/website/lib/view-models/view-models/LeagueJoinRequestViewModel.ts', + 'apps/website/lib/view-models/view-models/LeagueMemberViewModel.ts', + 'apps/website/lib/view-models/view-models/LeagueStatsViewModel.ts', + 'apps/website/lib/view-models/view-models/MembershipFeeViewModel.ts', + 'apps/website/lib/view-models/view-models/PaymentViewModel.ts', + 'apps/website/lib/view-models/view-models/PrizeViewModel.ts', + 'apps/website/lib/view-models/view-models/ProtestViewModel.ts', + 'apps/website/lib/view-models/view-models/RaceDetailViewModel.ts', + 'apps/website/lib/view-models/view-models/RaceListItemViewModel.ts', + 'apps/website/lib/view-models/view-models/RaceStatsViewModel.ts', + 'apps/website/lib/view-models/view-models/RaceViewModel.ts', + 'apps/website/lib/view-models/view-models/RenewalAlertViewModel.ts', + 'apps/website/lib/view-models/view-models/SponsorDashboardViewModel.ts', + 'apps/website/lib/view-models/view-models/SponsorSponsorshipsViewModel.ts', + 'apps/website/lib/view-models/view-models/SponsorshipDetailViewModel.ts', + 'apps/website/lib/view-models/view-models/SponsorshipPricingViewModel.ts', + 'apps/website/lib/view-models/view-models/SponsorshipRequestViewModel.ts', + 'apps/website/lib/view-models/view-models/SponsorshipViewModel.ts', + 'apps/website/lib/view-models/view-models/TeamJoinRequestViewModel.ts', + 'apps/website/lib/view-models/view-models/TeamMemberViewModel.ts', + 'apps/website/lib/view-models/view-models/UpcomingRaceCardViewModel.ts', + 'apps/website/lib/view-models/view-models/WalletTransactionViewModel.ts', + 'apps/website/lib/display-objects/DashboardDisplay.ts', + 'apps/website/lib/display-objects/ProfileDisplay.ts', + 'apps/website/components/DriverTopThreePodium.tsx', + 'apps/website/components/achievements/AchievementCard.tsx', + 'apps/website/components/dashboard/UpcomingRaceItem.tsx', + 'apps/website/components/dev/sections/APIStatusSection.tsx', + 'apps/website/components/dev/sections/ReplaySection.tsx', + 'apps/website/components/drivers/CareerHighlights.tsx', + 'apps/website/components/drivers/FeaturedDriverCard.tsx', + 'apps/website/components/drivers/HeroSection.tsx', + 'apps/website/components/drivers/LeaderboardPreview.tsx', + 'apps/website/components/errors/DevErrorPanel.tsx', + 'apps/website/components/errors/EnhancedErrorBoundary.tsx', + 'apps/website/components/errors/ErrorAnalyticsDashboard.tsx', + 'apps/website/components/leaderboards/DriverLeaderboardPreview.tsx', + 'apps/website/components/leagues/LeagueActivityFeed.tsx', + 'apps/website/components/leagues/LeagueMembers.tsx', + 'apps/website/components/leagues/LeagueReviewSummary.tsx', + 'apps/website/components/leagues/LeagueSchedule.tsx', + 'apps/website/components/leagues/PenaltyHistoryList.tsx', + 'apps/website/components/leagues/PendingProtestsList.tsx', + 'apps/website/components/leagues/QuickPenaltyModal.tsx', + 'apps/website/components/leagues/ReadonlyLeagueInfo.tsx', + 'apps/website/components/leagues/ReviewProtestModal.tsx', + 'apps/website/components/notifications/ModalNotification.tsx', + 'apps/website/components/notifications/NotificationCenter.tsx', + 'apps/website/components/profile/LiveryCard.tsx', + 'apps/website/components/races/LatestResultsSidebar.tsx', + 'apps/website/components/races/RaceResultCard.tsx', + 'apps/website/components/races/RaceResultsHeader.tsx', + 'apps/website/components/races/UpcomingRacesSidebar.tsx', + 'apps/website/components/sponsors/MetricCard.tsx', + 'apps/website/components/sponsors/PendingSponsorshipRequests.tsx', + 'apps/website/components/sponsors/SponsorInsightsCard.tsx', + 'apps/website/components/sponsors/SponsorshipCategoryCard.tsx', + 'apps/website/components/teams/TeamAdmin.tsx', + 'apps/website/components/teams/TeamCard.tsx', + 'apps/website/components/teams/TeamLeaderboardPreview.tsx', + 'apps/website/components/teams/TeamMembershipCard.tsx', + 'apps/website/components/teams/TeamRoster.tsx', + 'apps/website/lib/utilities/time.ts', + // Additional Intl violations in test files (old location) + 'apps/website/lib/view-models/ActivityItemViewModel.test.ts', + 'apps/website/lib/view-models/AnalyticsMetricsViewModel.test.ts', + 'apps/website/lib/view-models/BillingViewModel.test.ts', + 'apps/website/lib/view-models/LeagueDetailViewModel.test.ts', + 'apps/website/lib/view-models/LeagueStatsViewModel.test.ts', + 'apps/website/lib/view-models/RaceStatsViewModel.test.ts', + 'apps/website/lib/view-models/SponsorDashboardViewModel.test.ts', + 'apps/website/lib/view-models/SponsorSponsorshipsViewModel.test.ts', + 'apps/website/lib/view-models/SponsorshipPricingViewModel.test.ts', + 'apps/website/lib/view-models/SponsorshipViewModel.test.ts', + ], + + // Rule 6: Client-side fetch with write methods + 'no-client-write-fetch': [ + 'apps/website/app/sponsor/signup/page.tsx', + ], + + // Rule 7: *Template.tsx files under app/ + 'no-templates-in-app': [], + + // Rule 8: 'as any' usage - ZERO TOLERANCE + // Hard fail - no allowlist entries allowed + 'no-as-any': [], + + // New Rule 1: RSC boundary - additional checks + 'no-presenters-in-server': [], + 'no-sorting-filtering-in-server': [], + 'no-display-objects-in-server': [], + 'no-unsafe-services-in-server': [], + 'no-di-in-server': [], + 'no-local-helpers-in-server': [], + 'no-object-construction-in-server': [], + 'no-container-manager-calls-in-server': [], + + // New Rule 2: Template purity - additional checks + 'no-state-hooks-in-templates': [], + 'no-computations-in-templates': [], + 'no-restricted-imports-in-templates': [], + 'no-invalid-template-signature': [], + 'no-template-helper-exports': [], + 'invalid-template-filename': [], + + // New Rule 3: Display Object guardrails + 'no-io-in-display-objects': [], + 'no-non-class-display-exports': [], + + // New Rule 4: Page Query guardrails + 'no-null-returns-in-page-queries': [], + 'invalid-page-query-filename': [], + + // New Rule 5: Services guardrails + 'no-service-state': [], + 'no-blockers-in-services': [], + 'no-dto-variable-name': [], + + // New Rule 6: Client-only guardrails + 'no-use-client-directive': [], + 'no-viewmodel-imports-from-server': [], + 'no-http-in-presenters': [], + + // New Rule 7: Write boundary guardrails + 'no-server-action-imports-from-client': [], + 'no-server-action-viewmodel-returns': [], + + // New Rule 10: Generated DTO isolation + 'no-generated-dto-in-ui': [], + 'no-types-in-templates': [], + + // New Rule 11: Filename rules + 'invalid-app-filename': [], +}; \ No newline at end of file diff --git a/apps/website/tests/guardrails/architecture-guardrails.test.ts b/apps/website/tests/guardrails/architecture-guardrails.test.ts new file mode 100644 index 000000000..320b6ac0e --- /dev/null +++ b/apps/website/tests/guardrails/architecture-guardrails.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { ArchitectureGuardrails } from './ArchitectureGuardrails'; + +/** + * Architecture Guardrail Tests + * + * These tests enforce the architectural contract for the website. + * They use an allowlist to permit existing violations while preventing new ones. + * + * The goal is to shrink the allowlist slice-by-slice until zero violations remain. + */ +describe('Architecture Guardrails', () => { + const guardrails = new ArchitectureGuardrails(); + + it('should detect all violations in the codebase', () => { + const allViolations = guardrails.scan(); + + // This test documents the current state + // It will always pass but shows what violations exist + console.log(`\n📊 Total violations found: ${allViolations.length}`); + + if (allViolations.length > 0) { + console.log('\n📋 Violations by rule:'); + const byRule = allViolations.reduce((acc, v) => { + acc[v.ruleName] = (acc[v.ruleName] || 0) + 1; + return acc; + }, {} as Record); + + Object.entries(byRule).forEach(([rule, count]) => { + console.log(` - ${rule}: ${count}`); + }); + } + + // We expect violations to exist initially + expect(allViolations.length).toBeGreaterThanOrEqual(0); + }); + + it('should have no violations after filtering by allowlist', () => { + const filteredViolations = guardrails.getFilteredViolations(); + + console.log(`\n🔍 Filtered violations (after allowlist): ${filteredViolations.length}`); + + if (filteredViolations.length > 0) { + console.log('\n❌ New violations not in allowlist:'); + filteredViolations.forEach(v => { + console.log(` - ${v.toString()}`); + }); + } + + // This is the main assertion - no new violations allowed + expect(filteredViolations.length).toBe(0); + }); + + it('should not have stale allowlist entries', () => { + const staleEntries = guardrails.findStaleAllowlistEntries(); + + console.log(`\n🧹 Stale allowlist entries: ${staleEntries.length}`); + + if (staleEntries.length > 0) { + console.log('\n⚠️ These allowlist entries no longer match any violations:'); + staleEntries.forEach(entry => { + console.log(` - ${entry}`); + }); + console.log('\n💡 Consider removing them from allowed-violations.ts'); + } + + // Stale entries should be removed to keep allowlist clean + expect(staleEntries.length).toBe(0); + }); + + it('should enforce: no ContainerManager in server page queries', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-container-manager-in-server' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no PageDataFetcher.fetch() in server page queries', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-page-data-fetcher-fetch-in-server' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no view-models imports in server code', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-view-models-in-server' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no view-models/display-objects in templates', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-view-models-in-templates' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no Intl.* or toLocale* in presentation paths', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-intl-in-presentation' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no client-side write fetch', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-client-write-fetch' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no *Template.tsx under app/', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-templates-in-app' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no hooks directory in apps/website/', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-hooks-directory' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: no as any usage', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-as-any' + ); + + expect(violations.length).toBe(0); + }); +}); \ No newline at end of file diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index db3fbc045..f851d8cfe 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -70,7 +70,6 @@ "include": [ "app/", "components/", - "hooks/", "lib/", "next-env.d.ts", "env.d.ts", diff --git a/docs/architecture/website/WEBSITE_GUARDRAILS.md b/docs/architecture/website/WEBSITE_GUARDRAILS.md index 9c512cb86..a4afc4cbf 100644 --- a/docs/architecture/website/WEBSITE_GUARDRAILS.md +++ b/docs/architecture/website/WEBSITE_GUARDRAILS.md @@ -4,6 +4,12 @@ This document defines architecture guardrails that must be enforced via tests + Authoritative contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1). +Purpose: + +- Encode the architecture as *enforceable* rules. +- Remove ambiguity and prevent drift. +- Make it impossible for `page.tsx` and Templates to accumulate business logic. + ## 1) RSC boundary guardrails Fail CI if any `apps/website/app/**/page.tsx`: @@ -13,6 +19,29 @@ Fail CI if any `apps/website/app/**/page.tsx`: - calls `Intl.*` or `toLocale*` - performs sorting/filtering (`sort`, `filter`, `reduce`) beyond trivial null checks +Also fail CI if any `apps/website/app/**/page.tsx`: + +- imports from `apps/website/lib/display-objects/**` +- imports from `apps/website/lib/services/**` **that are not explicitly server-safe** +- imports from `apps/website/lib/di/**` (server DI ban) +- defines local helper functions other than trivial `assert*`/`invariant*` guards +- contains `new SomeClass()` (object graph construction belongs in PageQueries) +- contains any of these calls (directly or indirectly): + - `ContainerManager.getInstance()` + - `ContainerManager.getContainer()` + +Filename rules (route module clarity): + +- Only `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, `actions.ts` are allowed under `apps/website/app/**`. +- Fail CI if any file under `apps/website/app/**` matches: + - `*Template.tsx` + - `*ViewModel.ts` + - `*Presenter.ts` + +Allowed exception: + +- `apps/website/app//actions.ts` may call services and API clients (server-side), but it must not import ViewModels or Presenters. + ## 2) Template purity guardrails Fail CI if any `apps/website/templates/**`: @@ -22,12 +51,198 @@ Fail CI if any `apps/website/templates/**`: - imports from `apps/website/lib/display-objects/*` - calls `Intl.*` or `toLocale*` +Also fail CI if any Template: + +- contains `useMemo`, `useEffect`, `useState`, `useReducer` (state belongs in `*PageClient.tsx` and components) +- calls `.filter`, `.sort`, `.reduce` (derived computations must happen before ViewData reaches Templates) +- imports from: + - `apps/website/lib/page-queries/**` + - `apps/website/lib/services/**` + - `apps/website/lib/api/**` + - `apps/website/lib/di/**` + - `apps/website/lib/contracts/**` + Templates accept ViewData only. +Filename + signature rules: + +- Template filenames must end with `Template.tsx`. +- The first parameter type of a Template component must be `*ViewData` (or an object containing only `*ViewData` shapes). +- Templates must not export helper functions. + ## 3) Display Object guardrails Fail CI if any `apps/website/lib/display-objects/**`: - calls `Intl.*` or `toLocale*` +Also fail CI if any Display Object: + +- imports from `apps/website/lib/api/**`, `apps/website/lib/services/**`, or `apps/website/lib/page-queries/**` (no IO) +- imports from `apps/website/lib/view-models/**` (direction must be Presenter/ViewModel -> DisplayObject, not vice versa) +- exports non-class members (Display Objects must be class-based) + Display Objects must be deterministic. + +## 4) Page Query guardrails (server composition only) + +Fail CI if any `apps/website/lib/page-queries/**`: + +- imports from `apps/website/lib/view-models/**` +- imports from `apps/website/lib/display-objects/**` +- imports from `apps/website/lib/di/**` or references `ContainerManager` +- calls `Intl.*` or `toLocale*` +- calls `.sort`, `.filter`, `.reduce` (sorting/filtering belongs in API if canonical; otherwise client ViewModel) +- returns `null` (must return `PageQueryResult` union) + +Filename rules: + +- PageQueries must be named `*PageQuery.ts`. +- Page DTO types must be named `*PageDto` and live next to their PageQuery. + +## 5) Services guardrails (DTO-only, server-safe) + +Fail CI if any `apps/website/lib/services/**`: + +- imports from `apps/website/lib/view-models/**` or `apps/website/templates/**` +- imports from `apps/website/lib/display-objects/**` +- stores state on `this` other than injected dependencies (services must be stateless) +- uses blockers (blockers are client-only UX helpers) + +Naming rules: + +- Service methods returning API responses should use variable name `apiDto`. +- Service methods returning Page DTO should use variable name `pageDto`. + +## 6) Client-only guardrails (ViewModels, Presenters) + +Fail CI if any file under `apps/website/lib/view-models/**`: + +- lacks `'use client'` at top-level when it exports a ViewModel class intended for instantiation +- imports from `apps/website/lib/page-queries/**` or `apps/website/app/**` (dependency direction violation) + +Fail CI if any Presenter/ViewModel uses: + +- HTTP calls (`fetch`, axios, API clients) + +## 7) Write boundary guardrails (Server Actions only) + +Fail CI if any client module (`'use client'` file or `apps/website/components/**`) performs HTTP writes: + +- `fetch` with method `POST|PUT|PATCH|DELETE` + +Fail CI if any server action (`apps/website/app/**/actions.ts`): + +- imports from `apps/website/lib/view-models/**` or `apps/website/templates/**` +- returns ViewModels (must return primitives / redirect / revalidate) + +## 8) Model taxonomy guardrails (naming + type suffixes) + +Problem being prevented: + +- Calling everything “dto” collapses API Transport DTO, Page DTO, and ViewData. +- This causes wrong-layer dependencies and makes reviews error-prone. + +Fail CI if any file under `apps/website/**` contains a variable named exactly: + +- `dto` + +Allowed variable names (pick the right one): + +- `apiDto` (API Transport DTO from OpenAPI / backend HTTP) +- `pageDto` (Page DTO assembled by PageQueries) +- `viewData` (Template input) +- `commandDto` (write intent) + +Type naming rules (CI should fail if violated): + +1. Any PageQuery output type MUST end with `PageDto`. + - Applies to types defined in `apps/website/lib/page-queries/**`. + +2. Any Template prop type MUST end with `ViewData`. + - Applies to types used by `apps/website/templates/**`. + +3. API Transport DTO types may end with `DTO` (existing generated convention) or `ApiDto` (preferred for hand-written). + +Module boundary reinforcement: + +- `apps/website/templates/**` MUST NOT import API Transport DTO types directly. +- Prefer: PageQuery emits `pageDto` → Presenter emits `viewData`. + +## 9) Contracts enforcement (mandatory interfaces) + +Purpose: + +- Guardrails that rely on regex alone will always have loopholes. +- Contracts make the compiler enforce architecture: code must implement the right shapes. + +These contracts live under: + +- `apps/website/lib/contracts/**` + +### 9.1 Required contracts + +Fail CI if any of these are missing: + +1. PageQuery contract: `apps/website/lib/contracts/page-queries/PageQuery.ts` + - Requires `execute(...) -> PageQueryResult`. + +2. Service contract(s): `apps/website/lib/contracts/services/*` + - Services return `ApiDto`/`PageDto` only. + - No ViewModels. + +3. Presenter contract: `apps/website/lib/contracts/presenters/Presenter.ts` + - `present(input) -> output` (pure, deterministic). + +4. ViewModel base: `apps/website/lib/contracts/view-models/ViewModel.ts` + - ViewModels are client-only. + - Must not expose a method that returns Page DTO or API DTO. + +### 9.2 Enforcement rules + +Fail CI if: + +- Any file under `apps/website/lib/page-queries/**` defines a `class *PageQuery` that does NOT implement `PageQuery`. +- Any file under `apps/website/lib/services/**` defines a `class *Service` that does NOT implement a Service contract. +- Any file under `apps/website/lib/view-models/**` defines a `*Presenter` that does NOT implement `Presenter`. + +Additionally: + +- Fail if a PageQuery returns a shape that is not `PageQueryResult`. +- Fail if a service method returns a `*ViewModel` type. + +Note: + +- Enforcement can be implemented as a boundary test that parses TypeScript files (or a regex-based approximation as a first step), but the source of truth is: contracts must exist and be implemented. + +## 10) Generated DTO isolation (OpenAPI transport types do not reach UI) + +Purpose: + +- Generated OpenAPI DTOs are transport contracts. +- UI must not depend on transport contracts directly. +- Prevents “DTO soup” and forces the PageDto/ViewData boundary. + +Fail CI if any of these import from `apps/website/lib/types/generated/**`: + +- `apps/website/templates/**` +- `apps/website/components/**` +- `apps/website/hooks/**` and `apps/website/lib/hooks/**` + +Fail CI if any Template imports from `apps/website/lib/types/**`. + +Allowed locations for generated DTO imports: + +- `apps/website/lib/api/**` (API clients) +- `apps/website/lib/services/**` (transport orchestration) +- `apps/website/lib/page-queries/**` (Page DTO assembly) + +Enforced flow: + +- Generated `*DTO` -> `apiDto` (API client/service) +- `apiDto` -> `pageDto` (PageQuery) +- `pageDto` -> `viewData` (Presenter) + +Rationale: + +- If the API contract changes, the blast radius stays in infrastructure + server composition, not in Templates. diff --git a/package-lock.json b/package-lock.json index 9dc5d515f..5d1c34186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -303,9 +303,18 @@ "eslint-plugin-boundaries": "^5.3.1", "eslint-plugin-import": "^2.32.0", "eslint-plugin-unused-imports": "^3.0.0", + "eslint-plugin-website-guardrails": "file:./eslint-guardrails", "typescript": "^5.6.0" } }, + "apps/website/eslint-guardrails": { + "name": "eslint-plugin-website-guardrails", + "version": "1.0.0", + "dev": true, + "peerDependencies": { + "eslint": ">=8.0.0" + } + }, "apps/website/node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -8782,6 +8791,10 @@ } } }, + "node_modules/eslint-plugin-website-guardrails": { + "resolved": "apps/website/eslint-guardrails", + "link": true + }, "node_modules/eslint-rule-composer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", diff --git a/plans/website-architecture-violations.md b/plans/website-architecture-violations.md index 21380349f..fdce49be1 100644 --- a/plans/website-architecture-violations.md +++ b/plans/website-architecture-violations.md @@ -321,7 +321,45 @@ Rule violated: Action: -- Refactor `DriverService.getDriverProfile()` (and any similar methods) to return Page DTO only when used from server paths. +- Refactor any service method used by a PageQuery that currently returns a ViewModel to return a Page DTO instead. + +--- + +## 12) Generic integrity rules for untrusted transport data (no case studies) + +This is the durable architectural rule behind the “`as` looks vulnerable” concern. + +### 12.1 Rule: treat API Transport DTO values as untrusted input + +Even with OpenAPI generation, runtime values can drift (backend bug, contract mismatch, migrations, older clients). + +Therefore: + +- Never use `as SomeClosedUnion` on fields coming from an API response. +- Never assume string enums are safe. + +### 12.2 Where validation/coercion belongs + +- **API Transport DTO** remains raw (what the API sent). +- **Page DTO** can remain raw but should be structurally stable. +- **Presenter/ViewModel** is the correct place to normalize/coerce *for UI resilience*. + +This keeps the website as a delivery layer: we’re not enforcing business rules; we’re preventing UI crashes. + +### 12.3 Required pattern: parsers for string unions + +Define small pure parsers (in a Presenter-adjacent module) for every “closed set” field: + +- `parseSocialPlatform(value: unknown): SocialPlatform | 'unknown'` +- `parseAchievementIcon(value: unknown): AchievementIcon | 'unknown'` +- `parseAchievementRarity(value: unknown): AchievementRarity | 'unknown'` + +Policy (agreed): + +- ViewModel keeps `'unknown'` for debugging/telemetry. +- ViewData omits unknown items (UI stays clean). + +This keeps code safe without turning the website into a second source of truth (the API still owns validation). --- diff --git a/vitest.website.config.ts b/vitest.website.config.ts index 1f412007e..fc7308591 100644 --- a/vitest.website.config.ts +++ b/vitest.website.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ 'apps/website/lib/gateways/**/*.test.ts', 'apps/website/lib/blockers/**/*.test.ts', 'apps/website/lib/auth/**/*.test.ts', + 'apps/website/tests/guardrails/**/*.test.ts', ], exclude: ['node_modules/**', 'apps/website/.next/**', 'dist/**'], },