feat(messages): unified conversation inbox — connect demand ↔ supply, offers reach the seeker
Phase 1 of the messaging rework: one conversation/thread model (Inquiry) is the single source of truth, read perspectivally by both sides. - domain/inquiry: add kind (INQUIRY|OFFER), tenantOrgId (demand org), offeredPropertyIds - provider/service: InquiryFilters gains tenantOrgId+kind; new createInquiry (demand→supply) & createOffer (supply→demand) materialize threads; addMessage bumps unread; reply carries senderType (tenant vs supply_user) - hooks: useCreateInquiry, useCreateOffer; reply payload carries sender perspective - demand: InquiryQuickDialog → createInquiry via provider (session-based tenant/org, no hardcode); Anfragen.tsx reads via React Query (tenantOrgId) with Gesendet/Erhalten tabs, replies via provider, shows offered properties for OFFER threads; inquiryStore reduced to dialog-only (removes Zustand server-data island) - supply: OfferChatComposer send now materializes an OFFER conversation into the seeker's inbox (routed via latentNeed.tenantOrgId); Anfragencenter gains a "Gesendet" tab; ActiveInquiriesTab filters by perspective (org + kind) - seed: merge demand inquiries with tenantOrgId=org-mobimo + 2 OFFER seeds; provider seeds from both sets Closes the loop: a sent inquiry reaches the Verwaltung and a sent offer reaches the Nachfrager — both answerable in one thread. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
6206447dae
commit
a8b54af0b8
13 files changed
+349
-110
No files matched your search
@@ -1,17 +1,50 @@
|
||||
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
|
||||
import type { Inquiry, InquiryStatus, InquiryKind, InquiryMessage, Attachment } from '../domain/inquiry'
|
||||
|
||||
export interface InquiryFilters {
|
||||
status?: InquiryStatus
|
||||
propertyId?: string
|
||||
organizationId?: string
|
||||
organizationId?: string // Supply-Perspektive
|
||||
tenantOrgId?: string // Demand-Perspektive
|
||||
kind?: InquiryKind
|
||||
}
|
||||
|
||||
/** Demand→Supply: neue Anfrage zu einem Objekt. */
|
||||
export interface CreateInquiryInput {
|
||||
organizationId: string // Supply-Org (Objekt-Eigentümer)
|
||||
tenantOrgId: string // Demand-Org (Absender)
|
||||
propertyId: string
|
||||
needId?: string
|
||||
tenantName: string
|
||||
tenantCompany?: string
|
||||
tenantEmail?: string
|
||||
propertyAddress?: string
|
||||
subject: string
|
||||
message: string
|
||||
matchScore?: number
|
||||
}
|
||||
|
||||
/** Supply→Demand: Angebot zu einem latenten Bedarf. */
|
||||
export interface CreateOfferInput {
|
||||
organizationId: string // Supply-Org (Absender)
|
||||
tenantOrgId: string // Demand-Org (Empfänger, aus need.organizationId)
|
||||
needId: string
|
||||
propertyId: string // primäres Objekt (Kompatibilität)
|
||||
offeredPropertyIds: string[]
|
||||
subject: string
|
||||
message: string
|
||||
senderName: string // Verwaltungs-/Org-Name
|
||||
recipientName?: string
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
export interface IInquiryProvider {
|
||||
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
|
||||
getById(id: string): Promise<Inquiry | null>
|
||||
createInquiry(input: CreateInquiryInput): Promise<Inquiry>
|
||||
createOffer(input: CreateOfferInput): Promise<Inquiry>
|
||||
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
|
||||
markThreadAsRead(id: string): Promise<Inquiry>
|
||||
getUnreadCount(): Promise<number>
|
||||
getUnreadCount(filters?: InquiryFilters): Promise<number>
|
||||
addMessage(
|
||||
inquiryId: string,
|
||||
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
|
||||
|
||||
@@ -1,20 +1,99 @@
|
||||
import type { IInquiryProvider, InquiryFilters } from './IInquiryProvider'
|
||||
import type { IInquiryProvider, InquiryFilters, CreateInquiryInput, CreateOfferInput } from './IInquiryProvider'
|
||||
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
|
||||
import { mockInquiries } from '../mock-data/inquiries'
|
||||
import { mockDemandInquiries } from '../mock-data/demandInquiries'
|
||||
|
||||
const store: Inquiry[] = mockInquiries.map(i => ({ ...i, thread: [...i.thread] }))
|
||||
// Eine gemeinsame Konversationsmenge: Supply-Eingang (mockInquiries) + Demand-Perspektive
|
||||
// (mockDemandInquiries, inkl. erhaltener Angebote). Beide Seiten lesen perspektivisch gefiltert.
|
||||
const store: Inquiry[] = [...mockInquiries, ...mockDemandInquiries].map(i => ({ ...i, thread: [...i.thread] }))
|
||||
|
||||
function applyFilters(results: Inquiry[], filters?: InquiryFilters): Inquiry[] {
|
||||
let out = results
|
||||
if (filters?.status) out = out.filter(i => i.status === filters.status)
|
||||
if (filters?.propertyId) out = out.filter(i => i.propertyId === filters.propertyId)
|
||||
if (filters?.organizationId) out = out.filter(i => i.organizationId === filters.organizationId)
|
||||
if (filters?.tenantOrgId) out = out.filter(i => i.tenantOrgId === filters.tenantOrgId)
|
||||
if (filters?.kind) out = out.filter(i => (i.kind ?? 'INQUIRY') === filters.kind)
|
||||
return out
|
||||
}
|
||||
|
||||
export const MockupInquiryProvider: IInquiryProvider = {
|
||||
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
|
||||
let results = [...store]
|
||||
if (filters?.status) results = results.filter(i => i.status === filters.status)
|
||||
if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
|
||||
if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
|
||||
return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
return applyFilters([...store], filters).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
|
||||
},
|
||||
async getById(id: string): Promise<Inquiry | null> {
|
||||
return store.find(i => i.id === id) ?? null
|
||||
},
|
||||
async createInquiry(input: CreateInquiryInput): Promise<Inquiry> {
|
||||
const now = new Date().toISOString()
|
||||
const id = `inq-${crypto.randomUUID().slice(0, 8)}`
|
||||
const inquiry: Inquiry = {
|
||||
id,
|
||||
kind: 'INQUIRY',
|
||||
organizationId: input.organizationId,
|
||||
tenantOrgId: input.tenantOrgId,
|
||||
propertyId: input.propertyId,
|
||||
needId: input.needId,
|
||||
tenantName: input.tenantName,
|
||||
tenantCompany: input.tenantCompany,
|
||||
tenantEmail: input.tenantEmail,
|
||||
propertyAddress: input.propertyAddress,
|
||||
subject: input.subject,
|
||||
message: input.message,
|
||||
status: 'new',
|
||||
unreadCount: 1, // neu für die Verwaltung (Empfänger)
|
||||
isRead: false,
|
||||
matchScore: input.matchScore,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
thread: [{
|
||||
id: crypto.randomUUID(),
|
||||
inquiryId: id,
|
||||
senderType: 'tenant',
|
||||
senderName: input.tenantCompany ?? input.tenantName,
|
||||
subject: input.subject,
|
||||
body: input.message,
|
||||
attachments: [],
|
||||
createdAt: now,
|
||||
}],
|
||||
}
|
||||
store.unshift(inquiry)
|
||||
return inquiry
|
||||
},
|
||||
async createOffer(input: CreateOfferInput): Promise<Inquiry> {
|
||||
const now = new Date().toISOString()
|
||||
const id = `off-${crypto.randomUUID().slice(0, 8)}`
|
||||
const inquiry: Inquiry = {
|
||||
id,
|
||||
kind: 'OFFER',
|
||||
organizationId: input.organizationId,
|
||||
tenantOrgId: input.tenantOrgId,
|
||||
propertyId: input.propertyId,
|
||||
offeredPropertyIds: input.offeredPropertyIds,
|
||||
needId: input.needId,
|
||||
tenantName: input.recipientName ?? '',
|
||||
propertyManagerCompany: input.senderName,
|
||||
subject: input.subject,
|
||||
message: input.message,
|
||||
status: 'new',
|
||||
unreadCount: 1, // neu für den Nachfrager (Empfänger)
|
||||
isRead: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
thread: [{
|
||||
id: crypto.randomUUID(),
|
||||
inquiryId: id,
|
||||
senderType: 'supply_user',
|
||||
senderName: input.senderName,
|
||||
subject: input.subject,
|
||||
body: input.message,
|
||||
attachments: input.attachments ?? [],
|
||||
createdAt: now,
|
||||
}],
|
||||
}
|
||||
store.unshift(inquiry)
|
||||
return inquiry
|
||||
},
|
||||
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
|
||||
const idx = store.findIndex(i => i.id === id)
|
||||
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
|
||||
@@ -33,8 +112,8 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
||||
}
|
||||
return store[idx]
|
||||
},
|
||||
async getUnreadCount(): Promise<number> {
|
||||
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||
async getUnreadCount(filters?: InquiryFilters): Promise<number> {
|
||||
return applyFilters([...store], filters).reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
|
||||
},
|
||||
async addMessage(
|
||||
inquiryId: string,
|
||||
@@ -51,6 +130,10 @@ export const MockupInquiryProvider: IInquiryProvider = {
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
thread: [...store[idx].thread, message],
|
||||
// Neue Nachricht der Gegenseite → für den Empfänger ungelesen
|
||||
unreadCount: (store[idx].unreadCount ?? 0) + 1,
|
||||
isRead: false,
|
||||
status: store[idx].status === 'new' ? 'in_progress' : store[idx].status,
|
||||
updatedAt: message.createdAt,
|
||||
}
|
||||
return store[idx]
|
||||
|
||||
Reference in new issue
Block a user