Files
property-match/src/services/matchService.ts
T
Benjamin Sutter 6515acb7f0 feat: unified error handling + AI service modularisation
Error handling (Prompt 2):
- src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper
- 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry)
- inquiryService aligned from custom ServiceResult<T> to standard ServiceResponse types
- Results, MatchCenter, FutureAvailability pages show <ErrorState onRetry> on query failure

AI modularisation (Prompt 3):
- src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export)
- src/services/ai/IAIService.ts: typed interface + all response types
- src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService
- src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton
- src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief)
- src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag
- All existing import paths unchanged — zero call-site modifications

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:32:01 +02:00

220 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
import type { MatchFilters } from '../provider/IMatchProvider'
import type { Match, PropertyNeedMatch } from '../domain/match'
import type { Need } from '../domain/need'
import type { Property } from '../domain/property'
import type { StrongMatchItem } from '../domain/dashboard'
import type { ScoreBreakdown } from '../domain/match'
import type { AdditionalPropertyMatch } from '../domain/additionalMatch'
import type { ListResponse, ItemResponse } from './types'
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
import { ResultType } from '../domain/enums'
import { throwServiceError } from './errors'
const provider = MockupMatchProvider
export const matchService = {
async getAll(filters?: MatchFilters): Promise<ListResponse<Match>> {
try {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getById(id: string): Promise<ItemResponse<Match | null>> {
try {
const data = await provider.getById(id)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async getByNeed(needId: string): Promise<ListResponse<Match>> {
try {
const data = await provider.getByNeed(needId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getByProperty(propertyId: string): Promise<ListResponse<Match>> {
try {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async approve(id: string, reviewedBy: string): Promise<ItemResponse<Match>> {
try {
const data = await provider.approve(id, reviewedBy)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async getMatchesForProperty(propertyId: string): Promise<ListResponse<Match>> {
try {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getMatchDetail(id: string): Promise<ItemResponse<Match | null>> {
try {
const data = await provider.getById(id)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async getScoreBreakdown(matchId: string): Promise<ItemResponse<ScoreBreakdown | null>> {
try {
const match = await provider.getById(matchId)
return { data: match?.scoreBreakdown ?? null }
} catch (err) {
throwServiceError(err)
}
},
// ── Engine-based methods ──────────────────────────────────────────────────
computeMatch(need: Need, property: Property): Match {
return buildFullMatch(need, property)
},
async computeMatchesForNeed(needId: string): Promise<ListResponse<Match>> {
try {
const [need, properties] = await Promise.all([
MockupNeedProvider.getById(needId),
MockupPropertyProvider.getAll(),
])
if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
const data = computeRankedMatches(need, properties)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async getNeedMatchesForProperty(
propertyId: string,
opts?: { minScore?: number },
): Promise<PropertyNeedMatch[]> {
try {
const minScore = opts?.minScore ?? 80
const [matches, needs] = await Promise.all([
provider.getByProperty(propertyId),
MockupNeedProvider.getAll(),
])
return matches
.filter(m => m.matchScore >= minScore)
.sort((a, b) => b.matchScore - a.matchScore)
.map(m => {
const need = needs.find(n => n.id === m.needId)
if (!need) return null
return {
matchId: m.id,
score: m.matchScore,
needId: need.id,
needTitle: `${need.companyName}`,
company: need.companyName,
areaRequired: `${need.requiredArea.min}${need.requiredArea.max}`,
budget: `max ${need.budgetRange.maxPerSqm} CHF/m²/Jahr`,
locations: need.preferredLocations,
assetType: need.assetType,
timing: need.timing?.earliestMoveIn ?? '',
mustHaves: need.mustCriteriaText ?? [],
matchHighlights: m.positiveFactors.map(f => f.explanation),
} satisfies PropertyNeedMatch
})
.filter((x): x is PropertyNeedMatch => x !== null)
} catch (err) {
throwServiceError(err)
}
},
async getAdditionalMatchesForInquiry(
inquiryId: string,
opts?: { minScore?: number; excludePropertyId?: string },
): Promise<AdditionalPropertyMatch[]> {
try {
const minScore = opts?.minScore ?? 80
const inquiry = await MockupInquiryProvider.getById(inquiryId)
if (!inquiry) return []
const excludeId = opts?.excludePropertyId ?? inquiry.propertyId
const [allProperties, allMatches] = await Promise.all([
MockupPropertyProvider.getAll(),
provider.getAll(),
])
const portfolioProperties = allProperties.filter(
p => p.resultType === ResultType.VERIFIED_PORTFOLIO && p.id !== excludeId,
)
return portfolioProperties
.map(p => {
const match = allMatches.find(m => m.propertyId === p.id)
const score = match?.matchScore ?? 0
return { property: p, score, match }
})
.filter(({ score }) => score >= minScore)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(({ property: p, score, match }) => ({
propertyId: p.id,
title: p.title,
location: `${p.location.city}${p.location.district ? `, ${p.location.district}` : ''}`,
areaSqm: p.areaSqm,
rentPricePerSqm: p.rentPricePerSqm,
matchScore: score,
imageUrl: p.images?.[0],
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
?? (match?.negativeFactors ?? [])[0]?.explanation
?? undefined,
}))
} catch (err) {
throwServiceError(err)
}
},
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
try {
const [matches, properties] = await Promise.all([
provider.getAll(),
MockupPropertyProvider.getAll(),
])
return matches
.filter(m => m.matchScore >= minScore)
.slice(0, 5)
.map(m => {
const prop = properties.find(p => p.id === m.propertyId)
const topFactor = m.positiveFactors?.[0]
const firstAction = m.nextBestActions?.[0]
return {
matchId: m.id,
propertyId: m.propertyId,
propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
propertyAddress: prop?.address
? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
: '',
needSummary: m.needId,
matchScore: m.matchScore,
topReason: topFactor?.explanation ?? topFactor?.criterion ?? '',
missingDataCount: m.missingData?.length ?? 0,
nextBestAction: firstAction?.label ?? '',
} satisfies StrongMatchItem
})
} catch (err) {
throwServiceError(err)
}
},
}