fix: P0 stabilisation — score modifiers, logout cache clear, provider isolation, mutation error feedback

- scoreCalculator: apply dataQuality/confidence modifiers to finalScore (were computed but hardcoded to 0)
- authService: call queryClient.clear() on logout to prevent cross-session data leakage
- queryClient: extract to src/lib/queryClient.ts singleton so services can access it without circular imports
- matchSyncService: new service layer owns match-generation logic; MockupNeedProvider no longer imports other providers directly
- hooks (11 files): add onError + German toast feedback to every useMutation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 00:13:14 +02:00
parent 22c195b4a5
commit efc72b720e
17 changed files with 268 additions and 134 deletions
+11 -3
View File
@@ -586,9 +586,17 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
] ]
const mustHaveEval = scoreMustHaves(allMustHaveText, property) const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Final score: purely hard/soft weighted sum + must-have penalty ────────── // ── Modifiers: data quality and confidence adjust the final score ─────────
// These are intentionally applied after the hard/soft weighted sum so that
// a low-confidence or poor-data property can be penalised without distorting
// the individual criterion breakdown.
const dataQualityModifier = calcDataQualityModifier(property)
const confidenceModifier = calcConfidenceModifier(property)
// ── Final score: hard/soft weighted sum + must-have penalty + modifiers ───
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40 const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
+ dataQualityModifier + confidenceModifier
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal))) const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ── // ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -616,8 +624,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore, finalScore,
hardMatchScore, hardMatchScore,
softFactorScore, softFactorScore,
dataQualityModifier: 0, dataQualityModifier,
confidenceModifier: 0, confidenceModifier,
positiveFactors, positiveFactors,
negativeFactors, negativeFactors,
allHardFactors: hardFactors, allHardFactors: hardFactors,
+4
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { aiMonitoringService } from '../services/aiMonitoringService' import { aiMonitoringService } from '../services/aiMonitoringService'
import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider' import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider'
import type { ReviewStatus } from '../domain/enums' import type { ReviewStatus } from '../domain/enums'
import { useToastStore } from '../stores/toastStore'
const QK = 'aiOutputs' const QK = 'aiOutputs'
const STALE = 30_000 const STALE = 30_000
@@ -33,5 +34,8 @@ export function useUpdateAIOutputReviewStatus() {
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: [QK] }) qc.invalidateQueries({ queryKey: [QK] })
}, },
onError: () => {
useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error')
},
}) })
} }
+10
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { sourceService } from '../services/sourceService' import { sourceService } from '../services/sourceService'
import type { SourceFilters, SourceStatus, TermsStatus } from '../domain/dataSource' import type { SourceFilters, SourceStatus, TermsStatus } from '../domain/dataSource'
import { useToastStore } from '../stores/toastStore'
const STALE_SOURCES = 30_000 const STALE_SOURCES = 30_000
@@ -42,6 +43,9 @@ export function useTriggerMockRun() {
queryClient.invalidateQueries({ queryKey: ['data-source', sourceId] }) queryClient.invalidateQueries({ queryKey: ['data-source', sourceId] })
queryClient.invalidateQueries({ queryKey: ['connector-runs', sourceId] }) queryClient.invalidateQueries({ queryKey: ['connector-runs', sourceId] })
}, },
onError: () => {
useToastStore.getState().showToast('Verbindungstest fehlgeschlagen.', 'error')
},
}) })
} }
@@ -54,6 +58,9 @@ export function useUpdateSourceStatus() {
queryClient.invalidateQueries({ queryKey: ['data-sources'] }) queryClient.invalidateQueries({ queryKey: ['data-sources'] })
queryClient.invalidateQueries({ queryKey: ['data-source'] }) queryClient.invalidateQueries({ queryKey: ['data-source'] })
}, },
onError: () => {
useToastStore.getState().showToast('Quellstatus konnte nicht aktualisiert werden.', 'error')
},
}) })
} }
@@ -66,5 +73,8 @@ export function useMarkTermsStatus() {
queryClient.invalidateQueries({ queryKey: ['data-sources'] }) queryClient.invalidateQueries({ queryKey: ['data-sources'] })
queryClient.invalidateQueries({ queryKey: ['data-source'] }) queryClient.invalidateQueries({ queryKey: ['data-source'] })
}, },
onError: () => {
useToastStore.getState().showToast('AGB-Status konnte nicht gespeichert werden.', 'error')
},
}) })
} }
+7
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { futureSignalService } from '../services/futureSignalService' import { futureSignalService } from '../services/futureSignalService'
import type { ReviewStatus } from '../domain/enums' import type { ReviewStatus } from '../domain/enums'
import { STALE_SIGNALS } from '../lib/constants' import { STALE_SIGNALS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useFutureSignals() { export function useFutureSignals() {
return useQuery({ return useQuery({
@@ -39,6 +40,9 @@ export function useVerifySignal() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['futureSignals'] }) queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
}, },
onError: () => {
useToastStore.getState().showToast('Signal konnte nicht verifiziert werden.', 'error')
},
}) })
} }
@@ -51,5 +55,8 @@ export function useUpdateSignalReviewStatus() {
queryClient.invalidateQueries({ queryKey: ['futureSignals'] }) queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
queryClient.invalidateQueries({ queryKey: ['futureSignal'] }) queryClient.invalidateQueries({ queryKey: ['futureSignal'] })
}, },
onError: () => {
useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error')
},
}) })
} }
+4
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { inquiryService } from '../services/inquiryService' import { inquiryService } from '../services/inquiryService'
import type { InquiryFilters } from '../provider/IInquiryProvider' import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Attachment } from '../domain/inquiry' import type { Attachment } from '../domain/inquiry'
import { useToastStore } from '../stores/toastStore'
export function useActiveInquiries(filters?: InquiryFilters) { export function useActiveInquiries(filters?: InquiryFilters) {
return useQuery({ return useQuery({
@@ -34,6 +35,9 @@ export function useSendInquiryReply() {
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] }) qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
qc.invalidateQueries({ queryKey: ['inquiries'] }) qc.invalidateQueries({ queryKey: ['inquiries'] })
}, },
onError: () => {
useToastStore.getState().showToast('Antwort konnte nicht gesendet werden.', 'error')
},
}) })
} }
+13
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { marketIntelligenceService } from '../services/marketIntelligenceService' import { marketIntelligenceService } from '../services/marketIntelligenceService'
import { reviewService } from '../services/reviewService' import { reviewService } from '../services/reviewService'
import type { MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal' import type { MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
import { useToastStore } from '../stores/toastStore'
const STALE_SIGNALS = 30_000 const STALE_SIGNALS = 30_000
@@ -33,6 +34,9 @@ export function useUpdateSignalStatus() {
queryClient.invalidateQueries({ queryKey: ['market-signals'] }) queryClient.invalidateQueries({ queryKey: ['market-signals'] })
queryClient.invalidateQueries({ queryKey: ['market-signal'] }) queryClient.invalidateQueries({ queryKey: ['market-signal'] })
}, },
onError: () => {
useToastStore.getState().showToast('Signal-Status konnte nicht aktualisiert werden.', 'error')
},
}) })
} }
@@ -44,6 +48,9 @@ export function useConvertToFutureSignal() {
queryClient.invalidateQueries({ queryKey: ['market-signals'] }) queryClient.invalidateQueries({ queryKey: ['market-signals'] })
queryClient.invalidateQueries({ queryKey: ['market-signal'] }) queryClient.invalidateQueries({ queryKey: ['market-signal'] })
}, },
onError: () => {
useToastStore.getState().showToast('Konvertierung zu Future Signal fehlgeschlagen.', 'error')
},
}) })
} }
@@ -63,11 +70,17 @@ export function useLinkSignalToEntity() {
queryClient.invalidateQueries({ queryKey: ['market-signals'] }) queryClient.invalidateQueries({ queryKey: ['market-signals'] })
queryClient.invalidateQueries({ queryKey: ['market-signal'] }) queryClient.invalidateQueries({ queryKey: ['market-signal'] })
}, },
onError: () => {
useToastStore.getState().showToast('Signal konnte nicht verknüpft werden.', 'error')
},
}) })
} }
export function useCreateReviewTask() { export function useCreateReviewTask() {
return useMutation({ return useMutation({
mutationFn: (signalId: string) => reviewService.createReviewTask(signalId), mutationFn: (signalId: string) => reviewService.createReviewTask(signalId),
onError: () => {
useToastStore.getState().showToast('Überprüfungsaufgabe konnte nicht erstellt werden.', 'error')
},
}) })
} }
+4
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { matchService } from '../services/matchService' import { matchService } from '../services/matchService'
import { STALE_MATCHES } from '../lib/constants' import { STALE_MATCHES } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useMatches() { export function useMatches() {
return useQuery({ return useQuery({
@@ -38,6 +39,9 @@ export function useApproveMatch() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['matches'] }) queryClient.invalidateQueries({ queryKey: ['matches'] })
}, },
onError: () => {
useToastStore.getState().showToast('Match konnte nicht genehmigt werden.', 'error')
},
}) })
} }
+16
View File
@@ -1,6 +1,7 @@
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { offerService } from '../services/offerService' import { offerService } from '../services/offerService'
import type { AssetType } from '../domain/enums' import type { AssetType } from '../domain/enums'
import { useToastStore } from '../stores/toastStore'
export function useCreateOfferDraft() { export function useCreateOfferDraft() {
return useMutation({ return useMutation({
@@ -20,6 +21,9 @@ export function useCreateOfferDraft() {
input.assetType, input.assetType,
input.sizeRange, input.sizeRange,
), ),
onError: () => {
useToastStore.getState().showToast('Angebot konnte nicht erstellt werden.', 'error')
},
}) })
} }
@@ -27,23 +31,35 @@ export function useUpdateOfferField() {
return useMutation({ return useMutation({
mutationFn: (input: { offerDraftId: string; fieldId: string; value: string }) => mutationFn: (input: { offerDraftId: string; fieldId: string; value: string }) =>
offerService.updateOfferField(input.offerDraftId, input.fieldId, input.value), offerService.updateOfferField(input.offerDraftId, input.fieldId, input.value),
onError: () => {
useToastStore.getState().showToast('Feld konnte nicht gespeichert werden.', 'error')
},
}) })
} }
export function useMarkOfferChecked() { export function useMarkOfferChecked() {
return useMutation({ return useMutation({
mutationFn: (offerDraftId: string) => offerService.markOfferChecked(offerDraftId), mutationFn: (offerDraftId: string) => offerService.markOfferChecked(offerDraftId),
onError: () => {
useToastStore.getState().showToast('Prüfung konnte nicht gespeichert werden.', 'error')
},
}) })
} }
export function useGeneratePdfPreview() { export function useGeneratePdfPreview() {
return useMutation({ return useMutation({
mutationFn: (offerDraftId: string) => offerService.generatePdfPreview(offerDraftId), mutationFn: (offerDraftId: string) => offerService.generatePdfPreview(offerDraftId),
onError: () => {
useToastStore.getState().showToast('PDF-Vorschau konnte nicht generiert werden.', 'error')
},
}) })
} }
export function useSendOffer() { export function useSendOffer() {
return useMutation({ return useMutation({
mutationFn: (offerDraftId: string) => offerService.sendOffer(offerDraftId), mutationFn: (offerDraftId: string) => offerService.sendOffer(offerDraftId),
onError: () => {
useToastStore.getState().showToast('Angebot konnte nicht gesendet werden. Bitte versuchen Sie es erneut.', 'error')
},
}) })
} }
+13
View File
@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { reminderService } from '../services/reminderService' import { reminderService } from '../services/reminderService'
import { useToastStore } from '../stores/toastStore'
export function useReminders() { export function useReminders() {
return useQuery({ return useQuery({
@@ -32,6 +33,9 @@ export function useCompleteReminder() {
queryClient.invalidateQueries({ queryKey: ['reminders'] }) queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] }) queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
}, },
onError: () => {
useToastStore.getState().showToast('Erinnerung konnte nicht abgeschlossen werden.', 'error')
},
}) })
} }
@@ -44,6 +48,9 @@ export function useDismissReminder() {
queryClient.invalidateQueries({ queryKey: ['reminders'] }) queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] }) queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
}, },
onError: () => {
useToastStore.getState().showToast('Erinnerung konnte nicht verworfen werden.', 'error')
},
}) })
} }
@@ -56,6 +63,9 @@ export function useSnoozeReminder() {
queryClient.invalidateQueries({ queryKey: ['reminders'] }) queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] }) queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
}, },
onError: () => {
useToastStore.getState().showToast('Erinnerung konnte nicht verschoben werden.', 'error')
},
}) })
} }
@@ -68,5 +78,8 @@ export function useUpdateReminder() {
queryClient.invalidateQueries({ queryKey: ['reminders'] }) queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder', id] }) queryClient.invalidateQueries({ queryKey: ['reminder', id] })
}, },
onError: () => {
useToastStore.getState().showToast('Erinnerung konnte nicht aktualisiert werden.', 'error')
},
}) })
} }
+16
View File
@@ -3,6 +3,7 @@ import { reviewService } from '../services/reviewService'
import { useSessionStore } from '../stores/sessionStore' import { useSessionStore } from '../stores/sessionStore'
import type { ReviewFilters } from '../provider/IReviewProvider' import type { ReviewFilters } from '../provider/IReviewProvider'
import type { ReviewTaskStatus } from '../domain/review' import type { ReviewTaskStatus } from '../domain/review'
import { useToastStore } from '../stores/toastStore'
const STALE_REVIEW = 30_000 const STALE_REVIEW = 30_000
const QK = 'reviewQueue' const QK = 'reviewQueue'
@@ -37,6 +38,9 @@ export function useUpdateReviewStatus() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QK] }) queryClient.invalidateQueries({ queryKey: [QK] })
}, },
onError: () => {
useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error')
},
}) })
} }
@@ -48,6 +52,9 @@ export function useAssignReviewTask() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QK] }) queryClient.invalidateQueries({ queryKey: [QK] })
}, },
onError: () => {
useToastStore.getState().showToast('Aufgabe konnte nicht zugewiesen werden.', 'error')
},
}) })
} }
@@ -62,6 +69,9 @@ export function useAddReviewNote() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QK] }) queryClient.invalidateQueries({ queryKey: [QK] })
}, },
onError: () => {
useToastStore.getState().showToast('Notiz konnte nicht hinzugefügt werden.', 'error')
},
}) })
} }
@@ -72,6 +82,9 @@ export function useApproveReviewItem() {
mutationFn: ({ id, notes }: { id: string; notes?: string }) => mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
reviewService.approve(id, 'current-user', notes), reviewService.approve(id, 'current-user', notes),
onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }),
onError: () => {
useToastStore.getState().showToast('Genehmigung fehlgeschlagen.', 'error')
},
}) })
} }
@@ -81,5 +94,8 @@ export function useRejectReviewItem() {
mutationFn: ({ id, notes }: { id: string; notes?: string }) => mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
reviewService.reject(id, 'current-user', notes), reviewService.reject(id, 'current-user', notes),
onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }),
onError: () => {
useToastStore.getState().showToast('Ablehnung fehlgeschlagen.', 'error')
},
}) })
} }
+13
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { shortlistService } from '../services/shortlistService' import { shortlistService } from '../services/shortlistService'
import type { CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist' import type { CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
import { useToastStore } from '../stores/toastStore'
export function useShortlists() { export function useShortlists() {
return useQuery({ return useQuery({
@@ -26,6 +27,9 @@ export function useCreateShortlist() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shortlists'] }) queryClient.invalidateQueries({ queryKey: ['shortlists'] })
}, },
onError: () => {
useToastStore.getState().showToast('Merkliste konnte nicht erstellt werden.', 'error')
},
}) })
} }
@@ -38,6 +42,9 @@ export function useAddToShortlist() {
queryClient.invalidateQueries({ queryKey: ['shortlists'] }) queryClient.invalidateQueries({ queryKey: ['shortlists'] })
queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] }) queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] })
}, },
onError: () => {
useToastStore.getState().showToast('Eintrag konnte nicht zur Merkliste hinzugefügt werden.', 'error')
},
}) })
} }
@@ -50,6 +57,9 @@ export function useRemoveFromShortlist() {
queryClient.invalidateQueries({ queryKey: ['shortlists'] }) queryClient.invalidateQueries({ queryKey: ['shortlists'] })
queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] }) queryClient.invalidateQueries({ queryKey: ['shortlist', shortlistId] })
}, },
onError: () => {
useToastStore.getState().showToast('Eintrag konnte nicht von der Merkliste entfernt werden.', 'error')
},
}) })
} }
@@ -62,5 +72,8 @@ export function useUpdateShortlist() {
queryClient.invalidateQueries({ queryKey: ['shortlists'] }) queryClient.invalidateQueries({ queryKey: ['shortlists'] })
queryClient.invalidateQueries({ queryKey: ['shortlist', id] }) queryClient.invalidateQueries({ queryKey: ['shortlist', id] })
}, },
onError: () => {
useToastStore.getState().showToast('Merkliste konnte nicht aktualisiert werden.', 'error')
},
}) })
} }
+7
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { signalPipelineService } from '../services/signalPipelineService' import { signalPipelineService } from '../services/signalPipelineService'
import type { GateType } from '../domain/signalPipeline' import type { GateType } from '../domain/signalPipeline'
import { useToastStore } from '../stores/toastStore'
const STALE_PIPELINE = 15_000 const STALE_PIPELINE = 15_000
@@ -32,6 +33,9 @@ export function useEvaluateGate() {
onSuccess: (_data, { signalId }) => { onSuccess: (_data, { signalId }) => {
queryClient.invalidateQueries({ queryKey: ['signal-pipeline', signalId] }) queryClient.invalidateQueries({ queryKey: ['signal-pipeline', signalId] })
}, },
onError: () => {
useToastStore.getState().showToast('Gate-Evaluierung fehlgeschlagen.', 'error')
},
}) })
} }
@@ -44,5 +48,8 @@ export function usePublishToFutureAvailability() {
queryClient.invalidateQueries({ queryKey: ['signal-audit-trail', signalId] }) queryClient.invalidateQueries({ queryKey: ['signal-audit-trail', signalId] })
queryClient.invalidateQueries({ queryKey: ['market-signals'] }) queryClient.invalidateQueries({ queryKey: ['market-signals'] })
}, },
onError: () => {
useToastStore.getState().showToast('Veröffentlichung fehlgeschlagen. Bitte versuchen Sie es erneut.', 'error')
},
}) })
} }
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
import { STALE_PROPERTIES } from './constants'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_PROPERTIES,
retry: 1,
},
},
})
+2 -11
View File
@@ -2,22 +2,13 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router' import { BrowserRouter } from 'react-router'
import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material' import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import { theme } from './lib/theme' import { theme } from './lib/theme'
import { queryClient } from './lib/queryClient'
import { AuthProvider } from './provider/AuthProvider' import { AuthProvider } from './provider/AuthProvider'
import { STALE_PROPERTIES } from './lib/constants'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_PROPERTIES,
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
+5 -120
View File
@@ -1,120 +1,10 @@
import type { INeedProvider, NeedFilters } from './INeedProvider' import type { INeedProvider, NeedFilters } from './INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need' import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import { mockNeeds } from '../mock-data/needs' import { mockNeeds } from '../mock-data/needs'
import { matchStore } from './MockupMatchProvider' import { generateMatchesForNeed, syncMatchesForNeed } from '../services/matchSyncService'
import { propertyStore } from './MockupPropertyProvider'
import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums'
import type { Match } from '../domain/match'
import type { MatchEngineOutput } from '../domain/scoring'
import type { Property } from '../domain/property'
import { getEffectiveUnits } from '../domain/property'
import { calculateScore } from '../features/matching/scoreCalculator'
const store: Need[] = [...mockNeeds] const store: Need[] = [...mockNeeds]
// ── Helpers ────────────────────────────────────────────────────────────────────
function strengthFromScore(s: number): MatchStrength {
if (s >= 75) return MatchStrength.STRONG
if (s >= 55) return MatchStrength.MODERATE
return MatchStrength.WEAK
}
function buildMatch(
prop: Property,
unitId: string | undefined,
need: Need,
output: MatchEngineOutput,
effectiveResultType: string,
resultId: string,
now: string,
): Match {
const locationFactor = output.allHardFactors.find(f => f.criterion === 'location')
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
return {
id: crypto.randomUUID(),
propertyId: prop.id,
unitId,
needId: need.id,
resultId,
resultType: effectiveResultType as Match['resultType'],
matchScore: output.finalScore,
matchStrength: strengthFromScore(output.finalScore),
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: {
hardMatchScore: output.hardMatchScore,
softFactorScore: output.softFactorScore,
confidenceModifier: output.confidenceModifier,
dataQualityModifier: output.dataQualityModifier,
totalScore: output.finalScore,
},
positiveFactors: output.positiveFactors,
negativeFactors: output.negativeFactors,
allFactors: [...output.allHardFactors, ...output.allSoftFactors],
mustHaveEvaluation: output.mustHaveEvaluation,
tradeoffs: output.tradeOffs ?? [],
explainabilitySummary: isGoodLoc
? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.`
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'],
organizationId: 'org-wincasa',
createdAt: now,
updatedAt: now,
}
}
function scoreProperty(need: Need, prop: Property, overrideArea?: number, overridePrice?: number, overrideResultType?: string): MatchEngineOutput {
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
return calculateScore(need, {
...prop,
areaSqm: overrideArea ?? prop.areaSqm,
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
resultType: (overrideResultType ?? prop.resultType) as ResultType,
})
}
return calculateScore(need, prop)
}
function generateSyntheticMatches(need: Need) {
const now = new Date().toISOString()
const MIN_SCORE = 22
for (const prop of propertyStore) {
const hasExplicitUnits = (prop.units ?? []).length > 0
if (hasExplicitUnits) {
// Whole-property match (multi-unit building)
const output = scoreProperty(need, prop)
if (!output.excluded && output.finalScore >= MIN_SCORE) {
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
}
// Per released unit (pre-market)
for (const unit of prop.units!) {
if (!unit.schattenmarktRelease?.enabled) continue
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
}
} else {
// Single-unit / synthesised units
for (const unit of getEffectiveUnits(prop)) {
const isPreMarket = unit.schattenmarktRelease?.enabled === true
const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY
const effectiveResultType = (isPreMarket || isFutureProp) ? ResultType.FUTURE_AVAILABILITY : (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO)
const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType)
if (output.excluded || output.finalScore < MIN_SCORE) continue
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now))
}
}
}
}
// ── Provider ─────────────────────────────────────────────────────────────────── // ── Provider ───────────────────────────────────────────────────────────────────
export const MockupNeedProvider: INeedProvider = { export const MockupNeedProvider: INeedProvider = {
@@ -131,20 +21,15 @@ export const MockupNeedProvider: INeedProvider = {
async create(data: CreateNeedInput) { async create(data: CreateNeedInput) {
const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
store.push(next) store.push(next)
generateSyntheticMatches(next) generateMatchesForNeed(next)
return next return next
}, },
async update(id, data: UpdateNeedInput) { async update(id, data: UpdateNeedInput) {
const idx = store.findIndex(n => n.id === id) const idx = store.findIndex(n => n.id === id)
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() } store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
// Remove old matches and recompute with updated weights // Remove old matches and recompute with updated weights
const updated = store[idx] syncMatchesForNeed(store[idx])
const startLen = matchStore.length return store[idx]
for (let i = startLen - 1; i >= 0; i--) {
if (matchStore[i].needId === id) matchStore.splice(i, 1)
}
generateSyntheticMatches(updated)
return updated
}, },
async remove(id) { async remove(id) {
const idx = store.findIndex(n => n.id === id) const idx = store.findIndex(n => n.id === id)
@@ -154,5 +39,5 @@ export const MockupNeedProvider: INeedProvider = {
// Compute matches for all pre-existing needs so scores reflect their weightingProfile // Compute matches for all pre-existing needs so scores reflect their weightingProfile
for (const need of store) { for (const need of store) {
generateSyntheticMatches(need) generateMatchesForNeed(need)
} }
+4
View File
@@ -4,6 +4,7 @@ import type { ItemResponse } from './types'
import { UserRole, WorkspaceType } from '../domain/enums' import { UserRole, WorkspaceType } from '../domain/enums'
import { getPermissions, getAccessibleWorkspaces } from '../lib/permissions' import { getPermissions, getAccessibleWorkspaces } from '../lib/permissions'
import type { Permission } from '../lib/permissions' import type { Permission } from '../lib/permissions'
import { queryClient } from '../lib/queryClient'
// Mock organizations for org switching // Mock organizations for org switching
const MOCK_ORGANIZATIONS: { id: string; name: string }[] = [ const MOCK_ORGANIZATIONS: { id: string; name: string }[] = [
@@ -99,6 +100,9 @@ export const authService = {
async logout(): Promise<ItemResponse<void>> { async logout(): Promise<ItemResponse<void>> {
useSessionStore.getState().logout() useSessionStore.getState().logout()
// Clear all cached query data so stale data from the previous session
// cannot leak to the next user who logs in on the same browser tab.
queryClient.clear()
return { data: undefined } return { data: undefined }
}, },
+128
View File
@@ -0,0 +1,128 @@
/**
* Coordinates match generation between NeedProvider, PropertyProvider, and
* MatchProvider. Lives in the service layer so no provider needs to import
* another provider directly.
*/
import { matchStore } from '../provider/MockupMatchProvider'
import { propertyStore } from '../provider/MockupPropertyProvider'
import { MatchStrength, MatchStatus, RiskLevel, ResultType } from '../domain/enums'
import type { Match } from '../domain/match'
import type { MatchEngineOutput } from '../domain/scoring'
import type { Need } from '../domain/need'
import type { Property } from '../domain/property'
import { getEffectiveUnits } from '../domain/property'
import { calculateScore } from '../features/matching/scoreCalculator'
function strengthFromScore(s: number): MatchStrength {
if (s >= 75) return MatchStrength.STRONG
if (s >= 55) return MatchStrength.MODERATE
return MatchStrength.WEAK
}
function buildMatch(
prop: Property,
unitId: string | undefined,
need: Need,
output: MatchEngineOutput,
effectiveResultType: string,
resultId: string,
now: string,
): Match {
const locationFactor = output.allHardFactors.find(f => f.criterion === 'location')
const isGoodLoc = (locationFactor?.score ?? 0) >= 70
return {
id: crypto.randomUUID(),
propertyId: prop.id,
unitId,
needId: need.id,
resultId,
resultType: effectiveResultType as Match['resultType'],
matchScore: output.finalScore,
matchStrength: strengthFromScore(output.finalScore),
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: {
hardMatchScore: output.hardMatchScore,
softFactorScore: output.softFactorScore,
confidenceModifier: output.confidenceModifier,
dataQualityModifier: output.dataQualityModifier,
totalScore: output.finalScore,
},
positiveFactors: output.positiveFactors,
negativeFactors: output.negativeFactors,
allFactors: [...output.allHardFactors, ...output.allSoftFactors],
mustHaveEvaluation: output.mustHaveEvaluation,
tradeoffs: output.tradeOffs ?? [],
explainabilitySummary: isGoodLoc
? `${prop.location.city} trifft den Standortwunsch. Kernkriterien sind weitgehend erfüllt.`
: `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`,
confidenceLevel: isGoodLoc ? 0.88 : 0.60,
riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM,
uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'],
organizationId: 'org-wincasa',
createdAt: now,
updatedAt: now,
}
}
function scoreProperty(
need: Need,
prop: Property,
overrideArea?: number,
overridePrice?: number,
overrideResultType?: string,
): MatchEngineOutput {
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
return calculateScore(need, {
...prop,
areaSqm: overrideArea ?? prop.areaSqm,
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
resultType: (overrideResultType ?? prop.resultType) as ResultType,
})
}
return calculateScore(need, prop)
}
/** Generate matches for a single need against all properties and push them into matchStore. */
export function generateMatchesForNeed(need: Need): void {
const now = new Date().toISOString()
const MIN_SCORE = 22
for (const prop of propertyStore) {
const hasExplicitUnits = (prop.units ?? []).length > 0
if (hasExplicitUnits) {
const output = scoreProperty(need, prop)
if (!output.excluded && output.finalScore >= MIN_SCORE) {
matchStore.push(buildMatch(prop, undefined, need, output, prop.resultType ?? 'VERIFIED_PORTFOLIO', prop.id, now))
}
for (const unit of prop.units!) {
if (!unit.schattenmarktRelease?.enabled) continue
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
}
} else {
for (const unit of getEffectiveUnits(prop)) {
const isPreMarket = unit.schattenmarktRelease?.enabled === true
const isFutureProp = prop.resultType === ResultType.FUTURE_AVAILABILITY
const effectiveResultType = (isPreMarket || isFutureProp)
? ResultType.FUTURE_AVAILABILITY
: (prop.resultType ?? ResultType.VERIFIED_PORTFOLIO)
const output = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, effectiveResultType)
if (output.excluded || output.finalScore < MIN_SCORE) continue
const resultId = isPreMarket ? `schattenmarkt-${prop.id}` : prop.id
matchStore.push(buildMatch(prop, undefined, need, output, effectiveResultType, resultId, now))
}
}
}
}
/** Remove all existing matches for a need and regenerate them (used after a need update). */
export function syncMatchesForNeed(need: Need): void {
for (let i = matchStore.length - 1; i >= 0; i--) {
if (matchStore[i].needId === need.id) matchStore.splice(i, 1)
}
generateMatchesForNeed(need)
}