6515acb7f0
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>
220 lines
8.0 KiB
TypeScript
220 lines
8.0 KiB
TypeScript
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} 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[]> {
|
||
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)
|
||
}
|
||
},
|
||
}
|