From efc72b720e06c9f34f40722c8ba81f7cb8035613 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 24 May 2026 00:13:14 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20P0=20stabilisation=20=E2=80=94=20score?= =?UTF-8?q?=20modifiers,=20logout=20cache=20clear,=20provider=20isolation,?= =?UTF-8?q?=20mutation=20error=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/features/matching/scoreCalculator.ts | 14 ++- src/hooks/useAIMonitoring.ts | 4 + src/hooks/useDataSources.ts | 10 ++ src/hooks/useFutureSignals.ts | 7 ++ src/hooks/useInquiries.ts | 4 + src/hooks/useMarketSignals.ts | 13 +++ src/hooks/useMatches.ts | 4 + src/hooks/useOffers.ts | 16 +++ src/hooks/useReminders.ts | 13 +++ src/hooks/useReviewQueue.ts | 16 +++ src/hooks/useShortlists.ts | 13 +++ src/hooks/useSignalPipeline.ts | 7 ++ src/lib/queryClient.ts | 11 ++ src/main.tsx | 13 +-- src/provider/MockupNeedProvider.ts | 125 +--------------------- src/services/authService.ts | 4 + src/services/matchSyncService.ts | 128 +++++++++++++++++++++++ 17 files changed, 268 insertions(+), 134 deletions(-) create mode 100644 src/lib/queryClient.ts create mode 100644 src/services/matchSyncService.ts diff --git a/src/features/matching/scoreCalculator.ts b/src/features/matching/scoreCalculator.ts index 20321af..d8b2dce 100644 --- a/src/features/matching/scoreCalculator.ts +++ b/src/features/matching/scoreCalculator.ts @@ -586,9 +586,17 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu ] 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 rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact + + dataQualityModifier + confidenceModifier const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal))) // ── Factor classification — only use weighted soft factors for positive/negative ── @@ -616,8 +624,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu finalScore, hardMatchScore, softFactorScore, - dataQualityModifier: 0, - confidenceModifier: 0, + dataQualityModifier, + confidenceModifier, positiveFactors, negativeFactors, allHardFactors: hardFactors, diff --git a/src/hooks/useAIMonitoring.ts b/src/hooks/useAIMonitoring.ts index 48b7555..0ce4432 100644 --- a/src/hooks/useAIMonitoring.ts +++ b/src/hooks/useAIMonitoring.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { aiMonitoringService } from '../services/aiMonitoringService' import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider' import type { ReviewStatus } from '../domain/enums' +import { useToastStore } from '../stores/toastStore' const QK = 'aiOutputs' const STALE = 30_000 @@ -33,5 +34,8 @@ export function useUpdateAIOutputReviewStatus() { onSuccess: () => { qc.invalidateQueries({ queryKey: [QK] }) }, + onError: () => { + useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error') + }, }) } diff --git a/src/hooks/useDataSources.ts b/src/hooks/useDataSources.ts index e3d3420..ee66484 100644 --- a/src/hooks/useDataSources.ts +++ b/src/hooks/useDataSources.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { sourceService } from '../services/sourceService' import type { SourceFilters, SourceStatus, TermsStatus } from '../domain/dataSource' +import { useToastStore } from '../stores/toastStore' const STALE_SOURCES = 30_000 @@ -42,6 +43,9 @@ export function useTriggerMockRun() { queryClient.invalidateQueries({ queryKey: ['data-source', 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-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-source'] }) }, + onError: () => { + useToastStore.getState().showToast('AGB-Status konnte nicht gespeichert werden.', 'error') + }, }) } diff --git a/src/hooks/useFutureSignals.ts b/src/hooks/useFutureSignals.ts index ceec3d2..a417195 100644 --- a/src/hooks/useFutureSignals.ts +++ b/src/hooks/useFutureSignals.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { futureSignalService } from '../services/futureSignalService' import type { ReviewStatus } from '../domain/enums' import { STALE_SIGNALS } from '../lib/constants' +import { useToastStore } from '../stores/toastStore' export function useFutureSignals() { return useQuery({ @@ -39,6 +40,9 @@ export function useVerifySignal() { onSuccess: () => { 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: ['futureSignal'] }) }, + onError: () => { + useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error') + }, }) } diff --git a/src/hooks/useInquiries.ts b/src/hooks/useInquiries.ts index 0fea5f7..ff8d5c3 100644 --- a/src/hooks/useInquiries.ts +++ b/src/hooks/useInquiries.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { inquiryService } from '../services/inquiryService' import type { InquiryFilters } from '../provider/IInquiryProvider' import type { Attachment } from '../domain/inquiry' +import { useToastStore } from '../stores/toastStore' export function useActiveInquiries(filters?: InquiryFilters) { return useQuery({ @@ -34,6 +35,9 @@ export function useSendInquiryReply() { qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] }) qc.invalidateQueries({ queryKey: ['inquiries'] }) }, + onError: () => { + useToastStore.getState().showToast('Antwort konnte nicht gesendet werden.', 'error') + }, }) } diff --git a/src/hooks/useMarketSignals.ts b/src/hooks/useMarketSignals.ts index 7e033a1..07d1132 100644 --- a/src/hooks/useMarketSignals.ts +++ b/src/hooks/useMarketSignals.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { marketIntelligenceService } from '../services/marketIntelligenceService' import { reviewService } from '../services/reviewService' import type { MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal' +import { useToastStore } from '../stores/toastStore' const STALE_SIGNALS = 30_000 @@ -33,6 +34,9 @@ export function useUpdateSignalStatus() { queryClient.invalidateQueries({ queryKey: ['market-signals'] }) 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-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-signal'] }) }, + onError: () => { + useToastStore.getState().showToast('Signal konnte nicht verknüpft werden.', 'error') + }, }) } export function useCreateReviewTask() { return useMutation({ mutationFn: (signalId: string) => reviewService.createReviewTask(signalId), + onError: () => { + useToastStore.getState().showToast('Überprüfungsaufgabe konnte nicht erstellt werden.', 'error') + }, }) } diff --git a/src/hooks/useMatches.ts b/src/hooks/useMatches.ts index 7212be7..7cbfb0f 100644 --- a/src/hooks/useMatches.ts +++ b/src/hooks/useMatches.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { matchService } from '../services/matchService' import { STALE_MATCHES } from '../lib/constants' +import { useToastStore } from '../stores/toastStore' export function useMatches() { return useQuery({ @@ -38,6 +39,9 @@ export function useApproveMatch() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['matches'] }) }, + onError: () => { + useToastStore.getState().showToast('Match konnte nicht genehmigt werden.', 'error') + }, }) } diff --git a/src/hooks/useOffers.ts b/src/hooks/useOffers.ts index 9aac4ec..83662da 100644 --- a/src/hooks/useOffers.ts +++ b/src/hooks/useOffers.ts @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query' import { offerService } from '../services/offerService' import type { AssetType } from '../domain/enums' +import { useToastStore } from '../stores/toastStore' export function useCreateOfferDraft() { return useMutation({ @@ -20,6 +21,9 @@ export function useCreateOfferDraft() { input.assetType, input.sizeRange, ), + onError: () => { + useToastStore.getState().showToast('Angebot konnte nicht erstellt werden.', 'error') + }, }) } @@ -27,23 +31,35 @@ export function useUpdateOfferField() { return useMutation({ mutationFn: (input: { offerDraftId: string; fieldId: string; value: string }) => offerService.updateOfferField(input.offerDraftId, input.fieldId, input.value), + onError: () => { + useToastStore.getState().showToast('Feld konnte nicht gespeichert werden.', 'error') + }, }) } export function useMarkOfferChecked() { return useMutation({ mutationFn: (offerDraftId: string) => offerService.markOfferChecked(offerDraftId), + onError: () => { + useToastStore.getState().showToast('Prüfung konnte nicht gespeichert werden.', 'error') + }, }) } export function useGeneratePdfPreview() { return useMutation({ mutationFn: (offerDraftId: string) => offerService.generatePdfPreview(offerDraftId), + onError: () => { + useToastStore.getState().showToast('PDF-Vorschau konnte nicht generiert werden.', 'error') + }, }) } export function useSendOffer() { return useMutation({ mutationFn: (offerDraftId: string) => offerService.sendOffer(offerDraftId), + onError: () => { + useToastStore.getState().showToast('Angebot konnte nicht gesendet werden. Bitte versuchen Sie es erneut.', 'error') + }, }) } diff --git a/src/hooks/useReminders.ts b/src/hooks/useReminders.ts index ffbfe64..5ae20b4 100644 --- a/src/hooks/useReminders.ts +++ b/src/hooks/useReminders.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { reminderService } from '../services/reminderService' +import { useToastStore } from '../stores/toastStore' export function useReminders() { return useQuery({ @@ -32,6 +33,9 @@ export function useCompleteReminder() { queryClient.invalidateQueries({ queryKey: ['reminders'] }) 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: ['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: ['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: ['reminder', id] }) }, + onError: () => { + useToastStore.getState().showToast('Erinnerung konnte nicht aktualisiert werden.', 'error') + }, }) } diff --git a/src/hooks/useReviewQueue.ts b/src/hooks/useReviewQueue.ts index 282563b..c89215c 100644 --- a/src/hooks/useReviewQueue.ts +++ b/src/hooks/useReviewQueue.ts @@ -3,6 +3,7 @@ import { reviewService } from '../services/reviewService' import { useSessionStore } from '../stores/sessionStore' import type { ReviewFilters } from '../provider/IReviewProvider' import type { ReviewTaskStatus } from '../domain/review' +import { useToastStore } from '../stores/toastStore' const STALE_REVIEW = 30_000 const QK = 'reviewQueue' @@ -37,6 +38,9 @@ export function useUpdateReviewStatus() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QK] }) }, + onError: () => { + useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error') + }, }) } @@ -48,6 +52,9 @@ export function useAssignReviewTask() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QK] }) }, + onError: () => { + useToastStore.getState().showToast('Aufgabe konnte nicht zugewiesen werden.', 'error') + }, }) } @@ -62,6 +69,9 @@ export function useAddReviewNote() { onSuccess: () => { 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 }) => reviewService.approve(id, 'current-user', notes), 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 }) => reviewService.reject(id, 'current-user', notes), onSuccess: () => queryClient.invalidateQueries({ queryKey: [QK] }), + onError: () => { + useToastStore.getState().showToast('Ablehnung fehlgeschlagen.', 'error') + }, }) } diff --git a/src/hooks/useShortlists.ts b/src/hooks/useShortlists.ts index 8a21b2a..3297af2 100644 --- a/src/hooks/useShortlists.ts +++ b/src/hooks/useShortlists.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { shortlistService } from '../services/shortlistService' import type { CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist' +import { useToastStore } from '../stores/toastStore' export function useShortlists() { return useQuery({ @@ -26,6 +27,9 @@ export function useCreateShortlist() { onSuccess: () => { 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: ['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: ['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: ['shortlist', id] }) }, + onError: () => { + useToastStore.getState().showToast('Merkliste konnte nicht aktualisiert werden.', 'error') + }, }) } diff --git a/src/hooks/useSignalPipeline.ts b/src/hooks/useSignalPipeline.ts index 51ce976..0b8f57e 100644 --- a/src/hooks/useSignalPipeline.ts +++ b/src/hooks/useSignalPipeline.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { signalPipelineService } from '../services/signalPipelineService' import type { GateType } from '../domain/signalPipeline' +import { useToastStore } from '../stores/toastStore' const STALE_PIPELINE = 15_000 @@ -32,6 +33,9 @@ export function useEvaluateGate() { onSuccess: (_data, { 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: ['market-signals'] }) }, + onError: () => { + useToastStore.getState().showToast('Veröffentlichung fehlgeschlagen. Bitte versuchen Sie es erneut.', 'error') + }, }) } diff --git a/src/lib/queryClient.ts b/src/lib/queryClient.ts new file mode 100644 index 0000000..95675ef --- /dev/null +++ b/src/lib/queryClient.ts @@ -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, + }, + }, +}) diff --git a/src/main.tsx b/src/main.tsx index 324e47e..e32bfc7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,22 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router' 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 { queryClient } from './lib/queryClient' import { AuthProvider } from './provider/AuthProvider' -import { STALE_PROPERTIES } from './lib/constants' import './index.css' import App from './App.tsx' -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: STALE_PROPERTIES, - retry: 1, - }, - }, -}) - createRoot(document.getElementById('root')!).render( diff --git a/src/provider/MockupNeedProvider.ts b/src/provider/MockupNeedProvider.ts index 09f4342..9a8f519 100644 --- a/src/provider/MockupNeedProvider.ts +++ b/src/provider/MockupNeedProvider.ts @@ -1,120 +1,10 @@ import type { INeedProvider, NeedFilters } from './INeedProvider' import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need' import { mockNeeds } from '../mock-data/needs' -import { matchStore } from './MockupMatchProvider' -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' +import { generateMatchesForNeed, syncMatchesForNeed } from '../services/matchSyncService' 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 ─────────────────────────────────────────────────────────────────── export const MockupNeedProvider: INeedProvider = { @@ -131,20 +21,15 @@ export const MockupNeedProvider: INeedProvider = { async create(data: CreateNeedInput) { const next: Need = { id: crypto.randomUUID(), ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } store.push(next) - generateSyntheticMatches(next) + generateMatchesForNeed(next) return next }, async update(id, data: UpdateNeedInput) { const idx = store.findIndex(n => n.id === id) store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() } // Remove old matches and recompute with updated weights - const updated = store[idx] - const startLen = matchStore.length - for (let i = startLen - 1; i >= 0; i--) { - if (matchStore[i].needId === id) matchStore.splice(i, 1) - } - generateSyntheticMatches(updated) - return updated + syncMatchesForNeed(store[idx]) + return store[idx] }, async remove(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 for (const need of store) { - generateSyntheticMatches(need) + generateMatchesForNeed(need) } diff --git a/src/services/authService.ts b/src/services/authService.ts index 7d8c88a..07238d7 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -4,6 +4,7 @@ import type { ItemResponse } from './types' import { UserRole, WorkspaceType } from '../domain/enums' import { getPermissions, getAccessibleWorkspaces } from '../lib/permissions' import type { Permission } from '../lib/permissions' +import { queryClient } from '../lib/queryClient' // Mock organizations for org switching const MOCK_ORGANIZATIONS: { id: string; name: string }[] = [ @@ -99,6 +100,9 @@ export const authService = { async logout(): Promise> { 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 } }, diff --git a/src/services/matchSyncService.ts b/src/services/matchSyncService.ts new file mode 100644 index 0000000..b15d761 --- /dev/null +++ b/src/services/matchSyncService.ts @@ -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) +}