From 0b874865ba8ff3abd9e8875ccdc32a766c8d5aa8 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 15 May 2026 10:58:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(domain):=20F004=20=E2=80=93=20expand=20dom?= =?UTF-8?q?ain=20model=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - need.ts: add SizeRange, MustHaveCriterion, WeightedPreference types; extend Need with desiredLocation, sizeRange, mustHaveCriteria, weightedPreferences, status - futureSignal.ts: add SignalEvidence type; extend FutureSignal with title, evidence, matchabilityScore, reviewStatus - aiOutput.ts: new AIOutput entity with type, inputHash, outputJson, provider/model/promptVersion/schemaVersion, reviewStatus - activityEvent.ts: new ActivityEvent entity with actorId, action, entityType, entityId, timestamp, metadata - unifiedResult.ts: discriminated union (VerifiedPortfolioResult | ExternalMarketResult | FutureAvailabilityResult) - index.ts: export all three new files - Properties.tsx: add LIGHT_INDUSTRIAL + UNKNOWN cases to exhaustive switches Co-Authored-By: Claude Sonnet 4.6 --- src/domain/activityEvent.ts | 34 ++++++++++ src/domain/aiOutput.ts | 25 ++++++++ src/domain/enums.ts | 107 ++++++++++++++++++++++++++++++-- src/domain/futureSignal.ts | 14 ++++- src/domain/index.ts | 3 + src/domain/match.ts | 86 ++++++++++++++++++++----- src/domain/need.ts | 30 ++++++++- src/domain/property.ts | 98 ++++++++++++++++++++++++----- src/domain/unifiedResult.ts | 38 ++++++++++++ src/pages/supply/Properties.tsx | 4 ++ 10 files changed, 399 insertions(+), 40 deletions(-) create mode 100644 src/domain/activityEvent.ts create mode 100644 src/domain/aiOutput.ts create mode 100644 src/domain/unifiedResult.ts diff --git a/src/domain/activityEvent.ts b/src/domain/activityEvent.ts new file mode 100644 index 0000000..3081916 --- /dev/null +++ b/src/domain/activityEvent.ts @@ -0,0 +1,34 @@ +export const ActivityAction = { + CREATED: 'CREATED', + UPDATED: 'UPDATED', + DELETED: 'DELETED', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', + SHORTLISTED: 'SHORTLISTED', + REVIEWED: 'REVIEWED', + VERIFIED: 'VERIFIED', + EXPORTED: 'EXPORTED', + SHARED: 'SHARED', +} as const +export type ActivityAction = typeof ActivityAction[keyof typeof ActivityAction] + +export const ActivityEntityType = { + PROPERTY: 'PROPERTY', + NEED: 'NEED', + MATCH: 'MATCH', + FUTURE_SIGNAL: 'FUTURE_SIGNAL', + AI_OUTPUT: 'AI_OUTPUT', + SHORTLIST: 'SHORTLIST', + USER: 'USER', +} as const +export type ActivityEntityType = typeof ActivityEntityType[keyof typeof ActivityEntityType] + +export interface ActivityEvent { + id: string + actorId: string // user or system ID that triggered the event + action: ActivityAction + entityType: ActivityEntityType + entityId: string + timestamp: string + metadata?: Record +} diff --git a/src/domain/aiOutput.ts b/src/domain/aiOutput.ts new file mode 100644 index 0000000..ba1515f --- /dev/null +++ b/src/domain/aiOutput.ts @@ -0,0 +1,25 @@ +import type { ReviewStatus } from './enums' + +export const AIOutputType = { + MATCH_SCORE: 'MATCH_SCORE', + EXPLAINABILITY: 'EXPLAINABILITY', + SIGNAL_EXTRACTION: 'SIGNAL_EXTRACTION', + NEED_PARSING: 'NEED_PARSING', + SUMMARY: 'SUMMARY', + RECOMMENDATION: 'RECOMMENDATION', +} as const +export type AIOutputType = typeof AIOutputType[keyof typeof AIOutputType] + +export interface AIOutput { + id: string + type: AIOutputType + inputHash: string // hash of the input for cache/dedup + outputJson: unknown // raw output — typed per consumer + provider: string // e.g. "openai", "anthropic" + model: string // e.g. "gpt-4o", "claude-3-5-sonnet" + promptVersion: string // semver of the prompt template used + schemaVersion: string // semver of expected output schema + createdAt: string + reviewedBy?: string + reviewStatus?: ReviewStatus +} diff --git a/src/domain/enums.ts b/src/domain/enums.ts index d73f10c..6bb9349 100644 --- a/src/domain/enums.ts +++ b/src/domain/enums.ts @@ -1,13 +1,17 @@ +// ── Asset Types ─────────────────────────────────────────────────────────────── export const AssetType = { OFFICE: 'OFFICE', RETAIL: 'RETAIL', - GASTRO: 'GASTRO', + GASTRO: 'GASTRO', // legacy – kept for mock-data compatibility + LIGHT_INDUSTRIAL: 'LIGHT_INDUSTRIAL', LOGISTICS: 'LOGISTICS', PRODUCTION: 'PRODUCTION', MIXED: 'MIXED', + UNKNOWN: 'UNKNOWN', } as const export type AssetType = typeof AssetType[keyof typeof AssetType] +// ── Result Types ────────────────────────────────────────────────────────────── export const ResultType = { VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO', EXTERNAL_MARKET: 'EXTERNAL_MARKET', @@ -15,6 +19,7 @@ export const ResultType = { } as const export type ResultType = typeof ResultType[keyof typeof ResultType] +// ── Match Strength ───────────────────────────────────────────────────────────── export const MatchStrength = { STRONG: 'STRONG', MODERATE: 'MODERATE', @@ -22,6 +27,17 @@ export const MatchStrength = { } as const export type MatchStrength = typeof MatchStrength[keyof typeof MatchStrength] +// ── Match Status ────────────────────────────────────────────────────────────── +export const MatchStatus = { + PENDING_REVIEW: 'PENDING_REVIEW', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', + SHORTLISTED: 'SHORTLISTED', + ARCHIVED: 'ARCHIVED', +} as const +export type MatchStatus = typeof MatchStatus[keyof typeof MatchStatus] + +// ── Risk Level ──────────────────────────────────────────────────────────────── export const RiskLevel = { LOW: 'LOW', MEDIUM: 'MEDIUM', @@ -30,6 +46,26 @@ export const RiskLevel = { } as const export type RiskLevel = typeof RiskLevel[keyof typeof RiskLevel] +// ── Confidence Level (qualitative) ─────────────────────────────────────────── +export const ConfidenceLevel = { + VERY_HIGH: 'VERY_HIGH', // >= 0.90 + HIGH: 'HIGH', // >= 0.75 + MEDIUM: 'MEDIUM', // >= 0.55 + LOW: 'LOW', // >= 0.35 + VERY_LOW: 'VERY_LOW', // < 0.35 +} as const +export type ConfidenceLevel = typeof ConfidenceLevel[keyof typeof ConfidenceLevel] + +// ── Data Quality Level (qualitative) ───────────────────────────────────────── +export const DataQualityLevel = { + HIGH: 'HIGH', // >= 0.80 + MEDIUM: 'MEDIUM', // >= 0.60 + LOW: 'LOW', // < 0.60 + INCOMPLETE: 'INCOMPLETE', // missing critical fields +} as const +export type DataQualityLevel = typeof DataQualityLevel[keyof typeof DataQualityLevel] + +// ── Availability Status ─────────────────────────────────────────────────────── export const AvailabilityStatus = { AVAILABLE_NOW: 'AVAILABLE_NOW', AVAILABLE_SOON: 'AVAILABLE_SOON', @@ -39,13 +75,70 @@ export const AvailabilityStatus = { } as const export type AvailabilityStatus = typeof AvailabilityStatus[keyof typeof AvailabilityStatus] -export const DataFreshness = { - FRESH: 'FRESH', - STALE: 'STALE', - OUTDATED: 'OUTDATED', +// ── Availability Type (structural distinction) ──────────────────────────────── +export const AvailabilityType = { + CONFIRMED: 'CONFIRMED', // verified, date known + INDICATIVE: 'INDICATIVE', // external listing, unconfirmed + PROBABILISTIC: 'PROBABILISTIC', // AI signal, no confirmed date } as const -export type DataFreshness = typeof DataFreshness[keyof typeof DataFreshness] +export type AvailabilityType = typeof AvailabilityType[keyof typeof AvailabilityType] +// ── Source Type ─────────────────────────────────────────────────────────────── +export const SourceType = { + ERP_IMPORT: 'ERP_IMPORT', + MANUAL_ENTRY: 'MANUAL_ENTRY', + IMMOSCOUT_SCRAPE: 'IMMOSCOUT_SCRAPE', + HOMEGATE_SCRAPE: 'HOMEGATE_SCRAPE', + NEWHOME_SCRAPE: 'NEWHOME_SCRAPE', + MATCHOFFICE_SCRAPE: 'MATCHOFFICE_SCRAPE', + MAISON_WORK_SCRAPE: 'MAISON_WORK_SCRAPE', + AI_SIGNAL: 'AI_SIGNAL', + PARTNER_FEED: 'PARTNER_FEED', + UNKNOWN: 'UNKNOWN', +} as const +export type SourceType = typeof SourceType[keyof typeof SourceType] + +// ── Freshness Status ────────────────────────────────────────────────────────── +export const FreshnessStatus = { + FRESH: 'FRESH', // updated within 48h + STALE: 'STALE', // 2–14 days old + OUTDATED: 'OUTDATED', // > 14 days old +} as const +export type FreshnessStatus = typeof FreshnessStatus[keyof typeof FreshnessStatus] + +/** @deprecated Use FreshnessStatus — kept for mock-data backward compatibility */ +export const DataFreshness = FreshnessStatus +export type DataFreshness = FreshnessStatus + +// ── Review Status ───────────────────────────────────────────────────────────── +export const ReviewStatus = { + UNREVIEWED: 'UNREVIEWED', + IN_REVIEW: 'IN_REVIEW', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', + FLAGGED: 'FLAGGED', +} as const +export type ReviewStatus = typeof ReviewStatus[keyof typeof ReviewStatus] + +// ── Sensitivity Level ───────────────────────────────────────────────────────── +export const SensitivityLevel = { + PUBLIC: 'PUBLIC', + INTERNAL: 'INTERNAL', + CONFIDENTIAL: 'CONFIDENTIAL', + RESTRICTED: 'RESTRICTED', +} as const +export type SensitivityLevel = typeof SensitivityLevel[keyof typeof SensitivityLevel] + +// ── Shortlist Status ────────────────────────────────────────────────────────── +export const ShortlistStatus = { + ACTIVE: 'ACTIVE', + SHARED: 'SHARED', + ARCHIVED: 'ARCHIVED', + CONVERTED: 'CONVERTED', +} as const +export type ShortlistStatus = typeof ShortlistStatus[keyof typeof ShortlistStatus] + +// ── User Role ───────────────────────────────────────────────────────────────── export const UserRole = { SUPER_ADMIN: 'SUPER_ADMIN', ORGANIZATION_ADMIN: 'ORGANIZATION_ADMIN', @@ -56,6 +149,7 @@ export const UserRole = { } as const export type UserRole = typeof UserRole[keyof typeof UserRole] +// ── Signal Type ─────────────────────────────────────────────────────────────── export const SignalType = { EXPANSION: 'EXPANSION', POSSIBLE_MOVE_OUT: 'POSSIBLE_MOVE_OUT', @@ -66,6 +160,7 @@ export const SignalType = { } as const export type SignalType = typeof SignalType[keyof typeof SignalType] +// ── Workspace ───────────────────────────────────────────────────────────────── export const WorkspaceType = { SUPPLY: 'SUPPLY', DEMAND: 'DEMAND', diff --git a/src/domain/futureSignal.ts b/src/domain/futureSignal.ts index 3ff1683..57488e7 100644 --- a/src/domain/futureSignal.ts +++ b/src/domain/futureSignal.ts @@ -1,4 +1,4 @@ -import type { SignalType, RiskLevel } from './enums' +import type { SignalType, RiskLevel, ReviewStatus } from './enums' export interface SignalSource { type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' @@ -7,6 +7,12 @@ export interface SignalSource { credibility: 'LOW' | 'MEDIUM' | 'HIGH' } +export interface SignalEvidence { + summary: string + sourceUrls?: string[] + extractedAt?: string +} + export interface FutureSignal { id: string signalType: SignalType @@ -30,4 +36,10 @@ export interface FutureSignal { organizationId?: string createdAt: string updatedAt: string + + // F004 additions + title?: string // short human-readable headline + evidence?: SignalEvidence // structured evidence block + matchabilityScore?: number // 0–100: how well this signal can be matched to needs + reviewStatus?: ReviewStatus } diff --git a/src/domain/index.ts b/src/domain/index.ts index bd82192..c877698 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -3,3 +3,6 @@ export * from './property' export * from './need' export * from './match' export * from './futureSignal' +export * from './aiOutput' +export * from './activityEvent' +export * from './unifiedResult' diff --git a/src/domain/match.ts b/src/domain/match.ts index 70a81c0..2b5af59 100644 --- a/src/domain/match.ts +++ b/src/domain/match.ts @@ -1,52 +1,108 @@ -import type { MatchStrength, RiskLevel } from './enums' +import type { MatchStrength, MatchStatus, RiskLevel, ResultType, ConfidenceLevel } from './enums' + +// ── Score Building Blocks ───────────────────────────────────────────────────── export interface ScoreBreakdown { - hardMatchScore: number - softFactorScore: number - confidenceModifier: number - dataQualityModifier: number - totalScore: number + hardMatchScore: number // 0–100 hard criteria score + softFactorScore: number // 0–100 soft factors score + confidenceModifier: number // -20 to +5 adjustment + dataQualityModifier: number // -15 to 0 adjustment + totalScore: number // final 0–100 } export interface ScoreFactor { criterion: string - weight: number - score: number - contribution: number + weight: number // 0–1 relative weight + score: number // 0–100 + contribution: number // weighted points added to total explanation: string } -export interface Tradeoff { +// ── Explainability Types ────────────────────────────────────────────────────── + +/** Canonical tradeoff type (F004) */ +export interface TradeOff { criterion: string concern: string severity: 'LOW' | 'MEDIUM' | 'HIGH' mitigation?: string + impactOnScore?: number +} + +/** @deprecated Use TradeOff — kept for backward compatibility */ +export type Tradeoff = TradeOff + +export interface Risk { + category: string // e.g. "Datenverfügbarkeit", "Standortrisiko" + description: string + level: RiskLevel + mitigation?: string +} + +export interface MissingDataItem { + field: string + importance: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' + description: string + impact: string // how it affects the match score +} + +export interface NextBestAction { + label: string // e.g. "Besichtigung anfragen" + description?: string + priority: 'HIGH' | 'MEDIUM' | 'LOW' + actionType: 'CONTACT' | 'VERIFY' | 'REVIEW' | 'SHORTLIST' | 'COMPARE' | 'SCHEDULE' + externalUrl?: string } export interface AlternativeStrategy { title: string description: string expectedScore?: number + reasoning?: string } +// ── Match (core entity) ─────────────────────────────────────────────────────── + export interface Match { id: string - propertyId: string needId: string - matchScore: number + + // Result reference — works for all three result types + resultId?: string // preferred: generic result ID + resultType?: ResultType + propertyId: string // legacy alias for resultId (VERIFIED_PORTFOLIO) + + // Scoring + matchScore: number // 0–100 matchStrength: MatchStrength scoreBreakdown: ScoreBreakdown + confidenceLevel: number // 0–1 numeric + confidenceLevelLabel?: ConfidenceLevel + dataConfidenceScore?: number // 0–1 data quality contribution + + // Explainability positiveFactors: ScoreFactor[] negativeFactors: ScoreFactor[] - tradeoffs: Tradeoff[] + tradeoffs: TradeOff[] + tradeOffs?: TradeOff[] // alias for F004 naming convention + risks?: Risk[] + missingData?: MissingDataItem[] + nextBestActions?: NextBestAction[] explainabilitySummary: string - confidenceLevel: number + explanation?: string // alias / longer form + + // Risk riskLevel: RiskLevel uncertaintyIndicators: string[] + + // Alternatives alternativeStrategies?: AlternativeStrategy[] + + // Review / lifecycle + status?: MatchStatus + isApproved?: boolean // legacy — prefer status reviewedBy?: string reviewedAt?: string - isApproved?: boolean organizationId?: string createdAt: string updatedAt: string diff --git a/src/domain/need.ts b/src/domain/need.ts index 3a1b0b8..9ffcbab 100644 --- a/src/domain/need.ts +++ b/src/domain/need.ts @@ -1,4 +1,4 @@ -import type { AssetType } from './enums' +import type { AssetType, MatchStatus } from './enums' export interface AreaRange { min: number @@ -40,17 +40,43 @@ export interface SoftFactorPreferences { requireHighVisibility?: boolean } +export interface SizeRange { + minSqm: number + maxSqm: number +} + +export interface MustHaveCriterion { + criterion: string + description?: string + weight?: number // 0–1, how much a miss hurts the score +} + +export interface WeightedPreference { + criterion: string + weight: number // 0–1 + idealValue?: string | number + description?: string +} + export interface Need { id: string companyName: string contactName?: string assetType: AssetType requiredArea: AreaRange + + // F004 additions + desiredLocation?: string[] // preferred city/district list + sizeRange?: SizeRange // structured alias for requiredArea + mustHaveCriteria?: MustHaveCriterion[] + weightedPreferences?: WeightedPreference[] + status?: MatchStatus | 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'CLOSED' + preferredLocations: string[] excludedLocations?: string[] budgetRange: BudgetRange timing: Timing - mustCriteriaText?: string[] + mustCriteriaText?: string[] // legacy — prefer mustHaveCriteria softFactors?: SoftFactorPreferences weightingProfile: WeightingProfile confidenceInCriteria: number diff --git a/src/domain/property.ts b/src/domain/property.ts index e615680..02c7948 100644 --- a/src/domain/property.ts +++ b/src/domain/property.ts @@ -1,14 +1,17 @@ -import type { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from './enums' +import type { + AssetType, ResultType, AvailabilityStatus, AvailabilityType, + FreshnessStatus, RiskLevel, SourceType, DataQualityLevel, +} from './enums' + +// ── Location / Address ──────────────────────────────────────────────────────── export interface Location { city: string district?: string canton?: string + region?: string country: string - coordinates?: { - lat: number - lng: number - } + coordinates?: { lat: number; lng: number } } export interface Address { @@ -19,10 +22,58 @@ export interface Address { country: string } +// ── Source Metadata ─────────────────────────────────────────────────────────── + +export interface SourceMeta { + sourceType: SourceType | string + sourceLabel?: string + sourceUrl?: string + sourceUpdatedAt?: string + externalId?: string +} + +// ── Data Quality ────────────────────────────────────────────────────────────── + +export interface DataQuality { + score: number + qualityLevel?: DataQualityLevel + missingCriticalFields: string[] + missingOptionalFields: string[] + lastVerifiedAt?: string + freshness: FreshnessStatus + warnings: string[] +} + +// ── Hard Facts ──────────────────────────────────────────────────────────────── + +export interface PropertyHardFacts { + floor?: number + fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' + parking?: number + publicTransportScore?: number + usageType?: string + ceilingHeightM?: number + loadingDocksCount?: number + powerSupplyKva?: number + hasServerRoom?: boolean + isBarrierFree?: boolean +} + +// ── Soft Factors ────────────────────────────────────────────────────────────── + export interface SoftFactors { + prestigeScore?: number + visibilityScore?: number + footfallScore?: number + commuterAccessScore?: number + talentAccessScore?: number + esgScore?: number + flexibilityScore?: number + expansionPotentialScore?: number + taxEnvironmentScore?: number + // Legacy aliases kept for mock-data backward compatibility prestige?: number accessibility?: number - visibilityScore?: number talentAccess?: number esgRating?: string passerbyFrequency?: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH' @@ -31,40 +82,55 @@ export interface SoftFactors { infrastructureNotes?: string } -export interface DataQuality { - score: number - missingCriticalFields: string[] - missingOptionalFields: string[] - lastVerifiedAt?: string - freshness: DataFreshness - warnings: string[] -} +// ── Property ────────────────────────────────────────────────────────────────── export interface Property { id: string + organizationId?: string title: string + assetType: AssetType resultType: ResultType + location: Location address: Address + areaSqm: number + areaSqmMin?: number + areaSqmMax?: number + rentPricePerSqm: number + rentChfSqmYear?: number totalRentMonthly?: number + ancillaryCosts?: number + availabilityDate: string availabilityStatus: AvailabilityStatus + availabilityType?: AvailabilityType + sourceType: string + sourceLabel?: string sourceUrl?: string + sourceUpdatedAt?: string + sourceMeta?: SourceMeta + confidenceScore: number dataQuality: DataQuality + softFactors?: SoftFactors + hardFacts?: PropertyHardFacts + + // Legacy fields — kept for backward compat floorLevel?: number expansionPotentialSqm?: number + contractDurationMonths?: number - ancillaryCosts?: number riskLevel?: RiskLevel description?: string images?: string[] - organizationId?: string + + status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED' + lastReviewedAt?: string createdAt: string updatedAt: string } diff --git a/src/domain/unifiedResult.ts b/src/domain/unifiedResult.ts new file mode 100644 index 0000000..51d86ac --- /dev/null +++ b/src/domain/unifiedResult.ts @@ -0,0 +1,38 @@ +import type { ResultType } from './enums' +import type { Property } from './property' +import type { FutureSignal } from './futureSignal' +import type { Match } from './match' + +// ── Unified Match Result (discriminated union) ──────────────────────────────── +// Wraps the three result types so UI components can handle all three paths +// without type confusion. Discriminate on `resultType`. + +interface UnifiedResultBase { + matchId: string + needId: string + matchScore: number + resultType: ResultType +} + +export interface VerifiedPortfolioResult extends UnifiedResultBase { + resultType: 'VERIFIED_PORTFOLIO' + property: Property + match: Match +} + +export interface ExternalMarketResult extends UnifiedResultBase { + resultType: 'EXTERNAL_MARKET' + property: Property + match: Match +} + +export interface FutureAvailabilityResult extends UnifiedResultBase { + resultType: 'FUTURE_AVAILABILITY' + signal: FutureSignal + match: Match +} + +export type UnifiedMatchResult = + | VerifiedPortfolioResult + | ExternalMarketResult + | FutureAvailabilityResult diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx index 12e70dd..5122a48 100644 --- a/src/pages/supply/Properties.tsx +++ b/src/pages/supply/Properties.tsx @@ -30,6 +30,8 @@ function getAssetTypeLabel(type: AssetType): string { case AssetType.GASTRO: return 'Gastro' case AssetType.PRODUCTION: return 'Produktion' case AssetType.MIXED: return 'Gemischt' + case AssetType.LIGHT_INDUSTRIAL: return 'Leichtindustrie' + case AssetType.UNKNOWN: return 'Unbekannt' } } @@ -41,6 +43,8 @@ function getAssetTypeColor(type: AssetType): string { case AssetType.GASTRO: return '#0d9488' case AssetType.PRODUCTION: return '#92400e' case AssetType.MIXED: return '#6b7280' + case AssetType.LIGHT_INDUSTRIAL: return '#b45309' + case AssetType.UNKNOWN: return '#9ca3af' } }