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)
// ── 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,
+4
View File
@@ -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')
},
})
}
+10
View File
@@ -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')
},
})
}
+7
View File
@@ -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')
},
})
}
+4
View File
@@ -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')
},
})
}
+13
View File
@@ -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')
},
})
}
+4
View File
@@ -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')
},
})
}
+16
View File
@@ -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')
},
})
}
+13
View File
@@ -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')
},
})
}
+16
View File
@@ -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')
},
})
}
+13
View File
@@ -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')
},
})
}
+7
View File
@@ -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')
},
})
}
+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 { 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(
<StrictMode>
<BrowserRouter>
+5 -120
View File
@@ -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)
}
+4
View File
@@ -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<ItemResponse<void>> {
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 }
},
+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)
}