From 5ef8b9d69dac703739042efb42e56c8693dfad88 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 15 May 2026 11:12:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(services):=20F005=20=E2=80=93=20complete?= =?UTF-8?q?=20service=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/domain/index.ts | 2 + src/domain/review.ts | 29 +++++++++ src/domain/shortlist.ts | 24 ++++++++ src/hooks/index.ts | 8 ++- src/hooks/useMatches.ts | 10 ++++ src/hooks/useNeeds.ts | 2 + src/hooks/useProperties.ts | 2 + src/hooks/useReviewQueue.ts | 36 +++++++++++ src/hooks/useUnifiedResults.ts | 79 +++++++++++++++++++++++++ src/lib/mockUtils.ts | 1 + src/mock-data/index.ts | 2 + src/mock-data/reviewQueue.ts | 43 ++++++++++++++ src/mock-data/shortlists.ts | 35 +++++++++++ src/provider/IDashboardProvider.ts | 18 ++++++ src/provider/IReviewProvider.ts | 16 +++++ src/provider/IShortlistProvider.ts | 19 ++++++ src/provider/MockupDashboardProvider.ts | 48 +++++++++++++++ src/provider/MockupReviewProvider.ts | 42 +++++++++++++ src/provider/MockupShortlistProvider.ts | 67 +++++++++++++++++++++ src/services/aiService.ts | 18 +++++- src/services/authService.ts | 31 ++++++++++ src/services/dashboardService.ts | 12 ++++ src/services/reviewService.ts | 29 +++++++++ src/services/shortlistService.ts | 37 ++++++++++++ src/services/types.ts | 33 ++++++++++- 25 files changed, 636 insertions(+), 7 deletions(-) create mode 100644 src/domain/review.ts create mode 100644 src/domain/shortlist.ts create mode 100644 src/hooks/useReviewQueue.ts create mode 100644 src/hooks/useUnifiedResults.ts create mode 100644 src/lib/mockUtils.ts create mode 100644 src/mock-data/reviewQueue.ts create mode 100644 src/mock-data/shortlists.ts create mode 100644 src/provider/IDashboardProvider.ts create mode 100644 src/provider/IReviewProvider.ts create mode 100644 src/provider/IShortlistProvider.ts create mode 100644 src/provider/MockupDashboardProvider.ts create mode 100644 src/provider/MockupReviewProvider.ts create mode 100644 src/provider/MockupShortlistProvider.ts create mode 100644 src/services/authService.ts create mode 100644 src/services/dashboardService.ts create mode 100644 src/services/reviewService.ts create mode 100644 src/services/shortlistService.ts diff --git a/src/domain/index.ts b/src/domain/index.ts index c877698..200c277 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -6,3 +6,5 @@ export * from './futureSignal' export * from './aiOutput' export * from './activityEvent' export * from './unifiedResult' +export * from './shortlist' +export * from './review' diff --git a/src/domain/review.ts b/src/domain/review.ts new file mode 100644 index 0000000..8865747 --- /dev/null +++ b/src/domain/review.ts @@ -0,0 +1,29 @@ +export const ReviewPriority = { + HIGH: 'HIGH', + MEDIUM: 'MEDIUM', + LOW: 'LOW', +} as const +export type ReviewPriority = typeof ReviewPriority[keyof typeof ReviewPriority] + +export const ReviewQueueStatus = { + PENDING: 'PENDING', + IN_REVIEW: 'IN_REVIEW', + COMPLETED: 'COMPLETED', +} as const +export type ReviewQueueStatus = typeof ReviewQueueStatus[keyof typeof ReviewQueueStatus] + +export interface ReviewQueueItem { + id: string + matchId: string + needId: string + propertyId: string + matchScore: number + priority: ReviewPriority + status: ReviewQueueStatus + assignedTo?: string + notes?: string + dueAt?: string + organizationId?: string + createdAt: string + updatedAt: string +} diff --git a/src/domain/shortlist.ts b/src/domain/shortlist.ts new file mode 100644 index 0000000..1438cd4 --- /dev/null +++ b/src/domain/shortlist.ts @@ -0,0 +1,24 @@ +import type { ShortlistStatus } from './enums' + +export interface ShortlistItem { + propertyId: string + addedAt: string + note?: string +} + +export interface Shortlist { + id: string + title: string + description?: string + needId?: string + items: ShortlistItem[] + status: ShortlistStatus + createdBy: string + organizationId?: string + sharedWith?: string[] + createdAt: string + updatedAt: string +} + +export type CreateShortlistInput = Omit +export type UpdateShortlistInput = Partial diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1f41aa8..4d9e3ef 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,6 @@ -export { useProperties, useProperty } from './useProperties' -export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch } from './useMatches' +export { useProperties, useProperty, usePropertyDetail } from './useProperties' +export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch, useMatchDetail } from './useMatches' export { useFutureSignals, useFutureSignalsByProperty, useVerifySignal } from './useFutureSignals' -export { useNeeds, useNeed } from './useNeeds' +export { useNeeds, useNeed, useNeedProfiles } from './useNeeds' +export { useUnifiedResults } from './useUnifiedResults' +export { useReviewQueue, useApproveReviewItem, useRejectReviewItem } from './useReviewQueue' diff --git a/src/hooks/useMatches.ts b/src/hooks/useMatches.ts index 6889860..7212be7 100644 --- a/src/hooks/useMatches.ts +++ b/src/hooks/useMatches.ts @@ -40,3 +40,13 @@ export function useApproveMatch() { }, }) } + +export function useMatchDetail(id: string) { + return useQuery({ + queryKey: ['match', id], + queryFn: () => matchService.getById(id), + staleTime: STALE_MATCHES, + enabled: Boolean(id), + select: (res) => res.data ?? null, + }) +} diff --git a/src/hooks/useNeeds.ts b/src/hooks/useNeeds.ts index efa3fad..b753daa 100644 --- a/src/hooks/useNeeds.ts +++ b/src/hooks/useNeeds.ts @@ -17,3 +17,5 @@ export function useNeed(id: string) { select: (res) => res.data ?? null, }) } + +export const useNeedProfiles = useNeeds diff --git a/src/hooks/useProperties.ts b/src/hooks/useProperties.ts index 997720d..b7739c4 100644 --- a/src/hooks/useProperties.ts +++ b/src/hooks/useProperties.ts @@ -30,3 +30,5 @@ export function useProperty(id: string) { select: (res) => res.data ?? null, }) } + +export const usePropertyDetail = useProperty diff --git a/src/hooks/useReviewQueue.ts b/src/hooks/useReviewQueue.ts new file mode 100644 index 0000000..825f5ab --- /dev/null +++ b/src/hooks/useReviewQueue.ts @@ -0,0 +1,36 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { reviewService } from '../services/reviewService' +import type { ReviewFilters } from '../provider/IReviewProvider' + +const STALE_REVIEW = 30_000 + +export function useReviewQueue(filters?: ReviewFilters) { + return useQuery({ + queryKey: ['reviewQueue', filters ?? {}], + queryFn: () => reviewService.getQueue(filters), + staleTime: STALE_REVIEW, + select: (res) => res.data ?? [], + }) +} + +export function useApproveReviewItem() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, notes }: { id: string; notes?: string }) => + reviewService.approve(id, 'current-user', notes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reviewQueue'] }) + }, + }) +} + +export function useRejectReviewItem() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, notes }: { id: string; notes?: string }) => + reviewService.reject(id, 'current-user', notes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reviewQueue'] }) + }, + }) +} diff --git a/src/hooks/useUnifiedResults.ts b/src/hooks/useUnifiedResults.ts new file mode 100644 index 0000000..b358e7b --- /dev/null +++ b/src/hooks/useUnifiedResults.ts @@ -0,0 +1,79 @@ +import { useMemo } from 'react' +import { useMatches, useMatchesByNeed } from './useMatches' +import { useProperties } from './useProperties' +import { useFutureSignals } from './useFutureSignals' +import type { + UnifiedMatchResult, + VerifiedPortfolioResult, + ExternalMarketResult, + FutureAvailabilityResult, +} from '../domain/unifiedResult' + +export function useUnifiedResults(needId?: string) { + const allMatchesQuery = useMatches() + const needMatchesQuery = useMatchesByNeed(needId ?? '') + const propertiesQuery = useProperties() + const signalsQuery = useFutureSignals() + + const matchesQuery = needId ? needMatchesQuery : allMatchesQuery + + const isLoading = + matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading + const error = matchesQuery.error ?? propertiesQuery.error ?? signalsQuery.error + + const matches = matchesQuery.data ?? [] + const properties = propertiesQuery.data ?? [] + const signals = signalsQuery.data ?? [] + + const data = useMemo((): UnifiedMatchResult[] => { + return matches + .flatMap((match): UnifiedMatchResult[] => { + const rt = match.resultType ?? 'VERIFIED_PORTFOLIO' + + if (rt === 'FUTURE_AVAILABILITY') { + const refId = match.resultId ?? match.propertyId + const signal = signals.find( + s => s.id === refId || s.propertyId === refId, + ) + if (!signal) return [] + const result: FutureAvailabilityResult = { + matchId: match.id, + needId: match.needId, + matchScore: match.matchScore, + resultType: 'FUTURE_AVAILABILITY', + signal, + match, + } + return [result] + } + + const property = properties.find(p => p.id === (match.resultId ?? match.propertyId)) + if (!property) return [] + + if (rt === 'EXTERNAL_MARKET') { + const result: ExternalMarketResult = { + matchId: match.id, + needId: match.needId, + matchScore: match.matchScore, + resultType: 'EXTERNAL_MARKET', + property, + match, + } + return [result] + } + + const result: VerifiedPortfolioResult = { + matchId: match.id, + needId: match.needId, + matchScore: match.matchScore, + resultType: 'VERIFIED_PORTFOLIO', + property, + match, + } + return [result] + }) + .sort((a, b) => b.matchScore - a.matchScore) + }, [matches, properties, signals]) + + return { data, isLoading, error } +} diff --git a/src/lib/mockUtils.ts b/src/lib/mockUtils.ts new file mode 100644 index 0000000..2ad46a2 --- /dev/null +++ b/src/lib/mockUtils.ts @@ -0,0 +1 @@ +export const mockDelay = (ms = 150) => new Promise(res => setTimeout(res, ms)) diff --git a/src/mock-data/index.ts b/src/mock-data/index.ts index e7395db..200e90c 100644 --- a/src/mock-data/index.ts +++ b/src/mock-data/index.ts @@ -2,3 +2,5 @@ export { mockProperties } from './properties' export { mockNeeds } from './needs' export { mockMatches } from './matches' export { mockFutureSignals } from './futureSignals' +export { mockShortlists } from './shortlists' +export { mockReviewQueue } from './reviewQueue' diff --git a/src/mock-data/reviewQueue.ts b/src/mock-data/reviewQueue.ts new file mode 100644 index 0000000..1a6b401 --- /dev/null +++ b/src/mock-data/reviewQueue.ts @@ -0,0 +1,43 @@ +import type { ReviewQueueItem } from '../domain/review' + +export const mockReviewQueue: ReviewQueueItem[] = [ + { + id: 'review-001', + matchId: 'match-001', + needId: 'need-001', + propertyId: 'prop-001', + matchScore: 88, + priority: 'HIGH', + status: 'PENDING', + assignedTo: 'user-001', + dueAt: '2025-06-15T17:00:00Z', + organizationId: 'org-wincasa', + createdAt: '2025-05-10T08:00:00Z', + updatedAt: '2025-05-10T08:00:00Z', + }, + { + id: 'review-002', + matchId: 'match-002', + needId: 'need-001', + propertyId: 'prop-004', + matchScore: 64, + priority: 'MEDIUM', + status: 'PENDING', + dueAt: '2025-06-20T17:00:00Z', + organizationId: 'org-wincasa', + createdAt: '2025-05-10T08:15:00Z', + updatedAt: '2025-05-10T08:15:00Z', + }, + { + id: 'review-003', + matchId: 'match-004', + needId: 'need-002', + propertyId: 'prop-006', + matchScore: 47, + priority: 'LOW', + status: 'PENDING', + organizationId: 'org-wincasa', + createdAt: '2025-05-11T09:00:00Z', + updatedAt: '2025-05-11T09:00:00Z', + }, +] diff --git a/src/mock-data/shortlists.ts b/src/mock-data/shortlists.ts new file mode 100644 index 0000000..f236cf4 --- /dev/null +++ b/src/mock-data/shortlists.ts @@ -0,0 +1,35 @@ +import type { Shortlist } from '../domain/shortlist' +import { ShortlistStatus } from '../domain/enums' + +export const mockShortlists: Shortlist[] = [ + { + id: 'shortlist-001', + title: 'Top Büroflächen Zürich', + description: 'Beste Bürooptionen für Innovatech AG', + needId: 'need-001', + items: [ + { propertyId: 'prop-001', addedAt: '2025-03-01T10:00:00Z', note: 'Erste Wahl' }, + { propertyId: 'prop-004', addedAt: '2025-03-02T14:30:00Z', note: 'Interessante Alternative' }, + ], + status: ShortlistStatus.ACTIVE, + createdBy: 'user-001', + organizationId: 'org-wincasa', + createdAt: '2025-03-01T10:00:00Z', + updatedAt: '2025-03-02T14:30:00Z', + }, + { + id: 'shortlist-002', + title: 'Logistik Optionen Basel', + description: 'Auswahl für Schweizer Logistik GmbH', + needId: 'need-002', + items: [ + { propertyId: 'prop-002', addedAt: '2025-03-05T09:00:00Z', note: 'Perfekte Grösse' }, + ], + status: ShortlistStatus.SHARED, + createdBy: 'user-001', + organizationId: 'org-wincasa', + sharedWith: ['client@schweizer-logistik.ch'], + createdAt: '2025-03-05T09:00:00Z', + updatedAt: '2025-03-06T11:00:00Z', + }, +] diff --git a/src/provider/IDashboardProvider.ts b/src/provider/IDashboardProvider.ts new file mode 100644 index 0000000..c08ad70 --- /dev/null +++ b/src/provider/IDashboardProvider.ts @@ -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 +} diff --git a/src/provider/IReviewProvider.ts b/src/provider/IReviewProvider.ts new file mode 100644 index 0000000..944b479 --- /dev/null +++ b/src/provider/IReviewProvider.ts @@ -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 + getById(id: string): Promise + approve(id: string, reviewedBy: string, notes?: string): Promise + reject(id: string, reviewedBy: string, notes?: string): Promise + assign(id: string, assignTo: string): Promise +} diff --git a/src/provider/IShortlistProvider.ts b/src/provider/IShortlistProvider.ts new file mode 100644 index 0000000..21181fb --- /dev/null +++ b/src/provider/IShortlistProvider.ts @@ -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 + getById(id: string): Promise + create(data: CreateShortlistInput): Promise + update(id: string, data: UpdateShortlistInput): Promise + addItem(id: string, propertyId: string, note?: string): Promise + removeItem(id: string, propertyId: string): Promise + remove(id: string): Promise +} diff --git a/src/provider/MockupDashboardProvider.ts b/src/provider/MockupDashboardProvider.ts new file mode 100644 index 0000000..85b4ba4 --- /dev/null +++ b/src/provider/MockupDashboardProvider.ts @@ -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 + }, +} diff --git a/src/provider/MockupReviewProvider.ts b/src/provider/MockupReviewProvider.ts new file mode 100644 index 0000000..6decb42 --- /dev/null +++ b/src/provider/MockupReviewProvider.ts @@ -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 = { 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] + }, +} diff --git a/src/provider/MockupShortlistProvider.ts b/src/provider/MockupShortlistProvider.ts new file mode 100644 index 0000000..d26644c --- /dev/null +++ b/src/provider/MockupShortlistProvider.ts @@ -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) + }, +} diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 0fb8867..53db6c4 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -1,4 +1,5 @@ -import type { ItemResponse } from './types' +import type { ItemResponse, ServiceError } from './types' +import { ServiceErrorCode } from './types' import type { CreateNeedInput } from '../domain/need' export interface CriteriaExtractionResult { @@ -45,6 +46,21 @@ const MockupAIServiceProvider: AIServiceProvider = { const provider = MockupAIServiceProvider +const notConfiguredError = (): ServiceError => ({ + code: ServiceErrorCode.AI_GENERATION_FAILED, + message: 'OpenRouter nicht konfiguriert', +}) + +// Stub — swap for real OpenRouter implementation without changing call sites +export const openRouterAIService: AIServiceProvider = { + async extractCriteria(_input: string): Promise { + throw notConfiguredError() + }, + async generateFollowUp(_partialNeed: Partial): Promise { + throw notConfiguredError() + }, +} + export const aiService = { async extractCriteria(input: string): Promise> { const data = await provider.extractCriteria(input) diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..cf48305 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,31 @@ +import { useSessionStore } from '../stores/sessionStore' +import type { MockUser } from '../stores/sessionStore' +import type { ItemResponse } from './types' +import { UserRole } from '../domain/enums' + +export const authService = { + async getCurrentUser(): Promise> { + const data = useSessionStore.getState().currentUser + return { data } + }, + async login(email: string, _password: string): Promise> { + const user: MockUser = { + id: 'user-001', + email, + name: 'Admin User', + role: UserRole.ORGANIZATION_ADMIN, + organizationId: 'org-wincasa', + organizationName: 'Wincasa AG', + } + useSessionStore.getState().login(user) + return { data: user } + }, + async logout(): Promise> { + useSessionStore.getState().logout() + return { data: undefined } + }, + async isAuthenticated(): Promise> { + const data = useSessionStore.getState().isAuthenticated + return { data } + }, +} diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts new file mode 100644 index 0000000..33a3f81 --- /dev/null +++ b/src/services/dashboardService.ts @@ -0,0 +1,12 @@ +import { MockupDashboardProvider } from '../provider/MockupDashboardProvider' +import type { DashboardStats } from '../provider/IDashboardProvider' +import type { ItemResponse } from './types' + +const provider = MockupDashboardProvider + +export const dashboardService = { + async getStats(organizationId?: string): Promise> { + const data = await provider.getStats(organizationId) + return { data } + }, +} diff --git a/src/services/reviewService.ts b/src/services/reviewService.ts new file mode 100644 index 0000000..4b7734e --- /dev/null +++ b/src/services/reviewService.ts @@ -0,0 +1,29 @@ +import { MockupReviewProvider } from '../provider/MockupReviewProvider' +import type { ReviewFilters } from '../provider/IReviewProvider' +import type { ReviewQueueItem } from '../domain/review' +import type { ListResponse, ItemResponse } from './types' + +const provider = MockupReviewProvider + +export const reviewService = { + async getQueue(filters?: ReviewFilters): Promise> { + const data = await provider.getQueue(filters) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + }, + async getById(id: string): Promise> { + const data = await provider.getById(id) + return { data } + }, + async approve(id: string, reviewedBy: string, notes?: string): Promise> { + const data = await provider.approve(id, reviewedBy, notes) + return { data } + }, + async reject(id: string, reviewedBy: string, notes?: string): Promise> { + const data = await provider.reject(id, reviewedBy, notes) + return { data } + }, + async assign(id: string, assignTo: string): Promise> { + const data = await provider.assign(id, assignTo) + return { data } + }, +} diff --git a/src/services/shortlistService.ts b/src/services/shortlistService.ts new file mode 100644 index 0000000..734a034 --- /dev/null +++ b/src/services/shortlistService.ts @@ -0,0 +1,37 @@ +import { MockupShortlistProvider } from '../provider/MockupShortlistProvider' +import type { ShortlistFilters } from '../provider/IShortlistProvider' +import type { Shortlist, CreateShortlistInput, UpdateShortlistInput } from '../domain/shortlist' +import type { ListResponse, ItemResponse } from './types' + +const provider = MockupShortlistProvider + +export const shortlistService = { + async getAll(filters?: ShortlistFilters): Promise> { + const data = await provider.getAll(filters) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + }, + async getById(id: string): Promise> { + const data = await provider.getById(id) + return { data } + }, + async create(input: CreateShortlistInput): Promise> { + const data = await provider.create(input) + return { data } + }, + async update(id: string, input: UpdateShortlistInput): Promise> { + const data = await provider.update(id, input) + return { data } + }, + async addItem(id: string, propertyId: string, note?: string): Promise> { + const data = await provider.addItem(id, propertyId, note) + return { data } + }, + async removeItem(id: string, propertyId: string): Promise> { + const data = await provider.removeItem(id, propertyId) + return { data } + }, + async remove(id: string): Promise> { + await provider.remove(id) + return { data: undefined } + }, +} diff --git a/src/services/types.ts b/src/services/types.ts index 62a2ea8..3a9115c 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -1,14 +1,41 @@ -export interface ServiceMeta { +// ── Error Codes ─────────────────────────────────────────────────────────────── + +export const ServiceErrorCode = { + NETWORK_ERROR: 'network_error', + UNAUTHORIZED: 'unauthorized', + FORBIDDEN: 'forbidden', + VALIDATION_ERROR: 'validation_error', + NOT_FOUND: 'not_found', + AI_GENERATION_FAILED: 'ai_generation_failed', + BACKEND_UNAVAILABLE: 'backend_unavailable', +} as const +export type ServiceErrorCode = typeof ServiceErrorCode[keyof typeof ServiceErrorCode] + +export interface ServiceError { + code: ServiceErrorCode + message: string + details?: unknown +} + +// ── Pagination ──────────────────────────────────────────────────────────────── + +export interface Pagination { total: number page: number pageSize: number hasMore: boolean } +/** @deprecated Use Pagination */ +export type ServiceMeta = Pagination + +// ── Response Shapes ─────────────────────────────────────────────────────────── + export interface ServiceResponse { data: T - meta?: ServiceMeta - error?: string | null + meta?: Pagination + pagination?: Pagination + error?: ServiceError | string | null } export type ListResponse = ServiceResponse