feat(services): F005 – complete service layer

New domain types:
- shortlist.ts: Shortlist, ShortlistItem, CreateShortlistInput
- review.ts: ReviewQueueItem, ReviewPriority, ReviewQueueStatus

New mock data:
- shortlists.ts: 2 sample shortlists
- reviewQueue.ts: 3 pending review items

New providers (interface + mockup):
- IShortlistProvider / MockupShortlistProvider (with 150ms latency)
- IReviewProvider / MockupReviewProvider (priority-sorted, with latency)
- IDashboardProvider / MockupDashboardProvider (computed from all mock data)

New services:
- reviewService: getQueue, approve, reject, assign
- shortlistService: CRUD + addItem/removeItem
- authService: wraps sessionStore, swappable for real auth
- dashboardService: getStats(organizationId?)

Enhancements:
- types.ts: typed ServiceErrorCode + ServiceError, Pagination alias
- aiService.ts: openRouterAIService stub (throws ai_generation_failed)
- lib/mockUtils.ts: mockDelay(ms) utility

New hooks:
- useUnifiedResults(needId?): joins matches+properties+signals → UnifiedMatchResult[]
- useReviewQueue: + useApproveReviewItem + useRejectReviewItem
- useMatchDetail, usePropertyDetail, useNeedProfiles aliases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 11:12:06 +02:00
parent 0b874865ba
commit 5ef8b9d69d
25 changed files with 636 additions and 7 deletions
+5 -3
View File
@@ -1,4 +1,6 @@
export { useProperties, useProperty } from './useProperties'
export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch } from './useMatches'
export { useProperties, useProperty, usePropertyDetail } from './useProperties'
export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch, useMatchDetail } from './useMatches'
export { useFutureSignals, useFutureSignalsByProperty, useVerifySignal } from './useFutureSignals'
export { useNeeds, useNeed } from './useNeeds'
export { useNeeds, useNeed, useNeedProfiles } from './useNeeds'
export { useUnifiedResults } from './useUnifiedResults'
export { useReviewQueue, useApproveReviewItem, useRejectReviewItem } from './useReviewQueue'
+10
View File
@@ -40,3 +40,13 @@ export function useApproveMatch() {
},
})
}
export function useMatchDetail(id: string) {
return useQuery({
queryKey: ['match', id],
queryFn: () => matchService.getById(id),
staleTime: STALE_MATCHES,
enabled: Boolean(id),
select: (res) => res.data ?? null,
})
}
+2
View File
@@ -17,3 +17,5 @@ export function useNeed(id: string) {
select: (res) => res.data ?? null,
})
}
export const useNeedProfiles = useNeeds
+2
View File
@@ -30,3 +30,5 @@ export function useProperty(id: string) {
select: (res) => res.data ?? null,
})
}
export const usePropertyDetail = useProperty
+36
View File
@@ -0,0 +1,36 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { reviewService } from '../services/reviewService'
import type { ReviewFilters } from '../provider/IReviewProvider'
const STALE_REVIEW = 30_000
export function useReviewQueue(filters?: ReviewFilters) {
return useQuery({
queryKey: ['reviewQueue', filters ?? {}],
queryFn: () => reviewService.getQueue(filters),
staleTime: STALE_REVIEW,
select: (res) => res.data ?? [],
})
}
export function useApproveReviewItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
reviewService.approve(id, 'current-user', notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviewQueue'] })
},
})
}
export function useRejectReviewItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
reviewService.reject(id, 'current-user', notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviewQueue'] })
},
})
}
+79
View File
@@ -0,0 +1,79 @@
import { useMemo } from 'react'
import { useMatches, useMatchesByNeed } from './useMatches'
import { useProperties } from './useProperties'
import { useFutureSignals } from './useFutureSignals'
import type {
UnifiedMatchResult,
VerifiedPortfolioResult,
ExternalMarketResult,
FutureAvailabilityResult,
} from '../domain/unifiedResult'
export function useUnifiedResults(needId?: string) {
const allMatchesQuery = useMatches()
const needMatchesQuery = useMatchesByNeed(needId ?? '')
const propertiesQuery = useProperties()
const signalsQuery = useFutureSignals()
const matchesQuery = needId ? needMatchesQuery : allMatchesQuery
const isLoading =
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
const error = matchesQuery.error ?? propertiesQuery.error ?? signalsQuery.error
const matches = matchesQuery.data ?? []
const properties = propertiesQuery.data ?? []
const signals = signalsQuery.data ?? []
const data = useMemo((): UnifiedMatchResult[] => {
return matches
.flatMap((match): UnifiedMatchResult[] => {
const rt = match.resultType ?? 'VERIFIED_PORTFOLIO'
if (rt === 'FUTURE_AVAILABILITY') {
const refId = match.resultId ?? match.propertyId
const signal = signals.find(
s => s.id === refId || s.propertyId === refId,
)
if (!signal) return []
const result: FutureAvailabilityResult = {
matchId: match.id,
needId: match.needId,
matchScore: match.matchScore,
resultType: 'FUTURE_AVAILABILITY',
signal,
match,
}
return [result]
}
const property = properties.find(p => p.id === (match.resultId ?? match.propertyId))
if (!property) return []
if (rt === 'EXTERNAL_MARKET') {
const result: ExternalMarketResult = {
matchId: match.id,
needId: match.needId,
matchScore: match.matchScore,
resultType: 'EXTERNAL_MARKET',
property,
match,
}
return [result]
}
const result: VerifiedPortfolioResult = {
matchId: match.id,
needId: match.needId,
matchScore: match.matchScore,
resultType: 'VERIFIED_PORTFOLIO',
property,
match,
}
return [result]
})
.sort((a, b) => b.matchScore - a.matchScore)
}, [matches, properties, signals])
return { data, isLoading, error }
}