feat(services): F005 – complete service layer

New domain types:
- shortlist.ts: Shortlist, ShortlistItem, CreateShortlistInput
- review.ts: ReviewQueueItem, ReviewPriority, ReviewQueueStatus

New mock data:
- shortlists.ts: 2 sample shortlists
- reviewQueue.ts: 3 pending review items

New providers (interface + mockup):
- IShortlistProvider / MockupShortlistProvider (with 150ms latency)
- IReviewProvider / MockupReviewProvider (priority-sorted, with latency)
- IDashboardProvider / MockupDashboardProvider (computed from all mock data)

New services:
- reviewService: getQueue, approve, reject, assign
- shortlistService: CRUD + addItem/removeItem
- authService: wraps sessionStore, swappable for real auth
- dashboardService: getStats(organizationId?)

Enhancements:
- types.ts: typed ServiceErrorCode + ServiceError, Pagination alias
- aiService.ts: openRouterAIService stub (throws ai_generation_failed)
- lib/mockUtils.ts: mockDelay(ms) utility

New hooks:
- useUnifiedResults(needId?): joins matches+properties+signals → UnifiedMatchResult[]
- useReviewQueue: + useApproveReviewItem + useRejectReviewItem
- useMatchDetail, usePropertyDetail, useNeedProfiles aliases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 11:12:06 +02:00
parent 0b874865ba
commit 5ef8b9d69d
25 changed files with 636 additions and 7 deletions
+18
View File
@@ -0,0 +1,18 @@
export interface DashboardStats {
totalProperties: number
verifiedProperties: number
externalMarketProperties: number
futureSignalProperties: number
totalMatches: number
pendingReviews: number
approvedMatches: number
activeNeeds: number
totalSignals: number
verifiedSignals: number
averageMatchScore: number
highConfidenceMatches: number
}
export interface IDashboardProvider {
getStats(organizationId?: string): Promise<DashboardStats>
}
+16
View File
@@ -0,0 +1,16 @@
import type { ReviewQueueItem, ReviewPriority, ReviewQueueStatus } from '../domain/review'
export interface ReviewFilters {
priority?: ReviewPriority
status?: ReviewQueueStatus
assignedTo?: string
organizationId?: string
}
export interface IReviewProvider {
getQueue(filters?: ReviewFilters): Promise<ReviewQueueItem[]>
getById(id: string): Promise<ReviewQueueItem | null>
approve(id: string, reviewedBy: string, notes?: string): Promise<ReviewQueueItem>
reject(id: string, reviewedBy: string, notes?: string): Promise<ReviewQueueItem>
assign(id: string, assignTo: string): Promise<ReviewQueueItem>
}
+19
View File
@@ -0,0 +1,19 @@
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
import type { ShortlistStatus } from '../domain/enums'
export interface ShortlistFilters {
needId?: string
status?: ShortlistStatus
createdBy?: string
organizationId?: string
}
export interface IShortlistProvider {
getAll(filters?: ShortlistFilters): Promise<Shortlist[]>
getById(id: string): Promise<Shortlist | null>
create(data: CreateShortlistInput): Promise<Shortlist>
update(id: string, data: UpdateShortlistInput): Promise<Shortlist>
addItem(id: string, propertyId: string, note?: string): Promise<Shortlist>
removeItem(id: string, propertyId: string): Promise<Shortlist>
remove(id: string): Promise<void>
}
+48
View File
@@ -0,0 +1,48 @@
import type { IDashboardProvider, DashboardStats } from './IDashboardProvider'
import { mockProperties } from '../mock-data/properties'
import { mockMatches } from '../mock-data/matches'
import { mockNeeds } from '../mock-data/needs'
import { mockFutureSignals } from '../mock-data/futureSignals'
import { mockReviewQueue } from '../mock-data/reviewQueue'
import { mockDelay } from '../lib/mockUtils'
export const MockupDashboardProvider: IDashboardProvider = {
async getStats(organizationId?) {
await mockDelay()
let props = mockProperties
let matches = mockMatches
let needs = mockNeeds
let signals = mockFutureSignals
let queue = mockReviewQueue
if (organizationId) {
props = props.filter(p => p.organizationId === organizationId)
matches = matches.filter(m => m.organizationId === organizationId)
needs = needs.filter(n => n.organizationId === organizationId)
signals = signals.filter(s => s.organizationId === organizationId)
queue = queue.filter(r => r.organizationId === organizationId)
}
const avgScore =
matches.length > 0
? matches.reduce((sum, m) => sum + m.matchScore, 0) / matches.length
: 0
const stats: DashboardStats = {
totalProperties: props.length,
verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
externalMarketProperties: props.filter(p => p.resultType === 'EXTERNAL_MARKET').length,
futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length,
totalMatches: matches.length,
pendingReviews: queue.filter(r => r.status === 'PENDING').length,
approvedMatches: matches.filter(m => m.isApproved === true).length,
activeNeeds: needs.length,
totalSignals: signals.length,
verifiedSignals: signals.filter(s => s.isVerified).length,
averageMatchScore: Math.round(avgScore),
highConfidenceMatches: matches.filter(m => m.confidenceLevel >= 0.75).length,
}
return stats
},
}
+42
View File
@@ -0,0 +1,42 @@
import type { IReviewProvider, ReviewFilters } from './IReviewProvider'
import type { ReviewQueueItem } from '../domain/review'
import { mockReviewQueue } from '../mock-data/reviewQueue'
import { mockDelay } from '../lib/mockUtils'
const store: ReviewQueueItem[] = [...mockReviewQueue]
const priorityOrder: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
export const MockupReviewProvider: IReviewProvider = {
async getQueue(filters?: ReviewFilters) {
await mockDelay()
let results = [...store]
if (filters?.priority) results = results.filter(r => r.priority === filters.priority)
if (filters?.status) results = results.filter(r => r.status === filters.status)
if (filters?.assignedTo) results = results.filter(r => r.assignedTo === filters.assignedTo)
if (filters?.organizationId) results = results.filter(r => r.organizationId === filters.organizationId)
return results.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9))
},
async getById(id) {
await mockDelay()
return store.find(r => r.id === id) ?? null
},
async approve(id, _reviewedBy, notes?) {
await mockDelay()
const idx = store.findIndex(r => r.id === id)
store[idx] = { ...store[idx], status: 'COMPLETED', notes, updatedAt: new Date().toISOString() }
return store[idx]
},
async reject(id, _reviewedBy, notes?) {
await mockDelay()
const idx = store.findIndex(r => r.id === id)
store[idx] = { ...store[idx], status: 'COMPLETED', notes, updatedAt: new Date().toISOString() }
return store[idx]
},
async assign(id, assignTo) {
await mockDelay()
const idx = store.findIndex(r => r.id === id)
store[idx] = { ...store[idx], assignedTo: assignTo, status: 'IN_REVIEW', updatedAt: new Date().toISOString() }
return store[idx]
},
}
+67
View File
@@ -0,0 +1,67 @@
import type { IShortlistProvider, ShortlistFilters } from './IShortlistProvider'
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist'
import { mockShortlists } from '../mock-data/shortlists'
import { mockDelay } from '../lib/mockUtils'
const store: Shortlist[] = [...mockShortlists]
export const MockupShortlistProvider: IShortlistProvider = {
async getAll(filters?: ShortlistFilters) {
await mockDelay()
let results = [...store]
if (filters?.needId) results = results.filter(s => s.needId === filters.needId)
if (filters?.status) results = results.filter(s => s.status === filters.status)
if (filters?.createdBy) results = results.filter(s => s.createdBy === filters.createdBy)
if (filters?.organizationId) results = results.filter(s => s.organizationId === filters.organizationId)
return results
},
async getById(id) {
await mockDelay()
return store.find(s => s.id === id) ?? null
},
async create(data: CreateShortlistInput) {
await mockDelay()
const next: Shortlist = {
id: crypto.randomUUID(),
...data,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
store.push(next)
return next
},
async update(id, data: UpdateShortlistInput) {
await mockDelay()
const idx = store.findIndex(s => s.id === id)
store[idx] = { ...store[idx], ...data, updatedAt: new Date().toISOString() }
return store[idx]
},
async addItem(id, propertyId, note?) {
await mockDelay()
const idx = store.findIndex(s => s.id === id)
const alreadyAdded = store[idx].items.some(i => i.propertyId === propertyId)
if (!alreadyAdded) {
store[idx] = {
...store[idx],
items: [...store[idx].items, { propertyId, addedAt: new Date().toISOString(), note }],
updatedAt: new Date().toISOString(),
}
}
return store[idx]
},
async removeItem(id, propertyId) {
await mockDelay()
const idx = store.findIndex(s => s.id === id)
store[idx] = {
...store[idx],
items: store[idx].items.filter(i => i.propertyId !== propertyId),
updatedAt: new Date().toISOString(),
}
return store[idx]
},
async remove(id) {
await mockDelay()
const idx = store.findIndex(s => s.id === id)
store.splice(idx, 1)
},
}