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>
This commit is contained in:
+157
-108
@@ -12,44 +12,77 @@ 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>> {
|
||||
const data = await provider.getAll(filters)
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
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>> {
|
||||
const data = await provider.getById(id)
|
||||
return { data }
|
||||
try {
|
||||
const data = await provider.getById(id)
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
async getByNeed(needId: string): Promise<ListResponse<Match>> {
|
||||
const data = await provider.getByNeed(needId)
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
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>> {
|
||||
const data = await provider.getByProperty(propertyId)
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
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>> {
|
||||
const data = await provider.approve(id, reviewedBy)
|
||||
return { data }
|
||||
try {
|
||||
const data = await provider.approve(id, reviewedBy)
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async getMatchesForProperty(propertyId: string): Promise<ListResponse<Match>> {
|
||||
const data = await provider.getByProperty(propertyId)
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
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>> {
|
||||
const data = await provider.getById(id)
|
||||
return { data }
|
||||
try {
|
||||
const data = await provider.getById(id)
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async getScoreBreakdown(matchId: string): Promise<ItemResponse<ScoreBreakdown | null>> {
|
||||
const match = await provider.getById(matchId)
|
||||
return { data: match?.scoreBreakdown ?? null }
|
||||
try {
|
||||
const match = await provider.getById(matchId)
|
||||
return { data: match?.scoreBreakdown ?? null }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
// ── Engine-based methods ──────────────────────────────────────────────────
|
||||
@@ -59,112 +92,128 @@ export const matchService = {
|
||||
},
|
||||
|
||||
async computeMatchesForNeed(needId: string): Promise<ListResponse<Match>> {
|
||||
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 } }
|
||||
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[]> {
|
||||
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} m²`,
|
||||
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)
|
||||
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} m²`,
|
||||
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[]> {
|
||||
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,
|
||||
}))
|
||||
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[]> {
|
||||
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
|
||||
})
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user