From 5e1631e2d3f85c6c23f64d5b08c658c566ed21cd Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 21:50:31 +0200 Subject: [PATCH 1/7] =?UTF-8?q?KI-AGENT:=20Mobile=20Kundenerfassung=20und?= =?UTF-8?q?=20Logb=C3=BCcher=20erweitern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/history.ts | 5 +- mobile/app/more/customer/[id].tsx | 13 +- mobile/app/more/customers.tsx | 320 ++++++++++++++++++++++++-- mobile/app/more/plant/[id].tsx | 3 + mobile/app/project/[id].tsx | 3 + mobile/components/history-section.tsx | 246 ++++++++++++++++++++ mobile/src/lib/api.ts | 58 +++++ 7 files changed, 621 insertions(+), 27 deletions(-) create mode 100644 mobile/components/history-section.tsx diff --git a/backend/src/routes/history.ts b/backend/src/routes/history.ts index 0e74798..829f247 100644 --- a/backend/src/routes/history.ts +++ b/backend/src/routes/history.ts @@ -127,7 +127,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) { const data = await server.db .select() .from(historyitems) - .where(eq(column, parseId(id))) + .where(and( + eq(historyitems.tenant, req.user?.tenant_id), + eq(column, parseId(id)) + )) .orderBy(asc(historyitems.createdAt)); const userIds = Array.from( diff --git a/mobile/app/more/customer/[id].tsx b/mobile/app/more/customer/[id].tsx index 1349a8f..5d0a1fe 100644 --- a/mobile/app/more/customer/[id].tsx +++ b/mobile/app/more/customer/[id].tsx @@ -17,6 +17,7 @@ import * as DocumentPicker from 'expo-document-picker'; import * as WebBrowser from 'expo-web-browser'; import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera'; +import { HistorySection } from '@/components/history-section'; import { createCustomerInventoryItem, CreatedDocument, @@ -117,10 +118,18 @@ export default function CustomerDetailScreen() { return [ { label: 'Name', value: String(customer.name || '-') }, { label: 'Kundennummer', value: String(customer.customerNumber || '-') }, - { label: 'Typ', value: String(customer.type || '-') }, + { label: 'Kundentyp', value: customer.isCompany ? 'Firma' : 'Privat' }, + { label: 'Namenszusatz', value: String(customer.nameAddition || '-') }, { label: 'E-Mail', value: String(infoData.email || customer.email || '-') }, + { label: 'Rechnungs-E-Mail', value: String(infoData.invoiceEmail || '-') }, { label: 'Telefon', value: String(infoData.tel || customer.phone || '-') }, { label: 'Mobilnummer', value: String(infoData.mobileTel || '-') }, + { label: 'Straße', value: String(infoData.street || '-') }, + { label: 'Adresszusatz', value: String(infoData.special || '-') }, + { label: 'PLZ / Stadt', value: [infoData.zip, infoData.city].filter(Boolean).join(' ') || '-' }, + { label: 'Land', value: String(infoData.country || '-') }, + { label: 'Webseite', value: String(infoData.web || '-') }, + { label: 'USt-Id', value: String(infoData.ustid || '-') }, { label: 'Erstellt', value: formatDateTime(customer.createdAt || customer.created_at) }, { label: 'Aktualisiert', value: formatDateTime(customer.updatedAt || customer.updated_at) }, ]; @@ -373,6 +382,8 @@ export default function CustomerDetailScreen() { ) : null} + + Kundeninventar ({inventoryItems.length}) diff --git a/mobile/app/more/customers.tsx b/mobile/app/more/customers.tsx index b6a2893..90b34ff 100644 --- a/mobile/app/more/customers.tsx +++ b/mobile/app/more/customers.tsx @@ -34,8 +34,25 @@ export default function CustomersScreen() { const [createOpen, setCreateOpen] = useState(false); const [saving, setSaving] = useState(false); const [createError, setCreateError] = useState(null); + const [isCompanyInput, setIsCompanyInput] = useState(false); const [nameInput, setNameInput] = useState(''); + const [nameAdditionInput, setNameAdditionInput] = useState(''); + const [salutationInput, setSalutationInput] = useState(''); + const [titleInput, setTitleInput] = useState(''); + const [firstnameInput, setFirstnameInput] = useState(''); + const [lastnameInput, setLastnameInput] = useState(''); const [numberInput, setNumberInput] = useState(''); + const [streetInput, setStreetInput] = useState(''); + const [addressAdditionInput, setAddressAdditionInput] = useState(''); + const [zipInput, setZipInput] = useState(''); + const [cityInput, setCityInput] = useState(''); + const [countryInput, setCountryInput] = useState('Deutschland'); + const [phoneInput, setPhoneInput] = useState(''); + const [mobileInput, setMobileInput] = useState(''); + const [emailInput, setEmailInput] = useState(''); + const [invoiceEmailInput, setInvoiceEmailInput] = useState(''); + const [websiteInput, setWebsiteInput] = useState(''); + const [vatIdInput, setVatIdInput] = useState(''); const [notesInput, setNotesInput] = useState(''); const filtered = useMemo( @@ -72,17 +89,36 @@ export default function CustomersScreen() { function closeCreateModal() { setCreateOpen(false); setCreateError(null); + setIsCompanyInput(false); setNameInput(''); + setNameAdditionInput(''); + setSalutationInput(''); + setTitleInput(''); + setFirstnameInput(''); + setLastnameInput(''); setNumberInput(''); + setStreetInput(''); + setAddressAdditionInput(''); + setZipInput(''); + setCityInput(''); + setCountryInput('Deutschland'); + setPhoneInput(''); + setMobileInput(''); + setEmailInput(''); + setInvoiceEmailInput(''); + setWebsiteInput(''); + setVatIdInput(''); setNotesInput(''); } async function onCreateCustomer() { if (!token) return; - const name = nameInput.trim(); + const name = isCompanyInput + ? nameInput.trim() + : [salutationInput, titleInput, firstnameInput, lastnameInput].map((value) => value.trim()).filter(Boolean).join(' '); if (!name) { - setCreateError('Bitte einen Namen eingeben.'); + setCreateError(isCompanyInput ? 'Bitte einen Firmennamen eingeben.' : 'Bitte mindestens Vor- oder Nachname eingeben.'); return; } @@ -93,6 +129,25 @@ export default function CustomersScreen() { await createCustomer(token, { name, customerNumber: numberInput.trim() || null, + isCompany: isCompanyInput, + nameAddition: nameAdditionInput.trim() || null, + salutation: salutationInput.trim() || null, + title: titleInput.trim() || null, + firstname: firstnameInput.trim() || null, + lastname: lastnameInput.trim() || null, + infoData: { + street: streetInput.trim() || null, + special: addressAdditionInput.trim() || null, + zip: zipInput.trim() || null, + city: cityInput.trim() || null, + country: countryInput.trim() || null, + tel: phoneInput.trim() || null, + mobileTel: mobileInput.trim() || null, + email: emailInput.trim() || null, + invoiceEmail: invoiceEmailInput.trim() || null, + web: websiteInput.trim() || null, + ustid: vatIdInput.trim() || null, + }, notes: notesInput.trim() || null, }); closeCreateModal(); @@ -168,30 +223,187 @@ export default function CustomersScreen() { Neuer Kunde - - - + + Kundentyp + + setIsCompanyInput(false)}> + Privat + + setIsCompanyInput(true)}> + Firma + + - {createError ? {createError} : null} + Allgemeines + {isCompanyInput ? ( + <> + + + + ) : ( + <> + + + + + + + + )} + + + Adresse + + + + + + + + + Kontaktdaten + + + + + + + + + {createError ? {createError} : null} + @@ -313,6 +525,7 @@ const styles = StyleSheet.create({ borderRadius: 14, padding: 16, gap: 10, + maxHeight: '92%', }, modalTitle: { color: '#111827', @@ -323,6 +536,63 @@ const styles = StyleSheet.create({ minHeight: 92, textAlignVertical: 'top', }, + modalScroll: { + flexShrink: 1, + }, + modalForm: { + gap: 10, + paddingBottom: 4, + }, + fieldLabel: { + color: '#374151', + fontSize: 13, + fontWeight: '600', + }, + sectionLabel: { + color: '#111827', + fontSize: 14, + fontWeight: '700', + marginTop: 6, + }, + segmentedControl: { + flexDirection: 'row', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + padding: 3, + gap: 3, + }, + segment: { + flex: 1, + minHeight: 36, + borderRadius: 7, + alignItems: 'center', + justifyContent: 'center', + }, + segmentActive: { + backgroundColor: '#eff9ea', + }, + segmentText: { + color: '#6b7280', + fontWeight: '600', + }, + segmentTextActive: { + color: '#3d7a30', + }, + inputRow: { + flexDirection: 'row', + gap: 8, + }, + flexInput: { + flex: 1, + }, + zipInput: { + width: 105, + }, + formError: { + color: '#dc2626', + fontSize: 13, + }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/app/more/plant/[id].tsx b/mobile/app/more/plant/[id].tsx index d47a37c..243fb2f 100644 --- a/mobile/app/more/plant/[id].tsx +++ b/mobile/app/more/plant/[id].tsx @@ -5,6 +5,7 @@ import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import * as WebBrowser from 'expo-web-browser'; +import { HistorySection } from '@/components/history-section'; import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; @@ -233,6 +234,8 @@ export default function PlantDetailScreen() { + + Dokumente ({files.length}) diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index 77fddc3..d5e5e4b 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -17,6 +17,7 @@ import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import * as WebBrowser from 'expo-web-browser'; +import { HistorySection } from '@/components/history-section'; import { createProjectTask, fetchProjectById, @@ -366,6 +367,8 @@ export default function ProjectDetailScreen() { ) : null} + + Aufgaben ({tasks.length}) diff --git a/mobile/components/history-section.tsx b/mobile/components/history-section.tsx new file mode 100644 index 0000000..c3a735e --- /dev/null +++ b/mobile/components/history-section.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +import { + createResourceHistoryItem, + fetchResourceHistory, + HistoryItem, +} from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +type HistorySectionProps = { + resource: string; + resourceId: number | string; +}; + +function formatDateTime(value: unknown): string { + if (!value) return '-'; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getAuthor(item: HistoryItem): string { + if (!item.created_by && !item.createdBy) return 'FEDEO Bot'; + return String( + item.created_by_profile?.full_name || + item.created_by_profile?.fullName || + item.created_by_profile?.email || + 'Benutzer' + ); +} + +export function HistorySection({ resource, resourceId }: HistorySectionProps) { + const { token } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [formError, setFormError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [text, setText] = useState(''); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + if (!token || !resource || !resourceId) return; + setLoading(true); + setLoadError(null); + try { + const rows = await fetchResourceHistory(token, resource, resourceId); + setItems((rows || []).slice().reverse()); + } catch (err) { + setLoadError(err instanceof Error ? err.message : 'Das Logbuch konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [resource, resourceId, token]); + + useEffect(() => { + void load(); + }, [load]); + + function closeModal() { + if (saving) return; + setModalOpen(false); + setText(''); + setFormError(null); + } + + async function addItem() { + if (!token || saving) return; + const value = text.trim(); + if (!value) { + setFormError('Bitte einen Text für den Logbucheintrag eingeben.'); + return; + } + + setSaving(true); + setFormError(null); + try { + await createResourceHistoryItem(token, resource, resourceId, value); + setModalOpen(false); + setText(''); + await load(); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Der Logbucheintrag konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + } + + return ( + + + Logbuch ({items.length}) + setModalOpen(true)}> + + Eintrag + + + + {loading ? : null} + {!loading && loadError ? {loadError} : null} + {!loading && !loadError && items.length === 0 ? ( + Noch keine Logbucheinträge vorhanden. + ) : null} + + {!loading && !loadError + ? items.map((item) => ( + + {getAuthor(item)} + {item.text} + {formatDateTime(item.created_at || item.createdAt)} + + )) + : null} + + + + + + Logbucheintrag hinzufügen + + {formError ? {formError} : null} + + + Abbrechen + + + {saving ? 'Speichere...' : 'Speichern'} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#ffffff', + borderRadius: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 12, + gap: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 10, + }, + title: { color: '#111827', fontSize: 16, fontWeight: '700' }, + addButton: { + minHeight: 36, + borderRadius: 8, + backgroundColor: PRIMARY, + paddingHorizontal: 12, + alignItems: 'center', + justifyContent: 'center', + }, + addButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' }, + item: { + borderTopWidth: 1, + borderTopColor: '#e5e7eb', + paddingTop: 9, + gap: 2, + }, + itemAuthor: { color: '#111827', fontSize: 13, fontWeight: '700' }, + itemText: { color: '#374151', fontSize: 14, lineHeight: 20 }, + itemDate: { color: '#6b7280', fontSize: 12 }, + empty: { color: '#6b7280', fontSize: 13, paddingVertical: 4 }, + error: { color: '#dc2626', fontSize: 13 }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(17, 24, 39, 0.45)', + justifyContent: 'center', + padding: 20, + }, + modalKeyboardWrap: { width: '100%' }, + modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 12 }, + modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' }, + input: { + minHeight: 120, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + color: '#111827', + fontSize: 15, + textAlignVertical: 'top', + }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 }, + secondaryButton: { + minHeight: 40, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { color: '#374151', fontWeight: '600' }, + primaryButton: { + minHeight: 40, + borderRadius: 10, + backgroundColor: PRIMARY, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { color: '#ffffff', fontWeight: '700' }, + disabled: { opacity: 0.6 }, +}); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index ecd20a0..c312109 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -76,8 +76,32 @@ export type Customer = { archived?: boolean; infoData?: { email?: string | null; + invoiceEmail?: string | null; tel?: string | null; mobileTel?: string | null; + street?: string | null; + special?: string | null; + zip?: string | null; + city?: string | null; + country?: string | null; + web?: string | null; + ustid?: string | null; + [key: string]: unknown; + } | null; + [key: string]: unknown; +}; + +export type HistoryItem = { + id: number; + text: string; + created_at?: string | null; + createdAt?: string | null; + created_by?: string | null; + createdBy?: string | null; + created_by_profile?: { + full_name?: string | null; + fullName?: string | null; + email?: string | null; [key: string]: unknown; } | null; [key: string]: unknown; @@ -874,6 +898,13 @@ export async function createCustomer( name: string; customerNumber?: string | null; notes?: string | null; + isCompany?: boolean; + salutation?: string | null; + title?: string | null; + firstname?: string | null; + lastname?: string | null; + nameAddition?: string | null; + infoData?: Customer['infoData']; } ): Promise { return apiRequest('/api/resource/customers', { @@ -887,6 +918,33 @@ export async function fetchCustomerById(token: string, customerId: number): Prom return apiRequest(`/api/resource/customers/${customerId}`, { token }); } +export async function fetchResourceHistory( + token: string, + resource: string, + resourceId: number | string +): Promise { + return apiRequest( + `/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`, + { token } + ); +} + +export async function createResourceHistoryItem( + token: string, + resource: string, + resourceId: number | string, + text: string +): Promise { + return apiRequest( + `/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`, + { + method: 'POST', + token, + body: { text }, + } + ); +} + function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null { const rawCustomer = item.customer; if (!rawCustomer) return null; From 92c354c1b96438189910114b172493038cb2efe2 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:05:18 +0200 Subject: [PATCH 2/7] =?UTF-8?q?KI-AGENT:=20Mobile=20Termine=20und=20Kalend?= =?UTF-8?q?er=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/app/(tabs)/explore.tsx | 12 + mobile/app/_layout.tsx | 27 ++ mobile/app/more/calendar.tsx | 224 ++++++++++++++ mobile/app/more/event/[id].tsx | 137 ++++++++ mobile/app/more/events.tsx | 208 +++++++++++++ mobile/app/project/[id].tsx | 78 ++++- mobile/components/event-create-modal.tsx | 377 +++++++++++++++++++++++ mobile/src/lib/api.ts | 60 ++++ 8 files changed, 1122 insertions(+), 1 deletion(-) create mode 100644 mobile/app/more/calendar.tsx create mode 100644 mobile/app/more/event/[id].tsx create mode 100644 mobile/app/more/events.tsx create mode 100644 mobile/components/event-create-modal.tsx diff --git a/mobile/app/(tabs)/explore.tsx b/mobile/app/(tabs)/explore.tsx index c911d62..94c6742 100644 --- a/mobile/app/(tabs)/explore.tsx +++ b/mobile/app/(tabs)/explore.tsx @@ -32,6 +32,18 @@ const ITEMS = [ subtitle: '', href: '/more/plants', }, + { + key: 'events', + title: 'Termine', + subtitle: 'Termine anzeigen und eintragen', + href: '/more/events', + }, + { + key: 'calendar', + title: 'Kalender', + subtitle: 'Monatsübersicht und Tagesplanung', + href: '/more/calendar', + }, { key: 'inventory', title: 'Kundeninventar', diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 230b103..2140b40 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -109,6 +109,33 @@ export default function RootLayout() { headerTintColor: '#111827', }} /> + + + { + const date = new Date(start); + date.setDate(start.getDate() + index); + return date; + }); +} + +function formatTime(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '-' : date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + +export default function CalendarScreen() { + const { token } = useAuth(); + const today = useMemo(() => startOfDay(new Date()), []); + const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1)); + const [selectedDate, setSelectedDate] = useState(today); + const [events, setEvents] = useState([]); + const [projects, setProjects] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (showSpinner = true) => { + if (!token) return; + if (showSpinner) setLoading(true); + setError(null); + try { + const [eventRows, projectRows] = await Promise.all([fetchEvents(token), fetchProjects(token)]); + setEvents(eventRows); + setProjects(projectRows); + } catch (err) { + setError(err instanceof Error ? err.message : 'Der Kalender konnte nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [token]); + + useEffect(() => { + void load(true); + }, [load]); + + const eventsByDay = useMemo(() => { + const map = new Map(); + events.forEach((event) => { + const start = new Date(event.startDate); + if (Number.isNaN(start.getTime())) return; + const key = dateKey(start); + const rows = map.get(key) || []; + rows.push(event); + map.set(key, rows); + }); + map.forEach((rows) => rows.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())); + return map; + }, [events]); + + const days = useMemo(() => buildCalendarDays(month), [month]); + const selectedEvents = eventsByDay.get(dateKey(selectedDate)) || []; + + function changeMonth(delta: number) { + const next = new Date(month.getFullYear(), month.getMonth() + delta, 1); + setMonth(next); + setSelectedDate(next); + } + + function selectDay(date: Date) { + setSelectedDate(date); + if (date.getMonth() !== month.getMonth() || date.getFullYear() !== month.getFullYear()) { + setMonth(new Date(date.getFullYear(), date.getMonth(), 1)); + } + } + + async function onRefresh() { + setRefreshing(true); + await load(false); + } + + return ( + }> + + changeMonth(-1)}> + { setMonth(new Date(today.getFullYear(), today.getMonth(), 1)); setSelectedDate(today); }}> + {month.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })} + Heute + + changeMonth(1)}> + + + {error ? {error} : null} + {loading ? : null} + + + + {WEEKDAYS.map((weekday) => {weekday})} + + + {days.map((date) => { + const key = dateKey(date); + const dayEvents = eventsByDay.get(key) || []; + const outside = date.getMonth() !== month.getMonth(); + const selected = key === dateKey(selectedDate); + const isToday = key === dateKey(today); + return ( + selectDay(date)}> + + {date.getDate()} + + + {dayEvents.slice(0, 3).map((event) => ( + + ))} + + + ); + })} + + + + + + + {selectedDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })} + {selectedEvents.length} {selectedEvents.length === 1 ? 'Termin' : 'Termine'} + + setCreateOpen(true)}>+ Termin + + {selectedEvents.length === 0 ? Keine Termine an diesem Tag. : null} + {selectedEvents.map((event) => ( + router.push(`/more/event/${event.id}` as any)}> + + + {formatTime(event.startDate)} + {event.name} + {typeof event.project === 'object' && event.project?.name ? {event.project.name} : null} + + + + ))} + + + setCreateOpen(false)} + onCreated={() => load(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: '#f9fafb' }, + container: { padding: 12, gap: 12, paddingBottom: 28 }, + monthHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + navButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: '#ffffff', alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#e5e7eb' }, + navButtonText: { color: '#111827', fontSize: 29, lineHeight: 31 }, + monthTitle: { color: '#111827', fontSize: 18, fontWeight: '700', textAlign: 'center' }, + todayHint: { color: '#3d7a30', fontSize: 11, fontWeight: '600', textAlign: 'center', marginTop: 2 }, + calendarCard: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', overflow: 'hidden' }, + weekRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 7 }, + weekday: { width: '14.2857%', textAlign: 'center', color: '#6b7280', fontSize: 12, fontWeight: '700' }, + daysGrid: { flexDirection: 'row', flexWrap: 'wrap' }, + dayCell: { width: '14.2857%', height: 52, borderRightWidth: 1, borderBottomWidth: 1, borderColor: '#f0f1f2', alignItems: 'center', paddingTop: 6 }, + dayCellSelected: { backgroundColor: '#eff9ea' }, + dayNumber: { color: '#111827', fontSize: 13, fontWeight: '600' }, + dayNumberOutside: { color: '#b6bbc3' }, + dayNumberToday: { color: '#3d7a30', fontWeight: '900' }, + eventDots: { flexDirection: 'row', gap: 2, marginTop: 5 }, + eventDot: { width: 5, height: 5, borderRadius: 3 }, + agendaCard: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 8 }, + agendaHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }, + agendaTitle: { color: '#111827', fontSize: 16, fontWeight: '700' }, + agendaCount: { color: '#6b7280', fontSize: 12, marginTop: 2 }, + addButton: { backgroundColor: PRIMARY, borderRadius: 8, minHeight: 36, paddingHorizontal: 11, alignItems: 'center', justifyContent: 'center' }, + addButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' }, + eventRow: { minHeight: 62, flexDirection: 'row', alignItems: 'stretch', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 9, overflow: 'hidden' }, + colorBar: { width: 5 }, + eventMain: { flex: 1, padding: 9 }, + eventTime: { color: '#3d7a30', fontSize: 12, fontWeight: '700' }, + eventName: { color: '#111827', fontSize: 14, fontWeight: '700' }, + eventProject: { color: '#6b7280', fontSize: 12, marginTop: 2 }, + arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 24, paddingRight: 9 }, + empty: { color: '#6b7280', fontSize: 13, paddingVertical: 8 }, + error: { color: '#dc2626', fontSize: 13 }, + loader: { paddingVertical: 12 }, +}); diff --git a/mobile/app/more/event/[id].tsx b/mobile/app/more/event/[id].tsx new file mode 100644 index 0000000..3146996 --- /dev/null +++ b/mobile/app/more/event/[id].tsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; + +import { HistorySection } from '@/components/history-section'; +import { CalendarEvent, fetchEventById } from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +function formatDateTime(value: unknown): string { + if (!value) return '-'; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString('de-DE', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function relationName(value: unknown): string { + if (!value) return '-'; + if (typeof value === 'object') { + const relation = value as { id?: number | string; name?: string }; + return String(relation.name || relation.id || '-'); + } + return String(value); +} + +export default function EventDetailScreen() { + const params = useLocalSearchParams<{ id?: string }>(); + const eventId = Number(params.id); + const { token } = useAuth(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const validId = useMemo(() => Number.isFinite(eventId) && eventId > 0, [eventId]); + + const load = useCallback(async (showSpinner = true) => { + if (!token || !validId) return; + if (showSpinner) setLoading(true); + setError(null); + try { + setEvent(await fetchEventById(token, eventId)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [eventId, token, validId]); + + useEffect(() => { + void load(true); + }, [load]); + + async function onRefresh() { + setRefreshing(true); + await load(false); + } + + async function openLink() { + if (!event?.link) return; + const raw = String(event.link).trim(); + const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; + await WebBrowser.openBrowserAsync(url, { controlsColor: PRIMARY, showTitle: true }); + } + + const rows = event + ? [ + { label: 'Beginn', value: formatDateTime(event.startDate) }, + { label: 'Ende', value: formatDateTime(event.endDate) }, + { label: 'Projekt', value: relationName(event.project) }, + { label: 'Kunde', value: relationName(event.customer) }, + { label: 'Status', value: String(event.state || '-') }, + { label: 'Wiederholung', value: String(event.repeatInterval || '-') }, + ] + : []; + + return ( + }> + {error ? {error} : null} + {loading ? ( + Termin wird geladen... + ) : null} + {!loading && event ? ( + <> + + + + {event.name} + + + {rows.map((row) => ( + + {row.label} + {row.value} + + ))} + + {event.notes ? Notizen{event.notes} : null} + {event.link ? Link öffnen : null} + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { padding: 16, gap: 12, backgroundColor: '#f9fafb' }, + card: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 10 }, + headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 }, + colorMark: { width: 6, height: 28, borderRadius: 3 }, + title: { flex: 1, color: '#111827', fontSize: 19, fontWeight: '700' }, + table: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, overflow: 'hidden' }, + row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 }, + label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' }, + value: { color: '#111827', fontSize: 14, fontWeight: '500' }, + notesBox: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 10, backgroundColor: '#fafafa', gap: 4 }, + notesLabel: { color: '#6b7280', fontSize: 12, fontWeight: '600', textTransform: 'uppercase' }, + notes: { color: '#374151', fontSize: 14 }, + linkButton: { backgroundColor: PRIMARY, borderRadius: 9, minHeight: 40, alignItems: 'center', justifyContent: 'center' }, + linkButtonText: { color: '#ffffff', fontWeight: '700' }, + loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 }, + loadingText: { color: '#6b7280' }, + error: { color: '#dc2626', fontSize: 13 }, +}); diff --git a/mobile/app/more/events.tsx b/mobile/app/more/events.tsx new file mode 100644 index 0000000..f2c849c --- /dev/null +++ b/mobile/app/more/events.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { router } from 'expo-router'; + +import { EventCreateModal } from '@/components/event-create-modal'; +import { CalendarEvent, fetchEvents, fetchProjects, Project } from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +function formatEventDate(event: CalendarEvent): string { + const start = new Date(event.startDate); + const end = event.endDate ? new Date(event.endDate) : null; + if (Number.isNaN(start.getTime())) return String(event.startDate || '-'); + + const date = start.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); + const startTime = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + const endTime = end && !Number.isNaN(end.getTime()) + ? end.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + : null; + return `${date} · ${startTime}${endTime ? `–${endTime}` : ''}`; +} + +function projectName(event: CalendarEvent): string | null { + if (!event.project) return null; + if (typeof event.project === 'object') return event.project.name || (event.project.id ? `Projekt #${event.project.id}` : null); + return `Projekt #${event.project}`; +} + +export default function EventsScreen() { + const { token } = useAuth(); + const [events, setEvents] = useState([]); + const [projects, setProjects] = useState([]); + const [search, setSearch] = useState(''); + const [showPast, setShowPast] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (showSpinner = true) => { + if (!token) return; + if (showSpinner) setLoading(true); + setError(null); + try { + const [eventRows, projectRows] = await Promise.all([fetchEvents(token), fetchProjects(token)]); + setEvents(eventRows); + setProjects(projectRows); + } catch (err) { + setError(err instanceof Error ? err.message : 'Termine konnten nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [token]); + + useEffect(() => { + void load(true); + }, [load]); + + const visibleEvents = useMemo(() => { + const now = Date.now(); + const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); + return events + .filter((event) => showPast || new Date(event.endDate || event.startDate).getTime() >= now) + .filter((event) => { + if (terms.length === 0) return true; + const haystack = [event.name, event.notes, event.eventtype, projectName(event)] + .map((value) => String(value || '').toLowerCase()) + .join(' '); + return terms.every((term) => haystack.includes(term)); + }) + .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); + }, [events, search, showPast]); + + async function onRefresh() { + setRefreshing(true); + await load(false); + } + + return ( + + + + + setShowPast((value) => !value)}> + Vergangene anzeigen + + router.push('/more/calendar' as any)}> + Kalender + + + + + }> + {error ? {error} : null} + {loading ? ( + + + Termine werden geladen... + + ) : null} + {!loading && visibleEvents.length === 0 ? Keine Termine gefunden. : null} + {!loading + ? visibleEvents.map((event) => ( + [styles.eventRow, pressed ? styles.eventRowPressed : null]} + onPress={() => router.push(`/more/event/${event.id}` as any)}> + + + {event.name} + {formatEventDate(event)} + {projectName(event) ? {projectName(event)} : null} + {event.notes ? {String(event.notes)} : null} + + + + )) + : null} + + + setCreateOpen(true)}> + + + + + setCreateOpen(false)} + onCreated={() => load(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: '#ffffff' }, + toolbar: { padding: 14, gap: 10, borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, + searchInput: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: '#111827', + }, + toolbarActions: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 }, + filterButton: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 7 }, + filterButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' }, + filterButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' }, + filterButtonTextActive: { color: '#3d7a30' }, + calendarButton: { borderRadius: 8, backgroundColor: PRIMARY, paddingHorizontal: 13, paddingVertical: 7 }, + calendarButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' }, + list: { flex: 1 }, + listContent: { paddingBottom: 96 }, + eventRow: { minHeight: 82, flexDirection: 'row', alignItems: 'stretch', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, + eventRowPressed: { backgroundColor: '#f3f4f6' }, + colorBar: { width: 5 }, + eventMain: { flex: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 2 }, + eventName: { color: '#111827', fontSize: 15, fontWeight: '700' }, + eventDate: { color: '#374151', fontSize: 13 }, + eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' }, + eventNotes: { color: '#6b7280', fontSize: 12 }, + arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 25, paddingRight: 12 }, + loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 }, + loadingText: { color: '#6b7280' }, + empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 28 }, + error: { color: '#dc2626', fontSize: 13, padding: 14 }, + fab: { + position: 'absolute', + right: 18, + bottom: 20, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#111827', + shadowOpacity: 0.25, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 5, + }, + fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 }, +}); diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index d5e5e4b..dd9ab3c 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -17,10 +17,13 @@ import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import * as WebBrowser from 'expo-web-browser'; +import { EventCreateModal } from '@/components/event-create-modal'; import { HistorySection } from '@/components/history-section'; import { + CalendarEvent, createProjectTask, fetchProjectById, + fetchProjectEvents, fetchProjectFiles, fetchProjectTasks, Project, @@ -70,12 +73,14 @@ export default function ProjectDetailScreen() { const [project, setProject] = useState(null); const [files, setFiles] = useState([]); const [tasks, setTasks] = useState([]); + const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [uploading, setUploading] = useState(false); const [creatingTask, setCreatingTask] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false); + const [createEventModalOpen, setCreateEventModalOpen] = useState(false); const [createTaskError, setCreateTaskError] = useState(null); const [newTaskName, setNewTaskName] = useState(''); const [newTaskDescription, setNewTaskDescription] = useState(''); @@ -122,14 +127,16 @@ export default function ProjectDetailScreen() { setError(null); try { - const [projectData, fileData, taskData] = await Promise.all([ + const [projectData, fileData, taskData, eventData] = await Promise.all([ fetchProjectById(token, projectId), fetchProjectFiles(token, projectId), fetchProjectTasks(token, projectId), + fetchProjectEvents(token, projectId), ]); setProject(projectData); setFiles(fileData); setTasks(taskData); + setEvents(eventData.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())); } catch (err) { setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.'); } finally { @@ -369,6 +376,35 @@ export default function ProjectDetailScreen() { + + + Termine ({events.length}) + setCreateEventModalOpen(true)}> + Neuer Termin + + + {events.length === 0 ? ( + Keine Termine für dieses Projekt vorhanden. + ) : ( + events.map((event) => ( + router.push(`/more/event/${event.id}` as any)}> + + + {event.name} + + {formatDateTime(event.startDate)} + {event.endDate ? ` – ${formatDateTime(event.endDate)}` : ''} + + + + + )) + )} + + Aufgaben ({tasks.length}) @@ -508,6 +544,14 @@ export default function ProjectDetailScreen() { + + setCreateEventModalOpen(false)} + onCreated={() => load(false)} + /> ); } @@ -591,6 +635,38 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', }, + eventRow: { + minHeight: 60, + flexDirection: 'row', + alignItems: 'stretch', + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 9, + overflow: 'hidden', + }, + eventColor: { + width: 5, + }, + eventMain: { + flex: 1, + padding: 9, + gap: 2, + }, + eventName: { + color: '#111827', + fontSize: 14, + fontWeight: '700', + }, + eventDate: { + color: '#6b7280', + fontSize: 12, + }, + eventArrow: { + alignSelf: 'center', + color: '#9ca3af', + fontSize: 24, + paddingRight: 9, + }, sectionHeaderActions: { alignItems: 'flex-end', gap: 8, diff --git a/mobile/components/event-create-modal.tsx b/mobile/components/event-create-modal.tsx new file mode 100644 index 0000000..f0a005c --- /dev/null +++ b/mobile/components/event-create-modal.tsx @@ -0,0 +1,377 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +import { CalendarEvent, createEvent, Project } from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +type EventCreateModalProps = { + visible: boolean; + projects?: Project[]; + initialProjectId?: number | null; + initialDate?: Date | null; + onClose: () => void; + onCreated: (event: CalendarEvent) => void | Promise; +}; + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +function formatDateInput(date: Date): string { + return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; +} + +function formatTimeInput(date: Date): string { + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function createDefaults(initialDate?: Date | null) { + const start = initialDate ? new Date(initialDate) : new Date(); + if (!initialDate) { + start.setMinutes(Math.ceil(start.getMinutes() / 15) * 15, 0, 0); + } else { + start.setHours(9, 0, 0, 0); + } + const end = new Date(start.getTime() + 60 * 60 * 1000); + return { + startDate: formatDateInput(start), + startTime: formatTimeInput(start), + endDate: formatDateInput(end), + endTime: formatTimeInput(end), + }; +} + +function parseLocalDate(dateValue: string, timeValue: string): Date | null { + const match = dateValue.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); + const timeMatch = timeValue.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match || !timeMatch) return null; + + const day = Number(match[1]); + const month = Number(match[2]); + const year = Number(match[3]); + const hours = Number(timeMatch[1]); + const minutes = Number(timeMatch[2]); + if (hours > 23 || minutes > 59) return null; + + const date = new Date(year, month - 1, day, hours, minutes, 0, 0); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + return date; +} + +function relationId(value: unknown): number | null { + if (!value) return null; + if (typeof value === 'object') { + const id = (value as { id?: number | string }).id; + return id ? Number(id) : null; + } + const id = Number(value); + return Number.isFinite(id) ? id : null; +} + +export function EventCreateModal({ + visible, + projects = [], + initialProjectId = null, + initialDate = null, + onClose, + onCreated, +}: EventCreateModalProps) { + const { token } = useAuth(); + const [name, setName] = useState(''); + const [notes, setNotes] = useState(''); + const [link, setLink] = useState(''); + const [startDate, setStartDate] = useState(''); + const [startTime, setStartTime] = useState(''); + const [endDate, setEndDate] = useState(''); + const [endTime, setEndTime] = useState(''); + const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); + const [projectSearch, setProjectSearch] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!visible) return; + const defaults = createDefaults(initialDate); + setName(''); + setNotes(''); + setLink(''); + setStartDate(defaults.startDate); + setStartTime(defaults.startTime); + setEndDate(defaults.endDate); + setEndTime(defaults.endTime); + setSelectedProjectId(initialProjectId); + setProjectSearch(''); + setError(null); + }, [initialDate, initialProjectId, visible]); + + const selectedProject = useMemo( + () => projects.find((project) => Number(project.id) === selectedProjectId) || null, + [projects, selectedProjectId] + ); + + const projectOptions = useMemo(() => { + const terms = projectSearch.trim().toLowerCase().split(/\s+/).filter(Boolean); + return projects + .filter((project) => { + if (terms.length === 0) return true; + const haystack = `${project.name || ''} ${project.projectNumber || ''}`.toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }) + .slice(0, 8); + }, [projectSearch, projects]); + + async function save() { + if (!token || saving) return; + const title = name.trim(); + if (!title) { + setError('Bitte einen Namen für den Termin eingeben.'); + return; + } + + const start = parseLocalDate(startDate, startTime); + const end = parseLocalDate(endDate, endTime); + if (!start || !end) { + setError('Bitte Datum und Uhrzeit im angegebenen Format eingeben.'); + return; + } + if (end <= start) { + setError('Das Ende muss nach dem Beginn liegen.'); + return; + } + + setSaving(true); + setError(null); + try { + const event = await createEvent(token, { + name: title, + startDate: start.toISOString(), + endDate: end.toISOString(), + project: selectedProjectId, + customer: relationId(selectedProject?.customer), + notes: notes.trim() || null, + link: link.trim() || null, + state: 'Final', + repeatInterval: 'Keine Wiederholung', + }); + await onCreated(event); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + } + + return ( + + + + + Neuer Termin + + + + Beginn + + + + + + Ende + + + + + + Projekt + {selectedProject ? ( + + + {selectedProject.name} + {selectedProject.projectNumber ? ( + Nr. {selectedProject.projectNumber} + ) : null} + + setSelectedProjectId(null)}> + Ändern + + + ) : ( + <> + + {projectOptions.map((project) => ( + setSelectedProjectId(Number(project.id))}> + {project.name} + {project.projectNumber || `#${project.id}`} + + ))} + + )} + + + + {error ? {error} : null} + + + + + Abbrechen + + + {saving ? 'Speichere...' : 'Anlegen'} + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', justifyContent: 'center', padding: 20 }, + keyboardWrap: { width: '100%' }, + card: { maxHeight: '92%', backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 12 }, + title: { color: '#111827', fontSize: 18, fontWeight: '700' }, + scroll: { flexShrink: 1 }, + form: { gap: 10, paddingBottom: 4 }, + label: { color: '#374151', fontSize: 13, fontWeight: '700' }, + input: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + color: '#111827', + backgroundColor: '#ffffff', + }, + row: { flexDirection: 'row', gap: 8 }, + dateInput: { flex: 1 }, + timeInput: { width: 92 }, + multiline: { minHeight: 90, textAlignVertical: 'top' }, + selectedProject: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderColor: '#b9e3ae', + backgroundColor: '#eff9ea', + borderRadius: 10, + padding: 10, + gap: 8, + }, + selectedProjectMain: { flex: 1 }, + selectedProjectName: { color: '#111827', fontSize: 14, fontWeight: '700' }, + selectedProjectNumber: { color: '#6b7280', fontSize: 12 }, + removeProject: { color: '#3d7a30', fontSize: 13, fontWeight: '700' }, + projectOption: { + flexDirection: 'row', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + paddingVertical: 8, + gap: 8, + }, + projectOptionName: { flex: 1, color: '#111827', fontSize: 14, fontWeight: '600' }, + projectOptionNumber: { color: '#6b7280', fontSize: 12 }, + error: { color: '#dc2626', fontSize: 13 }, + actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 }, + secondaryButton: { + minHeight: 40, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { color: '#374151', fontWeight: '600' }, + primaryButton: { + minHeight: 40, + borderRadius: 10, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: PRIMARY, + }, + primaryButtonText: { color: '#ffffff', fontWeight: '700' }, + disabled: { opacity: 0.6 }, +}); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index c312109..87de4a4 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -54,6 +54,23 @@ export type Project = { [key: string]: unknown; }; +export type CalendarEvent = { + id: number; + name: string; + startDate: string; + endDate?: string | null; + eventtype?: string | null; + state?: string | null; + repeatInterval?: string | null; + project?: number | { id?: number; name?: string } | null; + customer?: number | { id?: number; name?: string } | null; + notes?: string | null; + link?: string | null; + color?: string | null; + archived?: boolean; + [key: string]: unknown; +}; + export type ProjectFile = { id: string; name?: string | null; @@ -886,6 +903,49 @@ export async function createProject( }); } +export async function fetchEvents(token: string, includeArchived = false): Promise { + const events = await apiRequest('/api/resource/events', { token }); + if (includeArchived) return events || []; + return (events || []).filter((event) => !event.archived); +} + +export async function createEvent( + token: string, + payload: { + name: string; + startDate: string; + endDate: string; + project?: number | null; + customer?: number | null; + notes?: string | null; + link?: string | null; + eventtype?: string; + state?: 'Entwurf' | 'Final'; + repeatInterval?: string; + } +): Promise { + return apiRequest('/api/resource/events', { + method: 'POST', + token, + body: payload, + }); +} + +export async function fetchEventById(token: string, eventId: number): Promise { + return apiRequest(`/api/resource/events/${eventId}`, { token }); +} + +function resolveEventProjectId(event: CalendarEvent): number | null { + if (!event.project) return null; + if (typeof event.project === 'object') return event.project.id ? Number(event.project.id) : null; + return Number(event.project); +} + +export async function fetchProjectEvents(token: string, projectId: number): Promise { + const events = await fetchEvents(token); + return events.filter((event) => resolveEventProjectId(event) === Number(projectId)); +} + export async function fetchCustomers(token: string, includeArchived = false): Promise { const customers = await apiRequest('/api/resource/customers', { token }); if (includeArchived) return customers || []; From 47bd8e80e445f3291e3937346eb35371e96d2846 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:17:52 +0200 Subject: [PATCH 3/7] =?UTF-8?q?KI-AGENT:=20iOS-Buildnummer=20f=C3=BCr=20Te?= =?UTF-8?q?stFlight=20auf=2012=20erh=C3=B6hen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/app.json | 2 +- mobile/package-lock.json | 101 ++++++++++++++++++++++++--------------- mobile/package.json | 6 +-- 3 files changed, 67 insertions(+), 42 deletions(-) diff --git a/mobile/app.json b/mobile/app.json index 9902d46..9907f63 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "software.federspiel.fedeo", - "buildNumber": "11", + "buildNumber": "12", "infoPlist": { "NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.", diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 18c6528..4f670e8 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -12,11 +12,11 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", - "expo": "~54.0.36", + "expo": "~54.0.37", "expo-camera": "~17.0.10", - "expo-constants": "~18.0.13", + "expo-constants": "~18.0.14", "expo-document-picker": "^14.0.8", - "expo-file-system": "~19.0.23", + "expo-file-system": "~19.0.24", "expo-font": "~14.0.12", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", @@ -54,12 +54,12 @@ } }, "node_modules/@0no-co/graphql.web": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.3.tgz", - "integrity": "sha512-4gFGBdyaFmQ6n9euhp5JtIGS4ZeivwDr1tCPENUxTvy5wyv532yOtFCr9zzYAJh1s6uibgC+TRXUcay+mxzCoQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.4.tgz", + "integrity": "sha512-imSwulOeDQodRy/olQmVEo2PiY6ntjkZ9eiGdw6lMYylh/tay9b7MusyJBmEnkL8GiKRKr6ltr+D42mY5bd8Bg==", "license": "MIT", "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" }, "peerDependenciesMeta": { "graphql": { @@ -2222,9 +2222,9 @@ "license": "MIT" }, "node_modules/@expo/xcpretty": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", - "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz", + "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==", "license": "BSD-3-Clause", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -4054,6 +4054,18 @@ "node": ">= 14" } }, + "node_modules/agent-cli-detector": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz", + "integrity": "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==", + "license": "MIT", + "bin": { + "agent-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -6104,13 +6116,13 @@ } }, "node_modules/expo": { - "version": "54.0.36", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.36.tgz", - "integrity": "sha512-HMHp1H+actmnX85NJE6lILKzSJV6pTDNkwghq9EMOP3zTynjvBYVqJGSLlm6sEVzJCC5Z2ZiKgLvtsHrqlY0dg==", + "version": "54.0.37", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.37.tgz", + "integrity": "sha512-afzbtO4i/K9ZCrzyLgEOaXW56w/yuAK61JkCOMrw/S/VFCAp6YwKWA494X5mIcrVmRAmclv/ec7ga8ndTkzhxw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.26", + "@expo/cli": "54.0.27", "@expo/config": "~12.0.14", "@expo/config-plugins": "~54.0.5", "@expo/devtools": "0.1.8", @@ -6121,11 +6133,11 @@ "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.12", "expo-asset": "~12.0.13", - "expo-constants": "~18.0.13", - "expo-file-system": "~19.0.23", + "expo-constants": "~18.0.14", + "expo-file-system": "~19.0.24", "expo-font": "~14.0.12", "expo-keep-awake": "~15.0.8", - "expo-modules-autolinking": "3.0.26", + "expo-modules-autolinking": "3.0.27", "expo-modules-core": "3.0.30", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", @@ -6200,13 +6212,13 @@ } }, "node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "version": "18.0.14", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.14.tgz", + "integrity": "sha512-BUZm9mkl/TX7zNaN0N4C83ws7mEnesCFiKhqK+rMZwTD2+Q4wWB2kBltqnC/LrCRaMyRi40XxhCRVwuKnJkxXQ==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" + "@expo/config": "~12.0.14", + "@expo/env": "~2.0.12" }, "peerDependencies": { "expo": "*", @@ -6223,9 +6235,9 @@ } }, "node_modules/expo-file-system": { - "version": "19.0.23", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", - "integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==", + "version": "19.0.24", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.24.tgz", + "integrity": "sha512-hZs0Ng+coNjx7YJ8MPwfJpoU1SRul6OZ9LZycFzozUeoR+denbyr944YrMP9NVn+Zf1tdC7p0K0zgah3ZySOYg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6318,9 +6330,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.26", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz", - "integrity": "sha512-WOaud6UKg16ciCOj8raKcMOoKFMHLXKI29U29yhgu1lf+Y7VxJyCktUcYo6AM+ccZ7zLD1uWZdMtgnpf+95OXA==", + "version": "3.0.27", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.27.tgz", + "integrity": "sha512-Ge216xmfXcba+aUQssfmUo9Hf92wXX3xcXJmOsC/wpuV5B5ozm4BTkRKpoCXEkdD8eJVmFy7ATS8yD3upFOPQA==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -6868,9 +6880,9 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.26", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.26.tgz", - "integrity": "sha512-BjsAoKINLEo3LRE+sDC6FCgjxuOWsyfOFOKz0txrbEcxSatzIjJDVuX8XaTdmeicZdcoN524yl1sfwCWfxhYMw==", + "version": "54.0.27", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.27.tgz", + "integrity": "sha512-BxKOr6e8wmUwhGSyEVKY+CHWgS3Cre9ibgx1QWcdCenKTVKAz62qGmYL51q0lCcKg6vQY+4Usw9RK5Il4ZFaIA==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", @@ -6884,7 +6896,7 @@ "@expo/metro": "~54.2.0", "@expo/metro-config": "~54.0.17", "@expo/osascript": "^2.3.8", - "@expo/package-manager": "^1.9.10", + "@expo/package-manager": "^1.9.11", "@expo/plist": "^0.4.9", "@expo/prebuild-config": "^54.0.9", "@expo/schema-utils": "^0.1.9", @@ -6895,6 +6907,7 @@ "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", + "agent-cli-detector": "^0.1.2", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", @@ -7095,9 +7108,9 @@ } }, "node_modules/expo/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -9040,6 +9053,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9060,6 +9076,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9080,6 +9099,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9100,6 +9122,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12656,9 +12681,9 @@ } }, "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/mobile/package.json b/mobile/package.json index 6550d81..ec9997f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -25,11 +25,11 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", - "expo": "~54.0.36", + "expo": "~54.0.37", "expo-camera": "~17.0.10", - "expo-constants": "~18.0.13", + "expo-constants": "~18.0.14", "expo-document-picker": "^14.0.8", - "expo-file-system": "~19.0.23", + "expo-file-system": "~19.0.24", "expo-font": "~14.0.12", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", From 358e40d74960f49dfa02c6ebb4ea4f0395f6fffb Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:42:40 +0200 Subject: [PATCH 4/7] KI-AGENT: Matrix durch nativen FEDEO-Chat ersetzen --- .env.example | 47 - README.md | 22 +- .../migrations/0038_communication_rooms.sql | 3 - .../migrations/0043_communication_rooms.sql | 3 - .../0070_native_communication_chat.sql | 42 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/communication_messages.ts | 34 + .../db/schema/communication_room_members.ts | 26 + backend/db/schema/communication_room_reads.ts | 26 + backend/db/schema/communication_rooms.ts | 4 - backend/db/schema/index.ts | 3 + backend/src/index.ts | 2 - backend/src/modules/bootstrap.service.ts | 15 - .../src/modules/matrix-push-worker.service.ts | 377 --- backend/src/modules/matrix.service.ts | 2387 --------------- backend/src/modules/system-status.service.ts | 6 - backend/src/routes/admin.ts | 23 +- backend/src/routes/communication.ts | 1325 +++------ backend/src/utils/secrets.ts | 18 - backend/src/utils/tenantFullExport.ts | 122 +- docker-compose.selfhost.yml | 313 -- docker-compose.yml | 337 +-- docs-site/content/index.md | 1 - .../content/kommunikationslösung-matrix.md | 371 --- docs/README.md | 1 - docs/kommunikationslösung-matrix.md | 371 --- frontend/components/MainNav.vue | 4 +- frontend/nuxt.config.ts | 1 - frontend/package-lock.json | 131 +- frontend/package.json | 1 - frontend/pages/administration/system.vue | 1 - frontend/pages/communication/chat.vue | 2613 ++--------------- frontend/pages/communication/index.vue | 498 +--- matrix/README.md | 197 -- matrix/dev/element-config.json | 21 - matrix/well-known/client | 11 - matrix/well-known/server | 3 - mobile/app/(tabs)/_layout.tsx | 4 +- mobile/app/(tabs)/communication.tsx | 967 ++---- mobile/src/lib/api.ts | 270 +- scripts/selfhost-setup.sh | 93 +- website/app/assets/css/main.css | 30 +- website/app/pages/datenschutz.vue | 2 +- website/app/pages/impressum.vue | 2 +- website/app/pages/index.vue | 50 +- website/app/pages/kontakt.vue | 2 +- website/app/pages/zielgruppen.vue | 6 +- 47 files changed, 1027 insertions(+), 9766 deletions(-) create mode 100644 backend/db/migrations/0070_native_communication_chat.sql create mode 100644 backend/db/schema/communication_messages.ts create mode 100644 backend/db/schema/communication_room_members.ts create mode 100644 backend/db/schema/communication_room_reads.ts delete mode 100644 backend/src/modules/matrix-push-worker.service.ts delete mode 100644 backend/src/modules/matrix.service.ts delete mode 100644 docs-site/content/kommunikationslösung-matrix.md delete mode 100644 docs/kommunikationslösung-matrix.md delete mode 100644 matrix/README.md delete mode 100644 matrix/dev/element-config.json delete mode 100644 matrix/well-known/client delete mode 100644 matrix/well-known/server diff --git a/.env.example b/.env.example index 12f3189..155ef1a 100644 --- a/.env.example +++ b/.env.example @@ -117,50 +117,3 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN -FEDEO_BOOTSTRAP_MATRIX=true - -# FEDEO Matrix-Kommunikation -# -# Diese Werte werden von docker-compose.selfhost.yml für den integrierten -# Matrix-Stack gelesen. Für produktive Systeme müssen alle Geheimnisse ersetzt -# werden. - -MATRIX_SERVER_NAME=app.example.com - -MATRIX_POSTGRES_DB=synapse -MATRIX_POSTGRES_USER=synapse -MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password - -MATRIX_TURN_SHARED_SECRET=change-this-turn-secret - -LIVEKIT_KEY=fedeo-livekit -LIVEKIT_SECRET=change-this-livekit-secret-please-replace - -# Backend-Integration im Selfhost-Stack -MATRIX_HOMESERVER_URL=http://matrix-synapse:8008 -MATRIX_RTC_HOST=app.example.com -MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt -MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu -MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret -MATRIX_SERVICE_USER_LOCALPART=fedeo_service -NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element - -# Lokale Matrix-Entwicklung -MATRIX_DEV_SYNAPSE_PORT=8008 -MATRIX_DEV_ELEMENT_PORT=8080 -MATRIX_DEV_RTC_JWT_PORT=8081 -MATRIX_DEV_LIVEKIT_PORT=7880 -MATRIX_DEV_LIVEKIT_TCP_PORT=7881 -MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000 -MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100 -MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1 -MATRIX_DEV_TURN_PORT=3478 -MATRIX_DEV_TURN_MIN_PORT=49160 -MATRIX_DEV_TURN_MAX_PORT=49200 - -# Lokale Backend-Integration gegen den Matrix-Entwicklungsstack -# MATRIX_HOMESERVER_URL=http://localhost:8008 -# MATRIX_RTC_JWT_URL=http://localhost:8081 -# MATRIX_LIVEKIT_URL=ws://localhost:7880 -# MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml -# NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080 diff --git a/README.md b/README.md index 036b716..e6f64aa 100644 --- a/README.md +++ b/README.md @@ -239,33 +239,16 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN - -MATRIX_SERVER_NAME=app.example.com -MATRIX_POSTGRES_DB=synapse -MATRIX_POSTGRES_USER=synapse -MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password -MATRIX_TURN_SHARED_SECRET=change-this-turn-secret -MATRIX_HOMESERVER_URL=http://matrix-synapse:8008 -MATRIX_RTC_HOST=app.example.com -MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt -MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu -MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret -MATRIX_SERVICE_USER_LOCALPART=fedeo_service -LIVEKIT_KEY=fedeo-livekit -LIVEKIT_SECRET=change-this-livekit-secret-please-replace -NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element ``` Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern. -## Docker Compose mit optionalem S3 und Matrix +## Docker Compose mit optionalem S3 Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen. Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist. -Der Matrix-Stack ist im Selfhost-Compose direkt enthalten. Er umfasst Synapse, eine eigene PostgreSQL-Datenbank für Synapse, Redis, `.well-known/matrix`, coturn, LiveKit, den LiveKit-JWT-Service und Element Web. Das einfache Selfhost-Setup nutzt nur `DOMAIN`: Synapse läuft unter `https://DOMAIN/_matrix`, Matrix-Well-Known unter `https://DOMAIN/.well-known/matrix`, LiveKit unter `https://DOMAIN/livekit/sfu`, der JWT-Service unter `https://DOMAIN/livekit/jwt` und Element Web unter `https://DOMAIN/element`. - Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest. ```yaml @@ -463,7 +446,6 @@ Im Deploy-Verzeichnis: docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d ``` -Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch und aktualisiert die für FEDEO relevanten Werte aus der `.env`. `MATRIX_REGISTRATION_SHARED_SECRET` muss in der `.env` gesetzt und geheim bleiben, weil FEDEO damit Matrix-Nutzer provisioniert. Danach Status prufen: @@ -541,8 +523,6 @@ Regelmassig sichern: - `./postgres` - `./minio` falls MinIO lokal genutzt wird -- `./matrix/postgres` falls Matrix lokal betrieben wird -- `./matrix/synapse` falls Matrix lokal betrieben wird - `./traefik/letsencrypt/acme.json` - deine `.env` - deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management diff --git a/backend/db/migrations/0038_communication_rooms.sql b/backend/db/migrations/0038_communication_rooms.sql index 2206110..5e09605 100644 --- a/backend/db/migrations/0038_communication_rooms.sql +++ b/backend/db/migrations/0038_communication_rooms.sql @@ -8,9 +8,6 @@ CREATE TABLE "communication_rooms" ( "entity_type" text, "entity_id" bigint, "entity_uuid" uuid, - "matrix_room_id" text, - "matrix_alias" text, - "parent_space_room_id" text, "archived" boolean DEFAULT false NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone, diff --git a/backend/db/migrations/0043_communication_rooms.sql b/backend/db/migrations/0043_communication_rooms.sql index ad6eea9..14cf31b 100644 --- a/backend/db/migrations/0043_communication_rooms.sql +++ b/backend/db/migrations/0043_communication_rooms.sql @@ -8,9 +8,6 @@ CREATE TABLE IF NOT EXISTS "communication_rooms" ( "entity_type" text, "entity_id" bigint, "entity_uuid" uuid, - "matrix_room_id" text, - "matrix_alias" text, - "parent_space_room_id" text, "archived" boolean DEFAULT false NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone, diff --git a/backend/db/migrations/0070_native_communication_chat.sql b/backend/db/migrations/0070_native_communication_chat.sql new file mode 100644 index 0000000..618121d --- /dev/null +++ b/backend/db/migrations/0070_native_communication_chat.sql @@ -0,0 +1,42 @@ +ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_room_id"; +ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_alias"; +ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "parent_space_room_id"; + +CREATE TABLE IF NOT EXISTS "communication_room_members" ( + "room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade, + "user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "communication_room_members_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id") +); + +CREATE INDEX IF NOT EXISTS "communication_room_members_user_idx" + ON "communication_room_members" ("user_id"); + +CREATE TABLE IF NOT EXISTS "communication_messages" ( + "id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade, + "room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade, + "author_user_id" uuid NOT NULL REFERENCES "auth_users"("id"), + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "communication_messages_room_message_idx" + ON "communication_messages" ("room_id", "id"); +CREATE INDEX IF NOT EXISTS "communication_messages_tenant_idx" + ON "communication_messages" ("tenant_id"); + +CREATE TABLE IF NOT EXISTS "communication_room_reads" ( + "room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade, + "user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade, + "last_read_message_id" bigint, + "read_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "communication_room_reads_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id") +); + +INSERT INTO "communication_room_members" ("room_id", "user_id") +SELECT room.id, tenant_user.user_id +FROM "communication_rooms" room +JOIN "auth_tenant_users" tenant_user ON tenant_user.tenant_id = room.tenant_id +WHERE room.type IN ('general', 'room') +ON CONFLICT DO NOTHING; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 5ecbfaf..9ba06fd 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -470,6 +470,13 @@ "when": 1788803000000, "tag": "0069_reset_email_entity_suggestions", "breakpoints": true + }, + { + "idx": 67, + "version": "7", + "when": 1788850800000, + "tag": "0070_native_communication_chat", + "breakpoints": true } ] } diff --git a/backend/db/schema/communication_messages.ts b/backend/db/schema/communication_messages.ts new file mode 100644 index 0000000..f25548e --- /dev/null +++ b/backend/db/schema/communication_messages.ts @@ -0,0 +1,34 @@ +import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { communicationRooms } from "./communication_rooms" +import { tenants } from "./tenants" + +export const communicationMessages = pgTable( + "communication_messages", + { + id: bigint("id", { mode: "number" }) + .primaryKey() + .generatedByDefaultAsIdentity(), + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + roomId: uuid("room_id") + .notNull() + .references(() => communicationRooms.id, { onDelete: "cascade" }), + authorUserId: uuid("author_user_id") + .notNull() + .references(() => authUsers.id), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + roomMessageIdx: index("communication_messages_room_message_idx").on(table.roomId, table.id), + tenantIdx: index("communication_messages_tenant_idx").on(table.tenantId), + }) +) + +export type CommunicationMessage = typeof communicationMessages.$inferSelect +export type NewCommunicationMessage = typeof communicationMessages.$inferInsert diff --git a/backend/db/schema/communication_room_members.ts b/backend/db/schema/communication_room_members.ts new file mode 100644 index 0000000..ecafa1e --- /dev/null +++ b/backend/db/schema/communication_room_members.ts @@ -0,0 +1,26 @@ +import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { communicationRooms } from "./communication_rooms" + +export const communicationRoomMembers = pgTable( + "communication_room_members", + { + roomId: uuid("room_id") + .notNull() + .references(() => communicationRooms.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade" }), + joinedAt: timestamp("joined_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.roomId, table.userId] }), + userIdx: index("communication_room_members_user_idx").on(table.userId), + }) +) + +export type CommunicationRoomMember = typeof communicationRoomMembers.$inferSelect +export type NewCommunicationRoomMember = typeof communicationRoomMembers.$inferInsert diff --git a/backend/db/schema/communication_room_reads.ts b/backend/db/schema/communication_room_reads.ts new file mode 100644 index 0000000..31bbcf0 --- /dev/null +++ b/backend/db/schema/communication_room_reads.ts @@ -0,0 +1,26 @@ +import { bigint, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { communicationRooms } from "./communication_rooms" + +export const communicationRoomReads = pgTable( + "communication_room_reads", + { + roomId: uuid("room_id") + .notNull() + .references(() => communicationRooms.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade" }), + lastReadMessageId: bigint("last_read_message_id", { mode: "number" }), + readAt: timestamp("read_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.roomId, table.userId] }), + }) +) + +export type CommunicationRoomRead = typeof communicationRoomReads.$inferSelect +export type NewCommunicationRoomRead = typeof communicationRoomReads.$inferInsert diff --git a/backend/db/schema/communication_rooms.ts b/backend/db/schema/communication_rooms.ts index 0302b25..b9cba4e 100644 --- a/backend/db/schema/communication_rooms.ts +++ b/backend/db/schema/communication_rooms.ts @@ -30,10 +30,6 @@ export const communicationRooms = pgTable( entityId: bigint("entity_id", { mode: "number" }), entityUuid: uuid("entity_uuid"), - matrixRoomId: text("matrix_room_id"), - matrixAlias: text("matrix_alias"), - parentSpaceRoomId: text("parent_space_room_id"), - archived: boolean("archived").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }) diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index e6d1e5e..b37bb94 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -16,6 +16,9 @@ export * from "./checkexecutions" export * from "./checks" export * from "./citys" export * from "./communication_rooms" +export * from "./communication_room_members" +export * from "./communication_messages" +export * from "./communication_room_reads" export * from "./contacts" export * from "./contracts" export * from "./contracttypes" diff --git a/backend/src/index.ts b/backend/src/index.ts index cdaf991..ec7120e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -61,7 +61,6 @@ import {loadSecrets, secrets} from "./utils/secrets"; import {initMailer} from "./utils/mailer" import {initS3} from "./utils/s3"; import { runBootstrap } from "./modules/bootstrap.service"; -import { startMatrixPushWorker } from "./modules/matrix-push-worker.service"; import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service"; import { startDocumentImportWorker } from "./modules/document-import/document-import.worker"; @@ -89,7 +88,6 @@ async function main() { await app.register(dbPlugin); await app.register(servicesPlugin); await runBootstrap(app); - startMatrixPushWorker(app); startCentralServicesHeartbeat(app); startDocumentImportWorker(app); diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index a6bd9dc..d362e67 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -19,7 +19,6 @@ import { tenants, texttemplates, } from "../../db/schema" -import { matrixService } from "./matrix.service" const adminPermissions = [ "mcp.tokens.write", @@ -456,18 +455,4 @@ export async function runBootstrap(server: FastifyInstance) { await ensureTenantBaseData(server, tenant.id, adminUser.id) console.log("✅ Bootstrap-Grunddaten geprüft") - if (process.env.FEDEO_BOOTSTRAP_MATRIX === "true") { - try { - const matrix = matrixService(server) - await matrix.provisionTenantRoom(adminUser.id, tenant.id, { - key: "allgemein", - name: "Allgemeiner Chat", - type: "general", - }) - console.log("✅ Bootstrap-Matrix-Kommunikation geprüft") - } catch (err) { - console.error("❌ Bootstrap-Matrix-Kommunikation fehlgeschlagen:", err) - throw err - } - } } diff --git a/backend/src/modules/matrix-push-worker.service.ts b/backend/src/modules/matrix-push-worker.service.ts deleted file mode 100644 index 44cd35f..0000000 --- a/backend/src/modules/matrix-push-worker.service.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { createHash } from "node:crypto" -import type { FastifyInstance } from "fastify" -import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm" -import { authProfiles, authTenantUsers, authUsers, communicationRooms, notificationsItems } from "../../db/schema" -import { matrixService } from "./matrix.service" -import { NotificationService, UserDirectory } from "./notification.service" - -type ChatRecipient = { - userId: string - email?: string | null - firstName?: string | null - lastName?: string | null - fullName?: string | null - matrixUserId?: string -} - -type MatrixPushWorkerEvent = { - at: string - type: string - roomKey?: string - roomId?: string | null - messageId?: string - sender?: string - targets?: number - created?: number - delivered?: number - failed?: number - error?: string -} - -const matrixPushWorkerState = { - enabled: false, - startedAt: null as string | null, - lastRunAt: null as string | null, - lastJoinAt: null as string | null, - lastJoinTotal: 0, - lastJoinJoined: 0, - lastJoinFailed: 0, - hasSyncToken: false, - lastSyncRooms: 0, - lastSyncMessages: 0, - lastMatchedRooms: 0, - lastNotificationsCreated: 0, - lastNotificationsDelivered: 0, - lastNotificationsFailed: 0, - lastError: null as string | null, - events: [] as MatrixPushWorkerEvent[], -} - -const rememberWorkerEvent = (event: MatrixPushWorkerEvent) => { - matrixPushWorkerState.events = [ - { - at: new Date().toISOString(), - ...event, - }, - ...matrixPushWorkerState.events, - ].slice(0, 25) -} - -export const getMatrixPushWorkerState = () => ({ - ...matrixPushWorkerState, - events: [...matrixPushWorkerState.events], -}) - -const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => { - const rows = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - return rows[0] || null -} - -const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => { - const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ") - return name || user.email || "Benutzer" -} - -const directRoomKey = (firstUserId: string, secondUserId: string) => { - const hash = createHash("sha256") - .update([firstUserId, secondUserId].sort().join(":")) - .digest("hex") - .slice(0, 16) - - return `direct_${hash}` -} - -const mentionAliasesForUser = (user: ChatRecipient) => { - const name = displayUserName(user) - return Array.from(new Set([ - name, - user.fullName, - [user.firstName, user.lastName].filter(Boolean).join(" "), - user.firstName, - user.email, - ].filter(Boolean).map((value) => String(value).toLowerCase()))) -} - -const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => { - const normalizedText = text.toLowerCase() - - return recipients - .filter((recipient) => mentionAliasesForUser(recipient).some((alias) => - normalizedText.includes(`@${alias}`) - )) - .map((recipient) => recipient.userId) -} - -export function startMatrixPushWorker(server: FastifyInstance) { - if (process.env.MATRIX_PUSH_WORKER_DISABLED === "1") { - server.log.info("Matrix-Push-Worker ist deaktiviert") - return - } - - matrixPushWorkerState.enabled = true - matrixPushWorkerState.startedAt = new Date().toISOString() - rememberWorkerEvent({ at: new Date().toISOString(), type: "started" }) - - const matrix = matrixService(server) - const notifications = new NotificationService(server, getUserDirectory) - const intervalMs = Math.max(Number(process.env.MATRIX_PUSH_WORKER_INTERVAL_MS || 3000), 1000) - let since: string | undefined - let running = false - let stopped = false - let timer: ReturnType | undefined - let lastServiceJoinSyncAt = 0 - let errorBackoffMs = 0 - - const getTenantRecipients = async (tenantId: number) => { - const rows = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(eq(authTenantUsers.tenant_id, tenantId)) - - return await Promise.all(rows.map(async (row) => ({ - ...row, - matrixUserId: await matrix.matrixUserIdForUser(row.userId, tenantId), - }))) - } - - const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => { - const rows = await server.db - .select({ - payload: notificationsItems.payload, - }) - .from(notificationsItems) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - eq(notificationsItems.eventType, "communication.message.new") - )) - .orderBy(desc(notificationsItems.createdAt)) - .limit(200) - - return rows.some((row) => (row.payload as any)?.messageId === messageId) - } - - const recipientsForMessage = ( - room: typeof communicationRooms.$inferSelect, - recipients: ChatRecipient[], - senderUserId: string | null, - text: string - ) => { - const candidates = senderUserId - ? recipients.filter((recipient) => recipient.userId !== senderUserId) - : recipients - const mentioned = new Set(mentionedRecipientIds(text, candidates)) - const directRecipients = new Set() - - if (room.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) { - directRecipients.add(room.entityUuid) - } else if (room.type === "direct" && senderUserId) { - candidates - .filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key) - .forEach((recipient) => directRecipients.add(recipient.userId)) - } - - return candidates - .filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId)) - .map((recipient) => ({ - ...recipient, - mentioned: mentioned.has(recipient.userId), - direct: directRecipients.has(recipient.userId), - })) - } - - const deliverMessageNotification = async ( - room: typeof communicationRooms.$inferSelect, - message: any, - recipients: ChatRecipient[] - ) => { - if (!message.id || message.own) return - - const sender = recipients.find((recipient) => recipient.matrixUserId === message.sender) || null - const text = message.body || message.attachment?.fileName || "Neue Nachricht" - const targets = recipientsForMessage(room, recipients, sender?.userId || null, text) - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "message_seen", - roomKey: room.key, - roomId: room.matrixRoomId, - messageId: message.id, - sender: message.sender, - targets: targets.length, - }) - if (!targets.length) return - - const senderName = sender ? displayUserName(sender) : message.senderDisplayName || message.sender || "Matrix" - const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text - - for (const target of targets) { - if (await hasChatNotificationForMessage(room.tenantId, target.userId, message.id)) { - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "notification_skipped_duplicate", - roomKey: room.key, - roomId: room.matrixRoomId, - messageId: message.id, - sender: message.sender, - targets: 1, - }) - continue - } - - const result = await notifications.trigger({ - tenantId: room.tenantId, - userId: target.userId, - eventType: "communication.message.new", - title: target.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`, - message: preview, - payload: { - link: `/communication/chat?room=${encodeURIComponent(room.key)}`, - roomKey: room.key, - roomName: room.name, - roomType: room.type, - messageId: message.id, - matrixSender: message.sender, - mentioned: target.mentioned, - direct: target.direct, - }, - channels: ["inapp", "push"], - }) - matrixPushWorkerState.lastNotificationsCreated += result.created || 0 - matrixPushWorkerState.lastNotificationsDelivered += result.delivered || 0 - matrixPushWorkerState.lastNotificationsFailed += result.failed || 0 - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "notification_triggered", - roomKey: room.key, - roomId: room.matrixRoomId, - messageId: message.id, - sender: message.sender, - targets: 1, - created: result.created || 0, - delivered: result.delivered || 0, - failed: result.failed || 0, - }) - } - } - - const runOnce = async () => { - if (running || stopped) return - running = true - - try { - matrixPushWorkerState.lastRunAt = new Date().toISOString() - matrixPushWorkerState.lastError = null - matrixPushWorkerState.lastSyncRooms = 0 - matrixPushWorkerState.lastSyncMessages = 0 - matrixPushWorkerState.lastMatchedRooms = 0 - matrixPushWorkerState.lastNotificationsCreated = 0 - matrixPushWorkerState.lastNotificationsDelivered = 0 - matrixPushWorkerState.lastNotificationsFailed = 0 - - if (!lastServiceJoinSyncAt || Date.now() - lastServiceJoinSyncAt > 60_000) { - const joinResult = await matrix.syncServiceJoinedTenantRooms() - lastServiceJoinSyncAt = Date.now() - matrixPushWorkerState.lastJoinAt = new Date().toISOString() - matrixPushWorkerState.lastJoinTotal = joinResult.total - matrixPushWorkerState.lastJoinJoined = joinResult.joined - matrixPushWorkerState.lastJoinFailed = joinResult.failed - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "service_join_sync", - targets: joinResult.total, - delivered: joinResult.joined, - failed: joinResult.failed, - }) - if (joinResult.failed) { - console.warn("Matrix-Push-Worker: Service-User konnte nicht alle Räume joinen", { - total: joinResult.total, - joined: joinResult.joined, - failed: joinResult.failed, - }) - } - } - - const initial = !since - const sync = await matrix.syncServiceRoomEvents(since, initial) - since = sync.nextBatch || since - matrixPushWorkerState.hasSyncToken = Boolean(since) - matrixPushWorkerState.lastSyncRooms = sync.rooms?.length || 0 - matrixPushWorkerState.lastSyncMessages = (sync.rooms || []) - .reduce((sum: number, room: any) => sum + (room.messages?.length || 0), 0) - - if (!initial && sync.rooms?.length) { - const roomIds = sync.rooms.map((room: any) => room.roomId).filter(Boolean) - const rooms = roomIds.length - ? await server.db - .select() - .from(communicationRooms) - .where(and( - inArray(communicationRooms.matrixRoomId, roomIds), - ne(communicationRooms.archived, true), - isNotNull(communicationRooms.matrixRoomId) - )) - : [] - const roomsByMatrixId = new Map(rooms.map((room) => [room.matrixRoomId, room])) - matrixPushWorkerState.lastMatchedRooms = rooms.length - const recipientsByTenant = new Map() - - for (const syncedRoom of sync.rooms) { - const room = roomsByMatrixId.get(syncedRoom.roomId) - if (!room || !syncedRoom.messages?.length) continue - - if (!recipientsByTenant.has(room.tenantId)) { - recipientsByTenant.set(room.tenantId, await getTenantRecipients(room.tenantId)) - } - - const recipients = recipientsByTenant.get(room.tenantId) || [] - for (const message of syncedRoom.messages) { - await deliverMessageNotification(room, message, recipients) - } - } - } - errorBackoffMs = 0 - } catch (err) { - matrixPushWorkerState.lastError = err instanceof Error ? err.message : String(err) - const retryAfterMs = Number((err as any)?.retryAfterMs || (err as any)?.body?.retry_after_ms || 0) - errorBackoffMs = Math.min( - Math.max(retryAfterMs || (errorBackoffMs ? errorBackoffMs * 2 : 30_000), 30_000), - 5 * 60_000 - ) - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "error", - error: matrixPushWorkerState.lastError, - }) - console.error("Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten", err) - server.log.error({ err }, "Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten") - } finally { - running = false - if (!stopped) { - const nextDelay = errorBackoffMs || (since ? 0 : intervalMs) - timer = setTimeout(() => void runOnce(), nextDelay) - } - } - } - - timer = setTimeout(() => void runOnce(), intervalMs) - server.addHook("onClose", async () => { - stopped = true - if (timer) clearTimeout(timer) - }) -} diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts deleted file mode 100644 index c75f49e..0000000 --- a/backend/src/modules/matrix.service.ts +++ /dev/null @@ -1,2387 +0,0 @@ -import { createHash, createHmac, randomBytes } from "node:crypto" -import { existsSync, readFileSync } from "node:fs" -import { resolve } from "node:path" -import { FastifyInstance } from "fastify" -import { authProfiles, authTenantUsers, authUsers, communicationRooms, tenants } from "../../db/schema" -import { and, eq, isNotNull } from "drizzle-orm" -import { secrets } from "../utils/secrets" -import jwt from "jsonwebtoken" - -type MatrixErrorResponse = { - errcode?: string - error?: string - retry_after_ms?: number -} - -type MatrixRoomEvent = { - event_id: string - sender: string - origin_server_ts: number - type: string - redacts?: string - content?: { - body?: string - msgtype?: string - url?: string - info?: { - mimetype?: string - size?: number - } - "m.new_content"?: { - body?: string - msgtype?: string - } - "m.relates_to"?: { - event_id?: string - key?: string - rel_type?: string - "m.in_reply_to"?: { - event_id?: string - } - } - } -} - -type MatrixJoinedMembersResponse = { - joined: Record -} - -type MatrixRoomSearchResponse = { - search_categories?: { - room_events?: { - count?: number - results?: Array<{ - result?: MatrixRoomEvent & { - room_id?: string - } - }> - } - } -} - -type MatrixSyncResponse = { - next_batch?: string - rooms?: { - join?: Record - } -} - -type MatrixUserSession = { - accessToken: string - matrixUserId: string - validUntilMs: number -} - -type MatrixLoginTokenResponse = { - login_token: string - expires_in_ms: number -} - -type LiveKitGrant = { - roomJoin: boolean - room: string - canPublish: boolean - canSubscribe: boolean -} - -type MatrixTenantRoomOptions = { - key?: string - name?: string - topic?: string - type?: string - entityType?: string | null - entityId?: number | null - entityUuid?: string | null - inviteUserIds?: string[] -} - -type MatrixAttachmentInput = { - buffer: Buffer - filename: string - mimeType: string - size: number -} - -type MatrixMessageOptions = { - replyToEventId?: string -} - -type MatrixCachedValue = { - exists: true - cachedUntil: number - value: T -} - -const matrixUserSessionCache = new Map() -const matrixJoinedRoomCache = new Map() -const matrixProvisionedUserCache = new Map() -const matrixTenantSpaceCache = new Map() -const matrixTenantRoomCache = new Map() -let matrixServiceSessionCache: MatrixUserSession | null = null - -const defaultTenantRooms: Required>[] = [ - { - key: "allgemein", - name: "Allgemeiner Chat", - type: "general", - }, -] - -const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") -const readLocalDevRegistrationSharedSecret = () => { - if (process.env.NODE_ENV === "production") return "" - - const candidates = [ - resolve(process.cwd(), "../matrix/dev/synapse/homeserver.yaml"), - resolve(process.cwd(), "matrix/dev/synapse/homeserver.yaml"), - ] - - for (const candidate of candidates) { - if (!existsSync(candidate)) continue - - const content = readFileSync(candidate, "utf8") - const match = content.match(/^registration_shared_secret:\s*["']?(.+?)["']?\s*$/m) - - if (match?.[1]) { - return match[1] - } - } - - return "" -} - -const normalizeMatrixLocalpartSeed = (value: string) => { - const normalized = value - .toLowerCase() - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/ä/g, "a") - .replace(/ö/g, "o") - .replace(/ü/g, "u") - .replace(/ß/g, "ss") - .replace(/[^a-z0-9._=-]+/g, "_") - .replace(/_+/g, "_") - .replace(/^[._=-]+|[._=-]+$/g, "") - - return normalized || "user" -} - -const normalizeMatrixAliasSeed = (value: string) => - normalizeMatrixLocalpartSeed(value) - .replace(/[.=]/g, "_") - .replace(/_+/g, "_") - -export function matrixService(server: FastifyInstance) { - const homeserverUrl = () => - trimTrailingSlash( - process.env.MATRIX_HOMESERVER_URL || - secrets.MATRIX_HOMESERVER_URL || - "http://localhost:8008" - ) - - const serverName = () => - process.env.MATRIX_SERVER_NAME || - secrets.MATRIX_SERVER_NAME || - "localhost" - - const registrationSharedSecret = () => - process.env.MATRIX_REGISTRATION_SHARED_SECRET || - secrets.MATRIX_REGISTRATION_SHARED_SECRET || - readLocalDevRegistrationSharedSecret() || - "" - - const rtcHost = () => - process.env.MATRIX_RTC_HOST || - secrets.MATRIX_RTC_HOST || - "call.fedeo.de" - - const rtcJwtUrl = () => - process.env.MATRIX_RTC_JWT_URL || - secrets.MATRIX_RTC_JWT_URL || - (process.env.NODE_ENV === "production" - ? `https://${rtcHost()}/livekit/jwt` - : `http://localhost:${process.env.MATRIX_DEV_RTC_JWT_PORT || "8081"}`) - - const livekitUrl = () => - process.env.MATRIX_LIVEKIT_URL || - secrets.MATRIX_LIVEKIT_URL || - (process.env.NODE_ENV === "production" - ? `wss://${rtcHost()}/livekit/sfu` - : `ws://localhost:${process.env.MATRIX_DEV_LIVEKIT_PORT || "7880"}`) - - const livekitKey = () => - process.env.LIVEKIT_KEY || - secrets.LIVEKIT_KEY || - (process.env.NODE_ENV === "production" ? "" : "devkey") - - const livekitSecret = () => - process.env.LIVEKIT_SECRET || - secrets.LIVEKIT_SECRET || - (process.env.NODE_ENV === "production" ? "" : "devsecret-local-matrix-stack-32-chars") - - const serviceUserLocalpart = () => - process.env.MATRIX_SERVICE_USER_LOCALPART || - secrets.MATRIX_SERVICE_USER_LOCALPART || - "fedeo_service" - - const serviceUserPassword = () => - createHmac("sha256", registrationSharedSecret()) - .update(`${serverName()}:fedeo-service-user`) - .digest("base64url") - - const getUserIdentitySeed = async (userId: string, tenantId: number | null) => { - const [user] = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - if (user?.email) { - return user.email.split("@")[0] || user.email - } - - if (tenantId) { - const displayName = await getCurrentUserDisplayName(userId, tenantId) - if (displayName && !displayName.startsWith("@")) { - return displayName - } - } - - return "user" - } - - const matrixLocalpartForUser = async (userId: string, tenantId: number | null) => { - const seed = normalizeMatrixLocalpartSeed(await getUserIdentitySeed(userId, tenantId)) - const hash = createHash("sha256").update(userId).digest("hex").slice(0, 8) - return `${seed}_${hash}` - } - - const matrixUserIdForUser = async (userId: string, tenantId: number | null) => - `@${await matrixLocalpartForUser(userId, tenantId)}:${serverName()}` - - const tenantSpaceAliasLocalpart = (tenant: { id: number, short?: string | null, name?: string | null }) => { - const seed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) - return `fedeo_${seed}_${tenant.id}` - } - - const tenantSpaceAlias = (tenant: { id: number, short?: string | null, name?: string | null }) => - `#${tenantSpaceAliasLocalpart(tenant)}:${serverName()}` - - const tenantRoomAliasLocalpart = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string - ) => { - const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) - const roomSeed = normalizeMatrixAliasSeed(roomKey) - return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}` - } - - const tenantRoomAlias = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string - ) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${serverName()}` - - const matrixIdentifierServerName = (value?: string | null) => { - const match = value?.match(/^[!#@][^:]+:(.+)$/) - return match?.[1] || null - } - - const belongsToCurrentMatrixServer = (value?: string | null) => { - const identifierServerName = matrixIdentifierServerName(value) - return !identifierServerName || identifierServerName === serverName() - } - - const normalizeTenantRoomOptions = (options: MatrixTenantRoomOptions = {}) => { - const fallbackName = options.key || options.name || "Allgemeiner Chat" - const key = normalizeMatrixAliasSeed(options.key || fallbackName) - const name = (options.name || fallbackName).trim() || "Allgemeiner Chat" - const topic = options.topic?.trim() - - return { - key, - name, - topic, - type: options.type || "room", - entityType: options.entityType || null, - entityId: options.entityId || null, - entityUuid: options.entityUuid || null, - inviteUserIds: options.inviteUserIds || [], - } - } - - const buildSharedSecretMac = ( - nonce: string, - username: string, - password: string, - admin: boolean - ) => { - const hmac = createHmac("sha1", registrationSharedSecret()) - hmac.update(nonce) - hmac.update("\0") - hmac.update(username) - hmac.update("\0") - hmac.update(password) - hmac.update("\0") - hmac.update(admin ? "admin" : "notadmin") - return hmac.digest("hex") - } - - const registerWithSharedSecret = async ( - username: string, - password: string, - admin: boolean - ) => { - const nonceResponse = await requestJson<{ nonce: string }>( - `${homeserverUrl()}/_synapse/admin/v1/register` - ) - const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, admin) - - return requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nonce: nonceResponse.nonce, - username, - password, - admin, - mac, - }), - }) - } - - const loginMatrixUser = async (username: string, password: string) => { - return requestJson<{ - access_token: string - user_id: string - }>(`${homeserverUrl()}/_matrix/client/v3/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: username, - }, - password, - }), - }) - } - - const ensureServiceAccessToken = async () => { - if (!registrationSharedSecret()) { - throw Object.assign( - new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), - { statusCode: 503 } - ) - } - - if (matrixServiceSessionCache && matrixServiceSessionCache.validUntilMs > Date.now() + 60_000) { - return matrixServiceSessionCache - } - - const username = serviceUserLocalpart() - const password = serviceUserPassword() - - try { - const login = await loginMatrixUser(username, password) - matrixServiceSessionCache = { - accessToken: login.access_token, - matrixUserId: login.user_id, - validUntilMs: Date.now() + 30 * 60 * 1000, - } - - return matrixServiceSessionCache - } catch (loginErr: any) { - if (loginErr.statusCode === 429) throw loginErr - } - - try { - await registerWithSharedSecret(username, password, true) - } catch (registerErr: any) { - if (registerErr.errcode !== "M_USER_IN_USE") throw registerErr - } - const login = await loginMatrixUser(username, password) - matrixServiceSessionCache = { - accessToken: login.access_token, - matrixUserId: login.user_id, - validUntilMs: Date.now() + 30 * 60 * 1000, - } - - return matrixServiceSessionCache - } - - const getCurrentUserDisplayName = async (userId: string, tenantId: number | null) => { - if (tenantId) { - const [profile] = await server.db - .select({ - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - }) - .from(authProfiles) - .where(and( - eq(authProfiles.user_id, userId), - eq(authProfiles.tenant_id, tenantId) - )) - .limit(1) - - const profileName = [profile?.firstName, profile?.lastName] - .filter(Boolean) - .join(" ") - .trim() - - if (profileName) return profileName - } - - const [user] = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - return user?.email || await matrixUserIdForUser(userId, tenantId) - } - - const requestJson = async (url: string, init?: RequestInit): Promise => { - const response = await fetch(url, init) - const text = await response.text() - const body = text ? JSON.parse(text) : {} - - if (!response.ok) { - const error = body as MatrixErrorResponse - throw Object.assign( - new Error(error.error || `Matrix request failed with ${response.status}`), - { - statusCode: response.status, - errcode: error.errcode, - retryAfterMs: error.retry_after_ms, - body, - } - ) - } - - return body as T - } - - const requestMatrixJson = async (path: string, accessToken: string, init?: RequestInit): Promise => { - return requestJson(`${homeserverUrl()}${path}`, { - ...init, - headers: { - ...(init?.headers || {}), - Authorization: `Bearer ${accessToken}`, - }, - }) - } - - const mxcToMediaPath = (mxcUri: string) => { - const match = mxcUri.match(/^mxc:\/\/([^/]+)\/(.+)$/) - if (!match) { - throw Object.assign( - new Error("Ungültige Matrix-Media-URI"), - { statusCode: 400 } - ) - } - - return `/_matrix/media/v3/download/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}` - } - - const matrixMediaUrl = (mxcUri: string) => `${homeserverUrl()}${mxcToMediaPath(mxcUri)}` - - const attachmentFromEvent = (event: MatrixRoomEvent) => { - const msgtype = event.content?.msgtype || "m.text" - - if (!["m.file", "m.image"].includes(msgtype) || !event.content?.url) { - return null - } - - const mimeType = event.content.info?.mimetype || "application/octet-stream" - - return { - type: msgtype === "m.image" ? "image" : "file", - url: event.content.url, - fileName: event.content.body || "Anhang", - mimeType, - size: event.content.info?.size || 0, - previewUrl: msgtype === "m.image" ? matrixMediaUrl(event.content.url) : null, - downloadUrl: matrixMediaUrl(event.content.url), - isImage: msgtype === "m.image" || mimeType.startsWith("image/"), - } - } - - const createAccessTokenForUser = async (userId: string, tenantId: number | null) => { - const matrixUserId = await matrixUserIdForUser(userId, tenantId) - const cacheKey = `${tenantId || "global"}:${userId}` - const cachedSession = matrixUserSessionCache.get(cacheKey) - - if (cachedSession && cachedSession.validUntilMs > Date.now() + 60_000) { - return cachedSession - } - - await provisionCurrentUser(userId, tenantId) - - const serviceLogin = await ensureServiceAccessToken() - const validUntilMs = Date.now() + 30 * 60 * 1000 - - const login = await requestMatrixJson<{ access_token: string }>( - `/_synapse/admin/v1/users/${encodeURIComponent(matrixUserId)}/login`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ valid_until_ms: validUntilMs }), - } - ) - - const session = { - accessToken: login.access_token, - matrixUserId, - validUntilMs, - } - - matrixUserSessionCache.set(cacheKey, session) - return session - } - - const getStatus = async () => { - const configured = Boolean(homeserverUrl() && serverName()) - - if (!configured) { - return { - configured: false, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - provisioningConfigured: Boolean(registrationSharedSecret()), - reachable: false, - calls: { - provider: "matrixrtc-livekit", - configured: Boolean(rtcJwtUrl() && livekitUrl()), - rtcHost: rtcHost(), - rtcJwtUrl: rtcJwtUrl(), - livekitUrl: livekitUrl(), - }, - } - } - - try { - const versions = await requestJson<{ versions: string[] }>( - `${homeserverUrl()}/_matrix/client/versions` - ) - - return { - configured: true, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - provisioningConfigured: Boolean(registrationSharedSecret()), - reachable: true, - calls: { - provider: "matrixrtc-livekit", - configured: Boolean(rtcJwtUrl() && livekitUrl()), - rtcHost: rtcHost(), - rtcJwtUrl: rtcJwtUrl(), - livekitUrl: livekitUrl(), - }, - versions: versions.versions, - } - } catch (err: any) { - return { - configured: true, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - provisioningConfigured: Boolean(registrationSharedSecret()), - reachable: false, - calls: { - provider: "matrixrtc-livekit", - configured: Boolean(rtcJwtUrl() && livekitUrl()), - rtcHost: rtcHost(), - rtcJwtUrl: rtcJwtUrl(), - livekitUrl: livekitUrl(), - }, - error: err.message, - } - } - } - - const provisionCurrentUser = async (userId: string, tenantId: number | null) => { - if (!registrationSharedSecret()) { - throw Object.assign( - new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), - { statusCode: 503 } - ) - } - - const username = await matrixLocalpartForUser(userId, tenantId) - const matrixUserId = await matrixUserIdForUser(userId, tenantId) - const displayName = await getCurrentUserDisplayName(userId, tenantId) - const cacheKey = `${tenantId || "global"}:${userId}` - const cachedUntil = matrixProvisionedUserCache.get(cacheKey) - - if (cachedUntil && cachedUntil > Date.now()) { - return { - matrixUserId, - localpart: username, - displayName, - created: false, - alreadyExisted: true, - } - } - - const password = randomBytes(32).toString("base64url") - - try { - await registerWithSharedSecret(username, password, false) - matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000) - - return { - matrixUserId, - localpart: username, - displayName, - created: true, - alreadyExisted: false, - } - } catch (err: any) { - if (err.errcode === "M_USER_IN_USE") { - matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000) - - return { - matrixUserId, - localpart: username, - displayName, - created: false, - alreadyExisted: true, - } - } - - throw err - } - } - - const getCurrentTenant = async (tenantId: number | null) => { - if (!tenantId) { - throw Object.assign( - new Error("No active tenant selected"), - { statusCode: 400 } - ) - } - - const [tenant] = await server.db - .select({ - id: tenants.id, - name: tenants.name, - short: tenants.short, - }) - .from(tenants) - .where(eq(tenants.id, tenantId)) - .limit(1) - - if (!tenant) { - throw Object.assign( - new Error("Tenant not found"), - { statusCode: 404 } - ) - } - - return tenant - } - - const getTenantSpaceStatus = async (tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - const alias = tenantSpaceAlias(tenant) - - try { - const directoryEntry = await requestJson<{ - room_id: string - servers: string[] - }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) - - return { - tenantId: tenant.id, - tenantName: tenant.name, - alias, - exists: true, - roomId: directoryEntry.room_id, - servers: directoryEntry.servers, - } - } catch (err: any) { - if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { - return { - tenantId: tenant.id, - tenantName: tenant.name, - alias, - exists: false, - roomId: null, - servers: [], - } - } - - throw err - } - } - - const provisionCurrentTenantSpace = async (userId: string, tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - const cacheKey = String(tenant.id) - const cachedSpace = matrixTenantSpaceCache.get(cacheKey) - - if (cachedSpace?.exists && cachedSpace.cachedUntil > Date.now()) { - return cachedSpace.value - } - - const existing = await getTenantSpaceStatus(tenant.id) - const userAccount = await provisionCurrentUser(userId, tenant.id) - - if (existing.exists) { - const value = { - ...existing, - created: false, - alreadyExisted: true, - invitedUserId: userAccount.matrixUserId, - } - - matrixTenantSpaceCache.set(cacheKey, { - exists: true, - cachedUntil: Date.now() + 30 * 60 * 1000, - value, - }) - return value - } - - const serviceLogin = await ensureServiceAccessToken() - const aliasLocalpart = tenantSpaceAliasLocalpart(tenant) - const createdRoom = await requestMatrixJson<{ room_id: string }>( - "/_matrix/client/v3/createRoom", - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - creation_content: { - type: "m.space", - }, - name: `FEDEO · ${tenant.name}`, - topic: `Kommunikationsbereich für ${tenant.name}`, - preset: "private_chat", - visibility: "private", - room_alias_name: aliasLocalpart, - invite: [userAccount.matrixUserId], - initial_state: [ - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "invited", - }, - }, - ], - }), - } - ) - - const value = { - tenantId: tenant.id, - tenantName: tenant.name, - alias: tenantSpaceAlias(tenant), - exists: true, - created: true, - alreadyExisted: false, - roomId: createdRoom.room_id, - invitedUserId: userAccount.matrixUserId, - serviceUserId: serviceLogin.matrixUserId, - } - - matrixTenantSpaceCache.set(cacheKey, { - exists: true, - cachedUntil: Date.now() + 30 * 60 * 1000, - value, - }) - return value - } - - const roomMetadataToApi = ( - tenant: { id: number, name?: string | null, short?: string | null }, - room: typeof communicationRooms.$inferSelect - ) => ({ - id: room.id, - tenantId: tenant.id, - tenantName: tenant.name, - key: room.key, - name: room.name, - topic: room.topic, - type: room.type, - entityType: room.entityType, - entityId: room.entityId, - entityUuid: room.entityUuid, - alias: room.matrixAlias || tenantRoomAlias(tenant, room.key), - exists: Boolean(room.matrixRoomId), - roomId: room.matrixRoomId, - parentSpaceRoomId: room.parentSpaceRoomId, - servers: [], - archived: room.archived, - }) - - const findTenantRoomMetadata = async (tenantId: number, key: string) => { - const [room] = await server.db - .select() - .from(communicationRooms) - .where(and( - eq(communicationRooms.tenantId, tenantId), - eq(communicationRooms.key, key) - )) - .limit(1) - - return room - } - - const ensureTenantRoomMetadata = async ( - tenant: { id: number, name?: string | null, short?: string | null }, - options: MatrixTenantRoomOptions - ) => { - const normalizedOptions = normalizeTenantRoomOptions(options) - const existing = await findTenantRoomMetadata(tenant.id, normalizedOptions.key) - const expectedAlias = tenantRoomAlias(tenant, normalizedOptions.key) - - if (existing) { - const hasStaleMatrixRoomId = !belongsToCurrentMatrixServer(existing.matrixRoomId) - const hasStaleMatrixAlias = !belongsToCurrentMatrixServer(existing.matrixAlias) - const shouldUpdate = - hasStaleMatrixRoomId || - hasStaleMatrixAlias || - (options.name !== undefined && existing.name !== normalizedOptions.name) || - (options.topic !== undefined && existing.topic !== normalizedOptions.topic) || - (options.type !== undefined && existing.type !== normalizedOptions.type) || - (options.entityType !== undefined && existing.entityType !== normalizedOptions.entityType) || - (options.entityId !== undefined && existing.entityId !== normalizedOptions.entityId) || - (options.entityUuid !== undefined && existing.entityUuid !== normalizedOptions.entityUuid) - - if (!shouldUpdate) return existing - - const [updated] = await server.db - .update(communicationRooms) - .set({ - name: options.name !== undefined ? normalizedOptions.name : existing.name, - topic: options.topic !== undefined ? normalizedOptions.topic : existing.topic, - type: options.type !== undefined ? normalizedOptions.type : existing.type, - entityType: options.entityType !== undefined ? normalizedOptions.entityType : existing.entityType, - entityId: options.entityId !== undefined ? normalizedOptions.entityId : existing.entityId, - entityUuid: options.entityUuid !== undefined ? normalizedOptions.entityUuid : existing.entityUuid, - matrixRoomId: hasStaleMatrixRoomId ? null : existing.matrixRoomId, - matrixAlias: hasStaleMatrixAlias ? expectedAlias : existing.matrixAlias, - parentSpaceRoomId: hasStaleMatrixRoomId ? null : existing.parentSpaceRoomId, - updatedAt: new Date(), - }) - .where(eq(communicationRooms.id, existing.id)) - .returning() - - return updated - } - - const [created] = await server.db - .insert(communicationRooms) - .values({ - tenantId: tenant.id, - key: normalizedOptions.key, - name: normalizedOptions.name, - topic: normalizedOptions.topic, - type: normalizedOptions.type, - entityType: normalizedOptions.entityType, - entityId: normalizedOptions.entityId, - entityUuid: normalizedOptions.entityUuid, - matrixAlias: expectedAlias, - }) - .returning() - - return created - } - - const markTenantRoomProvisioned = async ( - metadataId: string, - values: { - matrixRoomId: string - matrixAlias: string - parentSpaceRoomId?: string | null - } - ) => { - const [updated] = await server.db - .update(communicationRooms) - .set({ - matrixRoomId: values.matrixRoomId, - matrixAlias: values.matrixAlias, - parentSpaceRoomId: values.parentSpaceRoomId || null, - updatedAt: new Date(), - }) - .where(eq(communicationRooms.id, metadataId)) - .returning() - - return updated - } - - const getTenantRoomStatus = async ( - tenantId: number | null, - roomKey: string, - roomName?: string - ) => { - const tenant = await getCurrentTenant(tenantId) - const normalizedOptions = normalizeTenantRoomOptions({ key: roomKey, name: roomName }) - const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions) - const alias = metadata.matrixAlias || tenantRoomAlias(tenant, normalizedOptions.key) - - try { - const directoryEntry = await requestJson<{ - room_id: string - servers: string[] - }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) - const roomMetadata = metadata.matrixRoomId === directoryEntry.room_id - ? metadata - : await markTenantRoomProvisioned(metadata.id, { - matrixRoomId: directoryEntry.room_id, - matrixAlias: alias, - parentSpaceRoomId: metadata.parentSpaceRoomId, - }) - - return { - tenantId: tenant.id, - tenantName: tenant.name, - id: roomMetadata.id, - key: roomMetadata.key, - name: roomMetadata.name, - topic: roomMetadata.topic, - type: roomMetadata.type, - entityType: roomMetadata.entityType, - entityId: roomMetadata.entityId, - entityUuid: roomMetadata.entityUuid, - alias, - exists: true, - roomId: directoryEntry.room_id, - parentSpaceRoomId: roomMetadata.parentSpaceRoomId, - servers: directoryEntry.servers, - } - } catch (err: any) { - if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { - return { - tenantId: tenant.id, - tenantName: tenant.name, - id: metadata.id, - key: metadata.key, - name: metadata.name, - topic: metadata.topic, - type: metadata.type, - entityType: metadata.entityType, - entityId: metadata.entityId, - entityUuid: metadata.entityUuid, - alias, - exists: false, - roomId: belongsToCurrentMatrixServer(metadata.matrixRoomId) ? metadata.matrixRoomId : null, - parentSpaceRoomId: belongsToCurrentMatrixServer(metadata.parentSpaceRoomId) ? metadata.parentSpaceRoomId : null, - servers: [], - } - } - - throw err - } - } - - const provisionTenantRoom = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const tenant = await getCurrentTenant(tenantId) - const normalizedOptions = normalizeTenantRoomOptions(options) - const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions) - const key = normalizedOptions.key - const name = normalizedOptions.name - const topic = (normalizedOptions.topic || `Allgemeiner Kommunikationsraum für ${tenant.name}`).trim() - const cacheKey = `${tenant.id}:${key}` - const cachedRoom = matrixTenantRoomCache.get(cacheKey) - - if (cachedRoom?.exists && cachedRoom.cachedUntil > Date.now()) { - return cachedRoom.value - } - - const existing = await getTenantRoomStatus(tenant.id, key, name) - const userAccount = await provisionCurrentUser(userId, tenant.id) - const invitedMatrixUserIds = await matrixUserIdsForInvitees(userId, tenant.id, normalizedOptions.inviteUserIds || []) - const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id) - - if (existing.exists) { - await markTenantRoomProvisioned(metadata.id, { - matrixRoomId: existing.roomId, - matrixAlias: existing.alias, - parentSpaceRoomId: existing.parentSpaceRoomId, - }) - - const value = { - ...existing, - created: false, - alreadyExisted: true, - parentSpaceRoomId: tenantSpace.roomId, - invitedUserId: userAccount.matrixUserId, - } - - await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds) - - matrixTenantRoomCache.set(cacheKey, { - exists: true, - cachedUntil: Date.now() + 30 * 60 * 1000, - value, - }) - return value - } - - const serviceLogin = await ensureServiceAccessToken() - const createdRoom = await requestMatrixJson<{ room_id: string }>( - "/_matrix/client/v3/createRoom", - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name, - topic, - preset: "private_chat", - visibility: "private", - room_alias_name: tenantRoomAliasLocalpart(tenant, key), - invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])), - initial_state: [ - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "invited", - }, - }, - { - type: "m.space.parent", - state_key: tenantSpace.roomId, - content: { - via: [serverName()], - canonical: true, - }, - }, - ], - }), - } - ) - - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(tenantSpace.roomId)}/state/m.space.child/${encodeURIComponent(createdRoom.room_id)}`, - serviceLogin.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - via: [serverName()], - suggested: true, - order: "10", - }), - } - ) - - const value = { - id: metadata.id, - tenantId: tenant.id, - tenantName: tenant.name, - key, - name, - topic, - type: metadata.type, - entityType: metadata.entityType, - entityId: metadata.entityId, - entityUuid: metadata.entityUuid, - alias: tenantRoomAlias(tenant, key), - exists: true, - created: true, - alreadyExisted: false, - roomId: createdRoom.room_id, - parentSpaceRoomId: tenantSpace.roomId, - invitedUserId: userAccount.matrixUserId, - serviceUserId: serviceLogin.matrixUserId, - } - - await markTenantRoomProvisioned(metadata.id, { - matrixRoomId: value.roomId, - matrixAlias: value.alias, - parentSpaceRoomId: value.parentSpaceRoomId, - }) - - matrixTenantRoomCache.set(cacheKey, { - exists: true, - cachedUntil: Date.now() + 30 * 60 * 1000, - value, - }) - return value - } - - const matrixUserIdsForInvitees = async ( - currentUserId: string, - tenantId: number, - inviteUserIds: string[] - ) => { - const uniqueUserIds = Array.from(new Set(inviteUserIds.filter((id) => id && id !== currentUserId))) - - return await Promise.all(uniqueUserIds.map(async (inviteUserId) => { - const account = await provisionCurrentUser(inviteUserId, tenantId) - return account.matrixUserId - })) - } - - const inviteUsersToRoom = async (roomId: string | null, matrixUserIds: string[]) => { - if (!roomId || !matrixUserIds.length) return - - const serviceLogin = await ensureServiceAccessToken() - - for (const matrixUserId of matrixUserIds) { - try { - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user_id: matrixUserId }), - } - ) - } catch (err: any) { - if (err.statusCode === 403 || err.statusCode === 400) continue - throw err - } - } - } - - const ensureServiceUserJoinedRoom = async (room: { roomId?: string | null; alias?: string | null }) => { - const target = room.roomId || room.alias - if (!target) return { ok: false, status: "missing_room" } - if (!belongsToCurrentMatrixServer(target)) { - return { ok: false, status: "stale_room", roomId: target } - } - - const serviceLogin = await ensureServiceAccessToken() - - try { - await requestMatrixJson( - `/_synapse/admin/v1/join/${encodeURIComponent(target)}`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user_id: serviceLogin.matrixUserId }), - } - ) - - return { ok: true, status: "joined_admin", roomId: target } - } catch (adminErr: any) { - try { - await requestMatrixJson( - `/_matrix/client/v3/join/${encodeURIComponent(target)}`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - return { ok: true, status: "joined_client", roomId: target } - } catch (clientErr: any) { - return { - ok: false, - status: "failed", - roomId: target, - error: clientErr.message || adminErr.message || "Service-Join fehlgeschlagen", - } - } - } - } - - const syncServiceJoinedTenantRooms = async () => { - const rooms = await server.db - .select({ - id: communicationRooms.id, - tenantId: communicationRooms.tenantId, - key: communicationRooms.key, - name: communicationRooms.name, - matrixRoomId: communicationRooms.matrixRoomId, - matrixAlias: communicationRooms.matrixAlias, - }) - .from(communicationRooms) - .where(and( - eq(communicationRooms.archived, false), - isNotNull(communicationRooms.matrixRoomId) - )) - - const results = [] - for (const room of rooms) { - const result = await ensureServiceUserJoinedRoom({ - roomId: room.matrixRoomId, - alias: room.matrixAlias, - }) - results.push({ - roomId: room.matrixRoomId, - roomKey: room.key, - roomName: room.name, - ...result, - }) - } - - return { - total: results.length, - joined: results.filter((result) => result.ok).length, - failed: results.filter((result) => !result.ok).length, - results, - } - } - - const ensureCurrentUserJoinedRoom = async ( - userId: string, - tenantId: number | null, - room: { roomId: string, alias: string } - ) => { - const session = await createAccessTokenForUser(userId, tenantId) - const joinCacheKey = `${session.matrixUserId}:${room.roomId || room.alias}` - const joinedUntil = matrixJoinedRoomCache.get(joinCacheKey) - - if (joinedUntil && joinedUntil > Date.now()) { - return session - } - - await requestMatrixJson( - `/_matrix/client/v3/join/${encodeURIComponent(room.roomId || room.alias)}`, - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - matrixJoinedRoomCache.set(joinCacheKey, Date.now() + 30 * 60 * 1000) - return session - } - - const listTenantRooms = async (tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - - for (const room of defaultTenantRooms) { - await ensureTenantRoomMetadata(tenant, room) - } - - const rooms = await server.db - .select() - .from(communicationRooms) - .where(and( - eq(communicationRooms.tenantId, tenant.id), - eq(communicationRooms.archived, false) - )) - - return { - tenantId: tenant.id, - tenantName: tenant.name, - rooms: rooms.map((room) => roomMetadataToApi(tenant, room)).sort((a, b) => - String(a.name || a.key).localeCompare(String(b.name || b.key), "de") - ), - } - } - - const getTenantRoomMessages = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - const response = await requestMatrixJson<{ - chunk: MatrixRoomEvent[] - start?: string - end?: string - }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/messages?dir=b&limit=50`, - session.accessToken - ) - const members = await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, - session.accessToken - ) - - const replacementByEventId = new Map() - const reactionsByEventId = new Map>() - - for (const event of response.chunk) { - const relation = event.content?.["m.relates_to"] - - if ( - event.type === "m.room.message" && - relation?.rel_type === "m.replace" && - relation.event_id - ) { - replacementByEventId.set(relation.event_id, event) - } - - if ( - event.type === "m.reaction" && - relation?.rel_type === "m.annotation" && - relation.event_id && - relation.key - ) { - const eventReactions = reactionsByEventId.get(relation.event_id) || new Map() - const reaction = eventReactions.get(relation.key) || { - key: relation.key, - count: 0, - own: false, - } - - reaction.count += 1 - reaction.own = reaction.own || event.sender === session.matrixUserId - eventReactions.set(relation.key, reaction) - reactionsByEventId.set(relation.event_id, eventReactions) - } - } - - const messages = response.chunk - .filter((event) => - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && - event.content?.["m.relates_to"]?.rel_type !== "m.replace" - ) - .map((event) => { - const replacement = replacementByEventId.get(event.event_id) - const content = replacement?.content?.["m.new_content"] || replacement?.content || event.content - - return { - id: event.event_id, - sender: event.sender, - senderDisplayName: members.joined[event.sender]?.display_name || event.sender, - body: content?.body || "", - attachment: attachmentFromEvent({ ...event, content }), - timestamp: replacement?.origin_server_ts || event.origin_server_ts, - own: event.sender === session.matrixUserId, - edited: Boolean(replacement), - replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, - reactions: Array.from(reactionsByEventId.get(event.event_id)?.values() || []), - } - }) - .reverse() - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - matrixUserId: session.matrixUserId, - messages, - } - } - - const getTenantRoomMembers = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const members = await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, - session.accessToken - ) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - members: Object.entries(members.joined).map(([matrixUserId, member]) => ({ - matrixUserId, - displayName: member.display_name || matrixUserId, - avatarUrl: member.avatar_url || null, - own: matrixUserId === session.matrixUserId, - })), - } - } - - const searchTenantRoomMessages = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - query: string - ) => { - const searchTerm = query.trim() - - if (searchTerm.length < 2) { - return { - roomId: "", - alias: "", - key: options.key || "allgemein", - name: options.name || options.key || "Chat", - count: 0, - results: [], - } - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const [response, members] = await Promise.all([ - requestMatrixJson( - "/_matrix/client/v3/search", - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - search_categories: { - room_events: { - search_term: searchTerm, - keys: ["content.body"], - order_by: "recent", - filter: { - limit: 25, - rooms: [room.roomId], - }, - }, - }, - }), - } - ), - requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, - session.accessToken - ), - ]) - - const roomEvents = response.search_categories?.room_events - const results = (roomEvents?.results || []) - .map((item) => item.result) - .filter((event): event is MatrixRoomEvent & { room_id?: string } => - Boolean( - event?.event_id && - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") - ) - ) - .map((event) => ({ - id: event.event_id, - roomId: event.room_id || room.roomId, - key: room.key, - sender: event.sender, - senderDisplayName: members.joined[event.sender]?.display_name || event.sender, - body: event.content?.body || "", - attachment: attachmentFromEvent(event), - timestamp: event.origin_server_ts, - own: event.sender === session.matrixUserId, - })) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - count: roomEvents?.count || results.length, - results, - } - } - - const syncTenantRoomEvents = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - since?: string, - initial = false - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const filter = { - room: { - rooms: [room.roomId], - timeline: { - limit: 30, - }, - }, - presence: { - types: [], - }, - account_data: { - types: [], - }, - } - const params = new URLSearchParams({ - timeout: since && !initial ? "25000" : "0", - filter: JSON.stringify(filter), - }) - - if (since) params.set("since", since) - - const response = await requestMatrixJson( - `/_matrix/client/v3/sync?${params.toString()}`, - session.accessToken - ) - const joinedRoom = response.rooms?.join?.[room.roomId] - const timelineEvents = joinedRoom?.timeline?.events || [] - const stateEvents = joinedRoom?.state?.events || [] - - if (initial) { - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - nextBatch: response.next_batch || since || "", - messages: [], - replacements: [], - reactions: [], - redactions: [], - membersChanged: false, - } - } - - const messages = timelineEvents - .filter((event) => - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && - event.content?.["m.relates_to"]?.rel_type !== "m.replace" - ) - .map((event) => ({ - id: event.event_id, - sender: event.sender, - senderDisplayName: event.sender, - body: event.content?.body || "", - attachment: attachmentFromEvent(event), - timestamp: event.origin_server_ts, - own: event.sender === session.matrixUserId, - replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, - reactions: [], - })) - - const replacements = timelineEvents - .filter((event) => - event.type === "m.room.message" && - event.content?.["m.relates_to"]?.rel_type === "m.replace" && - Boolean(event.content?.["m.relates_to"]?.event_id) - ) - .map((event) => ({ - id: event.event_id, - targetEventId: event.content?.["m.relates_to"]?.event_id, - body: event.content?.["m.new_content"]?.body || event.content?.body || "", - timestamp: event.origin_server_ts, - sender: event.sender, - own: event.sender === session.matrixUserId, - })) - - const reactions = timelineEvents - .filter((event) => - event.type === "m.reaction" && - event.content?.["m.relates_to"]?.rel_type === "m.annotation" && - Boolean(event.content?.["m.relates_to"]?.event_id) && - Boolean(event.content?.["m.relates_to"]?.key) - ) - .map((event) => ({ - id: event.event_id, - targetEventId: event.content?.["m.relates_to"]?.event_id, - key: event.content?.["m.relates_to"]?.key, - sender: event.sender, - own: event.sender === session.matrixUserId, - })) - - const redactions = timelineEvents - .filter((event) => event.type === "m.room.redaction" && Boolean(event.redacts)) - .map((event) => ({ - id: event.event_id, - targetEventId: event.redacts, - sender: event.sender, - timestamp: event.origin_server_ts, - })) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - nextBatch: response.next_batch || since || "", - messages, - replacements, - reactions, - redactions, - membersChanged: [...timelineEvents, ...stateEvents].some((event) => event.type === "m.room.member"), - } - } - - const syncServiceRoomEvents = async (since?: string, initial = false) => { - const service = await ensureServiceAccessToken() - const filter = { - room: { - timeline: { - limit: 50, - }, - }, - presence: { - types: [], - }, - account_data: { - types: [], - }, - } - const params = new URLSearchParams({ - timeout: since && !initial ? "25000" : "0", - filter: JSON.stringify(filter), - }) - - if (since) params.set("since", since) - - const response = await requestMatrixJson( - `/_matrix/client/v3/sync?${params.toString()}`, - service.accessToken - ) - const joinedRooms = response.rooms?.join || {} - - return { - nextBatch: response.next_batch || since || "", - serviceUserId: service.matrixUserId, - rooms: Object.entries(joinedRooms).map(([roomId, joinedRoom]) => { - const timelineEvents = joinedRoom.timeline?.events || [] - const messages = initial - ? [] - : timelineEvents - .filter((event) => - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && - event.content?.["m.relates_to"]?.rel_type !== "m.replace" - ) - .map((event) => ({ - id: event.event_id, - roomId, - sender: event.sender, - senderDisplayName: event.sender, - body: event.content?.body || "", - attachment: attachmentFromEvent(event), - timestamp: event.origin_server_ts, - own: event.sender === service.matrixUserId, - replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, - })) - - return { - roomId, - messages, - membersChanged: [...timelineEvents, ...(joinedRoom.state?.events || [])] - .some((event) => event.type === "m.room.member"), - } - }), - } - } - - const sendTenantRoomMessage = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - text: string, - messageOptions: MatrixMessageOptions = {} - ) => { - const message = text.trim() - - if (!message) { - throw Object.assign( - new Error("Message text is required"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - - const content: Record = { - msgtype: "m.text", - body: message, - } - - if (messageOptions.replyToEventId) { - content["m.relates_to"] = { - "m.in_reply_to": { - event_id: messageOptions.replyToEventId, - }, - } - } - - const response = await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(content), - } - ) - - return { - id: response.event_id, - sender: session.matrixUserId, - senderDisplayName: await getCurrentUserDisplayName(userId, tenantId), - body: message, - timestamp: Date.now(), - own: true, - roomId: room.roomId, - alias: room.alias, - key: room.key, - replyToEventId: messageOptions.replyToEventId || null, - reactions: [], - } - } - - const sendTenantRoomReaction = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string, - key: string - ) => { - const reactionKey = key.trim() - if (!eventId || !reactionKey) { - throw Object.assign( - new Error("Reaction target and key are required"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - "m.relates_to": { - rel_type: "m.annotation", - event_id: eventId, - key: reactionKey, - }, - }), - } - ) - - return { success: true, eventId, key: reactionKey } - } - - const editTenantRoomMessage = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string, - text: string - ) => { - const message = text.trim() - if (!eventId || !message) { - throw Object.assign( - new Error("Nachricht und Zielnachricht sind erforderlich"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - const response = await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - msgtype: "m.text", - body: `* ${message}`, - "m.new_content": { - msgtype: "m.text", - body: message, - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: eventId, - }, - }), - } - ) - - return { - id: response.event_id, - targetEventId: eventId, - body: message, - timestamp: Date.now(), - own: true, - } - } - - const redactTenantRoomMessage = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string - ) => { - if (!eventId) { - throw Object.assign( - new Error("Zielnachricht ist erforderlich"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - reason: "Nachricht in FEDEO gelöscht", - }), - } - ) - - return { success: true, eventId } - } - - const markTenantRoomRead = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string - ) => { - if (!eventId) return { success: true, skipped: true } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`, - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - return { success: true, eventId } - } - - const sendTenantRoomAttachment = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - attachment: MatrixAttachmentInput - ) => { - if (!attachment.buffer?.length) { - throw Object.assign( - new Error("Attachment file is required"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const upload = await requestMatrixJson<{ content_uri: string }>( - `/_matrix/media/v3/upload?filename=${encodeURIComponent(attachment.filename)}`, - session.accessToken, - { - method: "POST", - headers: { "Content-Type": attachment.mimeType || "application/octet-stream" }, - body: attachment.buffer as any, - } - ) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - const isImage = (attachment.mimeType || "").startsWith("image/") - const messageContent = { - msgtype: isImage ? "m.image" : "m.file", - body: attachment.filename, - url: upload.content_uri, - info: { - mimetype: attachment.mimeType || "application/octet-stream", - size: attachment.size, - }, - } - const response = await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(messageContent), - } - ) - - return { - id: response.event_id, - sender: session.matrixUserId, - senderDisplayName: await getCurrentUserDisplayName(userId, tenantId), - body: attachment.filename, - attachment: { - type: isImage ? "image" : "file", - url: upload.content_uri, - fileName: attachment.filename, - mimeType: attachment.mimeType || "application/octet-stream", - size: attachment.size, - previewUrl: isImage ? matrixMediaUrl(upload.content_uri) : null, - downloadUrl: matrixMediaUrl(upload.content_uri), - isImage, - }, - timestamp: Date.now(), - own: true, - roomId: room.roomId, - alias: room.alias, - key: room.key, - } - } - - const getMediaContent = async ( - userId: string, - tenantId: number | null, - mxcUri: string - ) => { - const session = await createAccessTokenForUser(userId, tenantId) - const response = await fetch(matrixMediaUrl(mxcUri), { - headers: { - Authorization: `Bearer ${session.accessToken}`, - }, - }) - - if (!response.ok) { - throw Object.assign( - new Error(`Matrix media request failed with ${response.status}`), - { statusCode: response.status } - ) - } - - return { - buffer: Buffer.from(await response.arrayBuffer()), - contentType: response.headers.get("content-type") || "application/octet-stream", - contentLength: response.headers.get("content-length"), - } - } - - const createElementRoomSession = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - const token = await requestMatrixJson( - "/_matrix/client/v1/login/get_token", - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - matrixUserId: session.matrixUserId, - loginToken: token.login_token, - expiresInMs: token.expires_in_ms, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - } - } - - const createLiveKitRoomSession = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - if (!livekitKey() || !livekitSecret()) { - throw Object.assign( - new Error("LIVEKIT_KEY and LIVEKIT_SECRET are not configured"), - { statusCode: 503 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const displayName = await getCurrentUserDisplayName(userId, tenantId) - const liveKitRoomName = `fedeo-${tenantId || "global"}-${room.key}`.replace(/[^a-zA-Z0-9_-]/g, "_") - const now = Math.floor(Date.now() / 1000) - const expiresInSeconds = 60 * 60 - const video: LiveKitGrant = { - roomJoin: true, - room: liveKitRoomName, - canPublish: true, - canSubscribe: true, - } - const token = jwt.sign( - { - sub: session.matrixUserId, - name: displayName, - video, - nbf: now - 10, - exp: now + expiresInSeconds, - }, - livekitSecret(), - { - algorithm: "HS256", - issuer: livekitKey(), - } - ) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - matrixUserId: session.matrixUserId, - displayName, - liveKitUrl: livekitUrl(), - liveKitRoomName, - liveKitToken: token, - expiresInMs: expiresInSeconds * 1000, - } - } - - const listTenantCommunicationUsers = async (tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - const rows = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - profileActive: authProfiles.active, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenant.id) - )) - .where(eq(authTenantUsers.tenant_id, tenant.id)) - - const users = rows - .filter((row) => row.profileActive !== false) - .map((row) => ({ - userId: row.userId, - email: row.email, - displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email, - })) - - return await Promise.all(users.map(async (user) => ({ - ...user, - matrixUserId: await matrixUserIdForUser(user.userId, tenant.id), - }))) - } - - const inviteMatrixUserToRoom = async ( - room: { roomId: string }, - matrixUserId: string, - reason?: string - ) => { - const serviceLogin = await ensureServiceAccessToken() - - try { - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/invite`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user_id: matrixUserId, - reason, - }), - } - ) - - return "invited" - } catch (err: any) { - if ( - err.errcode === "M_FORBIDDEN" || - err.errcode === "M_BAD_STATE" || - err.errcode === "M_UNKNOWN" - ) { - return "already_available" - } - - throw err - } - } - - const syncTenantRoomMembers = async ( - requestingUserId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(requestingUserId, tenantId, options) - const users = await listTenantCommunicationUsers(tenantId) - const results = [] - - for (const user of users) { - try { - const account = await provisionCurrentUser(user.userId, tenantId) - await inviteMatrixUserToRoom( - { roomId: room.roomId }, - account.matrixUserId, - `FEDEO-Raumsynchronisation: ${room.name}` - ) - await ensureCurrentUserJoinedRoom(user.userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - results.push({ - userId: user.userId, - email: user.email, - displayName: user.displayName, - matrixUserId: account.matrixUserId, - status: "joined", - ok: true, - }) - } catch (err: any) { - results.push({ - userId: user.userId, - email: user.email, - displayName: user.displayName, - status: "failed", - ok: false, - error: err.message || "Synchronisation fehlgeschlagen", - }) - } - } - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - total: results.length, - joined: results.filter((item) => item.status === "joined").length, - invited: results.filter((item) => item.status === "invited").length, - alreadyAvailable: results.filter((item) => item.status === "already_available").length, - failed: results.filter((item) => !item.ok).length, - results, - } - } - - const inviteTenantRoomMember = async ( - requestingUserId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - targetUserId: string - ) => { - if (!targetUserId) { - throw Object.assign( - new Error("Benutzer ist erforderlich"), - { statusCode: 400 } - ) - } - - const users = await listTenantCommunicationUsers(tenantId) - const targetUser = users.find((user) => user.userId === targetUserId) - - if (!targetUser) { - throw Object.assign( - new Error("Benutzer gehört nicht zum aktiven Mandanten"), - { statusCode: 404 } - ) - } - - const room = await provisionTenantRoom(requestingUserId, tenantId, options) - const account = await provisionCurrentUser(targetUser.userId, tenantId) - const inviteStatus = await inviteMatrixUserToRoom( - { roomId: room.roomId }, - account.matrixUserId, - `FEDEO-Einladung: ${room.name}` - ) - await ensureCurrentUserJoinedRoom(targetUser.userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - userId: targetUser.userId, - email: targetUser.email, - displayName: targetUser.displayName, - matrixUserId: account.matrixUserId, - status: inviteStatus === "invited" ? "joined" : inviteStatus, - ok: true, - } - } - - const removeTenantRoomMember = async ( - requestingUserId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - matrixUserId: string - ) => { - if (!matrixUserId) { - throw Object.assign( - new Error("Matrix-Benutzer ist erforderlich"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(requestingUserId, tenantId, options) - const session = await createAccessTokenForUser(requestingUserId, tenantId) - - if (matrixUserId === session.matrixUserId) { - throw Object.assign( - new Error("Du kannst dich nicht selbst aus dem Raum entfernen"), - { statusCode: 400 } - ) - } - - const serviceLogin = await ensureServiceAccessToken() - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/kick`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user_id: matrixUserId, - reason: "FEDEO-Mitgliederverwaltung", - }), - } - ) - - matrixJoinedRoomCache.delete(`${matrixUserId}:${room.roomId}`) - return { - success: true, - roomId: room.roomId, - alias: room.alias, - key: room.key, - matrixUserId, - } - } - - const getGeneralRoomMessages = (userId: string, tenantId: number | null) => - getTenantRoomMessages(userId, tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - - const getGeneralRoomMembers = (userId: string, tenantId: number | null) => - getTenantRoomMembers(userId, tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - - const sendGeneralRoomMessage = ( - userId: string, - tenantId: number | null, - text: string, - messageOptions: MatrixMessageOptions = {} - ) => - sendTenantRoomMessage( - userId, - tenantId, - { - key: "allgemein", - name: "Allgemeiner Chat", - }, - text, - messageOptions - ) - - const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) => - sendTenantRoomAttachment( - userId, - tenantId, - { - key: "allgemein", - name: "Allgemeiner Chat", - }, - attachment - ) - - return { - getStatus, - matrixUserIdForUser, - getCurrentUserDisplayName, - provisionCurrentUser, - getTenantSpaceStatus, - provisionCurrentTenantSpace, - listTenantRooms, - listTenantCommunicationUsers, - getTenantRoomStatus, - provisionTenantRoom, - createAccessTokenForUser, - getTenantRoomMessages, - getTenantRoomMembers, - searchTenantRoomMessages, - syncTenantRoomEvents, - syncServiceRoomEvents, - syncServiceJoinedTenantRooms, - sendTenantRoomMessage, - sendTenantRoomReaction, - editTenantRoomMessage, - redactTenantRoomMessage, - markTenantRoomRead, - sendTenantRoomAttachment, - getMediaContent, - createElementRoomSession, - createLiveKitRoomSession, - inviteTenantRoomMember, - removeTenantRoomMember, - syncTenantRoomMembers, - getGeneralRoomMessages, - getGeneralRoomMembers, - sendGeneralRoomMessage, - sendGeneralRoomAttachment, - } -} diff --git a/backend/src/modules/system-status.service.ts b/backend/src/modules/system-status.service.ts index 0eb8641..c1f41a7 100644 --- a/backend/src/modules/system-status.service.ts +++ b/backend/src/modules/system-status.service.ts @@ -1,5 +1,4 @@ import { FastifyInstance } from "fastify" -import { matrixService } from "./matrix.service" type MetricSample = { labels: Record @@ -117,10 +116,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => { const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null const databaseCheck = await server.db.execute("SELECT NOW() as now") - const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({ - reachable: false, - error: err?.message || "Matrix-Status nicht verfügbar", - })) const minioUrl = s3EndpointUrl() return { @@ -165,7 +160,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => { url: nodeExporterMetricsUrl, error: nodeExporterError, }), - matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record), minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, { error: "S3_ENDPOINT ist nicht gesetzt", }), diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index cf8ae10..2c37636 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -30,7 +30,6 @@ import { } from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport"; import { buildSystemStatus } from "../modules/system-status.service"; -import { matrixService } from "../modules/matrix.service"; import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; @@ -385,27 +384,7 @@ export default async function adminRoutes(server: FastifyInstance) { }); } - let matrixProvisioned = false; - let matrixProvisioningError: string | null = null; - if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) { - try { - const matrix = matrixService(server); - await matrix.provisionTenantRoom(currentUser.id, result.tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - type: "general", - }); - matrixProvisioned = true; - } catch (err: any) { - matrixProvisioningError = err?.message || String(err); - server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden"); - } - } - - return { - matrixProvisioned, - matrixProvisioningError, - }; + return { chatReady: true }; }; const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index d08df09..58faeda 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -1,23 +1,20 @@ import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" -import multipart from "@fastify/multipart" -import { and, desc, eq, inArray, ne } from "drizzle-orm" -import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema" -import { matrixService } from "../modules/matrix.service" -import { getMatrixPushWorkerState } from "../modules/matrix-push-worker.service" +import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm" +import { + authProfiles, + authTenantUsers, + authUsers, + communicationMessages, + communicationRoomMembers, + communicationRoomReads, + communicationRooms, + notificationsItems, + projects, +} from "../../db/schema" import { NotificationService, UserDirectory } from "../modules/notification.service" -const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => { - const rows = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - return rows[0] || null -} - -type ChatRecipient = { +type ChatUser = { userId: string email?: string | null firstName?: string | null @@ -25,991 +22,429 @@ type ChatRecipient = { fullName?: string | null } +const getUserDirectory: UserDirectory = async (server, userId) => { + const [user] = await server.db + .select({ email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .limit(1) + return user || null +} + +const displayName = (user: Omit) => + user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email || "Benutzer" + +const directRoomKey = (firstUserId: string, secondUserId: string) => { + const hash = createHash("sha256") + .update([firstUserId, secondUserId].sort().join(":")) + .digest("hex") + .slice(0, 16) + return `direct_${hash}` +} + +const normalizeRoomKey = (value: string) => value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9._=-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^[._=-]+|[._=-]+$/g, "") + export default async function communicationRoutes(server: FastifyInstance) { - await server.register(multipart, { - limits: { fileSize: 25 * 1024 * 1024 }, + const notifications = new NotificationService(server, getUserDirectory) + + const requireTenant = (req: any) => { + const tenantId = Number(req.user.tenant_id) + if (!tenantId) throw Object.assign(new Error("Kein aktiver Mandant"), { statusCode: 400 }) + return tenantId + } + + const tenantUsers = async (tenantId: number): Promise => server.db + .select({ + userId: authTenantUsers.user_id, + email: authUsers.email, + firstName: authProfiles.first_name, + lastName: authProfiles.last_name, + fullName: authProfiles.full_name, + }) + .from(authTenantUsers) + .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) + .leftJoin(authProfiles, and( + eq(authProfiles.user_id, authTenantUsers.user_id), + eq(authProfiles.tenant_id, tenantId) + )) + .where(eq(authTenantUsers.tenant_id, tenantId)) + + const addMembers = async (roomId: string, userIds: string[]) => { + const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean))) + if (!uniqueUserIds.length) return + await server.db.insert(communicationRoomMembers) + .values(uniqueUserIds.map((userId) => ({ roomId, userId }))) + .onConflictDoNothing() + } + + const ensureGeneralRoom = async (tenantId: number, creatorId: string) => { + let [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))) + .limit(1) + + if (!room) { + const [created] = await server.db.insert(communicationRooms).values({ + tenantId, + key: "allgemein", + name: "Allgemeiner Chat", + topic: "Mandantenweiter Austausch", + type: "general", + createdBy: creatorId, + }).onConflictDoNothing().returning() + room = created + if (!room) { + [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))) + .limit(1) + } + } + + await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId)) + return room + } + + const requireRoom = async (tenantId: number, userId: string, roomKey: string) => { + const [row] = await server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and( + eq(communicationRooms.tenantId, tenantId), + eq(communicationRooms.key, roomKey), + eq(communicationRooms.archived, false), + eq(communicationRoomMembers.userId, userId) + )) + .limit(1) + if (!row) throw Object.assign(new Error("Chatraum nicht gefunden oder kein Zugriff"), { statusCode: 404 }) + return row.room + } + + const roomToApi = (room: typeof communicationRooms.$inferSelect) => ({ + id: room.id, + key: room.key, + name: room.name, + topic: room.topic, + type: room.type, + entityType: room.entityType, + entityId: room.entityId, + entityUuid: room.entityUuid, + exists: true, }) - const matrix = matrixService(server) - const notifications = new NotificationService(server, getUserDirectory) - const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || fallbackMessage }) - } - - const roomOptionsFromRequest = (req: any) => { - const params = req.params as { roomKey?: string } - const body = (req.body || {}) as { - key?: string - name?: string - topic?: string - type?: string - entityType?: string | null - entityId?: number | null - entityUuid?: string | null - } - - return { - key: params.roomKey || body.key, - name: body.name, - topic: body.topic, - type: body.type, - entityType: body.entityType, - entityId: body.entityId, - entityUuid: body.entityUuid, - } - } - - const projectRoomKey = (projectId: number) => `project_${projectId}` - - const directRoomKey = (firstUserId: string, secondUserId: string) => { - const hash = createHash("sha256") - .update([firstUserId, secondUserId].sort().join(":")) - .digest("hex") - .slice(0, 16) - - return `direct_${hash}` - } - - const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => { - const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ") - return name || user.email || "Benutzer" - } - - const getTenantRecipients = async (tenantId: number, senderUserId: string) => { - return await server.db - .select({ - userId: authTenantUsers.user_id, + const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => { + const conditions = [eq(communicationMessages.roomId, roomId)] + if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId)) + const rows = await server.db.select().from(communicationMessages) + .where(and(...conditions)) + .orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id)) + .limit(Math.min(Math.max(limit, 1), 100)) + const orderedRows = afterId > 0 ? rows : rows.reverse() + const authorIds = Array.from(new Set(orderedRows.map((message) => message.authorUserId))) + const authors = authorIds.length + ? await server.db.select({ + userId: authUsers.id, email: authUsers.email, firstName: authProfiles.first_name, lastName: authProfiles.last_name, fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(and( - eq(authTenantUsers.tenant_id, tenantId), - ne(authTenantUsers.user_id, senderUserId) - )) + }).from(authUsers) + .leftJoin(authProfiles, and(eq(authProfiles.user_id, authUsers.id), eq(authProfiles.tenant_id, tenantId))) + .where(inArray(authUsers.id, authorIds)) + : [] + const authorById = new Map(authors.map((author) => [author.userId, author])) + return orderedRows.map((message) => ({ + id: message.id, + body: message.body, + sender: message.authorUserId, + senderDisplayName: displayName(authorById.get(message.authorUserId) || {}), + timestamp: message.createdAt.getTime(), + own: message.authorUserId === currentUserId, + })) } - const getTenantUser = async (tenantId: number, userId: string): Promise => { - const [user] = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(and( - eq(authTenantUsers.tenant_id, tenantId), - eq(authTenantUsers.user_id, userId) - )) - .limit(1) - - return user || null - } - - const getSenderName = async (tenantId: number, senderUserId: string) => { - const [sender] = await server.db - .select({ - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authUsers) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authUsers.id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(eq(authUsers.id, senderUserId)) - .limit(1) - - return sender ? displayUserName(sender) : "FEDEO" - } - - const mentionAliasesForUser = (user: ChatRecipient) => { - const name = displayUserName(user) - return Array.from(new Set([ - name, - user.fullName, - [user.firstName, user.lastName].filter(Boolean).join(" "), - user.firstName, - user.email, - ].filter(Boolean).map((value) => String(value).toLowerCase()))) - } - - const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => { - const normalizedText = text.toLowerCase() - - return recipients - .filter((recipient) => mentionAliasesForUser(recipient).some((alias) => - normalizedText.includes(`@${alias}`) - )) - .map((recipient) => recipient.userId) - } - - const chatMessageRecipients = async ( + const notifyMessageRecipients = async ( tenantId: number, - senderUserId: string, - room: any, - text: string + room: typeof communicationRooms.$inferSelect, + senderId: string, + message: { id: number; body: string } ) => { - const recipients = await getTenantRecipients(tenantId, senderUserId) - const mentioned = new Set(mentionedRecipientIds(text, recipients)) - const directRecipients = new Set() - - if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) { - directRecipients.add(room.entityUuid) - } else if (room?.type === "direct" && room.key) { - recipients - .filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key) - .forEach((recipient) => directRecipients.add(recipient.userId)) - } - - return recipients - .filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId)) - .map((recipient) => ({ - ...recipient, - mentioned: mentioned.has(recipient.userId), - direct: directRecipients.has(recipient.userId), - })) - } - - const notifyUsersAboutChatMessage = async (req: any, room: any, message: any, text: string) => { - if (!req.user.tenant_id) return - try { - const recipients = await chatMessageRecipients(req.user.tenant_id, req.user.user_id, room, text) + const users = await tenantUsers(tenantId) + const sender = users.find((user) => user.userId === senderId) + const memberRows = await server.db.select({ userId: communicationRoomMembers.userId }) + .from(communicationRoomMembers).where(eq(communicationRoomMembers.roomId, room.id)) + const memberIds = new Set(memberRows.map((member) => member.userId)) + const normalizedText = message.body.toLowerCase() + const recipients = users.filter((user) => { + if (user.userId === senderId || !memberIds.has(user.userId)) return false + if (room.type === "direct") return true + const aliases = [displayName(user), user.fullName, user.firstName, user.email] + .filter(Boolean).map((value) => String(value).toLowerCase()) + return aliases.some((alias) => normalizedText.includes(`@${alias}`)) + }) if (!recipients.length) return - - const senderName = await getSenderName(req.user.tenant_id, req.user.user_id) - const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text - - for (const recipient of recipients) { - await notifications.trigger({ - tenantId: req.user.tenant_id, - userId: recipient.userId, - eventType: "communication.message.new", - title: recipient.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`, - message: preview, - payload: { - link: `/communication/chat?room=${encodeURIComponent(room.key)}`, - roomKey: room.key, - roomName: room.name, - roomType: room.type, - messageId: message.id, - mentioned: recipient.mentioned, - direct: recipient.direct, - }, - channels: ["inapp", "push"], - }) - } - } catch (err) { - req.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden") - } - } - - const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => { - const rows = await server.db - .select({ - payload: notificationsItems.payload, - }) - .from(notificationsItems) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - eq(notificationsItems.eventType, "communication.message.new") - )) - .orderBy(desc(notificationsItems.createdAt)) - .limit(200) - - return rows.some((row) => (row.payload as any)?.messageId === messageId) - } - - const notifyCurrentUserAboutIncomingMatrixMessages = async (req: any, room: any, messages: any[]) => { - if (!req.user.tenant_id || !messages.length) return - - try { - const currentUser = await getTenantUser(req.user.tenant_id, req.user.user_id) - if (!currentUser) return - - for (const message of messages) { - if (message.own || !message.id) continue - - const text = message.body || message.attachment?.fileName || "Neue Nachricht" - const mentioned = mentionedRecipientIds(text, [currentUser]).includes(currentUser.userId) - const direct = room?.type === "direct" - - if (!direct && !mentioned) continue - if (await hasChatNotificationForMessage(req.user.tenant_id, req.user.user_id, message.id)) continue - - const senderName = message.senderDisplayName || message.sender || "Matrix" - const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text - - await notifications.trigger({ - tenantId: req.user.tenant_id, - userId: req.user.user_id, - eventType: "communication.message.new", - title: mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`, - message: preview, - payload: { - link: `/communication/chat?room=${encodeURIComponent(room.key)}`, - roomKey: room.key, - roomName: room.name, - roomType: room.type, - messageId: message.id, - matrixSender: message.sender, - mentioned, - direct, - }, - channels: ["inapp", "push"], - }) - } - } catch (err) { - req.log.error({ err }, "Eingehende Matrix-Benachrichtigung konnte nicht ausgelöst werden") - } - } - - const unreadChatNotifications = async (tenantId: number, userId: string) => { - return await server.db - .select({ - id: notificationsItems.id, - payload: notificationsItems.payload, - }) - .from(notificationsItems) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - eq(notificationsItems.eventType, "communication.message.new"), - eq(notificationsItems.channel, "inapp"), - ne(notificationsItems.status, "read") - )) - } - - const markRoomNotificationsRead = async (tenantId: number, userId: string, roomKey: string) => { - const rows = await unreadChatNotifications(tenantId, userId) - const ids = rows - .filter((row) => (row.payload as any)?.roomKey === roomKey) - .map((row) => row.id) - - if (!ids.length) return { read: 0 } - - await server.db - .update(notificationsItems) - .set({ readAt: new Date(), status: "read" }) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - inArray(notificationsItems.id, ids) - )) - - return { read: ids.length } - } - - const uploadedAttachmentFromRequest = async (req: any) => { - const data = await req.file() - if (!data?.file) { - throw Object.assign( - new Error("Keine Datei hochgeladen"), - { statusCode: 400 } - ) - } - - const buffer = await data.toBuffer() - return { - buffer, - filename: data.filename || "Anhang", - mimeType: data.mimetype || "application/octet-stream", - size: buffer.length, - } - } - - const callModeFromRequest = (req: any): "audio" | "video" => { - const body = (req.body || {}) as { mode?: string } - return body.mode === "audio" ? "audio" : "video" - } - - const notifyTenantUsersAboutCall = async (req: any, room: { key?: string; name?: string }, mode: "audio" | "video") => { - if (!req.user.tenant_id) return - - try { - const recipientRows = await server.db - .select({ userId: authTenantUsers.user_id }) - .from(authTenantUsers) - .where(and( - eq(authTenantUsers.tenant_id, req.user.tenant_id), - ne(authTenantUsers.user_id, req.user.user_id) - )) - - const userIds = recipientRows.map((row) => row.userId) - if (!userIds.length) return - + const senderName = sender ? displayName(sender) : "FEDEO" await notifications.trigger({ - tenantId: req.user.tenant_id, - userIds, - eventType: "communication.call.started", - title: mode === "audio" ? "Audioanruf gestartet" : "Videokonferenz gestartet", - message: `${room.name || room.key || "Ein Chatraum"} hat eine laufende Besprechung.`, + tenantId, + userIds: recipients.map((recipient) => recipient.userId), + eventType: "communication.message.new", + title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : `${senderName} hat dich erwähnt`, + message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body, payload: { - link: "/communication/chat", + link: `/communication/chat?room=${encodeURIComponent(room.key)}`, roomKey: room.key, roomName: room.name, - mode, + roomType: room.type, + messageId: message.id, + mentioned: room.type !== "direct", + direct: room.type === "direct", }, channels: ["inapp", "push"], }) } catch (err) { - req.log.error({ err }, "Call-Benachrichtigung konnte nicht ausgelöst werden") + server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden") } } - server.get("/communication/matrix/status", async () => { - return matrix.getStatus() + const unreadNotifications = async (tenantId: number, userId: string) => server.db + .select({ id: notificationsItems.id, payload: notificationsItems.payload }) + .from(notificationsItems) + .where(and( + eq(notificationsItems.tenantId, tenantId), + eq(notificationsItems.userId, userId), + eq(notificationsItems.eventType, "communication.message.new"), + eq(notificationsItems.channel, "inapp"), + ne(notificationsItems.status, "read") + )) + + server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" })) + + server.get("/communication/chat/users", async (req: any) => { + const tenantId = requireTenant(req) + return { users: (await tenantUsers(tenantId)).map((user) => ({ + userId: user.userId, + email: user.email, + displayName: displayName(user), + })) } }) - server.get("/communication/matrix/me", async (req) => { - const userId = req.user.user_id - - return { - matrixUserId: await matrix.matrixUserIdForUser(userId, req.user.tenant_id), - displayName: await matrix.getCurrentUserDisplayName(userId, req.user.tenant_id), - } + server.get("/communication/chat/rooms", async (req: any) => { + const tenantId = requireTenant(req) + await ensureGeneralRoom(tenantId, req.user.user_id) + const rows = await server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and( + eq(communicationRooms.tenantId, tenantId), + eq(communicationRooms.archived, false), + eq(communicationRoomMembers.userId, req.user.user_id) + )).orderBy(asc(communicationRooms.name)) + return { rooms: rows.map(({ room }) => roomToApi(room)) } }) - server.post("/communication/matrix/me/provision", async (req, reply) => { - try { - return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix provisioning failed") - } + server.post("/communication/chat/rooms", async (req: any, reply) => { + const tenantId = requireTenant(req) + const body = (req.body || {}) as { key?: string; name?: string; topic?: string } + const name = body.name?.trim() + const key = normalizeRoomKey(body.key?.trim() || name || "") + if (!name || !key) return reply.code(400).send({ error: "Name und gültiger Raumschlüssel sind erforderlich" }) + const [created] = await server.db.insert(communicationRooms).values({ + tenantId, key, name, topic: body.topic?.trim() || null, type: "room", createdBy: req.user.user_id, + }).onConflictDoNothing().returning() + const room = created || (await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1))[0] + await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId)) + return { ...roomToApi(room), alreadyExisted: !created } }) - server.get("/communication/matrix/tenant-space", async (req, reply) => { - try { - return await matrix.getTenantSpaceStatus(req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix tenant space status failed") - } - }) - - server.post("/communication/matrix/tenant-space/provision", async (req, reply) => { - try { - return await matrix.provisionCurrentTenantSpace(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix tenant space provisioning failed") - } - }) - - server.get("/communication/matrix/rooms", async (req, reply) => { - try { - return await matrix.listTenantRooms(req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix rooms failed") - } - }) - - server.get("/communication/matrix/unread", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - - const rows = await unreadChatNotifications(req.user.tenant_id, req.user.user_id) - const rooms = rows.reduce((acc: Record, row) => { - const payload = row.payload as any - const roomKey = payload?.roomKey - if (!roomKey) return acc - - acc[roomKey] = acc[roomKey] || { count: 0, mentions: 0 } - acc[roomKey].count += 1 - - if (payload.mentioned) { - acc[roomKey].mentions += 1 - } - - return acc - }, {}) - - return { rooms } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix unread state failed") - } - }) - - server.get("/communication/matrix/push-worker", async () => { - return getMatrixPushWorkerState() - }) - - server.get("/communication/matrix/media", async (req, reply) => { - try { - const query = req.query as { uri?: string; name?: string } - if (!query.uri) return reply.code(400).send({ error: "Matrix-Media-URI fehlt" }) - - const media = await matrix.getMediaContent(req.user.user_id, req.user.tenant_id, query.uri) - reply.header("Content-Type", media.contentType) - if (media.contentLength) reply.header("Content-Length", media.contentLength) - if (query.name) { - reply.header("Content-Disposition", `inline; filename="${query.name.replace(/"/g, "")}"`) - } - return reply.send(media.buffer) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix media failed") - } - }) - - server.get("/communication/matrix/project-rooms", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - - const [roomsRes, projectRows] = await Promise.all([ - matrix.listTenantRooms(req.user.tenant_id), - server.db - .select({ - id: projects.id, - name: projects.name, - projectNumber: projects.projectNumber, - profiles: projects.profiles, - }) - .from(projects) - .where(and( - eq(projects.tenant, req.user.tenant_id), - eq(projects.archived, false) - )) - ]) - const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room])) - - return { - rooms: projectRows.map((project) => { - const key = projectRoomKey(project.id) - const existing = roomsByKey.get(key) as any - return { - ...(existing || {}), - key, - name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, - topic: `Projektkommunikation zu ${project.name}`, - type: "project", - entityType: "project", - entityId: project.id, - exists: Boolean(existing?.exists), - projectId: project.id, - projectNumber: project.projectNumber, - } - }) - } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix project rooms failed") - } - }) - - server.post("/communication/matrix/project-rooms/:projectId/provision", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const params = req.params as { projectId: string } - const projectId = Number(params.projectId) - const [project] = await server.db - .select() - .from(projects) - .where(and( - eq(projects.tenant, req.user.tenant_id), - eq(projects.id, projectId) - )) - .limit(1) - - if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" }) - - const profileIds = (project.profiles || []) as string[] - const profileRows = profileIds.length - ? await server.db - .select({ userId: authProfiles.user_id }) - .from(authProfiles) - .where(and( - eq(authProfiles.tenant_id, req.user.tenant_id), - inArray(authProfiles.id, profileIds) - )) - : [] - const inviteUserIds = profileRows.map((profile) => profile.userId).filter(Boolean) as string[] - - return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, { - key: projectRoomKey(project.id), + server.get("/communication/chat/project-rooms", async (req: any) => { + const tenantId = requireTenant(req) + const [roomRows, projectRows] = await Promise.all([ + server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "project"), eq(communicationRoomMembers.userId, req.user.user_id))), + server.db.select().from(projects).where(and(eq(projects.tenant, tenantId), eq(projects.archived, false))), + ]) + const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room])) + return { rooms: projectRows.map((project) => { + const room = roomsByProject.get(project.id) + return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : { + key: `project_${project.id}`, name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, topic: `Projektkommunikation zu ${project.name}`, - type: "project", - entityType: "project", - entityId: project.id, - inviteUserIds, - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix project room provisioning failed") - } - }) - - server.get("/communication/matrix/direct-rooms", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const [roomsRes, userRows] = await Promise.all([ - matrix.listTenantRooms(req.user.tenant_id), - server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, req.user.tenant_id) - )) - .where(and( - eq(authTenantUsers.tenant_id, req.user.tenant_id), - ne(authTenantUsers.user_id, req.user.user_id) - )) - ]) - const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room])) - - return { - rooms: userRows.map((user) => { - const key = directRoomKey(req.user.user_id, user.userId) - const existing = roomsByKey.get(key) as any - const name = displayUserName(user) - - return { - ...(existing || {}), - key, - name, - topic: `Direktnachricht mit ${name}`, - type: "direct", - entityType: "user", - entityUuid: user.userId, - exists: Boolean(existing?.exists), - userId: user.userId, - email: user.email, - } - }) + type: "project", entityType: "project", entityId: project.id, + projectId: project.id, projectNumber: project.projectNumber, exists: false, } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix direct rooms failed") - } + }) } }) - server.post("/communication/matrix/direct-rooms/:userId/provision", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const params = req.params as { userId: string } - const [target] = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, req.user.tenant_id) - )) - .where(and( - eq(authTenantUsers.tenant_id, req.user.tenant_id), - eq(authTenantUsers.user_id, params.userId) - )) - .limit(1) - - if (!target || target.userId === req.user.user_id) { - return reply.code(404).send({ error: "Benutzer nicht gefunden" }) + server.post("/communication/chat/project-rooms/:projectId/provision", async (req: any, reply) => { + const tenantId = requireTenant(req) + const projectId = Number(req.params.projectId) + const [project] = await server.db.select().from(projects) + .where(and(eq(projects.tenant, tenantId), eq(projects.id, projectId))).limit(1) + if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" }) + const key = `project_${project.id}` + let [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) + if (!room) { + [room] = await server.db.insert(communicationRooms).values({ + tenantId, key, + name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, + topic: `Projektkommunikation zu ${project.name}`, + type: "project", entityType: "project", entityId: project.id, createdBy: req.user.user_id, + }).onConflictDoNothing().returning() + if (!room) { + [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) } - - const targetName = displayUserName(target) - return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, { - key: directRoomKey(req.user.user_id, target.userId), - name: targetName, - topic: `Direktnachricht mit ${targetName}`, - type: "direct", - entityType: "user", - entityUuid: target.userId, - inviteUserIds: [target.userId], - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix direct room provisioning failed") } + const profileIds = (project.profiles || []) as string[] + const memberProfiles = profileIds.length + ? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles) + .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds))) + : [] + const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[] + await addMembers(room.id, [req.user.user_id, ...projectMemberIds]) + return roomToApi(room) }) - server.post("/communication/matrix/rooms", async (req, reply) => { - try { - return await matrix.provisionTenantRoom( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room provisioning failed") - } - }) - - server.get("/communication/matrix/rooms/general", async (req, reply) => { - try { - return await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room status failed") - } - }) - - server.post("/communication/matrix/rooms/general/provision", async (req, reply) => { - try { - return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room provisioning failed") - } - }) - - server.get("/communication/matrix/rooms/general/messages", async (req, reply) => { - try { - return await matrix.getGeneralRoomMessages(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix messages failed") - } - }) - - server.get("/communication/matrix/rooms/general/members", async (req, reply) => { - try { - return await matrix.getGeneralRoomMembers(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix members failed") - } - }) - - server.get("/communication/matrix/users", async (req, reply) => { - try { - const users = await matrix.listTenantCommunicationUsers(req.user.tenant_id) - return { users } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix users failed") - } - }) - - server.post("/communication/matrix/rooms/general/session", async (req, reply) => { - try { - return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix session failed") - } - }) - - server.post("/communication/matrix/rooms/general/call-session", async (req, reply) => { - try { - const room = { - key: "allgemein", - name: "Allgemeiner Chat", + server.get("/communication/chat/direct-rooms", async (req: any) => { + const tenantId = requireTenant(req) + const users = (await tenantUsers(tenantId)).filter((user) => user.userId !== req.user.user_id) + const existingRows = await server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id))) + const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room])) + return { rooms: users.map((user) => { + const key = directRoomKey(req.user.user_id, user.userId) + const room = roomsByKey.get(key) + return room ? { + ...roomToApi(room), + name: displayName(user), + topic: `Direktnachricht mit ${displayName(user)}`, + entityUuid: user.userId, + userId: user.userId, + email: user.email, + } : { + key, name: displayName(user), topic: `Direktnachricht mit ${displayName(user)}`, + type: "direct", entityType: "user", entityUuid: user.userId, + userId: user.userId, email: user.email, exists: false, } - const session = await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, room) - await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req)) - return session - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix call session failed") - } + }) } }) - server.post("/communication/matrix/rooms/general/members/sync", async (req, reply) => { - try { - return await matrix.syncTenantRoomMembers(req.user.user_id, req.user.tenant_id, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member sync failed") - } - }) - - server.post("/communication/matrix/rooms/general/messages", async (req, reply) => { - try { - const body = req.body as { text?: string; replyToEventId?: string } - const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "", { - replyToEventId: body.replyToEventId, - }) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") - await notifyUsersAboutChatMessage(req, room, message, body.text || "") - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message send failed") - } - }) - - server.post("/communication/matrix/rooms/general/attachments", async (req, reply) => { - try { - const attachment = await uploadedAttachmentFromRequest(req) - const message = await matrix.sendGeneralRoomAttachment(req.user.user_id, req.user.tenant_id, attachment) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") - await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`) - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix attachment send failed") - } - }) - - server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => { - try { - const params = req.params as { roomKey: string } - return await matrix.getTenantRoomStatus(req.user.tenant_id, params.roomKey) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room status failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/provision", async (req, reply) => { - try { - return await matrix.provisionTenantRoom( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room provisioning failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/read", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const params = req.params as { roomKey: string } - const body = (req.body || {}) as { eventId?: string } - const result = await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey) - if (body.eventId) { - await matrix.markTenantRoomRead(req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), body.eventId) + server.post("/communication/chat/direct-rooms/:userId/provision", async (req: any, reply) => { + const tenantId = requireTenant(req) + const targetId = String(req.params.userId) + const target = (await tenantUsers(tenantId)).find((user) => user.userId === targetId) + if (!target || targetId === req.user.user_id) return reply.code(404).send({ error: "Benutzer nicht gefunden" }) + const key = directRoomKey(req.user.user_id, targetId) + let [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) + if (!room) { + [room] = await server.db.insert(communicationRooms).values({ + tenantId, key, name: displayName(target), topic: `Direktnachricht mit ${displayName(target)}`, + type: "direct", entityType: "user", entityUuid: targetId, createdBy: req.user.user_id, + }).onConflictDoNothing().returning() + if (!room) { + [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) } - return result - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room read state failed") + } + await addMembers(room.id, [req.user.user_id, targetId]) + return { + ...roomToApi(room), + name: displayName(target), + topic: `Direktnachricht mit ${displayName(target)}`, + entityUuid: targetId, + userId: targetId, + email: target.email, } }) - server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { - try { - return await matrix.getTenantRoomMessages( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix messages failed") + server.get("/communication/chat/unread", async (req: any) => { + const tenantId = requireTenant(req) + const memberships = await server.db.select({ + roomId: communicationRoomMembers.roomId, + roomKey: communicationRooms.key, + lastReadMessageId: communicationRoomReads.lastReadMessageId, + }).from(communicationRoomMembers) + .innerJoin(communicationRooms, eq(communicationRooms.id, communicationRoomMembers.roomId)) + .leftJoin(communicationRoomReads, and(eq(communicationRoomReads.roomId, communicationRoomMembers.roomId), eq(communicationRoomReads.userId, req.user.user_id))) + .where(and(eq(communicationRoomMembers.userId, req.user.user_id), eq(communicationRooms.tenantId, tenantId))) + const rooms: Record = {} + for (const membership of memberships) { + const [result] = await server.db.select({ count: sql`count(*)::int` }).from(communicationMessages) + .where(and(eq(communicationMessages.roomId, membership.roomId), gt(communicationMessages.id, membership.lastReadMessageId || 0), ne(communicationMessages.authorUserId, req.user.user_id))) + rooms[membership.roomKey] = { count: result?.count || 0, mentions: 0 } } + return { rooms } }) - server.get("/communication/matrix/rooms/:roomKey/search", async (req, reply) => { - try { - const query = req.query as { q?: string } - return await matrix.searchTenantRoomMessages( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - query.q || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix search failed") - } + server.get("/communication/chat/rooms/:roomKey/messages", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + return { ...roomToApi(room), messages: await messagesForRoom(room.id, req.user.user_id, tenantId) } }) - server.get("/communication/matrix/rooms/:roomKey/sync", async (req, reply) => { - try { - const query = req.query as { since?: string; initial?: string } - const result = await matrix.syncTenantRoomEvents( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - query.since, - query.initial === "1" - ) - if (query.since && query.initial !== "1" && result.messages?.length) { - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, result.key, result.name) - await notifyCurrentUserAboutIncomingMatrixMessages(req, room, result.messages) - } - - return result - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix sync failed") - } + server.get("/communication/chat/rooms/:roomKey/sync", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const afterId = Math.max(Number(req.query?.afterId || 0), 0) + const messages = await messagesForRoom(room.id, req.user.user_id, tenantId, afterId, 100) + return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId } }) - server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => { - try { - return await matrix.getTenantRoomMembers( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix members failed") - } + server.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const body = String(req.body?.text || "").trim() + if (!body) return reply.code(400).send({ error: "Nachricht darf nicht leer sein" }) + if (body.length > 10_000) return reply.code(400).send({ error: "Nachricht ist zu lang" }) + const [created] = await server.db.insert(communicationMessages).values({ + tenantId, roomId: room.id, authorUserId: req.user.user_id, body, + }).returning() + await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body }) + return { id: created.id, body: created.body, sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true } }) - server.post("/communication/matrix/rooms/:roomKey/members/invite", async (req, reply) => { - try { - const body = req.body as { userId?: string } - return await matrix.inviteTenantRoomMember( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - body.userId || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member invite failed") - } + server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const requestedId = Math.max(Number(req.body?.messageId || 0), 0) + const [latest] = await server.db.select({ id: communicationMessages.id }).from(communicationMessages) + .where(eq(communicationMessages.roomId, room.id)).orderBy(desc(communicationMessages.id)).limit(1) + const latestId = latest?.id || 0 + const messageId = requestedId ? Math.min(requestedId, latestId) : latestId + await server.db.insert(communicationRoomReads).values({ + roomId: room.id, userId: req.user.user_id, lastReadMessageId: messageId || null, readAt: new Date(), + }).onConflictDoUpdate({ + target: [communicationRoomReads.roomId, communicationRoomReads.userId], + set: { lastReadMessageId: messageId || null, readAt: new Date() }, + }) + const notificationRows = await unreadNotifications(tenantId, req.user.user_id) + const ids = notificationRows.filter((item) => (item.payload as any)?.roomKey === room.key).map((item) => item.id) + if (ids.length) await server.db.update(notificationsItems).set({ readAt: new Date(), status: "read" }).where(inArray(notificationsItems.id, ids)) + return { read: true, messageId } }) - server.delete("/communication/matrix/rooms/:roomKey/members/:matrixUserId", async (req, reply) => { - try { - const params = req.params as { matrixUserId: string } - return await matrix.removeTenantRoomMember( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.matrixUserId - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member remove failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => { - try { - return await matrix.createElementRoomSession( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix session failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/call-session", async (req, reply) => { - try { - const room = roomOptionsFromRequest(req) - const session = await matrix.createLiveKitRoomSession( - req.user.user_id, - req.user.tenant_id, - room - ) - await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req)) - return session - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix call session failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/members/sync", async (req, reply) => { - try { - return await matrix.syncTenantRoomMembers( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member sync failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { - try { - const body = req.body as { text?: string; replyToEventId?: string } - const message = await matrix.sendTenantRoomMessage( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - body.text || "", - { - replyToEventId: body.replyToEventId, - } - ) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) - await notifyUsersAboutChatMessage(req, room, message, body.text || "") - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message send failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/messages/:eventId/reactions", async (req, reply) => { - try { - const params = req.params as { eventId: string } - const body = req.body as { key?: string } - return await matrix.sendTenantRoomReaction( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.eventId, - body.key || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix reaction send failed") - } - }) - - server.put("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => { - try { - const params = req.params as { eventId: string } - const body = req.body as { text?: string } - return await matrix.editTenantRoomMessage( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.eventId, - body.text || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message edit failed") - } - }) - - server.delete("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => { - try { - const params = req.params as { eventId: string } - return await matrix.redactTenantRoomMessage( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.eventId - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message delete failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => { - try { - const attachment = await uploadedAttachmentFromRequest(req) - const message = await matrix.sendTenantRoomAttachment( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - attachment - ) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) - await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`) - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix attachment send failed") - } + server.get("/communication/chat/rooms/:roomKey/members", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const members = await server.db.select({ + userId: authUsers.id, email: authUsers.email, + firstName: authProfiles.first_name, lastName: authProfiles.last_name, fullName: authProfiles.full_name, + }).from(communicationRoomMembers) + .innerJoin(authUsers, eq(authUsers.id, communicationRoomMembers.userId)) + .leftJoin(authProfiles, and(eq(authProfiles.user_id, authUsers.id), eq(authProfiles.tenant_id, tenantId))) + .where(eq(communicationRoomMembers.roomId, room.id)) + return { members: members.map((member) => ({ + userId: member.userId, displayName: displayName(member), email: member.email, own: member.userId === req.user.user_id, + })) } }) } diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts index aea020c..c523651 100644 --- a/backend/src/utils/secrets.ts +++ b/backend/src/utils/secrets.ts @@ -38,15 +38,6 @@ export let secrets = { DOKUBOX_IMAP_PASSWORD: string OPENAI_API_KEY: string STIRLING_API_KEY: string - MATRIX_HOMESERVER_URL?: string - MATRIX_SERVER_NAME?: string - MATRIX_RTC_HOST?: string - MATRIX_RTC_JWT_URL?: string - MATRIX_LIVEKIT_URL?: string - MATRIX_REGISTRATION_SHARED_SECRET?: string - MATRIX_SERVICE_USER_LOCALPART?: string - LIVEKIT_KEY?: string - LIVEKIT_SECRET?: string WEB_PUSH_PUBLIC_KEY?: string WEB_PUSH_PRIVATE_KEY?: string WEB_PUSH_SUBJECT?: string @@ -88,15 +79,6 @@ const secretKeys = [ "DOKUBOX_IMAP_PASSWORD", "OPENAI_API_KEY", "STIRLING_API_KEY", - "MATRIX_HOMESERVER_URL", - "MATRIX_SERVER_NAME", - "MATRIX_RTC_HOST", - "MATRIX_RTC_JWT_URL", - "MATRIX_LIVEKIT_URL", - "MATRIX_REGISTRATION_SHARED_SECRET", - "MATRIX_SERVICE_USER_LOCALPART", - "LIVEKIT_KEY", - "LIVEKIT_SECRET", "WEB_PUSH_PUBLIC_KEY", "WEB_PUSH_PRIVATE_KEY", "WEB_PUSH_SUBJECT", diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index 347b6c7..54bd8a0 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -86,47 +86,6 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = { const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"]) const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` -const matrixServerName = () => - process.env.MATRIX_SERVER_NAME || - secrets.MATRIX_SERVER_NAME || - process.env.DOMAIN || - "localhost" - -const normalizeMatrixLocalpartSeed = (value: string) => { - const normalized = value - .toLowerCase() - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/ä/g, "a") - .replace(/ö/g, "o") - .replace(/ü/g, "u") - .replace(/ß/g, "ss") - .replace(/[^a-z0-9._=-]+/g, "_") - .replace(/_+/g, "_") - .replace(/^[._=-]+|[._=-]+$/g, "") - - return normalized || "user" -} - -const normalizeMatrixAliasSeed = (value: string) => - normalizeMatrixLocalpartSeed(value) - .replace(/[.=]/g, "_") - .replace(/_+/g, "_") - -const tenantRoomAliasLocalpart = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string -) => { - const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) - const roomSeed = normalizeMatrixAliasSeed(roomKey) - return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}` -} - -const tenantRoomAlias = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string -) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${matrixServerName()}` - const tableColumns = async (client: any) => { const result = await client.query(` select table_name, column_name, data_type, is_generated @@ -343,6 +302,12 @@ export const buildTenantFullExport = async ( addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds])) } + const communicationRoomIds = collectIds(tables.communication_rooms || [], "id") + if (communicationRoomIds.length) { + addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds])) + addRows(tables, "communication_room_reads", await loadRows(client, "communication_room_reads", "room_id = any($1::uuid[])", [communicationRoomIds])) + } + if (tables.entitybankaccounts?.length) { tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts) } @@ -636,73 +601,6 @@ const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) => } } -const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => { - const rows = exportData.tables.communication_rooms || [] - if (!rows.length) return - - const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [ - Number(tenant.id), - { - id: Number(tenant.id), - name: tenant.name, - short: tenant.short, - }, - ])) - - for (const row of rows) { - const tenantId = Number(row.tenant_id) - const tenant = tenantById.get(tenantId) - - row.matrix_room_id = null - row.parent_space_room_id = null - - if (tenant && row.key) { - row.matrix_alias = tenantRoomAlias(tenant, String(row.key)) - } else { - row.matrix_alias = null - } - } -} - -const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => { - const rows = exportData.tables.communication_rooms || [] - if (!rows.length) return 0 - - const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [ - Number(tenant.id), - { - id: Number(tenant.id), - name: tenant.name, - short: tenant.short, - }, - ])) - let cleaned = 0 - - for (const row of rows) { - const tenantId = Number(row.tenant_id) - const key = String(row.key || "") - const tenant = tenantById.get(tenantId) - if (!tenantId || !key || !tenant) continue - - const alias = tenantRoomAlias(tenant, key) - const result = await client.query( - ` - update communication_rooms - set matrix_room_id = null, - parent_space_room_id = null, - matrix_alias = $3, - updated_at = now() - where tenant_id = $1 and key = $2 - `, - [tenantId, key, alias] - ) - - cleaned += result.rowCount || 0 - } - - return cleaned -} - const prepareColumnValue = (value: any, isJsonColumn: boolean) => { if (!isJsonColumn || value === null || typeof value === "undefined") return value if (typeof value === "string") return value @@ -853,7 +751,6 @@ export const importTenantFullExport = async ( const exportData = rawExportData encryptEntityBankAccountRowsForImport(exportData) - prepareCommunicationRoomsForImport(exportData) const client = await pool.connect() const importOrder = [ "tenants", @@ -955,13 +852,6 @@ export const importTenantFullExport = async ( await reportProgress(`${table} importiert`) } - const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData) - if (cleanedCommunicationRooms) { - importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms }) - } - progressDone += 1 - await reportProgress("Kommunikationsräume bereinigt") - await refreshSequences(client, columnsByTable) progressDone = progressTotal await reportProgress("Import abgeschlossen") diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 474e08c..821ff0f 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -93,8 +93,6 @@ services: condition: service_healthy createbuckets: condition: service_completed_successfully - matrix-synapse: - condition: service_healthy environment: NODE_ENV: production FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true} @@ -144,16 +142,6 @@ services: FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer} FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO} FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO} - FEDEO_BOOTSTRAP_MATRIX: ${FEDEO_BOOTSTRAP_MATRIX:-true} - MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://matrix-synapse:8008} - MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}} - MATRIX_RTC_HOST: ${MATRIX_RTC_HOST:-${DOMAIN}} - MATRIX_RTC_JWT_URL: ${MATRIX_RTC_JWT_URL:-} - MATRIX_LIVEKIT_URL: ${MATRIX_LIVEKIT_URL:-} - MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-change-this-matrix-registration-secret} - MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service} - LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit} - LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace} NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100} labels: - traefik.enable=true @@ -195,7 +183,6 @@ services: NODE_ENV: production NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE} - NUXT_PUBLIC_MATRIX_ELEMENT_URL: ${NUXT_PUBLIC_MATRIX_ELEMENT_URL:-} labels: - traefik.enable=true - traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) @@ -207,306 +194,6 @@ services: networks: - web - matrix-db: - image: postgres:16-alpine - container_name: fedeo-matrix-db - restart: unless-stopped - environment: - POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse} - POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse} - POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password} - POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C - volumes: - - ./matrix/postgres:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${MATRIX_POSTGRES_USER:-synapse} -d ${MATRIX_POSTGRES_DB:-synapse}"] - interval: 10s - timeout: 5s - retries: 10 - networks: - - internal - - matrix-redis: - image: redis:7-alpine - container_name: fedeo-matrix-redis - restart: unless-stopped - networks: - - internal - - matrix-synapse: - image: ghcr.io/element-hq/synapse:latest - container_name: fedeo-matrix-synapse - restart: unless-stopped - depends_on: - matrix-db: - condition: service_healthy - matrix-redis: - condition: service_started - environment: - DOMAIN: ${DOMAIN} - MATRIX_POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse} - MATRIX_POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse} - MATRIX_POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password} - MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-change-this-matrix-registration-secret} - MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}} - MATRIX_TURN_SHARED_SECRET: ${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret} - SYNAPSE_CONFIG_PATH: /data/homeserver.yaml - SYNAPSE_REPORT_STATS: "no" - SYNAPSE_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}} - entrypoint: /bin/sh - command: - - -ec - - | - if [ ! -f /data/homeserver.yaml ]; then - /start.py generate - fi - python - <<'PY' - import os - import yaml - - path = "/data/homeserver.yaml" - with open(path, "r", encoding="utf-8") as handle: - config = yaml.safe_load(handle) or {} - - domain = os.environ["DOMAIN"] - server_name = os.environ.get("MATRIX_SERVER_NAME") or domain - config["server_name"] = server_name - config["public_baseurl"] = f"https://{domain}/" - config["database"] = { - "name": "psycopg2", - "args": { - "user": os.environ.get("MATRIX_POSTGRES_USER", "synapse"), - "password": os.environ["MATRIX_POSTGRES_PASSWORD"], - "database": os.environ.get("MATRIX_POSTGRES_DB", "synapse"), - "host": "matrix-db", - "cp_min": 5, - "cp_max": 10, - }, - } - config["redis"] = {"enabled": True, "host": "matrix-redis"} - config["registration_shared_secret"] = os.environ["MATRIX_REGISTRATION_SHARED_SECRET"] - config["turn_uris"] = [ - f"turn:{domain}:3478?transport=udp", - f"turn:{domain}:3478?transport=tcp", - ] - config["turn_shared_secret"] = os.environ["MATRIX_TURN_SHARED_SECRET"] - config["turn_user_lifetime"] = "1h" - config["enable_registration"] = False - config["experimental_features"] = { - **(config.get("experimental_features") or {}), - "msc3266_enabled": True, - "msc4222_enabled": True, - } - config["login_via_existing_session"] = { - "enabled": True, - "require_ui_auth": False, - "token_timeout": "5m", - } - config["max_event_delay_duration"] = "24h" - config["rc_message"] = {"per_second": 0.5, "burst_count": 30} - config["rc_delayed_event_mgmt"] = {"per_second": 1, "burst_count": 20} - - with open(path, "w", encoding="utf-8") as handle: - yaml.safe_dump(config, handle, sort_keys=False) - PY - exec /start.py - volumes: - - ./matrix/synapse:/data - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8008/_matrix/client/versions', timeout=2)\""] - interval: 10s - timeout: 5s - retries: 30 - start_period: 20s - labels: - - traefik.enable=true - - traefik.http.routers.fedeo-matrix.rule=Host(`${DOMAIN}`) && PathPrefix(`/_matrix`) - - traefik.http.routers.fedeo-matrix.entrypoints=websecure - - traefik.http.routers.fedeo-matrix.tls.certresolver=letsencrypt - - traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008 - - traefik.docker.network=fedeo_web - networks: - - web - - internal - - matrix-well-known: - image: nginx:1.27-alpine - container_name: fedeo-matrix-well-known - restart: unless-stopped - command: - - /bin/sh - - -ec - - | - mkdir -p /usr/share/nginx/html/.well-known/matrix - cat >/usr/share/nginx/html/.well-known/matrix/client </usr/share/nginx/html/.well-known/matrix/server </tmp/livekit.yaml </app/config.json </tmp/livekit.yaml </tmp/livekit.yaml <-team:example.com` -- `#tenant--project-:example.com` -- `#tenant--ticket-:example.com` -- `#tenant--customer-:example.com` - -Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`. - -## Rechte und Rollen - -FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum. - -| FEDEO-Rolle | Matrix-Abbildung | -| --- | --- | -| Mandantenadmin | Space-Admin und Raumadmin | -| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen | -| Mitarbeitende | Mitglied mit Schreibrechten | -| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen | -| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten | - -Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können. - -## Chat - -Der Chat wird als erste Ausbaustufe umgesetzt. - -### Funktionen - -- Direktnachrichten -- Gruppenräume -- Mandanten-, Team-, Projekt- und Vorgangsräume -- Datei- und Bildanhänge -- Erwähnungen, Reaktionen und Lesestatus -- Suche in nicht verschlüsselten Räumen über den Homeserver -- lokale Suche in verschlüsselten Räumen über Client-Indizes -- Verknüpfung von Nachrichten mit FEDEO-Objekten - -### Integration in FEDEO - -FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen: - -- Matrix Raum-ID -- Matrix Event-ID -- FEDEO Objekt-Typ und Objekt-ID -- Zeitstempel -- beteiligter FEDEO-Nutzer -- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt - -So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört. - -## Audioanrufe - -Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button. - -### Anforderungen - -- WebRTC-Unterstützung im Browser -- STUN/TURN über coturn -- Geräteauswahl für Mikrofon und Lautsprecher -- Anrufbenachrichtigung im Web und mobil -- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend` - -Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden. - -## Videokonferenzen - -Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung. - -### Funktionen - -- Videokonferenzen aus Matrix-Räumen -- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten -- Bildschirmfreigabe -- Einladungslink für externe Gäste -- Wartebereich für externe Gäste -- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung -- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe - -### Konfiguration - -Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben. - -Beispiel: - -```json -{ - "m.homeserver": { - "base_url": "https://matrix.example.com" - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "https://call.example.com/livekit/jwt" - } - ] -} -``` - -## Authentifizierung und Nutzerverwaltung - -FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern. - -### Empfohlener Ablauf - -1. Nutzer wird in FEDEO angelegt. -2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer. -3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu. -4. Login erfolgt über FEDEO SSO/OIDC. -5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe. - -Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein: - -```text -@u_:example.com -``` - -Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden. - -## Datenschutz und Compliance - -Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln. - -### Empfehlungen - -- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren. -- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden. -- Medienaufbewahrung mandantenweit konfigurierbar machen. -- Externe Gäste optisch klar kennzeichnen. -- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken. -- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben. -- Administrative Zugriffe protokollieren. -- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren. - -## Federation - -Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden. - -### Betriebsmodi - -| Modus | Beschreibung | Empfehlung | -| --- | --- | --- | -| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen | -| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation | -| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities | - -Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus. - -## Brücken zu anderen Systemen - -Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören. - -Mögliche spätere Erweiterungen: - -- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation -- Slack- oder Teams-Brücke für externe Projektpartner -- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung -- SIP-Brücke für Telefonie - -Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen. - -## FEDEO-Produktoberfläche - -Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein. - -### Globaler Kommunikationsbereich - -- Raumliste -- Direktnachrichten -- Suche -- Anrufe -- laufende Besprechungen -- Benachrichtigungen - -### Objektbezogene Kommunikation - -In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab: - -- zugeordneter Raum -- relevante Nachrichtenreferenzen -- Start von Chat, Anruf oder Besprechung -- Teilnehmerverwaltung entsprechend FEDEO-Rechten - -So bleibt Kommunikation dort, wo die Arbeit stattfindet. - -## Backend-Integration - -Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben: - -- Matrix-Nutzer provisionieren -- Spaces und Räume anlegen -- Raum-Mitgliedschaften synchronisieren -- Matrix-Event-Webhooks empfangen -- FEDEO-Objekte mit Matrix-Räumen verknüpfen -- Benachrichtigungseinstellungen verwalten -- Admin-Aktionen auditieren - -Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann. - -## Deployment-Erweiterung - -Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden: - -- `matrix-synapse` -- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank -- `redis` -- `coturn` -- `element-web` optional als Fallback-Client -- `element-call` -- `livekit` -- `matrix-rtc-jwt-service` - -Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen. - -## Monitoring - -Wichtige Kennzahlen: - -- aktive Nutzerinnen und Nutzer -- Anzahl Räume pro Mandant -- Nachrichtenrate -- Medien-Speicherverbrauch -- Zustellverzögerung -- fehlgeschlagene Anrufe -- LiveKit Paketverlust, Latenz und Teilnehmerzahl -- TURN-Nutzung -- Federation-Fehler - -Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein. - -## Risiken und Gegenmaßnahmen - -| Risiko | Gegenmaßnahme | -| --- | --- | -| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug | -| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien | -| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie | -| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten | -| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren | -| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte | - -## Umsetzung in Phasen - -### Phase 1: Fundament und Chat - -- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben -- FEDEO-Nutzer zu Matrix synchronisieren -- Mandanten-Spaces und erste Teamräume anlegen -- Chat im FEDEO-Frontend integrieren -- Benachrichtigungen und Raumreferenzen speichern - -### Phase 2: Objektbezogene Kommunikation - -- Räume automatisch für Projekte, Vorgänge und Kunden anlegen -- Kommunikations-Tab in FEDEO-Objekten ergänzen -- Rechteänderungen aus FEDEO nach Matrix synchronisieren -- externe Gäste einladen und kennzeichnen - -### Phase 3: Audio und Video - -- coturn bereitstellen -- MatrixRTC, Element Call und LiveKit integrieren -- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen -- Gäste-Links und Wartebereich umsetzen - -### Phase 4: Compliance und Skalierung - -- Aufbewahrungsrichtlinien pro Mandant -- Monitoring und Admin-Dashboards -- Federation-Allowlist -- optionale Brücken -- optionale Aufzeichnung mit Einwilligungsworkflow - -## Offene Entscheidungen - -- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden? -- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt? -- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge? -- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden? -- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer? - -## Quellen und Standards - -- Matrix Specification: https://spec.matrix.org/ -- Matrix Application Services: https://matrix.org/docs/older/application-services/ -- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/ -- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html -- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md -- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/ -- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/ diff --git a/docs/README.md b/docs/README.md index 5c6a9db..273c8b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,5 +6,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO. - [Bedienung](./bedienung/README.md) - [Fach- und Technikkonzept für das Lagersystem](./lagersystem-konzept.md) -- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md) - [Zentraler Push-Server für Selfhost-Instanzen](./zentraler-push-server.md) diff --git a/docs/kommunikationslösung-matrix.md b/docs/kommunikationslösung-matrix.md deleted file mode 100644 index 682c779..0000000 --- a/docs/kommunikationslösung-matrix.md +++ /dev/null @@ -1,371 +0,0 @@ -# Kommunikationslösung auf Basis des Matrix-Standards - -Dieser Entwurf beschreibt eine FEDEO-Kommunikationslösung für Chat, Anrufe und Videokonferenzen auf Basis des Matrix-Standards. Ziel ist eine souverän betreibbare Lösung, die Mandantenfähigkeit, Datenschutz, Rechteverwaltung und die bestehenden FEDEO-Workflows berücksichtigt. - -## Zielbild - -FEDEO erhält einen integrierten Kommunikationsbereich, der interne Zusammenarbeit und externe Kommunikation abdeckt: - -- Chat in Einzel-, Gruppen-, Projekt-, Vorgangs- und Kundenräumen -- Audioanrufe aus Direktchats, Gruppenräumen und Kontakten -- Videokonferenzen mit Bildschirmfreigabe und Einladungslinks -- Ende-zu-Ende-verschlüsselte private Kommunikation -- revisionsfähige Verknüpfung von relevanten Kommunikationsereignissen mit FEDEO-Objekten -- optional föderierte Kommunikation mit externen Matrix-Organisationen - -Matrix wird dabei nicht als isolierter Messenger betrieben, sondern als Kommunikationsschicht neben dem bestehenden FEDEO-Backend. - -## Empfohlene Architektur - -```text -Nutzerinnen und Nutzer - | - | FEDEO Web, Mobile App, optional Element Desktop/Mobile - v -FEDEO Frontend - | - | FEDEO API, SSO, Rechte, Objektkontext - v -FEDEO Backend - | - | Provisionierung, Webhooks, Audit-Metadaten - v -Matrix Homeserver - | - +-- PostgreSQL für Matrix-Daten - +-- Redis für Worker und Caches - +-- Medien-Repository für Anhänge - +-- TURN/STUN für direkte Medienverbindungen - +-- MatrixRTC / LiveKit SFU für Gruppenanrufe und Videokonferenzen -``` - -### Kernkomponenten - -| Komponente | Empfehlung | Aufgabe | -| --- | --- | --- | -| Matrix Homeserver | Synapse | Standardnaher, bewährter Homeserver mit guter Betriebsdokumentation | -| Matrix Client im FEDEO Web | Matrix JS SDK oder eingebetteter Element-Web-Ausschnitt | Chat, Raumliste, Nachrichten, Reaktionen, Anhänge | -| Mobile Integration | Matrix SDK über FEDEO Mobile oder Deep Link zu Element X | Pushfähige mobile Kommunikation | -| Identität | OIDC/SSO über FEDEO Auth, perspektivisch Matrix Authentication Service | Einheitlicher Login und zentrale Nutzerverwaltung | -| Audio/Video | MatrixRTC mit Element Call und LiveKit SFU | Moderne Anrufe und Videokonferenzen | -| NAT Traversal | coturn | STUN/TURN für stabile Medienverbindungen | -| Reverse Proxy | bestehender Traefik-Ansatz | TLS, Routing, `.well-known/matrix/*` | -| Administration | FEDEO Admin-Oberfläche plus Synapse Admin API | Nutzer, Räume, Richtlinien, Sperren | - -## Betriebsmodell - -Für FEDEO ist ein eigener Matrix-Homeserver pro Installation oder pro großer Betreiberinstanz sinnvoll. Der Matrix-Server sollte nicht öffentlich als offener Registrierungsserver betrieben werden. Nutzer werden ausschließlich durch FEDEO angelegt, aktualisiert und deaktiviert. - -Empfohlene Domains: - -- `app.example.com`: FEDEO Oberfläche -- `matrix.example.com`: Matrix Client-Server und Federation API -- `call.example.com`: Element Call / MatrixRTC -- `livekit.example.com`: LiveKit SFU -- `turn.example.com`: TURN/STUN - -Die öffentliche Matrix-Serverkennung kann trotzdem `example.com` lauten. Dafür werden `.well-known/matrix/client` und `.well-known/matrix/server` über Traefik ausgeliefert. - -## Mandantenmodell - -Matrix selbst ist raumbasiert, FEDEO ist mandantenbasiert. Deshalb sollte FEDEO die Mandantenlogik explizit auf Matrix-Räume und Spaces abbilden. - -### Räume und Spaces - -- Pro FEDEO-Mandant wird ein Matrix Space angelegt. -- Projekte, Vorgänge, Helpdesk-Konversationen, interne Teams und Kundenkontakte werden als Räume im Mandanten-Space geführt. -- Direkträume werden nutzerbezogen angelegt, aber über FEDEO mandantengebunden sichtbar gemacht. -- Externe Räume erhalten einen klaren Status, zum Beispiel `intern`, `extern`, `kunde`, `lieferant`. - -### Raumalias-Konvention - -Beispiele: - -- `#tenant--team:example.com` -- `#tenant--project-:example.com` -- `#tenant--ticket-:example.com` -- `#tenant--customer-:example.com` - -Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`. - -## Rechte und Rollen - -FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum. - -| FEDEO-Rolle | Matrix-Abbildung | -| --- | --- | -| Mandantenadmin | Space-Admin und Raumadmin | -| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen | -| Mitarbeitende | Mitglied mit Schreibrechten | -| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen | -| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten | - -Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können. - -## Chat - -Der Chat wird als erste Ausbaustufe umgesetzt. - -### Funktionen - -- Direktnachrichten -- Gruppenräume -- Mandanten-, Team-, Projekt- und Vorgangsräume -- Datei- und Bildanhänge -- Erwähnungen, Reaktionen und Lesestatus -- Suche in nicht verschlüsselten Räumen über den Homeserver -- lokale Suche in verschlüsselten Räumen über Client-Indizes -- Verknüpfung von Nachrichten mit FEDEO-Objekten - -### Integration in FEDEO - -FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen: - -- Matrix Raum-ID -- Matrix Event-ID -- FEDEO Objekt-Typ und Objekt-ID -- Zeitstempel -- beteiligter FEDEO-Nutzer -- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt - -So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört. - -## Audioanrufe - -Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button. - -### Anforderungen - -- WebRTC-Unterstützung im Browser -- STUN/TURN über coturn -- Geräteauswahl für Mikrofon und Lautsprecher -- Anrufbenachrichtigung im Web und mobil -- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend` - -Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden. - -## Videokonferenzen - -Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung. - -### Funktionen - -- Videokonferenzen aus Matrix-Räumen -- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten -- Bildschirmfreigabe -- Einladungslink für externe Gäste -- Wartebereich für externe Gäste -- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung -- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe - -### Konfiguration - -Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben. - -Beispiel: - -```json -{ - "m.homeserver": { - "base_url": "https://matrix.example.com" - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "https://call.example.com/livekit/jwt" - } - ] -} -``` - -## Authentifizierung und Nutzerverwaltung - -FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern. - -### Empfohlener Ablauf - -1. Nutzer wird in FEDEO angelegt. -2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer. -3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu. -4. Login erfolgt über FEDEO SSO/OIDC. -5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe. - -Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein: - -```text -@u_:example.com -``` - -Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden. - -## Datenschutz und Compliance - -Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln. - -### Empfehlungen - -- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren. -- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden. -- Medienaufbewahrung mandantenweit konfigurierbar machen. -- Externe Gäste optisch klar kennzeichnen. -- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken. -- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben. -- Administrative Zugriffe protokollieren. -- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren. - -## Federation - -Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden. - -### Betriebsmodi - -| Modus | Beschreibung | Empfehlung | -| --- | --- | --- | -| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen | -| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation | -| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities | - -Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus. - -## Brücken zu anderen Systemen - -Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören. - -Mögliche spätere Erweiterungen: - -- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation -- Slack- oder Teams-Brücke für externe Projektpartner -- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung -- SIP-Brücke für Telefonie - -Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen. - -## FEDEO-Produktoberfläche - -Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein. - -### Globaler Kommunikationsbereich - -- Raumliste -- Direktnachrichten -- Suche -- Anrufe -- laufende Besprechungen -- Benachrichtigungen - -### Objektbezogene Kommunikation - -In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab: - -- zugeordneter Raum -- relevante Nachrichtenreferenzen -- Start von Chat, Anruf oder Besprechung -- Teilnehmerverwaltung entsprechend FEDEO-Rechten - -So bleibt Kommunikation dort, wo die Arbeit stattfindet. - -## Backend-Integration - -Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben: - -- Matrix-Nutzer provisionieren -- Spaces und Räume anlegen -- Raum-Mitgliedschaften synchronisieren -- Matrix-Event-Webhooks empfangen -- FEDEO-Objekte mit Matrix-Räumen verknüpfen -- Benachrichtigungseinstellungen verwalten -- Admin-Aktionen auditieren - -Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann. - -## Deployment-Erweiterung - -Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden: - -- `matrix-synapse` -- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank -- `redis` -- `coturn` -- `element-web` optional als Fallback-Client -- `element-call` -- `livekit` -- `matrix-rtc-jwt-service` - -Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen. - -## Monitoring - -Wichtige Kennzahlen: - -- aktive Nutzerinnen und Nutzer -- Anzahl Räume pro Mandant -- Nachrichtenrate -- Medien-Speicherverbrauch -- Zustellverzögerung -- fehlgeschlagene Anrufe -- LiveKit Paketverlust, Latenz und Teilnehmerzahl -- TURN-Nutzung -- Federation-Fehler - -Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein. - -## Risiken und Gegenmaßnahmen - -| Risiko | Gegenmaßnahme | -| --- | --- | -| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug | -| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien | -| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie | -| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten | -| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren | -| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte | - -## Umsetzung in Phasen - -### Phase 1: Fundament und Chat - -- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben -- FEDEO-Nutzer zu Matrix synchronisieren -- Mandanten-Spaces und erste Teamräume anlegen -- Chat im FEDEO-Frontend integrieren -- Benachrichtigungen und Raumreferenzen speichern - -### Phase 2: Objektbezogene Kommunikation - -- Räume automatisch für Projekte, Vorgänge und Kunden anlegen -- Kommunikations-Tab in FEDEO-Objekten ergänzen -- Rechteänderungen aus FEDEO nach Matrix synchronisieren -- externe Gäste einladen und kennzeichnen - -### Phase 3: Audio und Video - -- coturn bereitstellen -- MatrixRTC, Element Call und LiveKit integrieren -- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen -- Gäste-Links und Wartebereich umsetzen - -### Phase 4: Compliance und Skalierung - -- Aufbewahrungsrichtlinien pro Mandant -- Monitoring und Admin-Dashboards -- Federation-Allowlist -- optionale Brücken -- optionale Aufzeichnung mit Einwilligungsworkflow - -## Offene Entscheidungen - -- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden? -- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt? -- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge? -- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden? -- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer? - -## Quellen und Standards - -- Matrix Specification: https://spec.matrix.org/ -- Matrix Application Services: https://matrix.org/docs/older/application-services/ -- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/ -- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html -- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md -- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/ -- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/ diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index b89d439..cab9fdc 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -352,8 +352,8 @@ const links = computed(() => { icon: "i-heroicons-phone", }, { - label: "Matrix-Setup", - to: "/communication", + label: "Chat", + to: "/communication/chat", icon: "i-heroicons-chat-bubble-left-right", }, featureEnabled("export") ? { diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 8b07625..ecfffb3 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -81,7 +81,6 @@ export default defineNuxtConfig({ public: { apiBase: '', pdfLicense: '', - matrixElementUrl: process.env.NUXT_PUBLIC_MATRIX_ELEMENT_URL || 'http://localhost:8080' } }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c9a5ecd..c20dc17 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -74,7 +74,6 @@ "image-js": "^1.1.0", "leaflet": "^1.9.4", "license-checker": "^25.0.1", - "livekit-client": "^2.19.0", "maplibre-gl": "^4.7.0", "nuxt-editorjs": "^1.0.4", "nuxt-viewport": "^2.0.6", @@ -1867,12 +1866,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bufbuild/protobuf": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", - "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, "node_modules/@capacitor-community/bluetooth-le": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz", @@ -3167,21 +3160,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@livekit/mutex": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", - "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", - "license": "Apache-2.0" - }, - "node_modules/@livekit/protocol": { - "version": "1.45.8", - "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz", - "integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==", - "license": "Apache-2.0", - "dependencies": { - "@bufbuild/protobuf": "^1.10.0" - } - }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -3763,18 +3741,6 @@ } } }, - "node_modules/@nuxt/cli/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@nuxt/devalue": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz", @@ -8719,13 +8685,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/dom-mediacapture-record": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", - "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", - "license": "MIT", - "peer": true - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -12221,6 +12180,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -14238,15 +14198,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/jpeg-js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", @@ -14822,26 +14773,6 @@ "dev": true, "license": "MIT" }, - "node_modules/livekit-client": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz", - "integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==", - "license": "Apache-2.0", - "dependencies": { - "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.45.8", - "events": "^3.3.0", - "jose": "^6.1.0", - "loglevel": "^1.9.2", - "sdp-transform": "^2.15.0", - "tslib": "2.8.1", - "typed-emitter": "^2.1.0", - "webrtc-adapter": "9.0.5" - }, - "peerDependencies": { - "@types/dom-mediacapture-record": "^1" - } - }, "node_modules/local-pkg": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", @@ -14916,19 +14847,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -18523,16 +18441,6 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -18690,21 +18598,6 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, - "node_modules/sdp": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", - "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", - "license": "MIT" - }, - "node_modules/sdp-transform": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", - "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", - "license": "MIT", - "bin": { - "sdp-verify": "checker.js" - } - }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -20269,15 +20162,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", - "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", - "license": "MIT", - "optionalDependencies": { - "rxjs": "*" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -21832,19 +21716,6 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, - "node_modules/webrtc-adapter": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz", - "integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==", - "license": "BSD-3-Clause", - "dependencies": { - "sdp": "^3.2.0" - }, - "engines": { - "node": ">=6.0.0", - "npm": ">=3.10.0" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1dbf304..1dd119b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -87,7 +87,6 @@ "image-js": "^1.1.0", "leaflet": "^1.9.4", "license-checker": "^25.0.1", - "livekit-client": "^2.19.0", "maplibre-gl": "^4.7.0", "nuxt-editorjs": "^1.0.4", "nuxt-viewport": "^2.0.6", diff --git a/frontend/pages/administration/system.vue b/frontend/pages/administration/system.vue index 2bbf80f..22a96fe 100644 --- a/frontend/pages/administration/system.vue +++ b/frontend/pages/administration/system.vue @@ -13,7 +13,6 @@ const serviceLabels: Record = { backend: "Backend", database: "Datenbank", nodeExporter: "Node Exporter", - matrix: "Matrix", minio: "Dateispeicher", } diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index 24226f0..69ad250 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -1,2492 +1,305 @@ diff --git a/frontend/pages/communication/index.vue b/frontend/pages/communication/index.vue index a68184f..1fc4944 100644 --- a/frontend/pages/communication/index.vue +++ b/frontend/pages/communication/index.vue @@ -1,501 +1,7 @@ diff --git a/matrix/README.md b/matrix/README.md deleted file mode 100644 index 3077166..0000000 --- a/matrix/README.md +++ /dev/null @@ -1,197 +0,0 @@ -# Matrix-Stack in der FEDEO Compose - -Der Matrix-Stack liegt in derselben `docker-compose.yml` wie FEDEO und ist über das Compose-Profil `matrix` aktivierbar. - -## Enthaltene Dienste - -- `matrix-db`: PostgreSQL für Synapse -- `matrix-redis`: Redis für Synapse und LiveKit -- `matrix-synapse`: Matrix Homeserver -- `matrix-well-known`: Auslieferung von `.well-known/matrix/client` und `.well-known/matrix/server` -- `matrix-turn`: coturn für stabile WebRTC-Verbindungen -- `matrix-livekit`: LiveKit SFU für MatrixRTC-Konferenzen -- `matrix-rtc-jwt`: MatrixRTC Authorization Service für LiveKit-JWTs - -## Vorbereitung - -Lege im Repo eine `.env` auf Basis von `.env.example` an und passe mindestens diese Werte an: - -- `MATRIX_SERVER_NAME` -- `MATRIX_HOMESERVER_HOST` -- `MATRIX_RTC_HOST` -- `MATRIX_TURN_HOST` -- `MATRIX_POSTGRES_PASSWORD` -- `MATRIX_TURN_SHARED_SECRET` -- `LIVEKIT_KEY` -- `LIVEKIT_SECRET` - -Passe außerdem die Dateien in `matrix/well-known/` an, falls die Domains nicht `fedeo.de`, `matrix.fedeo.de` und `call.fedeo.de` heißen. - -## Synapse-Konfiguration erzeugen - -Synapse benötigt vor dem ersten Start eine generierte `homeserver.yaml`. Der Befehl bleibt innerhalb derselben Compose: - -```bash -docker compose --profile matrix run --rm \ - -e SYNAPSE_SERVER_NAME="${MATRIX_SERVER_NAME}" \ - -e SYNAPSE_REPORT_STATS=no \ - matrix-synapse generate -``` - -Danach `matrix/synapse/homeserver.yaml` prüfen und mindestens diese Punkte setzen: - -```yaml -public_baseurl: "https://matrix.fedeo.de/" - -database: - name: psycopg2 - args: - user: synapse - password: "" - database: synapse - host: matrix-db - cp_min: 5 - cp_max: 10 - -redis: - enabled: true - host: matrix-redis - -turn_uris: - - "turn::3478?transport=udp" - - "turn::3478?transport=tcp" -turn_shared_secret: "" -turn_user_lifetime: "1h" - -experimental_features: - msc3266_enabled: true - msc4222_enabled: true - -max_event_delay_duration: 24h -rc_message: - per_second: 0.5 - burst_count: 30 -rc_delayed_event_mgmt: - per_second: 1 - burst_count: 20 -``` - -## Start - -```bash -docker compose --profile matrix up -d -``` - -Ohne Profil startet weiterhin nur der bisherige FEDEO-Stack: - -```bash -docker compose up -d -``` - -## Hinweise - -- Die Matrix-Services sind bewusst im bestehenden Compose-Stack definiert, damit FEDEO nicht in mehrere Deployment-Dateien zerfällt. -- Die aktuellen Ports für TURN und LiveKit müssen auf der Firewall des Servers freigegeben werden. -- Federation sollte erst nach einer expliziten Entscheidung geöffnet werden. Für B2B-Kommunikation ist eine Allowlist sinnvoll. -- Die Werte in `.env.example` sind Platzhalter und nicht produktionssicher. - -## Lokaler Entwicklungsstack - -Für lokale Entwicklung gibt es zusätzlich das Profil `matrix-dev`. Es nutzt direkte Localhost-Ports und braucht keine öffentlichen Domains, kein ACME und keine Traefik-Router. - -Lokale Dienste: - -- Synapse: `http://localhost:8008` -- Element Web: `http://localhost:8080` -- MatrixRTC JWT-Service: `http://localhost:8081` -- LiveKit: `ws://localhost:7880` -- TURN: `localhost:3478` - -### Lokale Synapse-Konfiguration erzeugen - -```bash -docker compose --profile matrix-dev run --rm \ - -e SYNAPSE_SERVER_NAME=localhost \ - -e SYNAPSE_REPORT_STATS=no \ - matrix-dev-synapse generate -``` - -Danach `matrix/dev/synapse/homeserver.yaml` für die lokale Compose anpassen: - -```yaml -public_baseurl: "http://localhost:8008/" - -database: - name: psycopg2 - args: - user: synapse - password: "synapse-dev-password" - database: synapse - host: matrix-dev-db - cp_min: 5 - cp_max: 10 - -redis: - enabled: true - host: matrix-dev-redis - -enable_registration: true -enable_registration_without_verification: true - -turn_uris: - - "turn:localhost:3478?transport=udp" - - "turn:localhost:3478?transport=tcp" -turn_shared_secret: "matrix-dev-turn-secret" -turn_user_lifetime: "1h" - -experimental_features: - msc3266_enabled: true - msc4222_enabled: true -``` - -### Lokalen Stack starten - -```bash -docker compose --profile matrix-dev up -d \ - matrix-dev-db \ - matrix-dev-redis \ - matrix-dev-synapse \ - matrix-dev-turn \ - matrix-dev-livekit \ - matrix-dev-rtc-jwt \ - matrix-dev-element -``` - -Einen lokalen Admin-Nutzer kannst du danach im Synapse-Container anlegen: - -```bash -docker compose --profile matrix-dev exec matrix-dev-synapse \ - register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008 -``` - -Anschließend Element Web unter `http://localhost:8080` öffnen und mit dem lokalen Matrix-Nutzer anmelden. - -Wenn FEDEO selbst parallel lokal laufen soll, starte die FEDEO-Dienste separat wie gewohnt. Der lokale Matrix-Stack ist absichtlich über direkte Ports erreichbar, damit er unabhängig von DNS, TLS und Traefik getestet werden kann. - -## Erste FEDEO-Backend-Integration - -Das Backend stellt geschützte Matrix-Endpunkte unter `/api/communication/matrix/*` bereit: - -- `GET /api/communication/matrix/status`: prüft Konfiguration und Erreichbarkeit des Matrix-Homeservers -- `GET /api/communication/matrix/me`: zeigt die aus dem FEDEO-Nutzer abgeleitete Matrix-ID -- `POST /api/communication/matrix/me/provision`: legt den Matrix-Account für den angemeldeten FEDEO-Nutzer per Synapse-Shared-Secret-Registrierung an - -Für lokale Provisionierung muss `MATRIX_REGISTRATION_SHARED_SECRET` aus `matrix/dev/synapse/homeserver.yaml` in der Backend-Umgebung gesetzt werden. Die lokale Synapse-Konfiguration ist absichtlich nicht versioniert, weil sie Secrets enthält. - -In der lokalen Entwicklung liest das Backend dieses Secret als Fallback direkt aus `matrix/dev/synapse/homeserver.yaml`, sofern `NODE_ENV` nicht `production` ist. Auf Servern muss das Secret weiterhin explizit über die Umgebung oder das Secret-Management gesetzt werden. - -Für den eingebetteten Element-Login in FEDEO muss in der lokalen Synapse-Konfiguration außerdem der kurzlebige Login-Token-Flow aktiv sein: - -```yaml -login_via_existing_session: - enabled: true - require_ui_auth: false - token_timeout: "5m" -``` - -Nach einer Änderung an `matrix/dev/synapse/homeserver.yaml` muss `matrix-dev-synapse` neu gestartet werden. diff --git a/matrix/dev/element-config.json b/matrix/dev/element-config.json deleted file mode 100644 index ff240ec..0000000 --- a/matrix/dev/element-config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "default_server_config": { - "m.homeserver": { - "base_url": "http://localhost:8008", - "server_name": "localhost" - } - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "http://localhost:8081" - } - ], - "disable_custom_urls": false, - "disable_guests": true, - "brand": "FEDEO Matrix Dev", - "default_theme": "light", - "features": { - "feature_video_rooms": true - } -} diff --git a/matrix/well-known/client b/matrix/well-known/client deleted file mode 100644 index 8be3bb0..0000000 --- a/matrix/well-known/client +++ /dev/null @@ -1,11 +0,0 @@ -{ - "m.homeserver": { - "base_url": "https://matrix.fedeo.de" - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "https://call.fedeo.de/livekit/jwt" - } - ] -} diff --git a/matrix/well-known/server b/matrix/well-known/server deleted file mode 100644 index 0703b40..0000000 --- a/matrix/well-known/server +++ /dev/null @@ -1,3 +0,0 @@ -{ - "m.server": "matrix.fedeo.de:443" -} diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index aaed6e7..b670353 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -6,7 +6,7 @@ import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; -import { fetchMatrixUnreadCounts } from '@/src/lib/api'; +import { fetchChatUnreadCounts } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; export default function TabLayout() { @@ -22,7 +22,7 @@ export default function TabLayout() { } try { - const unread = await fetchMatrixUnreadCounts(token); + const unread = await fetchChatUnreadCounts(token); const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0); setCommunicationUnread(total); await Notifications.setBadgeCountAsync(total); diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index f9725fa..17c6611 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -1,15 +1,12 @@ -import * as DocumentPicker from 'expo-document-picker'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, - Alert, FlatList, KeyboardAvoidingView, Modal, Platform, Pressable, RefreshControl, - ScrollView, StyleSheet, Text, TextInput, @@ -17,148 +14,58 @@ import { } from 'react-native'; import { - createMatrixRoom, - deleteMatrixMessage, - editMatrixMessage, - fetchMatrixIdentity, - fetchMatrixMembers, - fetchMatrixMessages, - fetchMatrixRooms, - fetchMatrixStatus, - fetchMatrixUsers, - inviteMatrixMember, - markMatrixRoomRead, - MatrixMember, - MatrixMessage, - MatrixRoom, - MatrixStatus, - MatrixUser, - provisionMatrixRoom, - provisionMatrixUser, - reactToMatrixMessage, - removeMatrixMember, - sendMatrixMessage, - syncMatrixMembers, - syncMatrixRoom, - uploadMatrixAttachment, + ChatMember, + ChatMessage, + ChatRoom, + createChatRoom, + fetchChatMembers, + fetchChatMessages, + fetchChatRooms, + markChatRoomRead, + provisionChatRoom, + sendChatMessage, + syncChatRoom, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; -const REACTION_PRESETS = ['👍', '✅', '👀', '🙏']; - -type DialogMode = 'create-room' | 'edit-message' | 'invite-member' | null; function normalizeRoomKey(value: string): string { - return value - .trim() - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 48); + return value.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48); } -function messageTime(value: MatrixMessage['timestamp']): string { +function messageTime(value: ChatMessage['timestamp']): string { if (!value) return ''; - const date = typeof value === 'number' ? new Date(value) : new Date(value); - if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleString('de-DE', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); + return new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); } -function messagePreview(message: MatrixMessage): string { - return message.body || message.attachment?.fileName || 'Nachricht'; -} - -function sortMessages(messages: MatrixMessage[]): MatrixMessage[] { - return [...messages].sort((a, b) => { - const left = a.timestamp ? new Date(a.timestamp).getTime() : 0; - const right = b.timestamp ? new Date(b.timestamp).getTime() : 0; - return left - right; - }); -} - -function mergeMessages(current: MatrixMessage[], incoming: MatrixMessage[]): MatrixMessage[] { - const byId = new Map(); +function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] { + const byId = new Map(); for (const message of current) byId.set(message.id, message); - for (const message of incoming || []) byId.set(message.id, { ...byId.get(message.id), ...message }); - return sortMessages(Array.from(byId.values())); -} - -function applyReplacements(current: MatrixMessage[], replacements: MatrixMessage[] = []): MatrixMessage[] { - if (!replacements.length) return current; - const byTarget = new Map(); - for (const replacement of replacements) { - const targetId = String(replacement.targetEventId || replacement.replyToEventId || replacement.id || ''); - if (targetId) byTarget.set(targetId, replacement); - } - - return current.map((message) => { - const replacement = byTarget.get(message.id); - if (!replacement) return message; - return { - ...message, - body: replacement.body ?? message.body, - edited: true, - timestamp: replacement.timestamp || message.timestamp, - }; - }); -} - -function applyRedactions( - current: MatrixMessage[], - redactions: { redacts?: string; eventId?: string; targetEventId?: string }[] = [] -): MatrixMessage[] { - if (!redactions.length) return current; - const ids = new Set(redactions.map((item) => item.redacts || item.eventId || item.targetEventId).filter(Boolean)); - return current.filter((message) => !ids.has(message.id)); -} - -function applyReactions( - current: MatrixMessage[], - reactions: { targetEventId?: string; key: string; count?: number; own?: boolean; senders?: string[] }[] = [] -): MatrixMessage[] { - if (!reactions.length) return current; - return current.map((message) => { - const next = reactions.filter((reaction) => reaction.targetEventId === message.id); - if (!next.length) return message; - return { ...message, reactions: next }; - }); + for (const message of incoming) byId.set(message.id, message); + return Array.from(byId.values()).sort((a, b) => a.id - b.id); } export default function CommunicationScreen() { const { token } = useAuth(); - const messageListRef = useRef>(null); - const [status, setStatus] = useState(null); - const [matrixUserId, setMatrixUserId] = useState(''); - const [rooms, setRooms] = useState([]); - const [members, setMembers] = useState([]); - const [users, setUsers] = useState([]); - const [messages, setMessages] = useState([]); + const listRef = useRef>(null); + const [rooms, setRooms] = useState([]); + const [members, setMembers] = useState([]); + const [messages, setMessages] = useState([]); const [activeRoomKey, setActiveRoomKey] = useState('allgemein'); - const [syncSince, setSyncSince] = useState(); const [draft, setDraft] = useState(''); const [search, setSearch] = useState(''); - const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [sending, setSending] = useState(false); - const [provisioning, setProvisioning] = useState(false); + const [error, setError] = useState(null); const [membersOpen, setMembersOpen] = useState(false); - const [replyTarget, setReplyTarget] = useState(null); - const [editingMessage, setEditingMessage] = useState(null); - const [dialogMode, setDialogMode] = useState(null); + const [createOpen, setCreateOpen] = useState(false); const [roomName, setRoomName] = useState(''); const [roomTopic, setRoomTopic] = useState(''); const [roomKey, setRoomKey] = useState(''); - const [dialogText, setDialogText] = useState(''); - const [selectedUserId, setSelectedUserId] = useState(''); + const afterIdRef = useRef(0); const activeRoom = useMemo( () => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null, @@ -167,407 +74,135 @@ export default function CommunicationScreen() { const visibleRooms = useMemo(() => { const query = search.trim().toLowerCase(); - const base = [...rooms].sort((a, b) => { - const groupCompare = String(a.group || '').localeCompare(String(b.group || '')); - if (groupCompare !== 0) return groupCompare; - return String(a.name || a.key).localeCompare(String(b.name || b.key)); - }); - if (!query) return base; - return base.filter((room) => - [room.name, room.key, room.topic, room.email, room.projectNumber] - .filter(Boolean) - .some((value) => String(value).toLowerCase().includes(query)) - ); + return rooms.filter((room) => !query || [room.name, room.topic, room.email, room.projectNumber] + .filter(Boolean).some((value) => String(value).toLowerCase().includes(query))); }, [rooms, search]); - const inviteCandidates = useMemo(() => { - const existing = new Set(members.map((member) => member.matrixUserId)); - return users.filter((user) => !existing.has(user.matrixUserId)); - }, [members, users]); - - const clearRoomUnread = useCallback((roomKey: string) => { - setRooms((current) => - current.map((room) => (room.key === roomKey ? { ...room, unread: 0, mentions: 0 } : room)) - ); - }, []); - const loadRooms = useCallback(async () => { + if (!token) return []; + const result = await fetchChatRooms(token); + setRooms(result); + return result; + }, [token]); + + const loadRoom = useCallback(async (key: string) => { if (!token) return; - const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([ - fetchMatrixStatus(token), - fetchMatrixIdentity(token), - fetchMatrixRooms(token), - fetchMatrixUsers(token), + const [nextMessages, nextMembers] = await Promise.all([ + fetchChatMessages(token, key), + fetchChatMembers(token, key), ]); - setStatus(nextStatus); - setMatrixUserId(identity.matrixUserId || ''); - setRooms(nextRooms); + setMessages(nextMessages); + setMembers(nextMembers); + afterIdRef.current = nextMessages[nextMessages.length - 1]?.id || 0; + await markChatRoomRead(token, key, afterIdRef.current); + }, [token]); - if (!nextRooms.some((room) => room.key === activeRoomKey) && nextRooms[0]) { - setActiveRoomKey(nextRooms[0].key); - } - - setUsers(nextUsers); - }, [activeRoomKey, token]); - - const loadRoomContent = useCallback( - async (roomKeyToLoad: string, showSpinner = true) => { - if (!token || !roomKeyToLoad) return; - if (showSpinner) setLoading(true); - setError(null); - - try { - const [nextMessages, nextMembers, sync] = await Promise.all([ - fetchMatrixMessages(token, roomKeyToLoad), - fetchMatrixMembers(token, roomKeyToLoad), - syncMatrixRoom(token, roomKeyToLoad, undefined, true), - ]); - const merged = mergeMessages(nextMessages, sync.messages || []); - setMessages(sortMessages(merged)); - setMembers(sync.members || nextMembers); - setSyncSince(sync.nextBatch); - const last = merged.at(-1); - if (last?.id) { - await markMatrixRoomRead(token, roomKeyToLoad, last.id); - clearRoomUnread(roomKeyToLoad); - } - requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.'); - } finally { - setLoading(false); - setRefreshing(false); - } - }, - [clearRoomUnread, token] - ); - - const refreshAll = useCallback( - async (showSpinner = true) => { - if (!token) return; - if (showSpinner) setLoading(true); - setError(null); - - try { - await loadRooms(); - await loadRoomContent(activeRoomKey, false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Matrix-Kommunikation konnte nicht geladen werden.'); - setLoading(false); - setRefreshing(false); - } - }, - [activeRoomKey, loadRoomContent, loadRooms, token] - ); - - const pollSync = useCallback(async () => { - if (!token || !activeRoom?.exists || !syncSince) return; + const refresh = useCallback(async () => { + if (!token) return; try { - const sync = await syncMatrixRoom(token, activeRoom.key, syncSince); - const incomingMessages = sync.messages || []; - setSyncSince(sync.nextBatch || syncSince); - setMembers((current) => sync.members || current); - setMessages((current) => { - let next = mergeMessages(current, incomingMessages); - next = applyReplacements(next, sync.replacements); - next = applyReactions(next, sync.reactions); - next = applyRedactions(next, sync.redactions); - return next; - }); - const last = incomingMessages.at(-1); - if (last?.id) { - await markMatrixRoomRead(token, activeRoom.key, last.id); - clearRoomUnread(activeRoom.key); + setError(null); + const nextRooms = await loadRooms(); + const selected = nextRooms.find((room) => room.key === activeRoomKey) || nextRooms[0]; + if (selected?.exists) { + setActiveRoomKey(selected.key); + await loadRoom(selected.key); } - } catch { - // Polling errors are surfaced by manual refresh to avoid noisy chat usage. + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Chat konnte nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); } - }, [activeRoom, clearRoomUnread, syncSince, token]); + }, [activeRoomKey, loadRoom, loadRooms, token]); + + useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { - void refreshAll(true); - }, [refreshAll]); - - useEffect(() => { - if (!activeRoomKey) return; - setReplyTarget(null); - setEditingMessage(null); - setMessages([]); - setMembers([]); - setSyncSince(undefined); - void loadRoomContent(activeRoomKey, true); - }, [activeRoomKey, loadRoomContent]); - - useEffect(() => { - const id = setInterval(() => void pollSync(), 5000); + if (!token || !activeRoom?.exists) return undefined; + const id = setInterval(async () => { + try { + const result = await syncChatRoom(token, activeRoom.key, afterIdRef.current); + if (!result.messages?.length) return; + setMessages((current) => mergeMessages(current, result.messages || [])); + afterIdRef.current = result.nextId || afterIdRef.current; + await markChatRoomRead(token, activeRoom.key, afterIdRef.current); + } catch { + // Beim nächsten Intervall erneut versuchen. + } + }, 3000); return () => clearInterval(id); - }, [pollSync]); + }, [activeRoom, token]); - async function ensureActiveRoom(): Promise { - if (!token || !activeRoom) return null; - if (activeRoom.exists) return activeRoom; - - setProvisioning(true); - try { - const room = await provisionMatrixRoom(token, activeRoom); - setRooms((current) => current.map((item) => (item.key === activeRoom.key ? { ...item, ...room, exists: true } : item))); - await loadRoomContent(activeRoom.key, false); - return { ...activeRoom, ...room, exists: true }; - } catch (err) { - setError(err instanceof Error ? err.message : 'Raum konnte nicht bereitgestellt werden.'); - return null; - } finally { - setProvisioning(false); - } - } - - async function sendMessage() { - if (!token || !draft.trim()) return; - const room = await ensureActiveRoom(); - if (!room) return; - - setSending(true); - try { - const message = editingMessage - ? await editMatrixMessage(token, room.key, editingMessage.id, draft.trim()) - : await sendMatrixMessage(token, room.key, draft.trim(), replyTarget?.id); - setMessages((current) => - editingMessage - ? current.map((item) => (item.id === editingMessage.id ? { ...item, ...message, edited: true } : item)) - : mergeMessages(current, [message]) - ); - setDraft(''); - setReplyTarget(null); - setEditingMessage(null); - requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gesendet werden.'); - } finally { - setSending(false); - } - } - - async function pickAttachment() { + async function selectRoom(room: ChatRoom) { if (!token) return; - const room = await ensureActiveRoom(); - if (!room) return; - - const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false }); - if (result.canceled || !result.assets[0]) return; + try { + setLoading(true); + const readyRoom = room.exists ? room : await provisionChatRoom(token, room); + setActiveRoomKey(readyRoom.key); + await loadRooms(); + await loadRoom(readyRoom.key); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht geöffnet werden.'); + } finally { + setLoading(false); + } + } + async function submitMessage() { + if (!token || !activeRoom?.exists || !draft.trim() || sending) return; + const text = draft.trim(); + setDraft(''); setSending(true); try { - const asset = result.assets[0]; - const message = await uploadMatrixAttachment(token, room.key, { - uri: asset.uri, - name: asset.name || 'Anhang', - mimeType: asset.mimeType, - }); + const message = await sendChatMessage(token, activeRoom.key, text); setMessages((current) => mergeMessages(current, [message])); - requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Anhang konnte nicht hochgeladen werden.'); + afterIdRef.current = Math.max(afterIdRef.current, message.id); + await markChatRoomRead(token, activeRoom.key, message.id); + } catch (nextError) { + setDraft(text); + setError(nextError instanceof Error ? nextError.message : 'Nachricht konnte nicht gesendet werden.'); } finally { setSending(false); } } - async function provisionUser() { - if (!token) return; - setProvisioning(true); - try { - const identity = await provisionMatrixUser(token); - setMatrixUserId(identity.matrixUserId || ''); - await refreshAll(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Matrix-Benutzer konnte nicht erstellt werden.'); - } finally { - setProvisioning(false); - } - } - - async function createRoomFromDialog() { + async function submitRoom() { if (!token || !roomName.trim()) return; - const key = roomKey.trim() || normalizeRoomKey(roomName); - if (!key) return; - - setProvisioning(true); try { - const room = await createMatrixRoom(token, { - key, + const created = await createChatRoom(token, { + key: normalizeRoomKey(roomKey || roomName), name: roomName.trim(), topic: roomTopic.trim() || null, - type: 'room', }); - setRooms((current) => [{ ...room, group: 'Räume', exists: true }, ...current.filter((item) => item.key !== key)]); - setActiveRoomKey(key); - setDialogMode(null); + setCreateOpen(false); setRoomName(''); - setRoomTopic(''); setRoomKey(''); - } catch (err) { - setError(err instanceof Error ? err.message : 'Raum konnte nicht erstellt werden.'); - } finally { - setProvisioning(false); + setRoomTopic(''); + await loadRooms(); + await selectRoom(created); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht erstellt werden.'); } } - async function inviteSelectedUser() { - if (!token || !activeRoom || !selectedUserId) return; - const room = await ensureActiveRoom(); - if (!room) return; - - setProvisioning(true); - try { - await inviteMatrixMember(token, room.key, selectedUserId); - setMembers(await fetchMatrixMembers(token, room.key)); - setDialogMode(null); - setSelectedUserId(''); - } catch (err) { - setError(err instanceof Error ? err.message : 'Mitglied konnte nicht eingeladen werden.'); - } finally { - setProvisioning(false); - } - } - - function openEdit(message: MatrixMessage) { - setEditingMessage(message); - setDialogText(message.body || ''); - setDialogMode('edit-message'); - } - - function confirmDelete(message: MatrixMessage) { - Alert.alert('Nachricht löschen', 'Diese Nachricht wirklich entfernen?', [ - { text: 'Abbrechen', style: 'cancel' }, - { - text: 'Löschen', - style: 'destructive', - onPress: () => { - if (!token || !activeRoom) return; - void deleteMatrixMessage(token, activeRoom.key, message.id) - .then(() => setMessages((current) => current.filter((item) => item.id !== message.id))) - .catch((err) => setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gelöscht werden.')); - }, - }, - ]); - } - - function confirmRemoveMember(member: MatrixMember) { - Alert.alert('Mitglied entfernen', `${member.displayName || member.matrixUserId} aus dem Raum entfernen?`, [ - { text: 'Abbrechen', style: 'cancel' }, - { - text: 'Entfernen', - style: 'destructive', - onPress: () => { - if (!token || !activeRoom) return; - void removeMatrixMember(token, activeRoom.key, member.matrixUserId) - .then(() => setMembers((current) => current.filter((item) => item.matrixUserId !== member.matrixUserId))) - .catch((err) => setError(err instanceof Error ? err.message : 'Mitglied konnte nicht entfernt werden.')); - }, - }, - ]); - } - - async function addReaction(message: MatrixMessage, key: string) { - if (!token || !activeRoom) return; - try { - await reactToMatrixMessage(token, activeRoom.key, message.id, key); - await pollSync(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Reaktion konnte nicht gesendet werden.'); - } - } - - async function syncMembersNow() { - if (!token || !activeRoom) return; - setProvisioning(true); - try { - await syncMatrixMembers(token, activeRoom.key); - setMembers(await fetchMatrixMembers(token, activeRoom.key)); - } catch (err) { - setError(err instanceof Error ? err.message : 'Mitglieder konnten nicht synchronisiert werden.'); - } finally { - setProvisioning(false); - } - } - - function renderRoom({ item }: { item: MatrixRoom }) { - const active = item.key === activeRoomKey; + function renderRoom({ item }: { item: ChatRoom }) { + const active = item.key === activeRoom?.key; return ( - setActiveRoomKey(item.key)}> - {item.group || 'Raum'} - - {item.name || item.key} - - - {!item.exists ? bereitstellen : null} - {item.unread ? {item.mentions ? `@${item.mentions}` : item.unread} : null} - + void selectRoom(item)}> + {item.group || 'Räume'} + {item.name} + {item.unread ? {item.unread} : null} ); } - function renderMessage({ item }: { item: MatrixMessage }) { - const own = Boolean(item.own || item.sender === matrixUserId); - const reply = item.replyToEventId ? messages.find((message) => message.id === item.replyToEventId) : null; - + function renderMessage({ item }: { item: ChatMessage }) { return ( - - - - - {own ? 'Du' : item.senderDisplayName || item.sender} - - {messageTime(item.timestamp)} - - {reply ? ( - - - {reply.senderDisplayName || reply.sender}: {messagePreview(reply)} - - - ) : null} - {item.body ? {item.body} : null} - {item.attachment ? ( - - - {item.attachment.fileName || 'Anhang'} - - - {item.attachment.mimeType || 'Datei'} {item.attachment.size ? `· ${Math.round(item.attachment.size / 1024)} KB` : ''} - - - ) : null} - {item.edited ? bearbeitet : null} - {item.reactions?.length ? ( - - {item.reactions.map((reaction) => ( - - {reaction.key} {reaction.count || ''} - - ))} - - ) : null} - - setReplyTarget(item)}> - Antworten - - {REACTION_PRESETS.map((reaction) => ( - addReaction(item, reaction)}> - {reaction} - - ))} - {own && !item.attachment ? ( - openEdit(item)}> - Bearbeiten - - ) : null} - {own ? ( - confirmDelete(item)}> - Löschen - - ) : null} - + + + {!item.own ? {item.senderDisplayName || 'Benutzer'} : null} + {item.body} + {messageTime(item.timestamp)} ); @@ -577,222 +212,56 @@ export default function CommunicationScreen() { - Kommunikation - - {activeRoom?.name || 'Matrix Chat'} - + Chat + {activeRoom?.name || 'FEDEO Kommunikation'} - setDialogMode('create-room')}> - + - - setMembersOpen((value) => !value)}> - - + setCreateOpen(true)}>+ + setMembersOpen((value) => !value)}> {error ? {error} : null} - {status && status.enabled === false ? ( - - Matrix ist nicht aktiv - Die Kommunikation ist serverseitig noch nicht aktiviert. - - ) : null} - - {!matrixUserId ? ( - - Matrix-Benutzer fehlt - Lege deinen Matrix-Zugang an, um Räume nutzen zu können. - - {provisioning ? 'Wird erstellt...' : 'Benutzer erstellen'} - - - ) : null} - - - item.key} - renderItem={renderRoom} - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.roomList} - /> + + item.key} renderItem={renderRoom} + showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} /> {membersOpen ? ( - - Mitglieder · {members.length} - - - Sync - - setDialogMode('invite-member')}> - Einladen - - - - - {members.map((member) => ( - confirmRemoveMember(member)}> - {member.displayName || member.matrixUserId} - - ))} - - - ) : null} - - {!activeRoom?.exists ? ( - - Raum noch nicht bereitgestellt - Beim Öffnen wird der Matrix-Raum inklusive Einladungen angelegt. - - {provisioning ? 'Wird bereitgestellt...' : 'Raum bereitstellen'} - + Teilnehmer · {members.length} + {members.map((member) => member.own ? 'Du' : member.displayName).join(' · ')} ) : null} {loading ? ( - - - Nachrichten werden geladen... - + Nachrichten werden geladen … ) : ( - item.id} - renderItem={renderMessage} + String(item.id)} renderItem={renderMessage} contentContainerStyle={styles.messageList} - refreshControl={ - { - setRefreshing(true); - void refreshAll(false); - }} - /> - } + refreshControl={ { setRefreshing(true); void refresh(); }} />} ListEmptyComponent={Noch keine Nachrichten in diesem Raum.} - onContentSizeChange={() => messageListRef.current?.scrollToEnd({ animated: true })} - /> + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} /> )} - {(replyTarget || editingMessage) && !dialogMode ? ( - - - {editingMessage ? 'Bearbeiten' : 'Antwort auf'} - - {messagePreview(editingMessage || replyTarget!)} - - - { - setReplyTarget(null); - setEditingMessage(null); - setDraft(''); - }}> - Abbrechen - - - ) : null} - - - + - - - - {sending ? '...' : 'Senden'} + + void submitMessage()}> + {sending ? '…' : 'Senden'} - setDialogMode(null)}> - - - {dialogMode === 'create-room' ? ( - <> - Raum erstellen - - - - - setDialogMode(null)}> - Abbrechen - - - Erstellen - - - - ) : null} - - {dialogMode === 'edit-message' ? ( - <> - Nachricht bearbeiten - - - setDialogMode(null)}> - Abbrechen - - { - setDraft(dialogText); - setDialogMode(null); - }}> - Übernehmen - - - - ) : null} - - {dialogMode === 'invite-member' ? ( - <> - Mitglied einladen - - {inviteCandidates.map((user) => ( - setSelectedUserId(user.userId)}> - {user.displayName || user.email || user.userId} - {user.matrixUserId} - - ))} - {!inviteCandidates.length ? Keine weiteren Benutzer verfügbar. : null} - - - setDialogMode(null)}> - Abbrechen - - - Einladen - - - - ) : null} + setCreateOpen(false)}> + + Chatraum erstellen + + + + + setCreateOpen(false)}>Abbrechen + void submitRoom()}>Erstellen - + ); @@ -800,134 +269,26 @@ export default function CommunicationScreen() { const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: '#f9fafb' }, - header: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingVertical: 12, - backgroundColor: '#ffffff', - borderBottomColor: '#e5e7eb', - borderBottomWidth: 1, - }, - headerMain: { flex: 1, minWidth: 0 }, - title: { color: '#111827', fontSize: 20, fontWeight: '800' }, - subtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 }, - headerButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderColor: '#d1d5db', - backgroundColor: '#ffffff', - }, - headerButtonText: { color: '#111827', fontSize: 20, fontWeight: '700' }, - error: { margin: 12, color: '#991b1b', backgroundColor: '#fee2e2', borderRadius: 8, padding: 10 }, - notice: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#fff7ed', borderWidth: 1, borderColor: '#fed7aa', gap: 8 }, - noticeTitle: { color: '#111827', fontWeight: '700', fontSize: 15 }, - noticeText: { color: '#6b7280', fontSize: 13 }, - roomPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 10 }, - searchInput: { - marginHorizontal: 16, - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 9, - fontSize: 14, - backgroundColor: '#ffffff', - }, - roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, - roomChip: { width: 150, padding: 10, borderRadius: 10, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: '#ffffff', gap: 3 }, - roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, - roomGroup: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' }, - roomName: { color: '#111827', fontSize: 14, fontWeight: '700' }, - roomTextActive: { color: '#2f6f25' }, - roomMetaRow: { flexDirection: 'row', gap: 5, minHeight: 18 }, - roomBadge: { color: '#ffffff', backgroundColor: PRIMARY, borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 }, - roomBadgeMuted: { color: '#6b7280', backgroundColor: '#f3f4f6', borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 }, - membersPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', padding: 12, gap: 8 }, - membersHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - membersTitle: { color: '#111827', fontWeight: '700' }, - membersActions: { flexDirection: 'row', gap: 16 }, - memberList: { gap: 8 }, - memberPill: { backgroundColor: '#f3f4f6', paddingHorizontal: 10, paddingVertical: 7, borderRadius: 16 }, - memberName: { color: '#374151', fontSize: 12 }, - provisionPanel: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#e5e7eb', gap: 8 }, - loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, - loadingText: { color: '#6b7280' }, - messageList: { padding: 12, gap: 10, flexGrow: 1 }, - emptyText: { color: '#6b7280', textAlign: 'center', padding: 18 }, - messageRow: { alignItems: 'flex-start' }, - messageRowOwn: { alignItems: 'flex-end' }, - messageBubble: { - maxWidth: '88%', - minWidth: 120, - backgroundColor: '#ffffff', - borderRadius: 12, - borderWidth: 1, - borderColor: '#e5e7eb', - padding: 10, - gap: 6, - }, - messageBubbleOwn: { backgroundColor: '#3f8f32', borderColor: '#3f8f32' }, - messageHeader: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 }, - messageSender: { flex: 1, color: '#111827', fontSize: 12, fontWeight: '700' }, - messageSenderOwn: { color: '#ffffff' }, - messageTime: { color: '#6b7280', fontSize: 11 }, - messageTimeOwn: { color: '#dff4d9' }, - messageBody: { color: '#111827', fontSize: 15, lineHeight: 21 }, - messageBodyOwn: { color: '#ffffff' }, - replyBox: { backgroundColor: '#f3f4f6', borderRadius: 8, borderLeftWidth: 3, borderLeftColor: PRIMARY, padding: 7 }, - replyBoxOwn: { backgroundColor: '#327628', borderLeftColor: '#dff4d9' }, - replyText: { color: '#4b5563', fontSize: 12 }, - replyTextOwn: { color: '#dff4d9' }, - attachmentBox: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 8, gap: 2 }, - attachmentBoxOwn: { borderColor: '#dff4d9' }, - attachmentTitle: { color: '#111827', fontWeight: '700' }, - attachmentMeta: { color: '#6b7280', fontSize: 12 }, - editedText: { color: '#6b7280', fontSize: 11 }, - reactionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 5 }, - reactionPill: { backgroundColor: '#f3f4f6', borderRadius: 12, overflow: 'hidden', paddingHorizontal: 7, paddingVertical: 2, fontSize: 12 }, - reactionPillOwn: { backgroundColor: '#dff4d9' }, - messageActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingTop: 2 }, - actionText: { color: '#3f8f32', fontSize: 12, fontWeight: '700' }, - actionTextOwn: { color: '#ffffff' }, - actionTextDestructive: { color: '#fee2e2' }, - composerContext: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - paddingHorizontal: 12, - paddingVertical: 8, - backgroundColor: '#ffffff', - borderTopWidth: 1, - borderTopColor: '#e5e7eb', - }, - composerContextMain: { flex: 1, minWidth: 0 }, - composerContextLabel: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' }, - composerContextText: { color: '#111827', fontSize: 13, fontWeight: '600' }, - composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 10, backgroundColor: '#ffffff', borderTopWidth: 1, borderTopColor: '#e5e7eb' }, - attachButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: '#f3f4f6' }, - attachButtonText: { color: '#111827', fontSize: 22, fontWeight: '700' }, - composerInput: { flex: 1, maxHeight: 110, borderRadius: 18, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 }, - sendButton: { minWidth: 70, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: PRIMARY, paddingHorizontal: 12 }, - sendButtonDisabled: { opacity: 0.45 }, - sendButtonText: { color: '#ffffff', fontWeight: '800' }, - primaryButton: { alignSelf: 'flex-start', backgroundColor: PRIMARY, borderRadius: 9, paddingHorizontal: 12, paddingVertical: 9 }, - primaryButtonText: { color: '#ffffff', fontWeight: '800' }, - linkText: { color: '#3f8f32', fontWeight: '800' }, - modalBackdrop: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', alignItems: 'center', justifyContent: 'center', padding: 18 }, - modalCard: { width: '100%', maxWidth: 420, borderRadius: 12, backgroundColor: '#ffffff', padding: 16, gap: 12 }, - modalTitle: { color: '#111827', fontSize: 18, fontWeight: '800' }, - modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 }, - modalTextarea: { minHeight: 110, textAlignVertical: 'top' }, - modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 14 }, - inviteList: { maxHeight: 280 }, - inviteRow: { padding: 10, borderRadius: 9, borderWidth: 1, borderColor: '#e5e7eb', marginBottom: 8 }, - inviteRowActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' }, - inviteName: { color: '#111827', fontWeight: '700' }, - inviteMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 }, + header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, + headerMain: { flex: 1 }, title: { fontSize: 20, fontWeight: '800', color: '#111827' }, subtitle: { color: '#6b7280', marginTop: 2 }, + headerButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#d1d5db' }, + headerButtonText: { fontSize: 20, fontWeight: '700' }, error: { margin: 10, padding: 10, borderRadius: 8, color: '#991b1b', backgroundColor: '#fee2e2' }, + roomPanel: { paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, + searchInput: { marginHorizontal: 16, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10 }, + roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, roomChip: { width: 150, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 }, + roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, roomGroup: { color: '#6b7280', fontSize: 10, textTransform: 'uppercase' }, + roomName: { color: '#111827', fontSize: 14, fontWeight: '700', marginTop: 2 }, roomTextActive: { color: '#2f6f25' }, + roomBadge: { position: 'absolute', right: 6, top: 6, backgroundColor: PRIMARY, color: '#fff', borderRadius: 9, paddingHorizontal: 6, overflow: 'hidden', fontSize: 11 }, + membersPanel: { padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, membersTitle: { fontWeight: '700', color: '#111827' }, membersText: { marginTop: 4, color: '#6b7280', fontSize: 12 }, + loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, loadingText: { color: '#6b7280' }, + messageList: { flexGrow: 1, padding: 14, gap: 10 }, emptyText: { marginTop: 40, textAlign: 'center', color: '#9ca3af' }, + messageRow: { flexDirection: 'row' }, messageRowOwn: { justifyContent: 'flex-end' }, messageBubble: { maxWidth: '82%', padding: 11, borderRadius: 16, backgroundColor: '#f3f4f6' }, + messageBubbleOwn: { backgroundColor: PRIMARY }, messageAuthor: { color: '#2f6f25', fontSize: 12, fontWeight: '700', marginBottom: 3 }, messageBody: { color: '#111827', fontSize: 15 }, messageBodyOwn: { color: '#fff' }, + messageTime: { marginTop: 4, color: '#9ca3af', fontSize: 10, textAlign: 'right' }, messageTimeOwn: { color: '#e7f7e2' }, + composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e5e7eb' }, + composerInput: { flex: 1, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 18, paddingHorizontal: 13, paddingVertical: 9 }, + sendButton: { backgroundColor: PRIMARY, borderRadius: 18, paddingHorizontal: 15, paddingVertical: 10 }, sendButtonDisabled: { opacity: 0.45 }, sendButtonText: { color: '#fff', fontWeight: '700' }, + modalBackdrop: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.45)' }, modalCard: { padding: 18, gap: 12, borderRadius: 14, backgroundColor: '#fff' }, + modalTitle: { fontSize: 18, fontWeight: '800', color: '#111827' }, modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, padding: 10 }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 18 }, linkText: { color: '#2f6f25', fontWeight: '700' }, primaryButton: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 9, backgroundColor: PRIMARY }, primaryButtonText: { color: '#fff', fontWeight: '700' }, }); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 87de4a4..1f5ad58 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -259,27 +259,12 @@ export function isAuthenticationError(error: unknown): boolean { return error instanceof ApiError && error.status === 401; } -export type MatrixStatus = { - enabled?: boolean; - ready?: boolean; - configured?: boolean; - homeserverUrl?: string | null; - [key: string]: unknown; -}; - -export type MatrixIdentity = { - matrixUserId: string; - displayName?: string | null; -}; - -export type MatrixRoom = { +export type ChatRoom = { key: string; name: string; topic?: string | null; type?: 'room' | 'project' | 'direct' | string; group?: string; - roomId?: string | null; - alias?: string | null; exists?: boolean; projectId?: number; projectNumber?: string | null; @@ -294,66 +279,31 @@ export type MatrixRoom = { [key: string]: unknown; }; -export type MatrixAttachment = { - fileName?: string | null; - mimeType?: string | null; - size?: number | null; - mxcUri?: string | null; - previewUrl?: string | null; - downloadUrl?: string | null; -}; - -export type MatrixReaction = { - key: string; - count?: number; - own?: boolean; - senders?: string[]; - [key: string]: unknown; -}; - -export type MatrixMessage = { - id: string; +export type ChatMessage = { + id: number; sender: string; senderDisplayName?: string | null; body?: string | null; timestamp?: string | number | null; own?: boolean; - edited?: boolean; - redacted?: boolean; - msgtype?: string; - attachment?: MatrixAttachment | null; - replyToEventId?: string | null; - reactions?: MatrixReaction[]; [key: string]: unknown; }; -export type MatrixMember = { - matrixUserId: string; - displayName?: string | null; - avatarUrl?: string | null; - membership?: string; - [key: string]: unknown; -}; - -export type MatrixUser = { +export type ChatMember = { userId: string; - matrixUserId: string; displayName?: string | null; email?: string | null; + own?: boolean; [key: string]: unknown; }; -export type MatrixSyncResponse = { - nextBatch?: string; - messages?: MatrixMessage[]; - replacements?: MatrixMessage[]; - reactions?: (MatrixReaction & { targetEventId?: string })[]; - redactions?: { redacts?: string; eventId?: string; targetEventId?: string }[]; - members?: MatrixMember[]; +export type ChatSyncResponse = { + nextId?: number; + messages?: ChatMessage[]; [key: string]: unknown; }; -export type MatrixUnreadCounts = Record; +export type ChatUnreadCounts = Record; function buildUrl(path: string): string { if (path.startsWith('http://') || path.startsWith('https://')) { @@ -429,77 +379,26 @@ export async function apiRequest(path: string, options: RequestOptions = {}): return payload as T; } -async function apiFormRequest(path: string, token: string, formData: FormData): Promise { - const { signal, cleanup } = createTimeoutSignal(); - let response: Response; - - try { - response = await fetch(buildUrl(path), { - method: 'POST', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - }, - body: formData, - signal, - }); - } catch (error) { - if (isAbortError(error)) { - throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`); - } - throw error; - } finally { - cleanup(); - } - - const payload = await parseJson(response); - - if (!response.ok) { - const message = - (payload as { message?: string; error?: string } | null)?.message || - (payload as { message?: string; error?: string } | null)?.error || - `Request failed (${response.status}) for ${path}`; - throw new ApiError(message, response.status); - } - - return payload as T; -} - -function matrixRoomPath(roomKey: string, suffix = ''): string { - return `/api/communication/matrix/rooms/${encodeURIComponent(roomKey)}${suffix}`; +function chatRoomPath(roomKey: string, suffix = ''): string { + return `/api/communication/chat/rooms/${encodeURIComponent(roomKey)}${suffix}`; } export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> { return apiRequest<{ status: string; [key: string]: unknown }>('/health'); } -export async function fetchMatrixStatus(token: string): Promise { - return apiRequest('/api/communication/matrix/status', { token }); -} - -export async function fetchMatrixIdentity(token: string): Promise { - return apiRequest('/api/communication/matrix/me', { token }); -} - -export async function provisionMatrixUser(token: string): Promise { - return apiRequest('/api/communication/matrix/me/provision', { - method: 'POST', - token, - }); -} - -export async function fetchMatrixRooms(token: string): Promise { +export async function fetchChatRooms(token: string): Promise { const [rooms, projectRooms, directRooms, unread] = await Promise.all([ - apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/rooms', { token }), - apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/project-rooms', { token }), - apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/direct-rooms', { token }), - apiRequest<{ rooms?: Record }>('/api/communication/matrix/unread', { + apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/rooms', { token }), + apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/project-rooms', { token }), + apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/direct-rooms', { token }), + apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', { token, }), ]); const unreadByRoom = unread.rooms || {}; - const decorate = (room: MatrixRoom, group: string): MatrixRoom => ({ + const decorate = (room: ChatRoom, group: string): ChatRoom => ({ ...room, group, unread: unreadByRoom[room.key]?.count || 0, @@ -513,165 +412,70 @@ export async function fetchMatrixRooms(token: string): Promise { ]; } -export async function fetchMatrixUnreadCounts(token: string): Promise { - const response = await apiRequest<{ rooms?: MatrixUnreadCounts }>('/api/communication/matrix/unread', { token }); +export async function fetchChatUnreadCounts(token: string): Promise { + const response = await apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', { token }); return response.rooms || {}; } -export async function fetchMatrixUsers(token: string): Promise { - const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token }); - return response.users || []; -} - -export async function createMatrixRoom( +export async function createChatRoom( token: string, payload: { key: string; name: string; topic?: string | null; type?: string } -): Promise { - return apiRequest('/api/communication/matrix/rooms', { +): Promise { + return apiRequest('/api/communication/chat/rooms', { method: 'POST', token, body: payload, }); } -export async function provisionMatrixRoom(token: string, room: MatrixRoom): Promise { - if (room.provisionEndpoint) { - return apiRequest(room.provisionEndpoint, { method: 'POST', token }); - } - +export async function provisionChatRoom(token: string, room: ChatRoom): Promise { if (room.type === 'project' && room.projectId) { - return apiRequest(`/api/communication/matrix/project-rooms/${room.projectId}/provision`, { + return apiRequest(`/api/communication/chat/project-rooms/${room.projectId}/provision`, { method: 'POST', token, }); } if (room.type === 'direct' && room.userId) { - return apiRequest(`/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`, { + return apiRequest(`/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`, { method: 'POST', token, }); } - return apiRequest(matrixRoomPath(room.key, '/provision'), { - method: 'POST', - token, - body: { - key: room.key, - name: room.name, - topic: room.topic, - type: room.type || 'room', - entityType: room.entityType, - entityId: room.entityId, - entityUuid: room.entityUuid, - }, - }); + return room; } -export async function fetchMatrixMessages(token: string, roomKey: string): Promise { - const response = await apiRequest<{ messages?: MatrixMessage[] }>(matrixRoomPath(roomKey, '/messages'), { token }); +export async function fetchChatMessages(token: string, roomKey: string): Promise { + const response = await apiRequest<{ messages?: ChatMessage[] }>(chatRoomPath(roomKey, '/messages'), { token }); return response.messages || []; } -export async function syncMatrixRoom( - token: string, - roomKey: string, - since?: string, - initial = false -): Promise { - const query = new URLSearchParams(); - if (since) query.set('since', since); - if (initial) query.set('initial', '1'); - const suffix = query.toString() ? `/sync?${query.toString()}` : '/sync'; - return apiRequest(matrixRoomPath(roomKey, suffix), { token }); +export async function syncChatRoom(token: string, roomKey: string, afterId = 0): Promise { + return apiRequest(chatRoomPath(roomKey, `/sync?afterId=${afterId}`), { token }); } -export async function fetchMatrixMembers(token: string, roomKey: string): Promise { - const response = await apiRequest<{ members?: MatrixMember[] }>(matrixRoomPath(roomKey, '/members'), { token }); +export async function fetchChatMembers(token: string, roomKey: string): Promise { + const response = await apiRequest<{ members?: ChatMember[] }>(chatRoomPath(roomKey, '/members'), { token }); return response.members || []; } -export async function sendMatrixMessage( - token: string, - roomKey: string, - text: string, - replyToEventId?: string | null -): Promise { - return apiRequest(matrixRoomPath(roomKey, '/messages'), { +export async function sendChatMessage(token: string, roomKey: string, text: string): Promise { + return apiRequest(chatRoomPath(roomKey, '/messages'), { method: 'POST', token, - body: { text, replyToEventId }, - }); -} - -export async function editMatrixMessage(token: string, roomKey: string, eventId: string, text: string): Promise { - return apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), { - method: 'PUT', - token, body: { text }, }); } -export async function deleteMatrixMessage(token: string, roomKey: string, eventId: string): Promise { - await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), { - method: 'DELETE', - token, - }); -} - -export async function reactToMatrixMessage(token: string, roomKey: string, eventId: string, key: string): Promise { - await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}/reactions`), { +export async function markChatRoomRead(token: string, roomKey: string, messageId?: number): Promise { + await apiRequest(chatRoomPath(roomKey, '/read'), { method: 'POST', token, - body: { key }, + body: { messageId }, }); } -export async function markMatrixRoomRead(token: string, roomKey: string, eventId?: string): Promise { - await apiRequest(matrixRoomPath(roomKey, '/read'), { - method: 'POST', - token, - body: { eventId }, - }); -} - -export async function syncMatrixMembers(token: string, roomKey: string): Promise { - await apiRequest(matrixRoomPath(roomKey, '/members/sync'), { - method: 'POST', - token, - }); -} - -export async function inviteMatrixMember(token: string, roomKey: string, userId: string): Promise { - await apiRequest(matrixRoomPath(roomKey, '/members/invite'), { - method: 'POST', - token, - body: { userId }, - }); -} - -export async function removeMatrixMember(token: string, roomKey: string, matrixUserId: string): Promise { - await apiRequest(matrixRoomPath(roomKey, `/members/${encodeURIComponent(matrixUserId)}`), { - method: 'DELETE', - token, - }); -} - -export async function uploadMatrixAttachment( - token: string, - roomKey: string, - file: { uri: string; name: string; mimeType?: string | null } -): Promise { - const formData = new FormData(); - formData.append('file', { - uri: file.uri, - name: file.name, - type: file.mimeType || 'application/octet-stream', - } as unknown as Blob); - - return apiFormRequest(matrixRoomPath(roomKey, '/attachments'), token, formData); -} - export async function renderPrintLabel( token: string, context: Record, diff --git a/scripts/selfhost-setup.sh b/scripts/selfhost-setup.sh index aacf3fd..eb44c12 100755 --- a/scripts/selfhost-setup.sh +++ b/scripts/selfhost-setup.sh @@ -235,7 +235,7 @@ choose_mode() { echo echo "Setup-Modus" - echo " 1) einfach - Domain, Admin, lokale Datenbank, MinIO, Matrix" + echo " 1) einfach - Domain, Admin, lokale Datenbank und MinIO" echo " 2) advanced - zusätzlich SMTP, externe Schlüssel und optionale Dienste" echo @@ -266,12 +266,10 @@ FEDEO Selfhost Setup Dieses Script führt dich durch die lokale Betriebsstruktur: $ROOT_DIR/ - $(basename "$COMPOSE_FILE") Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO, Matrix und Monitoring + $(basename "$COMPOSE_FILE") Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO und Monitoring .env Zielkonfiguration, wird von diesem Script geschrieben postgres/ persistente FEDEO-Datenbank minio/ lokaler S3-kompatibler Dateispeicher - matrix/postgres/ persistente Synapse-Datenbank - matrix/synapse/ generierte Synapse-Konfiguration und Matrix-Daten traefik/letsencrypt/ Let's-Encrypt-Zertifikate traefik/logs/ Traefik-Logs @@ -279,11 +277,6 @@ Dieses Script führt dich durch die lokale Betriebsstruktur: https://DOMAIN/ FEDEO Frontend https://DOMAIN/backend FEDEO API - https://DOMAIN/_matrix Matrix Homeserver - https://DOMAIN/.well-known Matrix Discovery - https://DOMAIN/livekit/sfu LiveKit - https://DOMAIN/livekit/jwt LiveKit JWT-Service - https://DOMAIN/element Element Web EOF } @@ -303,32 +296,28 @@ write_env() { local admin_last_name="${12}" local tenant_name="${13}" local tenant_short="${14}" - local matrix_db_password="${15}" - local matrix_turn_secret="${16}" - local matrix_registration_secret="${17}" - local livekit_secret="${18}" - local mailer_host="${19}" - local mailer_port="${20}" - local mailer_ssl="${21}" - local mailer_user="${22}" - local mailer_pass="${23}" - local mailer_from="${24}" - local web_push_public="${25}" - local web_push_private="${26}" - local pdf_license="${27}" - local openai_key="${28}" - local stirling_key="${29}" - local gocardless_secret_id="${30}" - local gocardless_secret_key="${31}" - local dokubox_host="${32}" - local dokubox_port="${33}" - local dokubox_secure="${34}" - local dokubox_user="${35}" - local dokubox_password="${36}" - local central_services_enabled="${37}" - local central_services_url="${38}" - local central_instance_id="${39}" - local central_instance_secret="${40}" + local mailer_host="${15}" + local mailer_port="${16}" + local mailer_ssl="${17}" + local mailer_user="${18}" + local mailer_pass="${19}" + local mailer_from="${20}" + local web_push_public="${21}" + local web_push_private="${22}" + local pdf_license="${23}" + local openai_key="${24}" + local stirling_key="${25}" + local gocardless_secret_id="${26}" + local gocardless_secret_key="${27}" + local dokubox_host="${28}" + local dokubox_port="${29}" + local dokubox_secure="${30}" + local dokubox_user="${31}" + local dokubox_password="${32}" + local central_services_enabled="${33}" + local central_services_url="${34}" + local central_instance_id="${35}" + local central_instance_secret="${36}" cat >"$ENV_FILE" <Funktionen Open Source Selfhost - Matrix + Chat Kontakt diff --git a/website/app/pages/impressum.vue b/website/app/pages/impressum.vue index e26a7d0..2891098 100644 --- a/website/app/pages/impressum.vue +++ b/website/app/pages/impressum.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt diff --git a/website/app/pages/index.vue b/website/app/pages/index.vue index 19548cb..ad72952 100644 --- a/website/app/pages/index.vue +++ b/website/app/pages/index.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt @@ -167,42 +167,42 @@ -
+

Tief integriert

-

Matrix als Kommunikationsschicht für FEDEO.

+

Chat direkt in FEDEO.

- FEDEO verbindet Chat, Räume, Anrufe und Videokonferenzen mit Projekten, Vorgängen, Kontakten und Berechtigungen. Matrix läuft dabei nicht daneben, sondern wird durch FEDEO provisioniert, verknüpft und betrieben. + FEDEO verbindet Nachrichten und Räume direkt mit Projekten, Kontakten und Berechtigungen. Identitäten, Zugriffe und Gesprächsverläufe bleiben dabei vollständig im FEDEO-Kontext.

-
-
-
+
+
+
{{ item.tag }}

{{ item.title }}

{{ item.description }}

-
+
FEDEO Web & App
FEDEO Backend
SSO, Rechte, Objektkontext
-
Matrix Homeserver
Synapse, Räume, Events
+
FEDEO Chat
Räume, Nachrichten, Lesestatus
-
FEDEO Kommunikation
Räume, Gäste, Föderation
+
FEDEO Datenbank
Mandantengetrennte Speicherung
-

Föderation

-

FEDEO-Instanzen können kontrolliert miteinander sprechen.

+

Klare Zuständigkeit

+

Kommunikation folgt den FEDEO-Berechtigungen.

- Für sensible Unternehmenskommunikation bleibt die Föderation standardmäßig steuerbar: geschlossen für interne Installationen, per Allowlist für Partner und Kunden oder bewusst geöffnet für öffentliche Matrix-Szenarien. + Allgemeine, projektbezogene und direkte Chats werden mandantengetrennt gespeichert. Nutzer sehen nur Räume, für die ihnen FEDEO einen Zugriff zuweist.

@@ -257,10 +257,10 @@ const features = [ { tag: 'Büro', title: 'Aufgaben und Kommunikation', - description: 'Aufgaben, interne Nachrichten und Matrix-Räume laufen dort zusammen, wo die Arbeit entsteht.', + description: 'Aufgaben und interne Nachrichten laufen dort zusammen, wo die Arbeit entsteht.', details: [ 'Aufgaben mit Bezug zu Projekten, Vorgängen und Teams', - 'Matrix-Räume für projektnahe Abstimmung', + 'Chaträume für projektnahe Abstimmung', 'Benachrichtigungen und Lesestatus für laufende Arbeit' ] }, @@ -316,31 +316,31 @@ const features = [ } ] -const matrixStack = [ +const chatStack = [ { tag: 'Chat', title: 'Projekt- und Vorgangsräume', - description: 'Matrix-Räume werden aus FEDEO-Kontexten wie Projekten, Tickets, Teams und Kontakten heraus genutzt.' + description: 'Chaträume werden aus FEDEO-Kontexten wie Projekten, Teams und Kontakten heraus genutzt.' }, { tag: 'Identität', title: 'FEDEO bleibt führend', - description: 'Nutzer, Rollen und Raumzugriffe werden aus FEDEO heraus provisioniert und bei Änderungen synchronisiert.' + description: 'Nutzer, Rollen und Raumzugriffe werden direkt über FEDEO verwaltet.' }, { tag: 'Kommunikation', - title: 'Chat, Gäste und Räume', - description: 'Teamräume, externe Gäste und projektnahe Abstimmung bleiben direkt mit FEDEO-Kontexten verbunden.' + title: 'Nachrichten und Räume', + description: 'Allgemeine, direkte und projektnahe Abstimmung bleibt direkt mit FEDEO-Kontexten verbunden.' }, { tag: 'Betrieb', - title: 'Selfhost-ready Stack', - description: 'Synapse, PostgreSQL, Redis, .well-known, Element und TURN/STUN sind im Selfhost-Stack vorbereitet.' + title: 'Selfhost-ready', + description: 'Chatdaten werden mit der bestehenden FEDEO-Datenbank und ohne zusätzliche Kommunikationsdienste betrieben.' }, { - tag: 'Föderation', - title: 'Instanzübergreifende Zusammenarbeit', - description: 'Mehrere FEDEO- oder Matrix-Instanzen können über freigegebene Domains föderieren, ohne die lokale Kontrolle über Nutzer und Räume aufzugeben.' + tag: 'Sicherheit', + title: 'Mandantengetrennte Kommunikation', + description: 'Teilnehmerrechte und Lesestatus werden serverseitig geprüft und innerhalb der jeweiligen FEDEO-Instanz gespeichert.' } ] diff --git a/website/app/pages/kontakt.vue b/website/app/pages/kontakt.vue index 0d6ac3d..7abbfc0 100644 --- a/website/app/pages/kontakt.vue +++ b/website/app/pages/kontakt.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt diff --git a/website/app/pages/zielgruppen.vue b/website/app/pages/zielgruppen.vue index 794bfbd..183ac94 100644 --- a/website/app/pages/zielgruppen.vue +++ b/website/app/pages/zielgruppen.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt @@ -112,7 +112,7 @@ const audiences = [ points: [ 'Vorgänge mit Historie, Dokumentation und Zuständigkeit', 'Rollen und Berechtigungen für unterschiedliche Teams', - 'Nachvollziehbare Kommunikation über Matrix-Räume' + 'Nachvollziehbare Kommunikation in projektbezogenen Chats' ] }, { @@ -122,7 +122,7 @@ const audiences = [ points: [ 'Offener Code und nachvollziehbare Architektur', 'Docker-basierter Selfhost-Stack', - 'Matrix-Föderation und Integrationen kontrollierbar betreiben' + 'Mandanteninterne Kommunikation kontrolliert betreiben' ] }, { From 15e21a97e271227a4d68e3b57c29d6325794de30 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:49:17 +0200 Subject: [PATCH 5/7] KI-AGENT: Terminpicker und Dokumentaufruf verbessert --- backend/src/plugins/auth.ts | 6 +- backend/src/routes/files.ts | 16 ++- mobile/app.json | 3 +- mobile/app/more/customer/[id].tsx | 3 +- mobile/app/more/plant/[id].tsx | 3 +- mobile/app/project/[id].tsx | 3 +- mobile/components/event-create-modal.tsx | 152 +++++++++++------------ mobile/package-lock.json | 24 ++++ mobile/package.json | 1 + mobile/src/lib/file-opening.ts | 9 ++ 10 files changed, 135 insertions(+), 85 deletions(-) create mode 100644 mobile/src/lib/file-opening.ts diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 22bd53d..5a7a207 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -84,10 +84,12 @@ export default fp(async (server: FastifyInstance) => { const urlPath = req.url.split("?")[0] const queryToken = (req.query as any)?.downloadToken + const isDownloadTokenRoute = + (urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download")) + || urlPath.startsWith("/api/files/content/") const downloadToken = typeof queryToken === "string" - && urlPath.startsWith("/api/email/attachments/") - && urlPath.endsWith("/download") + && isDownloadTokenRoute ? queryToken : null diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 1494510..16765a7 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -5,6 +5,7 @@ import { GetObjectCommand } from "@aws-sdk/client-s3" import archiver from "archiver" +import jwt from "jsonwebtoken" import { secrets } from "../utils/secrets" import { saveFile } from "../utils/files" @@ -19,6 +20,17 @@ import { export default async function fileRoutes(server: FastifyInstance) { + const createDownloadUrl = (req: any, fileId: string) => { + const downloadToken = jwt.sign({ + user_id: req.user.user_id, + email: req.user.email, + tenant_id: req.user.tenant_id, + is_admin: Boolean(req.user.is_admin), + }, secrets.JWT_SECRET!, { expiresIn: "15m" }) + + return `/api/files/content/${fileId}?downloadToken=${encodeURIComponent(downloadToken)}` + } + const getPortalCustomerId = async (req: any) => { const tenantId = req.user?.tenant_id const userId = req.user?.user_id @@ -282,7 +294,7 @@ export default async function fileRoutes(server: FastifyInstance) { const file = await loadSingleFileForRequest(req, id) if (!file) return reply.code(404).send({ error: "Not found" }) - return { ...file, url: `/api/files/content/${file.id}` } + return { ...file, url: createDownloadUrl(req, file.id) } } else { // ------------------------------------------------- // MULTIPLE PRESIGNED URLs @@ -300,7 +312,7 @@ export default async function fileRoutes(server: FastifyInstance) { const output = selected.map(file => ({ ...file, - url: `/api/files/content/${file.id}` + url: createDownloadUrl(req, file.id) })) return { files: output } diff --git a/mobile/app.json b/mobile/app.json index 9907f63..9f8bf72 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -70,7 +70,8 @@ }, "disableAndroid": true } - ] + ], + "@react-native-community/datetimepicker" ], "experiments": { "typedRoutes": true, diff --git a/mobile/app/more/customer/[id].tsx b/mobile/app/more/customer/[id].tsx index 5d0a1fe..cba4b28 100644 --- a/mobile/app/more/customer/[id].tsx +++ b/mobile/app/more/customer/[id].tsx @@ -32,6 +32,7 @@ import { uploadCustomerFile, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; +import { getOpenableFileUrl } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; @@ -276,7 +277,7 @@ export default function CustomerDetailScreen() { async function onOpenFile(file: ProjectFile) { if (!file.url) return; - await WebBrowser.openBrowserAsync(file.url, { + await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, controlsColor: PRIMARY, showTitle: true, diff --git a/mobile/app/more/plant/[id].tsx b/mobile/app/more/plant/[id].tsx index 243fb2f..05000af 100644 --- a/mobile/app/more/plant/[id].tsx +++ b/mobile/app/more/plant/[id].tsx @@ -8,6 +8,7 @@ import * as WebBrowser from 'expo-web-browser'; import { HistorySection } from '@/components/history-section'; import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; +import { getOpenableFileUrl } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; @@ -90,7 +91,7 @@ export default function PlantDetailScreen() { async function onOpenFile(file: ProjectFile) { if (!file.url) return; - await WebBrowser.openBrowserAsync(file.url, { + await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, controlsColor: PRIMARY, showTitle: true, diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index dd9ab3c..240fc2a 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -34,6 +34,7 @@ import { updateTask, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; +import { getOpenableFileUrl } from '@/src/lib/file-opening'; const PRIMARY = '#69c350'; const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen']; @@ -193,7 +194,7 @@ export default function ProjectDetailScreen() { async function onOpenFile(file: ProjectFile) { if (!file.url) return; - await WebBrowser.openBrowserAsync(file.url, { + await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, controlsColor: PRIMARY, showTitle: true, diff --git a/mobile/components/event-create-modal.tsx b/mobile/components/event-create-modal.tsx index f0a005c..fafd7c7 100644 --- a/mobile/components/event-create-modal.tsx +++ b/mobile/components/event-create-modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { KeyboardAvoidingView, Modal, @@ -29,11 +30,11 @@ function pad(value: number): string { return String(value).padStart(2, '0'); } -function formatDateInput(date: Date): string { +function formatDate(date: Date): string { return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; } -function formatTimeInput(date: Date): string { +function formatTime(date: Date): string { return `${pad(date.getHours())}:${pad(date.getMinutes())}`; } @@ -45,35 +46,7 @@ function createDefaults(initialDate?: Date | null) { start.setHours(9, 0, 0, 0); } const end = new Date(start.getTime() + 60 * 60 * 1000); - return { - startDate: formatDateInput(start), - startTime: formatTimeInput(start), - endDate: formatDateInput(end), - endTime: formatTimeInput(end), - }; -} - -function parseLocalDate(dateValue: string, timeValue: string): Date | null { - const match = dateValue.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); - const timeMatch = timeValue.trim().match(/^(\d{1,2}):(\d{2})$/); - if (!match || !timeMatch) return null; - - const day = Number(match[1]); - const month = Number(match[2]); - const year = Number(match[3]); - const hours = Number(timeMatch[1]); - const minutes = Number(timeMatch[2]); - if (hours > 23 || minutes > 59) return null; - - const date = new Date(year, month - 1, day, hours, minutes, 0, 0); - if ( - date.getFullYear() !== year || - date.getMonth() !== month - 1 || - date.getDate() !== day - ) { - return null; - } - return date; + return { start, end }; } function relationId(value: unknown): number | null { @@ -98,10 +71,9 @@ export function EventCreateModal({ const [name, setName] = useState(''); const [notes, setNotes] = useState(''); const [link, setLink] = useState(''); - const [startDate, setStartDate] = useState(''); - const [startTime, setStartTime] = useState(''); - const [endDate, setEndDate] = useState(''); - const [endTime, setEndTime] = useState(''); + const [start, setStart] = useState(() => createDefaults(initialDate).start); + const [end, setEnd] = useState(() => createDefaults(initialDate).end); + const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null); const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); const [projectSearch, setProjectSearch] = useState(''); const [saving, setSaving] = useState(false); @@ -113,10 +85,9 @@ export function EventCreateModal({ setName(''); setNotes(''); setLink(''); - setStartDate(defaults.startDate); - setStartTime(defaults.startTime); - setEndDate(defaults.endDate); - setEndTime(defaults.endTime); + setStart(defaults.start); + setEnd(defaults.end); + setActivePicker(null); setSelectedProjectId(initialProjectId); setProjectSearch(''); setError(null); @@ -138,6 +109,27 @@ export function EventCreateModal({ .slice(0, 8); }, [projectSearch, projects]); + function updatePicker(event: DateTimePickerEvent, selected?: Date) { + if (Platform.OS === 'android') setActivePicker(null); + if (event.type === 'dismissed' || !selected || !activePicker) return; + + const isStart = activePicker.startsWith('start'); + const isDate = activePicker.endsWith('Date'); + const current = new Date(isStart ? start : end); + if (isDate) { + current.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate()); + } else { + current.setHours(selected.getHours(), selected.getMinutes(), 0, 0); + } + + if (isStart) { + setStart(current); + if (end <= current) setEnd(new Date(current.getTime() + 60 * 60 * 1000)); + } else { + setEnd(current); + } + } + async function save() { if (!token || saving) return; const title = name.trim(); @@ -146,12 +138,6 @@ export function EventCreateModal({ return; } - const start = parseLocalDate(startDate, startTime); - const end = parseLocalDate(endDate, endTime); - if (!start || !end) { - setError('Bitte Datum und Uhrzeit im angegebenen Format eingeben.'); - return; - } if (end <= start) { setError('Das Ende muss nach dem Beginn liegen.'); return; @@ -198,44 +184,42 @@ export function EventCreateModal({ Beginn - - + setActivePicker('startDate')}> + {formatDate(start)} + + setActivePicker('startTime')}> + {formatTime(start)} + Ende - - + setActivePicker('endDate')}> + {formatDate(end)} + + setActivePicker('endTime')}> + {formatTime(end)} + + {activePicker ? ( + + + {Platform.OS === 'ios' ? ( + setActivePicker(null)}> + Übernehmen + + ) : null} + + ) : null} + Projekt {selectedProject ? ( @@ -324,8 +308,22 @@ const styles = StyleSheet.create({ backgroundColor: '#ffffff', }, row: { flexDirection: 'row', gap: 8 }, + pickerButton: { + minHeight: 44, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#ffffff', + }, + pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' }, dateInput: { flex: 1 }, timeInput: { width: 92 }, + pickerWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 8 }, + pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 }, + pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' }, multiline: { minHeight: 90, textAlignVertical: 'top' }, selectedProject: { flexDirection: 'row', diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 4f670e8..4d30e21 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -2892,6 +2893,29 @@ } } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.4.4", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz", + "integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", diff --git a/mobile/package.json b/mobile/package.json index ec9997f..d856f28 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", diff --git a/mobile/src/lib/file-opening.ts b/mobile/src/lib/file-opening.ts new file mode 100644 index 0000000..53dde3a --- /dev/null +++ b/mobile/src/lib/file-opening.ts @@ -0,0 +1,9 @@ +import { getApiBaseUrlSync } from '@/src/lib/server-config'; + +export function getOpenableFileUrl(rawUrl: string): string { + if (/^https?:\/\//i.test(rawUrl)) return rawUrl; + + const apiBaseUrl = getApiBaseUrlSync().replace(/\/+$/, ''); + const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`; + return `${apiBaseUrl}${path}`; +} From e90cf819e7117560a75f9aeb4cdd607ce01ab28a Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:50:46 +0200 Subject: [PATCH 6/7] =?UTF-8?q?KI-AGENT:=20iOS-Buildnummer=20f=C3=BCr=20Te?= =?UTF-8?q?stFlight=20auf=2013=20erh=C3=B6hen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index 9f8bf72..aa09021 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "software.federspiel.fedeo", - "buildNumber": "12", + "buildNumber": "13", "infoPlist": { "NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.", From 718ecefd4640c4ede2078e6e8cc49be1e0224be4 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:53:42 +0200 Subject: [PATCH 7/7] KI-AGENT: Scrollen in Dashboard-Seiten wiederherstellen --- frontend/layouts/default.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index af772cf..38dd5ae 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -240,7 +240,7 @@ onMounted(() => { -
+
- + { - +