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:
@@ -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 }
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user