diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 023eca6ae..27833d310 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import React from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { @@ -24,8 +24,7 @@ import { import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; -import { useServices } from '@/lib/services/ServiceProvider'; -import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; +import { useDashboardOverview } from '@/hooks/useDashboardService'; // Helper functions @@ -80,26 +79,7 @@ function getGreeting(): string { import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; export default function DashboardPage() { - const { dashboardService } = useServices(); - const [dashboardData, setDashboardData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchDashboardData = async () => { - try { - const data = await dashboardService.getDashboardOverview(); - setDashboardData(data); - } catch (err) { - console.error('Failed to fetch dashboard data:', err); - setError('Failed to load dashboard data'); - } finally { - setIsLoading(false); - } - }; - - fetchDashboardData(); - }, [dashboardService]); + const { data: dashboardData, isLoading, error } = useDashboardOverview(); if (isLoading) { return ( @@ -112,7 +92,7 @@ export default function DashboardPage() { if (error || !dashboardData) { return (
-
{error || 'Failed to load dashboard'}
+
Failed to load dashboard
); } diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx index 561222f73..b91aff7ef 100644 --- a/apps/website/app/leaderboards/drivers/page.tsx +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Trophy, @@ -20,7 +20,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useDriverLeaderboard } from '@/hooks/useDriverService'; import Image from 'next/image'; // ============================================================================ @@ -173,23 +173,13 @@ function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) { export default function DriverLeaderboardPage() { const router = useRouter(); - const [drivers, setDrivers] = useState([]); - const [loading, setLoading] = useState(true); + const { data: leaderboardData, isLoading: loading } = useDriverLeaderboard(); const [searchQuery, setSearchQuery] = useState(''); const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all'); const [sortBy, setSortBy] = useState('rank'); const [showFilters, setShowFilters] = useState(false); - useEffect(() => { - const load = async () => { - const { driverService } = useServices(); - const viewModel = await driverService.getDriverLeaderboard(); - setDrivers(viewModel.drivers); - setLoading(false); - }; - - void load(); - }, []); + const drivers = leaderboardData?.drivers || []; const filteredDrivers = drivers.filter((driver) => { const matchesSearch = driver.name.toLowerCase().includes(searchQuery.toLowerCase()) || diff --git a/apps/website/hooks/useDashboardService.ts b/apps/website/hooks/useDashboardService.ts new file mode 100644 index 000000000..4a7801d3d --- /dev/null +++ b/apps/website/hooks/useDashboardService.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { useServices } from '@/lib/services/ServiceProvider'; + +export function useDashboardOverview() { + const { dashboardService } = useServices(); + + return useQuery({ + queryKey: ['dashboardOverview'], + queryFn: () => dashboardService.getDashboardOverview(), + }); +} \ No newline at end of file diff --git a/apps/website/hooks/useDriverService.ts b/apps/website/hooks/useDriverService.ts new file mode 100644 index 000000000..5bcd621eb --- /dev/null +++ b/apps/website/hooks/useDriverService.ts @@ -0,0 +1,80 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useServices } from '@/lib/services/ServiceProvider'; +import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO'; +import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; +import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; +import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel'; +import type { DriverDTO } from '@/lib/types/generated/DriverDTO'; + +export function useDriverLeaderboard() { + const { driverService } = useServices(); + + return useQuery({ + queryKey: ['driverLeaderboard'], + queryFn: () => driverService.getDriverLeaderboard(), + }); +} + +export function useCurrentDriver() { + const { driverService } = useServices(); + + return useQuery({ + queryKey: ['currentDriver'], + queryFn: () => driverService.getCurrentDriver(), + }); +} + +export function useDriverProfile(driverId: string) { + const { driverService } = useServices(); + + return useQuery({ + queryKey: ['driverProfile', driverId], + queryFn: () => driverService.getDriverProfile(driverId), + enabled: !!driverId, + }); +} + +export function useCompleteDriverOnboarding() { + const { driverService } = useServices(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CompleteOnboardingInputDTO) => driverService.completeDriverOnboarding(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['currentDriver'] }); + }, + }); +} + +export function useUpdateDriverProfile() { + const { driverService } = useServices(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (updates: { bio?: string; country?: string }) => driverService.updateProfile(updates), + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['driverProfile', data.currentDriver?.id] }); + queryClient.invalidateQueries({ queryKey: ['currentDriver'] }); + }, + }); +} + +export function useFindDriverById(id: string) { + const { driverService } = useServices(); + + return useQuery({ + queryKey: ['driver', id], + queryFn: () => driverService.findById(id), + enabled: !!id, + }); +} + +export function useFindDriversByIds(ids: string[]) { + const { driverService } = useServices(); + + return useQuery({ + queryKey: ['drivers', ids], + queryFn: () => driverService.findByIds(ids), + enabled: ids.length > 0, + }); +} \ No newline at end of file diff --git a/apps/website/hooks/useLeagueService.ts b/apps/website/hooks/useLeagueService.ts new file mode 100644 index 000000000..684480915 --- /dev/null +++ b/apps/website/hooks/useLeagueService.ts @@ -0,0 +1,125 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useServices } from '@/lib/services/ServiceProvider'; +import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO'; +import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; +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 { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel'; +import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; + +export function useAllLeagues() { + const { leagueService } = useServices(); + + return useQuery({ + queryKey: ['allLeagues'], + queryFn: () => leagueService.getAllLeagues(), + }); +} + +export function useLeagueStandings(leagueId: string, currentUserId: string) { + const { leagueService } = useServices(); + + return useQuery({ + queryKey: ['leagueStandings', leagueId, currentUserId], + queryFn: () => leagueService.getLeagueStandings(leagueId, currentUserId), + enabled: !!leagueId && !!currentUserId, + }); +} + +export function useLeagueStats() { + const { leagueService } = useServices(); + + return useQuery({ + queryKey: ['leagueStats'], + queryFn: () => leagueService.getLeagueStats(), + }); +} + +export function useLeagueSchedule(leagueId: string) { + const { leagueService } = useServices(); + + return useQuery({ + queryKey: ['leagueSchedule', leagueId], + queryFn: () => leagueService.getLeagueSchedule(leagueId), + enabled: !!leagueId, + }); +} + +export function useLeagueMemberships(leagueId: string, currentUserId: string) { + const { leagueService } = useServices(); + + return useQuery({ + queryKey: ['leagueMemberships', leagueId, currentUserId], + queryFn: () => leagueService.getLeagueMemberships(leagueId, currentUserId), + enabled: !!leagueId && !!currentUserId, + }); +} + +export function useCreateLeague() { + const { leagueService } = useServices(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateLeagueInputDTO) => leagueService.createLeague(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['allLeagues'] }); + }, + }); +} + +export function useRemoveLeagueMember() { + const { leagueService } = useServices(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ leagueId, performerDriverId, targetDriverId }: { + leagueId: string; + performerDriverId: string; + targetDriverId: string; + }) => leagueService.removeMember(leagueId, performerDriverId, targetDriverId), + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['leagueMemberships', variables.leagueId] }); + queryClient.invalidateQueries({ queryKey: ['leagueStandings', variables.leagueId] }); + }, + }); +} + +export function useUpdateLeagueMemberRole() { + const { leagueService } = useServices(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ leagueId, performerDriverId, targetDriverId, newRole }: { + leagueId: string; + performerDriverId: string; + targetDriverId: string; + newRole: string; + }) => leagueService.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole), + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['leagueMemberships', variables.leagueId] }); + }, + }); +} + +export function useLeagueDetail(leagueId: string, currentDriverId: string) { + const { leagueService } = useServices(); + + return useQuery({ + queryKey: ['leagueDetail', leagueId, currentDriverId], + queryFn: () => leagueService.getLeagueDetail(leagueId, currentDriverId), + enabled: !!leagueId && !!currentDriverId, + }); +} + +export function useLeagueDetailPageData(leagueId: string) { + const { leagueService } = useServices(); + + return useQuery({ + queryKey: ['leagueDetailPageData', leagueId], + queryFn: () => leagueService.getLeagueDetailPageData(leagueId), + enabled: !!leagueId, + }); +} \ No newline at end of file diff --git a/apps/website/lib/services/ServiceProvider.tsx b/apps/website/lib/services/ServiceProvider.tsx index 765e3f45a..c84e0fa3f 100644 --- a/apps/website/lib/services/ServiceProvider.tsx +++ b/apps/website/lib/services/ServiceProvider.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { createContext, useContext, useMemo, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ServiceFactory } from './ServiceFactory'; // Import all service types @@ -58,6 +59,15 @@ export interface Services { penaltyService: PenaltyService; } +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }, + }, +}); + const ServicesContext = createContext(null); interface ServiceProviderProps { @@ -98,9 +108,11 @@ export function ServiceProvider({ children }: ServiceProviderProps) { }, []); return ( - - {children} - + + + {children} + + ); } diff --git a/apps/website/package.json b/apps/website/package.json index d2ab1fbab..c605f4ac1 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -17,6 +17,7 @@ "@core/social": "file:../../core/social", "@core/testing-support": "file:../../core/testing-support", "@faker-js/faker": "^9.2.0", + "@tanstack/react-query": "^5.90.12", "@vercel/kv": "^3.0.0", "electron": "39.2.7", "framer-motion": "^12.23.25", diff --git a/core/league/application/ports/ILeagueStandingsRepository.ts b/core/league/application/ports/ILeagueStandingsRepository.ts deleted file mode 100644 index ce1fbe4a2..000000000 --- a/core/league/application/ports/ILeagueStandingsRepository.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface RawStanding { - id: string; - leagueId: string; - driverId: string; - position: number; - points: number; - wins: number; - racesCompleted: number; - // These properties might be optional or present depending on the data source - seasonId?: string; - podiums?: number; -} - -export interface ILeagueStandingsRepository { - getLeagueStandings(leagueId: string): Promise; -} diff --git a/core/league/application/use-cases/GetLeagueStandingsUseCase.ts b/core/league/application/use-cases/GetLeagueStandingsUseCase.ts deleted file mode 100644 index 005301ad4..000000000 --- a/core/league/application/use-cases/GetLeagueStandingsUseCase.ts +++ /dev/null @@ -1,22 +0,0 @@ -// TODO is this even used? either remove or it must be within racing domain - -export interface GetLeagueStandingsUseCase { - execute(leagueId: string): Promise; -} - -export interface StandingItemViewModel { - id: string; - leagueId: string; - seasonId: string; - driverId: string; - position: number; - points: number; - wins: number; - podiums: number; - racesCompleted: number; -} - -export interface LeagueStandingsViewModel { - leagueId: string; - standings: StandingItemViewModel[]; -} diff --git a/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.test.ts b/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.test.ts deleted file mode 100644 index b252c5e7e..000000000 --- a/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect, vi, type Mock } from 'vitest'; -import { GetLeagueStandingsUseCaseImpl } from './GetLeagueStandingsUseCaseImpl'; -import type { ILeagueStandingsRepository, RawStanding } from '../ports/ILeagueStandingsRepository'; - -describe('GetLeagueStandingsUseCaseImpl', () => { - let repository: { - getLeagueStandings: Mock; - }; - let useCase: GetLeagueStandingsUseCaseImpl; - - beforeEach(() => { - repository = { - getLeagueStandings: vi.fn(), - } as unknown as ILeagueStandingsRepository as any; - - useCase = new GetLeagueStandingsUseCaseImpl(repository as unknown as ILeagueStandingsRepository); - }); - - it('maps raw standings from repository to view model', async () => { - const leagueId = 'league-1'; - const rawStandings: RawStanding[] = [ - { - id: 's1', - leagueId, - seasonId: 'season-1', - driverId: 'driver-1', - position: 1, - points: 100, - wins: 3, - podiums: 5, - racesCompleted: 10, - }, - { - id: 's2', - leagueId, - seasonId: null, - driverId: 'driver-2', - position: 2, - points: 80, - wins: 1, - podiums: null, - racesCompleted: 10, - }, - ]; - - repository.getLeagueStandings.mockResolvedValue(rawStandings); - - const result = await useCase.execute(leagueId); - - expect(repository.getLeagueStandings).toHaveBeenCalledWith(leagueId); - expect(result.leagueId).toBe(leagueId); - expect(result.standings).toEqual([ - { - id: 's1', - leagueId, - seasonId: 'season-1', - driverId: 'driver-1', - position: 1, - points: 100, - wins: 3, - podiums: 5, - racesCompleted: 10, - }, - { - id: 's2', - leagueId, - seasonId: '', - driverId: 'driver-2', - position: 2, - points: 80, - wins: 1, - podiums: 0, - racesCompleted: 10, - }, - ]); - }); -}); diff --git a/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.ts b/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.ts deleted file mode 100644 index a1027d5fb..000000000 --- a/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ILeagueStandingsRepository, RawStanding } from '../ports/ILeagueStandingsRepository'; -import { GetLeagueStandingsUseCase, LeagueStandingsViewModel, StandingItemViewModel } from './GetLeagueStandingsUseCase'; - -// TODO is this even used? either remove or it must be within racing domain - -export class GetLeagueStandingsUseCaseImpl implements GetLeagueStandingsUseCase { - constructor(private repository: ILeagueStandingsRepository) {} - - async execute(leagueId: string): Promise { - const rawStandings = await this.repository.getLeagueStandings(leagueId); - - const standingItems: StandingItemViewModel[] = rawStandings.map((standing: RawStanding) => { - return { - id: standing.id, - leagueId: standing.leagueId, - seasonId: standing.seasonId ?? '', - driverId: standing.driverId, - position: standing.position, - points: standing.points, - wins: standing.wins, - podiums: standing.podiums ?? 0, - racesCompleted: standing.racesCompleted, - }; - }); - - return { - leagueId: leagueId, - standings: standingItems, - }; - } -} diff --git a/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts b/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts index f131c65bc..f7464ea21 100644 --- a/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts +++ b/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts @@ -1,28 +1,27 @@ -import { describe, it, expect, vi, type Mock } from 'vitest'; -import { - GetNotificationPreferencesQuery, - UpdateChannelPreferenceUseCase, - UpdateTypePreferenceUseCase, - UpdateQuietHoursUseCase, - SetDigestModeUseCase, - type GetNotificationPreferencesInput, - type GetNotificationPreferencesResult, - type UpdateChannelPreferenceCommand, - type UpdateChannelPreferenceResult, - type UpdateTypePreferenceCommand, - type UpdateTypePreferenceResult, - type UpdateQuietHoursCommand, - type UpdateQuietHoursResult, - type SetDigestModeCommand, - type SetDigestModeResult, -} from './NotificationPreferencesUseCases'; -import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; -import type { NotificationPreference , ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { describe, expect, it, vi, type Mock } from 'vitest'; +import type { ChannelPreference, NotificationPreference, TypePreference } from '../../domain/entities/NotificationPreference'; +import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; -import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; +import { + GetNotificationPreferencesQuery, + SetDigestModeUseCase, + UpdateChannelPreferenceUseCase, + UpdateQuietHoursUseCase, + UpdateTypePreferenceUseCase, + type GetNotificationPreferencesInput, + type GetNotificationPreferencesResult, + type SetDigestModeCommand, + type SetDigestModeResult, + type UpdateChannelPreferenceCommand, + type UpdateChannelPreferenceResult, + type UpdateQuietHoursCommand, + type UpdateQuietHoursResult, + type UpdateTypePreferenceCommand, + type UpdateTypePreferenceResult, +} from './NotificationPreferencesUseCases'; describe('NotificationPreferencesUseCases', () => { let preferenceRepository: { diff --git a/core/notifications/application/use-cases/SendNotificationUseCase.ts b/core/notifications/application/use-cases/SendNotificationUseCase.ts index ae6ebe3de..24662aee2 100644 --- a/core/notifications/application/use-cases/SendNotificationUseCase.ts +++ b/core/notifications/application/use-cases/SendNotificationUseCase.ts @@ -81,13 +81,15 @@ export class SendNotificationUseCase { ...(command.data ? { data: command.data } : {}), ...(command.actionUrl ? { actionUrl: command.actionUrl } : {}), }); - + await this.notificationRepository.create(notification); - - return { + + this.output.present({ notification, deliveryResults: [], - }; + }); + + return Result.ok(undefined); } // Determine which channels to use diff --git a/core/racing/index.ts b/core/racing/index.ts index 32b6a5e18..9e280bf52 100644 --- a/core/racing/index.ts +++ b/core/racing/index.ts @@ -40,11 +40,7 @@ export * from './domain/repositories/ISponsorshipPricingRepository'; export * from './application/dtos/LeagueDriverSeasonStatsDTO'; export * from './application/dtos/LeagueScoringConfigDTO'; - -export * from './application/ports/output/CreateLeagueWithSeasonAndScoringOutputPort'; -export * from './application/ports/output/DashboardOverviewOutputPort'; -export * from './application/ports/output/DriversLeaderboardOutputPort'; - + export * from './application/use-cases/CreateSponsorUseCase'; export * from './application/use-cases/GetSponsorDashboardUseCase'; export * from './application/use-cases/GetSponsorSponsorshipsUseCase'; @@ -53,5 +49,3 @@ export * from './application/use-cases/AcceptSponsorshipRequestUseCase'; export * from './application/use-cases/RejectSponsorshipRequestUseCase'; export * from './application/use-cases/GetPendingSponsorshipRequestsUseCase'; export * from './application/use-cases/GetEntitySponsorshipPricingUseCase'; - -export * from './application/ports/output/CreateSponsorOutputPort'; diff --git a/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts b/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts index d1b095fdb..3c80a5c00 100644 --- a/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts +++ b/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts @@ -1,43 +1,65 @@ -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger , UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ISocialGraphRepository } from '../../domain/repositories/ISocialGraphRepository'; import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO'; import type { FriendDTO } from '../dto/FriendDTO'; -import type { - CurrentUserSocialViewModel, - ICurrentUserSocialPresenter, -} from '../presenters/ISocialPresenters'; export interface GetCurrentUserSocialParams { driverId: string; } +export type GetCurrentUserSocialInput = GetCurrentUserSocialParams; + +export interface GetCurrentUserSocialResult { + currentUser: CurrentUserSocialDTO; + friends: FriendDTO[]; +} + +export type GetCurrentUserSocialErrorCode = 'REPOSITORY_ERROR'; + +export type GetCurrentUserSocialApplicationError = ApplicationErrorCode< + GetCurrentUserSocialErrorCode, + { message: string } +>; + /** * Application-level use case to retrieve the current user's social context. * * Keeps orchestration in the social bounded context while delegating - * data access to domain repositories and presenting via a presenter. + * data access to domain repositories and presenting via an output port. */ -export class GetCurrentUserSocialUseCase - implements AsyncUseCase { +export class GetCurrentUserSocialUseCase { constructor( private readonly socialGraphRepository: ISocialGraphRepository, - public readonly presenter: ICurrentUserSocialPresenter, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetCurrentUserSocialParams): Promise { - this.logger.debug('GetCurrentUserSocialUseCase: Starting execution', { params }); - try { - const { driverId } = params; + async execute( + input: GetCurrentUserSocialInput, + ): Promise> { + this.logger.debug('GetCurrentUserSocialUseCase.execute: Starting execution', { input }); - this.logger.debug(`GetCurrentUserSocialUseCase: Fetching friends for driverId: ${driverId}`); + try { + const { driverId } = input; + + this.logger.debug( + 'GetCurrentUserSocialUseCase.execute: Fetching friends for driverId', + { driverId }, + ); const friendsDomain = await this.socialGraphRepository.getFriends(driverId); - this.logger.debug('GetCurrentUserSocialUseCase: Successfully fetched friends from social graph repository', { friendsCount: friendsDomain.length }); + this.logger.debug( + 'GetCurrentUserSocialUseCase.execute: Successfully fetched friends from social graph repository', + { friendsCount: friendsDomain.length }, + ); if (friendsDomain.length === 0) { - this.logger.warn(`GetCurrentUserSocialUseCase: No friends found for driverId: ${driverId}`); + this.logger.warn( + `GetCurrentUserSocialUseCase.execute: No friends found for driverId: ${driverId}`, + ); } - const friends: FriendDTO[] = friendsDomain.map((friend) => ({ + const friends: FriendDTO[] = friendsDomain.map(friend => ({ driverId: friend.id, displayName: friend.name, avatarUrl: '', @@ -52,16 +74,32 @@ export class GetCurrentUserSocialUseCase countryCode: '', }; - const viewModel: CurrentUserSocialViewModel = { + const result: GetCurrentUserSocialResult = { currentUser, friends, }; - this.presenter.present(viewModel); - this.logger.info('GetCurrentUserSocialUseCase: Successfully presented current user social data'); + this.output.present(result); + this.logger.info( + 'GetCurrentUserSocialUseCase.execute: Successfully presented current user social data', + ); + + return Result.ok(undefined); } catch (error) { - this.logger.error('GetCurrentUserSocialUseCase: Error during execution', { error }); - throw error; + const err = error instanceof Error ? error : new Error(String(error)); + + this.logger.error( + 'GetCurrentUserSocialUseCase.execute: Error during execution', + err, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: err.message, + }, + } as GetCurrentUserSocialApplicationError); } } } \ No newline at end of file diff --git a/core/social/application/use-cases/GetUserFeedUseCase.ts b/core/social/application/use-cases/GetUserFeedUseCase.ts index 85b558f16..bde92b260 100644 --- a/core/social/application/use-cases/GetUserFeedUseCase.ts +++ b/core/social/application/use-cases/GetUserFeedUseCase.ts @@ -1,71 +1,74 @@ -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger , UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { IFeedRepository } from '../../domain/repositories/IFeedRepository'; -import type { FeedItemDTO } from '../dto/FeedItemDTO'; import type { FeedItem } from '../../domain/types/FeedItem'; -import type { - IUserFeedPresenter, - UserFeedViewModel, -} from '../presenters/ISocialPresenters'; export interface GetUserFeedParams { driverId: string; limit?: number; } -export class GetUserFeedUseCase - implements AsyncUseCase { +export type GetUserFeedInput = GetUserFeedParams; + +export interface GetUserFeedResult { + items: FeedItem[]; +} + +export type GetUserFeedErrorCode = 'REPOSITORY_ERROR'; + +export type GetUserFeedApplicationError = ApplicationErrorCode< + GetUserFeedErrorCode, + { message: string } +>; + +export class GetUserFeedUseCase { constructor( private readonly feedRepository: IFeedRepository, - public readonly presenter: IUserFeedPresenter, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetUserFeedParams): Promise { - const { driverId, limit } = params; - this.logger.debug('Executing GetUserFeedUseCase', { driverId, limit }); + async execute( + input: GetUserFeedInput, + ): Promise> { + const { driverId, limit } = input; + this.logger.debug('GetUserFeedUseCase.execute started', { driverId, limit }); try { const items = await this.feedRepository.getFeedForDriver(driverId, limit); - this.logger.info('Successfully retrieved user feed', { driverId, itemCount: items.length }); + + this.logger.info('GetUserFeedUseCase.execute succeeded', { + driverId, + itemCount: items.length, + }); + if (items.length === 0) { this.logger.warn(`No feed items found for driverId: ${driverId}`); } - const dtoItems = items.map(mapFeedItemToDTO); - const viewModel: UserFeedViewModel = { - items: dtoItems, + const result: GetUserFeedResult = { + items, }; - this.presenter.present(viewModel); + this.output.present(result); + + return Result.ok(undefined); } catch (error) { - this.logger.error('Failed to retrieve user feed', error); - throw error; // Re-throw the error so it can be handled upstream + const err = error instanceof Error ? error : new Error(String(error)); + + this.logger.error( + 'GetUserFeedUseCase.execute failed', + err, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: err.message, + }, + } as GetUserFeedApplicationError); } } -} - -function mapFeedItemToDTO(item: FeedItem): FeedItemDTO { - const mappedType = (item.type as string).replace(/-/g, '_') as FeedItemDTO['type']; - - const dto: FeedItemDTO = { - id: item.id, - timestamp: - item.timestamp instanceof Date - ? item.timestamp.toISOString() - : new Date(item.timestamp).toISOString(), - type: mappedType, - headline: item.headline, - }; - - if (item.actorFriendId !== undefined) dto.actorFriendId = item.actorFriendId; - if (item.actorDriverId !== undefined) dto.actorDriverId = item.actorDriverId; - if (item.leagueId !== undefined) dto.leagueId = item.leagueId; - if (item.raceId !== undefined) dto.raceId = item.raceId; - if (item.teamId !== undefined) dto.teamId = item.teamId; - if (item.position !== undefined) dto.position = item.position; - if (item.body !== undefined) dto.body = item.body; - if (item.ctaLabel !== undefined) dto.ctaLabel = item.ctaLabel; - if (item.ctaHref !== undefined) dto.ctaHref = item.ctaHref; - - return dto; } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 667137d5b..041353f28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -241,6 +241,7 @@ "@core/social": "file:../../core/social", "@core/testing-support": "file:../../core/testing-support", "@faker-js/faker": "^9.2.0", + "@tanstack/react-query": "^5.90.12", "@vercel/kv": "^3.0.0", "electron": "39.2.7", "framer-motion": "^12.23.25", @@ -3490,6 +3491,32 @@ "node": ">=10" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@teppeis/multimaps": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz",