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
+2
View File
@@ -6,3 +6,5 @@ export * from './futureSignal'
export * from './aiOutput'
export * from './activityEvent'
export * from './unifiedResult'
export * from './shortlist'
export * from './review'
+29
View File
@@ -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
}
+24
View File
@@ -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<Shortlist, 'id' | 'createdAt' | 'updatedAt'>
export type UpdateShortlistInput = Partial<CreateShortlistInput>
+5 -3
View File
@@ -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'
+10
View File
@@ -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,
})
}
+2
View File
@@ -17,3 +17,5 @@ export function useNeed(id: string) {
select: (res) => res.data ?? null,
})
}
export const useNeedProfiles = useNeeds
+2
View File
@@ -30,3 +30,5 @@ export function useProperty(id: string) {
select: (res) => res.data ?? null,
})
}
export const usePropertyDetail = useProperty
+36
View File
@@ -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'] })
},
})
}
+79
View File
@@ -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 }
}
+1
View File
@@ -0,0 +1 @@
export const mockDelay = (ms = 150) => new Promise<void>(res => setTimeout(res, ms))
+2
View File
@@ -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'
+43
View File
@@ -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',
},
]
+35
View File
@@ -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',
},
]
+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)
},
}
+17 -1
View File
@@ -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<CriteriaExtractionResult> {
throw notConfiguredError()
},
async generateFollowUp(_partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
throw notConfiguredError()
},
}
export const aiService = {
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
const data = await provider.extractCriteria(input)
+31
View File
@@ -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<ItemResponse<MockUser | null>> {
const data = useSessionStore.getState().currentUser
return { data }
},
async login(email: string, _password: string): Promise<ItemResponse<MockUser>> {
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<ItemResponse<void>> {
useSessionStore.getState().logout()
return { data: undefined }
},
async isAuthenticated(): Promise<ItemResponse<boolean>> {
const data = useSessionStore.getState().isAuthenticated
return { data }
},
}
+12
View File
@@ -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<ItemResponse<DashboardStats>> {
const data = await provider.getStats(organizationId)
return { data }
},
}
+29
View File
@@ -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<ListResponse<ReviewQueueItem>> {
const data = await provider.getQueue(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<ReviewQueueItem | null>> {
const data = await provider.getById(id)
return { data }
},
async approve(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewQueueItem>> {
const data = await provider.approve(id, reviewedBy, notes)
return { data }
},
async reject(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewQueueItem>> {
const data = await provider.reject(id, reviewedBy, notes)
return { data }
},
async assign(id: string, assignTo: string): Promise<ItemResponse<ReviewQueueItem>> {
const data = await provider.assign(id, assignTo)
return { data }
},
}
+37
View File
@@ -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<ListResponse<Shortlist>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Shortlist | null>> {
const data = await provider.getById(id)
return { data }
},
async create(input: CreateShortlistInput): Promise<ItemResponse<Shortlist>> {
const data = await provider.create(input)
return { data }
},
async update(id: string, input: UpdateShortlistInput): Promise<ItemResponse<Shortlist>> {
const data = await provider.update(id, input)
return { data }
},
async addItem(id: string, propertyId: string, note?: string): Promise<ItemResponse<Shortlist>> {
const data = await provider.addItem(id, propertyId, note)
return { data }
},
async removeItem(id: string, propertyId: string): Promise<ItemResponse<Shortlist>> {
const data = await provider.removeItem(id, propertyId)
return { data }
},
async remove(id: string): Promise<ItemResponse<void>> {
await provider.remove(id)
return { data: undefined }
},
}
+30 -3
View File
@@ -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<T> {
data: T
meta?: ServiceMeta
error?: string | null
meta?: Pagination
pagination?: Pagination
error?: ServiceError | string | null
}
export type ListResponse<T> = ServiceResponse<T[]>