Compare commits

...

7 Commits

Author SHA1 Message Date
718ecefd46 KI-AGENT: Scrollen in Dashboard-Seiten wiederherstellen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 3m18s
Build and Push Docker Images / build-website (push) Successful in 36s
Build and Push Docker Images / build-central-services-api (push) Successful in 23s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 1m21s
2026-09-08 22:53:42 +02:00
e90cf819e7 KI-AGENT: iOS-Buildnummer für TestFlight auf 13 erhöhen 2026-09-08 22:50:46 +02:00
15e21a97e2 KI-AGENT: Terminpicker und Dokumentaufruf verbessert 2026-09-08 22:49:17 +02:00
358e40d749 KI-AGENT: Matrix durch nativen FEDEO-Chat ersetzen 2026-09-08 22:42:40 +02:00
47bd8e80e4 KI-AGENT: iOS-Buildnummer für TestFlight auf 12 erhöhen 2026-09-08 22:17:52 +02:00
92c354c1b9 KI-AGENT: Mobile Termine und Kalender ergänzen 2026-09-08 22:05:18 +02:00
5e1631e2d3 KI-AGENT: Mobile Kundenerfassung und Logbücher erweitern 2026-09-08 21:50:31 +02:00
66 changed files with 2898 additions and 9847 deletions

View File

@@ -117,50 +117,3 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN 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

View File

@@ -239,33 +239,16 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN 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. 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. 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. 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. 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 ```yaml
@@ -463,7 +446,6 @@ Im Deploy-Verzeichnis:
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d 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: Danach Status prufen:
@@ -541,8 +523,6 @@ Regelmassig sichern:
- `./postgres` - `./postgres`
- `./minio` falls MinIO lokal genutzt wird - `./minio` falls MinIO lokal genutzt wird
- `./matrix/postgres` falls Matrix lokal betrieben wird
- `./matrix/synapse` falls Matrix lokal betrieben wird
- `./traefik/letsencrypt/acme.json` - `./traefik/letsencrypt/acme.json`
- deine `.env` - deine `.env`
- deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management - deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management

View File

@@ -8,9 +8,6 @@ CREATE TABLE "communication_rooms" (
"entity_type" text, "entity_type" text,
"entity_id" bigint, "entity_id" bigint,
"entity_uuid" uuid, "entity_uuid" uuid,
"matrix_room_id" text,
"matrix_alias" text,
"parent_space_room_id" text,
"archived" boolean DEFAULT false NOT NULL, "archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone, "updated_at" timestamp with time zone,

View File

@@ -8,9 +8,6 @@ CREATE TABLE IF NOT EXISTS "communication_rooms" (
"entity_type" text, "entity_type" text,
"entity_id" bigint, "entity_id" bigint,
"entity_uuid" uuid, "entity_uuid" uuid,
"matrix_room_id" text,
"matrix_alias" text,
"parent_space_room_id" text,
"archived" boolean DEFAULT false NOT NULL, "archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone, "updated_at" timestamp with time zone,

View File

@@ -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;

View File

@@ -470,6 +470,13 @@
"when": 1788803000000, "when": 1788803000000,
"tag": "0069_reset_email_entity_suggestions", "tag": "0069_reset_email_entity_suggestions",
"breakpoints": true "breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1788850800000,
"tag": "0070_native_communication_chat",
"breakpoints": true
} }
] ]
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -30,10 +30,6 @@ export const communicationRooms = pgTable(
entityId: bigint("entity_id", { mode: "number" }), entityId: bigint("entity_id", { mode: "number" }),
entityUuid: uuid("entity_uuid"), 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), archived: boolean("archived").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })

View File

@@ -16,6 +16,9 @@ export * from "./checkexecutions"
export * from "./checks" export * from "./checks"
export * from "./citys" export * from "./citys"
export * from "./communication_rooms" export * from "./communication_rooms"
export * from "./communication_room_members"
export * from "./communication_messages"
export * from "./communication_room_reads"
export * from "./contacts" export * from "./contacts"
export * from "./contracts" export * from "./contracts"
export * from "./contracttypes" export * from "./contracttypes"

View File

@@ -61,7 +61,6 @@ import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer" import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3"; import {initS3} from "./utils/s3";
import { runBootstrap } from "./modules/bootstrap.service"; import { runBootstrap } from "./modules/bootstrap.service";
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service"; import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker"; import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
@@ -89,7 +88,6 @@ async function main() {
await app.register(dbPlugin); await app.register(dbPlugin);
await app.register(servicesPlugin); await app.register(servicesPlugin);
await runBootstrap(app); await runBootstrap(app);
startMatrixPushWorker(app);
startCentralServicesHeartbeat(app); startCentralServicesHeartbeat(app);
startDocumentImportWorker(app); startDocumentImportWorker(app);

View File

@@ -19,7 +19,6 @@ import {
tenants, tenants,
texttemplates, texttemplates,
} from "../../db/schema" } from "../../db/schema"
import { matrixService } from "./matrix.service"
const adminPermissions = [ const adminPermissions = [
"mcp.tokens.write", "mcp.tokens.write",
@@ -456,18 +455,4 @@ export async function runBootstrap(server: FastifyInstance) {
await ensureTenantBaseData(server, tenant.id, adminUser.id) await ensureTenantBaseData(server, tenant.id, adminUser.id)
console.log("✅ Bootstrap-Grunddaten geprüft") 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
}
}
} }

View File

@@ -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<typeof setTimeout> | 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<string>()
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<number, ChatRecipient[]>()
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)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { matrixService } from "./matrix.service"
type MetricSample = { type MetricSample = {
labels: Record<string, string> labels: Record<string, string>
@@ -117,10 +116,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
const databaseCheck = await server.db.execute("SELECT NOW() as now") 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() const minioUrl = s3EndpointUrl()
return { return {
@@ -165,7 +160,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
url: nodeExporterMetricsUrl, url: nodeExporterMetricsUrl,
error: nodeExporterError, error: nodeExporterError,
}), }),
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, { minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
error: "S3_ENDPOINT ist nicht gesetzt", error: "S3_ENDPOINT ist nicht gesetzt",
}), }),

View File

@@ -84,10 +84,12 @@ export default fp(async (server: FastifyInstance) => {
const urlPath = req.url.split("?")[0] const urlPath = req.url.split("?")[0]
const queryToken = (req.query as any)?.downloadToken const queryToken = (req.query as any)?.downloadToken
const isDownloadTokenRoute =
(urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download"))
|| urlPath.startsWith("/api/files/content/")
const downloadToken = const downloadToken =
typeof queryToken === "string" typeof queryToken === "string"
&& urlPath.startsWith("/api/email/attachments/") && isDownloadTokenRoute
&& urlPath.endsWith("/download")
? queryToken ? queryToken
: null : null

View File

@@ -30,7 +30,6 @@ import {
} from "../utils/tenantFullExport"; } from "../utils/tenantFullExport";
import type { TenantFullExport } from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport";
import { buildSystemStatus } from "../modules/system-status.service"; import { buildSystemStatus } from "../modules/system-status.service";
import { matrixService } from "../modules/matrix.service";
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
@@ -385,27 +384,7 @@ export default async function adminRoutes(server: FastifyInstance) {
}); });
} }
let matrixProvisioned = false; return { chatReady: true };
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,
};
}; };
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import {
GetObjectCommand GetObjectCommand
} from "@aws-sdk/client-s3" } from "@aws-sdk/client-s3"
import archiver from "archiver" import archiver from "archiver"
import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files" import { saveFile } from "../utils/files"
@@ -19,6 +20,17 @@ import {
export default async function fileRoutes(server: FastifyInstance) { 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 getPortalCustomerId = async (req: any) => {
const tenantId = req.user?.tenant_id const tenantId = req.user?.tenant_id
const userId = req.user?.user_id const userId = req.user?.user_id
@@ -282,7 +294,7 @@ export default async function fileRoutes(server: FastifyInstance) {
const file = await loadSingleFileForRequest(req, id) const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" }) 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 { } else {
// ------------------------------------------------- // -------------------------------------------------
// MULTIPLE PRESIGNED URLs // MULTIPLE PRESIGNED URLs
@@ -300,7 +312,7 @@ export default async function fileRoutes(server: FastifyInstance) {
const output = selected.map(file => ({ const output = selected.map(file => ({
...file, ...file,
url: `/api/files/content/${file.id}` url: createDownloadUrl(req, file.id)
})) }))
return { files: output } return { files: output }

View File

@@ -127,7 +127,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
const data = await server.db const data = await server.db
.select() .select()
.from(historyitems) .from(historyitems)
.where(eq(column, parseId(id))) .where(and(
eq(historyitems.tenant, req.user?.tenant_id),
eq(column, parseId(id))
))
.orderBy(asc(historyitems.createdAt)); .orderBy(asc(historyitems.createdAt));
const userIds = Array.from( const userIds = Array.from(

View File

@@ -38,15 +38,6 @@ export let secrets = {
DOKUBOX_IMAP_PASSWORD: string DOKUBOX_IMAP_PASSWORD: string
OPENAI_API_KEY: string OPENAI_API_KEY: string
STIRLING_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_PUBLIC_KEY?: string
WEB_PUSH_PRIVATE_KEY?: string WEB_PUSH_PRIVATE_KEY?: string
WEB_PUSH_SUBJECT?: string WEB_PUSH_SUBJECT?: string
@@ -88,15 +79,6 @@ const secretKeys = [
"DOKUBOX_IMAP_PASSWORD", "DOKUBOX_IMAP_PASSWORD",
"OPENAI_API_KEY", "OPENAI_API_KEY",
"STIRLING_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_PUBLIC_KEY",
"WEB_PUSH_PRIVATE_KEY", "WEB_PUSH_PRIVATE_KEY",
"WEB_PUSH_SUBJECT", "WEB_PUSH_SUBJECT",

View File

@@ -86,47 +86,6 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"]) const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"])
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` 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 tableColumns = async (client: any) => {
const result = await client.query(` const result = await client.query(`
select table_name, column_name, data_type, is_generated 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])) 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) { if (tables.entitybankaccounts?.length) {
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts) 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) => { const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
if (!isJsonColumn || value === null || typeof value === "undefined") return value if (!isJsonColumn || value === null || typeof value === "undefined") return value
if (typeof value === "string") return value if (typeof value === "string") return value
@@ -853,7 +751,6 @@ export const importTenantFullExport = async (
const exportData = rawExportData const exportData = rawExportData
encryptEntityBankAccountRowsForImport(exportData) encryptEntityBankAccountRowsForImport(exportData)
prepareCommunicationRoomsForImport(exportData)
const client = await pool.connect() const client = await pool.connect()
const importOrder = [ const importOrder = [
"tenants", "tenants",
@@ -955,13 +852,6 @@ export const importTenantFullExport = async (
await reportProgress(`${table} importiert`) 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) await refreshSequences(client, columnsByTable)
progressDone = progressTotal progressDone = progressTotal
await reportProgress("Import abgeschlossen") await reportProgress("Import abgeschlossen")

View File

@@ -93,8 +93,6 @@ services:
condition: service_healthy condition: service_healthy
createbuckets: createbuckets:
condition: service_completed_successfully condition: service_completed_successfully
matrix-synapse:
condition: service_healthy
environment: environment:
NODE_ENV: production NODE_ENV: production
FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true} 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_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer}
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO} FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-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} NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -195,7 +183,6 @@ services:
NODE_ENV: production NODE_ENV: production
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE} NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
NUXT_PUBLIC_MATRIX_ELEMENT_URL: ${NUXT_PUBLIC_MATRIX_ELEMENT_URL:-}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) - traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)
@@ -207,306 +194,6 @@ services:
networks: networks:
- web - 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 <<EOF
{
"m.homeserver": {
"base_url": "https://${DOMAIN}"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://${DOMAIN}/livekit/jwt"
}
]
}
EOF
cat >/usr/share/nginx/html/.well-known/matrix/server <<EOF
{
"m.server": "${MATRIX_SERVER_NAME:-${DOMAIN}}:443"
}
EOF
exec nginx -g 'daemon off;'
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolalloworiginlist=*
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowmethods=GET,OPTIONS
- traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowheaders=Content-Type,Authorization
- traefik.http.routers.fedeo-matrix-well-known.rule=Host(`${DOMAIN}`) && PathPrefix(`/.well-known/matrix`)
- traefik.http.routers.fedeo-matrix-well-known.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-well-known.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-well-known.middlewares=fedeo-matrix-well-known-cors
- traefik.http.services.fedeo-matrix-well-known.loadbalancer.server.port=80
- traefik.docker.network=fedeo_web
networks:
- web
matrix-turn:
image: instrumentisto/coturn:4
container_name: fedeo-matrix-turn
restart: unless-stopped
command:
- --fingerprint
- --use-auth-secret
- --static-auth-secret=${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
- --realm=${MATRIX_SERVER_NAME:-${DOMAIN}}
- --listening-port=3478
- --tls-listening-port=5349
- --min-port=49160
- --max-port=49200
- --no-cli
- --no-tlsv1
- --no-tlsv1_1
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "5349:5349/tcp"
- "49160-49200:49160-49200/udp"
networks:
- internal
matrix-livekit:
image: livekit/livekit-server:v1.9
container_name: fedeo-matrix-livekit
restart: unless-stopped
depends_on:
- matrix-redis
entrypoint: /bin/sh
command:
- -ec
- |
cat >/tmp/livekit.yaml <<EOF
port: 7880
redis:
address: matrix-redis:6379
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
keys:
${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
room:
auto_create: true
EOF
exec /livekit-server --config /tmp/livekit.yaml
ports:
- "7881:7881/tcp"
- "50000-50100:50000-50100/udp"
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-livekit-strip.stripprefix.prefixes=/livekit/sfu
- traefik.http.routers.fedeo-matrix-livekit.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/sfu`)
- traefik.http.routers.fedeo-matrix-livekit.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-livekit.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-livekit.middlewares=fedeo-matrix-livekit-strip
- traefik.http.services.fedeo-matrix-livekit.loadbalancer.server.port=7880
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
container_name: fedeo-matrix-rtc-jwt
restart: unless-stopped
depends_on:
- matrix-livekit
- matrix-synapse
environment:
LIVEKIT_URL: wss://${DOMAIN}/livekit/sfu
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
LIVEKIT_FULL_ACCESS_HOMESERVERS: ${MATRIX_SERVER_NAME:-${DOMAIN}}
LIVEKIT_JWT_BIND: :8080
labels:
- traefik.enable=true
- traefik.http.middlewares.fedeo-matrix-rtc-jwt-strip.stripprefix.prefixes=/livekit/jwt
- traefik.http.routers.fedeo-matrix-rtc-jwt.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/jwt`)
- traefik.http.routers.fedeo-matrix-rtc-jwt.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-rtc-jwt.tls.certresolver=letsencrypt
- traefik.http.routers.fedeo-matrix-rtc-jwt.middlewares=fedeo-matrix-rtc-jwt-strip
- traefik.http.services.fedeo-matrix-rtc-jwt.loadbalancer.server.port=8080
- traefik.docker.network=fedeo_web
networks:
- web
- internal
matrix-element:
image: vectorim/element-web:latest
container_name: fedeo-matrix-element
user: "0:0"
restart: unless-stopped
entrypoint: /bin/sh
command:
- -ec
- |
cat >/app/config.json <<EOF
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://${DOMAIN}",
"server_name": "${MATRIX_SERVER_NAME:-${DOMAIN}}"
}
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://${DOMAIN}/livekit/jwt"
}
],
"disable_custom_urls": false,
"disable_guests": true,
"brand": "FEDEO Matrix",
"default_theme": "light",
"features": {
"feature_video_rooms": true
}
}
EOF
exec nginx -g 'daemon off;'
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-matrix-element.rule=Host(`${DOMAIN}`) && PathPrefix(`/element`)
- traefik.http.routers.fedeo-matrix-element.entrypoints=websecure
- traefik.http.routers.fedeo-matrix-element.tls.certresolver=letsencrypt
- traefik.http.middlewares.fedeo-matrix-element-strip.stripprefix.prefixes=/element
- traefik.http.routers.fedeo-matrix-element.middlewares=fedeo-matrix-element-strip
- traefik.http.services.fedeo-matrix-element.loadbalancer.server.port=80
- traefik.docker.network=fedeo_web
networks:
- web
networks: networks:
web: web:
name: fedeo_web name: fedeo_web

View File

@@ -11,18 +11,16 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik"
- "traefik.port=3000" - "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https" - "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
# Web Entrypoint
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure" - "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)" - "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend.entrypoints=web" - "traefik.http.routers.fedeo-frontend.entrypoints=web"
- "traefik.http.routers.fedeo-frontend.priority=1" - "traefik.http.routers.fedeo-frontend.priority=1"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)" - "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" # - "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge" - "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-frontend-secure.priority=1" - "traefik.http.routers.fedeo-frontend-secure.priority=1"
docs: docs:
image: git.federspiel.tech/flfeders/fedeo/docs:dev image: git.federspiel.tech/flfeders/fedeo/docs:dev
restart: always restart: always
@@ -32,20 +30,18 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik"
- "traefik.port=3000" - "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https" - "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-docs-strip.stripprefix.prefixes=/docs" - "traefik.http.middlewares.fedeo-docs-strip.stripprefix.prefixes=/docs"
# Web Entrypoint
- "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure" - "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure"
- "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)" - "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
- "traefik.http.routers.fedeo-docs.entrypoints=web" - "traefik.http.routers.fedeo-docs.entrypoints=web"
- "traefik.http.routers.fedeo-docs.priority=120" - "traefik.http.routers.fedeo-docs.priority=120"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)" - "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
- "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured" - "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge" - "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-docs-secure.middlewares=fedeo-docs-strip" - "traefik.http.routers.fedeo-docs-secure.middlewares=fedeo-docs-strip"
- "traefik.http.routers.fedeo-docs-secure.priority=120" - "traefik.http.routers.fedeo-docs-secure.priority=120"
backend: backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev image: git.federspiel.tech/flfeders/fedeo/backend:dev
restart: always restart: always
@@ -78,16 +74,13 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik"
- "traefik.port=3100" - "traefik.port=3100"
# Middlewares
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https" - "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend" - "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
# Web Entrypoint
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure" - "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
- "traefik.http.routers.fedeo-backend.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)" - "traefik.http.routers.fedeo-backend.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend.entrypoints=web" - "traefik.http.routers.fedeo-backend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)" - "traefik.http.routers.fedeo-backend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" # - "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge" - "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip" - "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
@@ -107,323 +100,6 @@ services:
networks: networks:
- traefik - traefik
matrix-db:
image: postgres:16-alpine
restart: unless-stopped
profiles:
- matrix
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
networks:
- traefik
matrix-redis:
image: redis:7-alpine
restart: unless-stopped
profiles:
- matrix
networks:
- traefik
matrix-synapse:
image: ghcr.io/element-hq/synapse:latest
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-db
- matrix-redis
environment:
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
volumes:
- ./matrix/synapse:/data
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=8008"
- "traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008"
# Matrix Client-Server API
- "traefik.http.routers.fedeo-matrix.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
- "traefik.http.routers.fedeo-matrix.entrypoints=web"
- "traefik.http.routers.fedeo-matrix.middlewares=fedeo-matrix-redirect-web-secure"
- "traefik.http.routers.fedeo-matrix.service=fedeo-matrix"
- "traefik.http.middlewares.fedeo-matrix-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.routers.fedeo-matrix-secure.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix`)"
- "traefik.http.routers.fedeo-matrix-secure.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-secure.service=fedeo-matrix"
# Matrix Federation API, nur öffnen wenn Federation gewünscht ist.
- "traefik.http.routers.fedeo-matrix-federation.rule=Host(`${MATRIX_HOMESERVER_HOST:-matrix.fedeo.de}`) && PathPrefix(`/_matrix/federation`)"
- "traefik.http.routers.fedeo-matrix-federation.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-federation.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-federation.service=fedeo-matrix"
matrix-well-known:
image: nginx:1.27-alpine
restart: unless-stopped
profiles:
- matrix
volumes:
- ./matrix/well-known:/usr/share/nginx/html/.well-known/matrix:ro
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=80"
- "traefik.http.services.fedeo-matrix-well-known.loadbalancer.server.port=80"
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolalloworiginlist=*"
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowmethods=GET,OPTIONS"
- "traefik.http.middlewares.fedeo-matrix-well-known-cors.headers.accesscontrolallowheaders=Content-Type,Authorization"
- "traefik.http.routers.fedeo-matrix-well-known.rule=Host(`${MATRIX_SERVER_NAME:-fedeo.de}`) && PathPrefix(`/.well-known/matrix`)"
- "traefik.http.routers.fedeo-matrix-well-known.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-well-known.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-well-known.middlewares=fedeo-matrix-well-known-cors"
- "traefik.http.routers.fedeo-matrix-well-known.service=fedeo-matrix-well-known"
matrix-turn:
image: instrumentisto/coturn:4
restart: unless-stopped
profiles:
- matrix
command:
- --fingerprint
- --use-auth-secret
- --static-auth-secret=${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret}
- --realm=${MATRIX_SERVER_NAME:-fedeo.de}
- --listening-port=3478
- --tls-listening-port=5349
- --min-port=49160
- --max-port=49200
- --no-cli
- --no-tlsv1
- --no-tlsv1_1
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "5349:5349/tcp"
- "49160-49200:49160-49200/udp"
networks:
- traefik
matrix-livekit:
image: livekit/livekit-server:v1.9
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-redis
entrypoint: /bin/sh
command:
- -ec
- |
cat >/tmp/livekit.yaml <<EOF
port: 7880
redis:
address: matrix-redis:6379
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
keys:
${LIVEKIT_KEY:-fedeo-livekit}: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
room:
auto_create: true
EOF
exec /livekit-server --config /tmp/livekit.yaml
ports:
- "7881:7881/tcp"
- "50000-50100:50000-50100/udp"
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=7880"
- "traefik.http.services.fedeo-matrix-livekit.loadbalancer.server.port=7880"
- "traefik.http.middlewares.fedeo-matrix-livekit-strip.stripprefix.prefixes=/livekit/sfu"
- "traefik.http.routers.fedeo-matrix-livekit.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/sfu`)"
- "traefik.http.routers.fedeo-matrix-livekit.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-livekit.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-livekit.middlewares=fedeo-matrix-livekit-strip"
- "traefik.http.routers.fedeo-matrix-livekit.service=fedeo-matrix-livekit"
matrix-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
restart: unless-stopped
profiles:
- matrix
depends_on:
- matrix-livekit
- matrix-synapse
environment:
- LIVEKIT_URL=wss://${MATRIX_RTC_HOST:-call.fedeo.de}/livekit/sfu
- LIVEKIT_KEY=${LIVEKIT_KEY:-fedeo-livekit}
- LIVEKIT_SECRET=${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
- LIVEKIT_FULL_ACCESS_HOMESERVERS=${MATRIX_SERVER_NAME:-fedeo.de}
- LIVEKIT_JWT_BIND=:8080
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=8080"
- "traefik.http.services.fedeo-matrix-rtc-jwt.loadbalancer.server.port=8080"
- "traefik.http.middlewares.fedeo-matrix-rtc-jwt-strip.stripprefix.prefixes=/livekit/jwt"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.rule=Host(`${MATRIX_RTC_HOST:-call.fedeo.de}`) && PathPrefix(`/livekit/jwt`)"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.entrypoints=web-secured"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.middlewares=fedeo-matrix-rtc-jwt-strip"
- "traefik.http.routers.fedeo-matrix-rtc-jwt.service=fedeo-matrix-rtc-jwt"
matrix-dev-db:
image: postgres:16-alpine
restart: unless-stopped
profiles:
- matrix-dev
environment:
- POSTGRES_DB=synapse
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=synapse-dev-password
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --lc-collate=C --lc-ctype=C
volumes:
- ./matrix/dev/postgres:/var/lib/postgresql/data
networks:
- traefik
matrix-dev-redis:
image: redis:7-alpine
restart: unless-stopped
profiles:
- matrix-dev
networks:
- traefik
matrix-dev-synapse:
image: ghcr.io/element-hq/synapse:latest
restart: unless-stopped
profiles:
- matrix-dev
depends_on:
- matrix-dev-db
- matrix-dev-redis
environment:
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
volumes:
- ./matrix/dev/synapse:/data
ports:
- "${MATRIX_DEV_SYNAPSE_PORT:-8008}:8008"
networks:
- traefik
matrix-dev-turn:
image: instrumentisto/coturn:4
restart: unless-stopped
profiles:
- matrix-dev
command:
- --fingerprint
- --use-auth-secret
- --static-auth-secret=matrix-dev-turn-secret
- --realm=localhost
- --listening-port=3478
- --min-port=49160
- --max-port=49200
- --no-cli
- --no-tls
- --no-dtls
ports:
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/tcp"
- "${MATRIX_DEV_TURN_PORT:-3478}:3478/udp"
- "${MATRIX_DEV_TURN_MIN_PORT:-49160}-${MATRIX_DEV_TURN_MAX_PORT:-49200}:49160-49200/udp"
networks:
- traefik
matrix-dev-livekit:
image: livekit/livekit-server:v1.9
restart: unless-stopped
profiles:
- matrix-dev
depends_on:
- matrix-dev-redis
entrypoint: /bin/sh
command:
- -ec
- |
cat >/tmp/livekit.yaml <<EOF
port: 7880
redis:
address: matrix-dev-redis:6379
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
node_ip: ${MATRIX_DEV_LIVEKIT_NODE_IP:-127.0.0.1}
use_external_ip: false
enable_loopback_candidate: true
keys:
devkey: devsecret-local-matrix-stack-32-chars
room:
auto_create: true
EOF
exec /livekit-server --config /tmp/livekit.yaml
ports:
- "${MATRIX_DEV_LIVEKIT_PORT:-7880}:7880"
- "${MATRIX_DEV_LIVEKIT_TCP_PORT:-7881}:7881/tcp"
- "${MATRIX_DEV_LIVEKIT_RTC_MIN_PORT:-50000}-${MATRIX_DEV_LIVEKIT_RTC_MAX_PORT:-50100}:50000-50100/udp"
networks:
- traefik
matrix-dev-rtc-jwt:
image: ghcr.io/element-hq/lk-jwt-service:latest
restart: unless-stopped
profiles:
- matrix-dev
depends_on:
- matrix-dev-livekit
- matrix-dev-synapse
environment:
- LIVEKIT_URL=ws://localhost:${MATRIX_DEV_LIVEKIT_PORT:-7880}
- LIVEKIT_KEY=devkey
- LIVEKIT_SECRET=devsecret-local-matrix-stack-32-chars
- LIVEKIT_FULL_ACCESS_HOMESERVERS=localhost
- LIVEKIT_JWT_BIND=:8080
ports:
- "${MATRIX_DEV_RTC_JWT_PORT:-8081}:8080"
networks:
- traefik
matrix-dev-element:
image: vectorim/element-web:latest
restart: unless-stopped
profiles:
- matrix-dev
volumes:
- ./matrix/dev/element-config.json:/app/config.json:ro
ports:
- "${MATRIX_DEV_ELEMENT_PORT:-8080}:80"
networks:
- traefik
# db:
# image: postgres
# restart: always
# shm_size: 128mb
# environment:
# POSTGRES_PASSWORD: abc
# POSTGRES_USER: sandelcom
# POSTGRES_DB: sensorfy
# volumes:
# - ./pg-data:/var/lib/postgresql/data
# ports:
# - "5432:5432"
traefik: traefik:
image: traefik:v2.11 image: traefik:v2.11
restart: unless-stopped restart: unless-stopped
@@ -442,18 +118,19 @@ services:
- "--accesslog.bufferingsize=5000" - "--accesslog.bufferingsize=5000"
- "--accesslog.fields.defaultMode=keep" - "--accesslog.fields.defaultMode=keep"
- "--accesslog.fields.headers.defaultMode=keep" - "--accesslog.fields.headers.defaultMode=keep"
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" # - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true"
- "--certificatesresolvers.mytlschallenge.acme.email=moin@fedeo.de" - "--certificatesresolvers.mytlschallenge.acme.email=moin@fedeo.de"
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports: ports:
- 80:80 - 80:80
- 443:443 - 443:443
volumes: volumes:
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS) - "./traefik/letsencrypt:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/logs:/logs" - "./traefik/logs:/logs"
networks: networks:
- traefik - traefik
networks: networks:
traefik: traefik:
external: false external: false

View File

@@ -5,4 +5,3 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Einstieg ## Einstieg
- [Bedienung](./bedienung/README.md) - [Bedienung](./bedienung/README.md)
- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md)

View File

@@ -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-<mandant>-team:example.com`
- `#tenant-<mandant>-project-<projekt>:example.com`
- `#tenant-<mandant>-ticket-<ticket>:example.com`
- `#tenant-<mandant>-customer-<kunde>: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_<fedeo_user_id>: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/

View File

@@ -6,5 +6,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
- [Bedienung](./bedienung/README.md) - [Bedienung](./bedienung/README.md)
- [Fach- und Technikkonzept für das Lagersystem](./lagersystem-konzept.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) - [Zentraler Push-Server für Selfhost-Instanzen](./zentraler-push-server.md)

View File

@@ -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-<mandant>-team:example.com`
- `#tenant-<mandant>-project-<projekt>:example.com`
- `#tenant-<mandant>-ticket-<ticket>:example.com`
- `#tenant-<mandant>-customer-<kunde>: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_<fedeo_user_id>: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/

View File

@@ -352,8 +352,8 @@ const links = computed(() => {
icon: "i-heroicons-phone", icon: "i-heroicons-phone",
}, },
{ {
label: "Matrix-Setup", label: "Chat",
to: "/communication", to: "/communication/chat",
icon: "i-heroicons-chat-bubble-left-right", icon: "i-heroicons-chat-bubble-left-right",
}, },
featureEnabled("export") ? { featureEnabled("export") ? {

View File

@@ -240,7 +240,7 @@ onMounted(() => {
</UCard> </UCard>
</UContainer> </UContainer>
</div> </div>
<div class="safearea flex min-h-screen w-full flex-col overflow-hidden" v-else> <div class="safearea flex h-dvh min-h-0 w-full flex-col overflow-hidden" v-else>
<!-- <div <!-- <div
class="border-b border-default bg-default px-3 py-2" class="border-b border-default bg-default px-3 py-2"
style="padding-top: max(env(safe-area-inset-top, 0px), 0.5rem);" style="padding-top: max(env(safe-area-inset-top, 0px), 0.5rem);"
@@ -248,7 +248,7 @@ onMounted(() => {
<TenantDropdown class="min-w-0 w-full max-w-sm" /> <TenantDropdown class="min-w-0 w-full max-w-sm" />
</div>--> </div>-->
<UDashboardGroup class="flex min-h-0 flex-1 overflow-hidden"> <UDashboardGroup class="flex h-full min-h-0 flex-1 overflow-hidden">
<UDashboardSidebar <UDashboardSidebar
@@ -305,7 +305,7 @@ onMounted(() => {
</UDashboardSidebar> </UDashboardSidebar>
<UDashboardPanel class="flex min-w-0 flex-1 flex-col overflow-hidden"> <UDashboardPanel class="flex h-full max-h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<slot/> <slot/>
</UDashboardPanel> </UDashboardPanel>

View File

@@ -81,7 +81,6 @@ export default defineNuxtConfig({
public: { public: {
apiBase: '', apiBase: '',
pdfLicense: '', pdfLicense: '',
matrixElementUrl: process.env.NUXT_PUBLIC_MATRIX_ELEMENT_URL || 'http://localhost:8080'
} }
}, },

View File

@@ -74,7 +74,6 @@
"image-js": "^1.1.0", "image-js": "^1.1.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
"livekit-client": "^2.19.0",
"maplibre-gl": "^4.7.0", "maplibre-gl": "^4.7.0",
"nuxt-editorjs": "^1.0.4", "nuxt-editorjs": "^1.0.4",
"nuxt-viewport": "^2.0.6", "nuxt-viewport": "^2.0.6",
@@ -1867,12 +1866,6 @@
"node": ">=6.9.0" "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": { "node_modules/@capacitor-community/bluetooth-le": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz",
@@ -3167,21 +3160,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", "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": { "node_modules/@nuxt/devalue": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz",
@@ -8719,13 +8685,6 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -12221,6 +12180,7 @@
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.8.x" "node": ">=0.8.x"
@@ -14238,15 +14198,6 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/jpeg-js": {
"version": "0.4.4", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
@@ -14822,26 +14773,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/local-pkg": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -14916,19 +14847,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -18523,16 +18441,6 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause" "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": { "node_modules/safe-array-concat": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "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==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "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": { "node_modules/secure-json-parse": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", "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" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -21832,19 +21716,6 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "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": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@@ -87,7 +87,6 @@
"image-js": "^1.1.0", "image-js": "^1.1.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
"livekit-client": "^2.19.0",
"maplibre-gl": "^4.7.0", "maplibre-gl": "^4.7.0",
"nuxt-editorjs": "^1.0.4", "nuxt-editorjs": "^1.0.4",
"nuxt-viewport": "^2.0.6", "nuxt-viewport": "^2.0.6",

View File

@@ -13,7 +13,6 @@ const serviceLabels: Record<string, string> = {
backend: "Backend", backend: "Backend",
database: "Datenbank", database: "Datenbank",
nodeExporter: "Node Exporter", nodeExporter: "Node Exporter",
matrix: "Matrix",
minio: "Dateispeicher", minio: "Dateispeicher",
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,501 +1,7 @@
<script setup> <script setup>
const toast = useToast() await navigateTo("/communication/chat", { replace: true })
const { $api } = useNuxtApp()
const status = ref(null)
const identity = ref(null)
const tenantSpace = ref(null)
const generalRoom = ref(null)
const provisionResult = ref(null)
const tenantSpaceProvisionResult = ref(null)
const generalRoomProvisionResult = ref(null)
const loading = ref(false)
const provisioning = ref(false)
const tenantSpaceProvisioning = ref(false)
const generalRoomProvisioning = ref(false)
const lastUpdated = ref(null)
const statusItems = computed(() => [
{
label: "Konfiguration",
value: status.value?.configured ? "Aktiv" : "Nicht aktiv",
icon: status.value?.configured ? "i-heroicons-check-circle" : "i-heroicons-x-circle",
color: status.value?.configured ? "success" : "error"
},
{
label: "Homeserver",
value: status.value?.homeserverUrl || "-",
icon: "i-heroicons-server-stack",
color: "neutral"
},
{
label: "Servername",
value: status.value?.serverName || "-",
icon: "i-heroicons-identification",
color: "neutral"
},
{
label: "Provisionierung",
value: status.value?.provisioningConfigured ? "Bereit" : "Nicht eingerichtet",
icon: status.value?.provisioningConfigured ? "i-heroicons-key" : "i-heroicons-exclamation-triangle",
color: status.value?.provisioningConfigured ? "success" : "warning"
},
{
label: "Erreichbarkeit",
value: status.value?.reachable ? "Erreichbar" : "Nicht erreichbar",
icon: status.value?.reachable ? "i-heroicons-signal" : "i-heroicons-signal-slash",
color: status.value?.reachable ? "success" : "error"
},
{
label: "Audio/Video",
value: status.value?.calls?.configured ? "Bereit" : "Nicht eingerichtet",
icon: status.value?.calls?.configured ? "i-heroicons-video-camera" : "i-heroicons-video-camera-slash",
color: status.value?.calls?.configured ? "success" : "warning"
}
])
const canUseMatrixChat = computed(() =>
Boolean(status.value?.reachable && status.value?.provisioningConfigured)
)
const loadMatrixInfo = async () => {
loading.value = true
try {
const [statusRes, identityRes, tenantSpaceRes, generalRoomRes] = await Promise.all([
$api("/api/communication/matrix/status"),
$api("/api/communication/matrix/me"),
$api("/api/communication/matrix/tenant-space"),
$api("/api/communication/matrix/rooms/general")
])
status.value = statusRes
identity.value = identityRes
tenantSpace.value = tenantSpaceRes
generalRoom.value = generalRoomRes
lastUpdated.value = new Date()
} catch (error) {
toast.add({
title: "Matrix-Status konnte nicht geladen werden",
color: "error"
})
} finally {
loading.value = false
}
}
const provisionMatrixAccount = async () => {
provisioning.value = true
try {
const res = await $api("/api/communication/matrix/me/provision", {
method: "POST"
})
provisionResult.value = res
identity.value = {
...identity.value,
matrixUserId: res.matrixUserId,
displayName: res.displayName
}
toast.add({
title: res.alreadyExisted ? "Matrix-Konto ist bereits vorhanden" : "Matrix-Konto erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Matrix-Konto konnte nicht erstellt werden",
color: "error"
})
} finally {
provisioning.value = false
}
}
const provisionTenantSpace = async () => {
tenantSpaceProvisioning.value = true
try {
const res = await $api("/api/communication/matrix/tenant-space/provision", {
method: "POST"
})
tenantSpaceProvisionResult.value = res
tenantSpace.value = {
tenantId: res.tenantId,
tenantName: res.tenantName,
alias: res.alias,
exists: true,
roomId: res.roomId,
servers: res.servers || []
}
toast.add({
title: res.alreadyExisted ? "Mandanten-Space ist bereits vorhanden" : "Mandanten-Space erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Mandanten-Space konnte nicht erstellt werden",
color: "error"
})
} finally {
tenantSpaceProvisioning.value = false
}
}
const provisionGeneralRoom = async () => {
generalRoomProvisioning.value = true
try {
const res = await $api("/api/communication/matrix/rooms/general/provision", {
method: "POST"
})
generalRoomProvisionResult.value = res
generalRoom.value = {
tenantId: res.tenantId,
tenantName: res.tenantName,
key: res.key,
name: res.name,
alias: res.alias,
exists: true,
roomId: res.roomId,
servers: res.servers || []
}
tenantSpace.value = {
...tenantSpace.value,
exists: true,
roomId: res.parentSpaceRoomId || tenantSpace.value?.roomId
}
toast.add({
title: res.alreadyExisted ? "Allgemeiner Chat ist bereits vorhanden" : "Allgemeiner Chat erstellt",
color: "success"
})
} catch (error) {
toast.add({
title: "Allgemeiner Chat konnte nicht erstellt werden",
color: "error"
})
} finally {
generalRoomProvisioning.value = false
}
}
const formatDateTime = (value) => {
if (!value) return "-"
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(value)
}
onMounted(async () => {
await loadMatrixInfo()
})
</script> </script>
<template> <template>
<div class="min-h-0 flex-1 overflow-y-auto"> <div />
<div class="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-highlighted">
Kommunikation
</h1>
<p class="mt-1 text-sm text-muted">
Matrix-Verbindung und persönliche Kommunikationsidentität.
</p>
</div>
<UButton
icon="i-heroicons-arrow-path"
color="neutral"
variant="outline"
:loading="loading"
@click="loadMatrixInfo"
>
Aktualisieren
</UButton>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<UCard
v-for="item in statusItems"
:key="item.label"
:ui="{ root: 'rounded-lg', body: 'p-4 sm:p-4' }"
>
<div class="flex items-start gap-3">
<UIcon
:name="item.icon"
class="mt-0.5 size-5 shrink-0"
:class="{
'text-success': item.color === 'success',
'text-error': item.color === 'error',
'text-warning': item.color === 'warning',
'text-muted': item.color === 'neutral'
}"
/>
<div class="min-w-0">
<p class="text-xs font-medium uppercase text-muted">
{{ item.label }}
</p>
<p class="mt-1 break-words text-sm font-medium text-highlighted">
{{ item.value }}
</p>
</div>
</div>
</UCard>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
<div class="space-y-4">
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-circle" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Eigene Matrix-Identität
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Matrix-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ identity?.matrixUserId || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Anzeigename
</p>
<p class="mt-1 text-sm text-highlighted">
{{ identity?.displayName || "-" }}
</p>
</div>
</div>
<UAlert
v-if="provisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="provisionResult.alreadyExisted ? 'Matrix-Konto vorhanden' : 'Matrix-Konto erstellt'"
:description="provisionResult.matrixUserId"
/>
<UAlert
v-if="status && !status.reachable"
icon="i-heroicons-exclamation-triangle"
color="error"
variant="soft"
title="Matrix-Homeserver nicht erreichbar"
:description="status.error || 'Bitte prüfe den lokalen Matrix-Stack und die Backend-Konfiguration.'"
/>
<UAlert
v-else-if="status && !status.provisioningConfigured"
icon="i-heroicons-key"
color="warning"
variant="soft"
title="Matrix-Provisionierung nicht eingerichtet"
description="Bitte setze MATRIX_REGISTRATION_SHARED_SECRET in der Backend-Umgebung."
/>
</div>
<template #footer>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-muted">
Aktualisiert: {{ formatDateTime(lastUpdated) }}
</p>
<UButton
icon="i-heroicons-user-plus"
:loading="provisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionMatrixAccount"
>
Matrix-Konto erstellen
</UButton>
</div>
</template>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-building-office-2" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Mandanten-Space
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Alias
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.alias || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Status
</p>
<UBadge
class="mt-1"
:color="tenantSpace?.exists ? 'success' : 'neutral'"
variant="soft"
>
{{ tenantSpace?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
</UBadge>
</div>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Raum-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ tenantSpace?.roomId || "-" }}
</p>
</div>
<UAlert
v-if="tenantSpaceProvisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="tenantSpaceProvisionResult.alreadyExisted ? 'Mandanten-Space vorhanden' : 'Mandanten-Space erstellt'"
:description="tenantSpaceProvisionResult.alias"
/>
</div>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<UButton
icon="i-heroicons-plus"
:loading="tenantSpaceProvisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionTenantSpace"
>
Mandanten-Space erstellen
</UButton>
</div>
</template>
</UCard>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Allgemeiner Chat
</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-muted">
Alias
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ generalRoom?.alias || "-" }}
</p>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Status
</p>
<UBadge
class="mt-1"
:color="generalRoom?.exists ? 'success' : 'neutral'"
variant="soft"
>
{{ generalRoom?.exists ? "Vorhanden" : "Noch nicht erstellt" }}
</UBadge>
</div>
</div>
<div>
<p class="text-xs font-medium uppercase text-muted">
Raum-ID
</p>
<p class="mt-1 break-all font-mono text-sm text-highlighted">
{{ generalRoom?.roomId || "-" }}
</p>
</div>
<UAlert
v-if="generalRoomProvisionResult"
icon="i-heroicons-check-circle"
color="success"
variant="soft"
:title="generalRoomProvisionResult.alreadyExisted ? 'Allgemeiner Chat vorhanden' : 'Allgemeiner Chat erstellt'"
:description="generalRoomProvisionResult.alias"
/>
</div>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<UButton
icon="i-heroicons-plus"
:loading="generalRoomProvisioning"
:disabled="!status?.reachable || !status?.provisioningConfigured"
@click="provisionGeneralRoom"
>
Allgemeinen Chat erstellen
</UButton>
<UButton
v-if="generalRoom?.exists"
to="/communication/chat"
icon="i-heroicons-chat-bubble-left-right"
color="neutral"
variant="outline"
>
Zum Chat
</UButton>
</div>
</template>
</UCard>
</div>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-video-camera" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">
Nächste Ausbaustufe
</h2>
</div>
</template>
<div class="space-y-3 text-sm text-muted">
<div class="flex gap-2">
<UIcon name="i-heroicons-building-office-2" class="mt-0.5 size-4 shrink-0" />
<span>Team- und Projekt-Räume im Mandanten-Space anlegen.</span>
</div>
<div class="flex gap-2">
<UIcon name="i-heroicons-users" class="mt-0.5 size-4 shrink-0" />
<span>FEDEO-Nutzer in Matrix-Räume synchronisieren.</span>
</div>
<div class="flex gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="mt-0.5 size-4 shrink-0" />
<span>Nativen FEDEO-Chat schrittweise an Matrix-Sync anbinden.</span>
</div>
</div>
</UCard>
</div>
</div>
</div>
</template> </template>

View File

@@ -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: "<MATRIX_POSTGRES_PASSWORD>"
database: synapse
host: matrix-db
cp_min: 5
cp_max: 10
redis:
enabled: true
host: matrix-redis
turn_uris:
- "turn:<MATRIX_TURN_HOST>:3478?transport=udp"
- "turn:<MATRIX_TURN_HOST>:3478?transport=tcp"
turn_shared_secret: "<MATRIX_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.

View File

@@ -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
}
}

View File

@@ -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"
}
]
}

View File

@@ -1,3 +0,0 @@
{
"m.server": "matrix.fedeo.de:443"
}

View File

@@ -11,7 +11,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "software.federspiel.fedeo", "bundleIdentifier": "software.federspiel.fedeo",
"buildNumber": "11", "buildNumber": "13",
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.", "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.", "NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
@@ -70,7 +70,8 @@
}, },
"disableAndroid": true "disableAndroid": true
} }
] ],
"@react-native-community/datetimepicker"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,

View File

@@ -6,7 +6,7 @@ import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol'; import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme'; import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme'; 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'; import { useAuth } from '@/src/providers/auth-provider';
export default function TabLayout() { export default function TabLayout() {
@@ -22,7 +22,7 @@ export default function TabLayout() {
} }
try { try {
const unread = await fetchMatrixUnreadCounts(token); const unread = await fetchChatUnreadCounts(token);
const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0); const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0);
setCommunicationUnread(total); setCommunicationUnread(total);
await Notifications.setBadgeCountAsync(total); await Notifications.setBadgeCountAsync(total);

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,18 @@ const ITEMS = [
subtitle: '', subtitle: '',
href: '/more/plants', 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', key: 'inventory',
title: 'Kundeninventar', title: 'Kundeninventar',

View File

@@ -109,6 +109,33 @@ export default function RootLayout() {
headerTintColor: '#111827', headerTintColor: '#111827',
}} }}
/> />
<Stack.Screen
name="more/events"
options={{
title: 'Termine',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/calendar"
options={{
title: 'Kalender',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/event/[id]"
options={{
title: 'Termin',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerTintColor: '#111827',
}}
/>
<Stack.Screen <Stack.Screen
name="more/plant/[id]" name="more/plant/[id]"
options={{ options={{

View File

@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
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';
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function dateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function buildCalendarDays(month: Date): Date[] {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const mondayOffset = (first.getDay() + 6) % 7;
const start = new Date(first);
start.setDate(first.getDate() - mondayOffset);
return Array.from({ length: 42 }, (_, index) => {
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<CalendarEvent[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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<string, CalendarEvent[]>();
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 (
<ScrollView
style={styles.screen}
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.monthHeader}>
<Pressable style={styles.navButton} onPress={() => changeMonth(-1)}><Text style={styles.navButtonText}></Text></Pressable>
<Pressable onPress={() => { setMonth(new Date(today.getFullYear(), today.getMonth(), 1)); setSelectedDate(today); }}>
<Text style={styles.monthTitle}>{month.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}</Text>
<Text style={styles.todayHint}>Heute</Text>
</Pressable>
<Pressable style={styles.navButton} onPress={() => changeMonth(1)}><Text style={styles.navButtonText}></Text></Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? <ActivityIndicator style={styles.loader} /> : null}
<View style={styles.calendarCard}>
<View style={styles.weekRow}>
{WEEKDAYS.map((weekday) => <Text key={weekday} style={styles.weekday}>{weekday}</Text>)}
</View>
<View style={styles.daysGrid}>
{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 (
<Pressable key={key} style={[styles.dayCell, selected ? styles.dayCellSelected : null]} onPress={() => selectDay(date)}>
<Text style={[styles.dayNumber, outside ? styles.dayNumberOutside : null, isToday ? styles.dayNumberToday : null]}>
{date.getDate()}
</Text>
<View style={styles.eventDots}>
{dayEvents.slice(0, 3).map((event) => (
<View key={String(event.id)} style={[styles.eventDot, { backgroundColor: event.color || PRIMARY }]} />
))}
</View>
</Pressable>
);
})}
</View>
</View>
<View style={styles.agendaCard}>
<View style={styles.agendaHeader}>
<View>
<Text style={styles.agendaTitle}>{selectedDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}</Text>
<Text style={styles.agendaCount}>{selectedEvents.length} {selectedEvents.length === 1 ? 'Termin' : 'Termine'}</Text>
</View>
<Pressable style={styles.addButton} onPress={() => setCreateOpen(true)}><Text style={styles.addButtonText}>+ Termin</Text></Pressable>
</View>
{selectedEvents.length === 0 ? <Text style={styles.empty}>Keine Termine an diesem Tag.</Text> : null}
{selectedEvents.map((event) => (
<Pressable key={String(event.id)} style={styles.eventRow} onPress={() => router.push(`/more/event/${event.id}` as any)}>
<View style={[styles.colorBar, { backgroundColor: event.color || PRIMARY }]} />
<View style={styles.eventMain}>
<Text style={styles.eventTime}>{formatTime(event.startDate)}</Text>
<Text style={styles.eventName}>{event.name}</Text>
{typeof event.project === 'object' && event.project?.name ? <Text style={styles.eventProject}>{event.project.name}</Text> : null}
</View>
<Text style={styles.arrow}></Text>
</Pressable>
))}
</View>
<EventCreateModal
visible={createOpen}
projects={projects}
initialDate={selectedDate}
onClose={() => setCreateOpen(false)}
onCreated={() => load(false)}
/>
</ScrollView>
);
}
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 },
});

View File

@@ -17,6 +17,7 @@ import * as DocumentPicker from 'expo-document-picker';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera'; import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
import { HistorySection } from '@/components/history-section';
import { import {
createCustomerInventoryItem, createCustomerInventoryItem,
CreatedDocument, CreatedDocument,
@@ -31,6 +32,7 @@ import {
uploadCustomerFile, uploadCustomerFile,
} from '@/src/lib/api'; } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
@@ -117,10 +119,18 @@ export default function CustomerDetailScreen() {
return [ return [
{ label: 'Name', value: String(customer.name || '-') }, { label: 'Name', value: String(customer.name || '-') },
{ label: 'Kundennummer', value: String(customer.customerNumber || '-') }, { 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: '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: 'Telefon', value: String(infoData.tel || customer.phone || '-') },
{ label: 'Mobilnummer', value: String(infoData.mobileTel || '-') }, { 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: 'Erstellt', value: formatDateTime(customer.createdAt || customer.created_at) },
{ label: 'Aktualisiert', value: formatDateTime(customer.updatedAt || customer.updated_at) }, { label: 'Aktualisiert', value: formatDateTime(customer.updatedAt || customer.updated_at) },
]; ];
@@ -267,7 +277,7 @@ export default function CustomerDetailScreen() {
async function onOpenFile(file: ProjectFile) { async function onOpenFile(file: ProjectFile) {
if (!file.url) return; if (!file.url) return;
await WebBrowser.openBrowserAsync(file.url, { await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY, controlsColor: PRIMARY,
showTitle: true, showTitle: true,
@@ -373,6 +383,8 @@ export default function CustomerDetailScreen() {
) : null} ) : null}
</View> </View>
<HistorySection resource="customers" resourceId={customerId} />
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Kundeninventar ({inventoryItems.length})</Text> <Text style={styles.sectionTitle}>Kundeninventar ({inventoryItems.length})</Text>

View File

@@ -34,8 +34,25 @@ export default function CustomersScreen() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [isCompanyInput, setIsCompanyInput] = useState(false);
const [nameInput, setNameInput] = useState(''); 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 [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 [notesInput, setNotesInput] = useState('');
const filtered = useMemo( const filtered = useMemo(
@@ -72,17 +89,36 @@ export default function CustomersScreen() {
function closeCreateModal() { function closeCreateModal() {
setCreateOpen(false); setCreateOpen(false);
setCreateError(null); setCreateError(null);
setIsCompanyInput(false);
setNameInput(''); setNameInput('');
setNameAdditionInput('');
setSalutationInput('');
setTitleInput('');
setFirstnameInput('');
setLastnameInput('');
setNumberInput(''); setNumberInput('');
setStreetInput('');
setAddressAdditionInput('');
setZipInput('');
setCityInput('');
setCountryInput('Deutschland');
setPhoneInput('');
setMobileInput('');
setEmailInput('');
setInvoiceEmailInput('');
setWebsiteInput('');
setVatIdInput('');
setNotesInput(''); setNotesInput('');
} }
async function onCreateCustomer() { async function onCreateCustomer() {
if (!token) return; 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) { if (!name) {
setCreateError('Bitte einen Namen eingeben.'); setCreateError(isCompanyInput ? 'Bitte einen Firmennamen eingeben.' : 'Bitte mindestens Vor- oder Nachname eingeben.');
return; return;
} }
@@ -93,6 +129,25 @@ export default function CustomersScreen() {
await createCustomer(token, { await createCustomer(token, {
name, name,
customerNumber: numberInput.trim() || null, 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, notes: notesInput.trim() || null,
}); });
closeCreateModal(); closeCreateModal();
@@ -168,22 +223,178 @@ export default function CustomersScreen() {
<View style={styles.modalCard}> <View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neuer Kunde</Text> <Text style={styles.modalTitle}>Neuer Kunde</Text>
<ScrollView
style={styles.modalScroll}
contentContainerStyle={styles.modalForm}
keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>Kundentyp</Text>
<View style={styles.segmentedControl}>
<Pressable
style={[styles.segment, !isCompanyInput ? styles.segmentActive : null]}
onPress={() => setIsCompanyInput(false)}>
<Text style={[styles.segmentText, !isCompanyInput ? styles.segmentTextActive : null]}>Privat</Text>
</Pressable>
<Pressable
style={[styles.segment, isCompanyInput ? styles.segmentActive : null]}
onPress={() => setIsCompanyInput(true)}>
<Text style={[styles.segmentText, isCompanyInput ? styles.segmentTextActive : null]}>Firma</Text>
</Pressable>
</View>
<Text style={styles.sectionLabel}>Allgemeines</Text>
{isCompanyInput ? (
<>
<TextInput <TextInput
placeholder="Name" placeholder="Firmenname *"
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
style={styles.searchInput} style={styles.searchInput}
value={nameInput} value={nameInput}
onChangeText={setNameInput} onChangeText={setNameInput}
/> />
<TextInput <TextInput
placeholder="Kundennummer (optional)" placeholder="Firmenname Zusatz"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={nameAdditionInput}
onChangeText={setNameAdditionInput}
/>
</>
) : (
<>
<View style={styles.inputRow}>
<TextInput
placeholder="Anrede"
placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.flexInput]}
value={salutationInput}
onChangeText={setSalutationInput}
/>
<TextInput
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.flexInput]}
value={titleInput}
onChangeText={setTitleInput}
/>
</View>
<TextInput
placeholder="Vorname"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={firstnameInput}
onChangeText={setFirstnameInput}
/>
<TextInput
placeholder="Nachname"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={lastnameInput}
onChangeText={setLastnameInput}
/>
</>
)}
<TextInput
placeholder="Kundennummer (wird sonst automatisch vergeben)"
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
style={styles.searchInput} style={styles.searchInput}
value={numberInput} value={numberInput}
onChangeText={setNumberInput} onChangeText={setNumberInput}
/> />
<Text style={styles.sectionLabel}>Adresse</Text>
<TextInput <TextInput
placeholder="Notizen (optional)" placeholder="Straße + Hausnummer"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={streetInput}
onChangeText={setStreetInput}
/>
<TextInput
placeholder="Adresszusatz"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={addressAdditionInput}
onChangeText={setAddressAdditionInput}
/>
<View style={styles.inputRow}>
<TextInput
placeholder="PLZ"
placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.zipInput]}
value={zipInput}
onChangeText={setZipInput}
keyboardType="numbers-and-punctuation"
/>
<TextInput
placeholder="Stadt"
placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.flexInput]}
value={cityInput}
onChangeText={setCityInput}
/>
</View>
<TextInput
placeholder="Land"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={countryInput}
onChangeText={setCountryInput}
/>
<Text style={styles.sectionLabel}>Kontaktdaten</Text>
<TextInput
placeholder="E-Mail"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={emailInput}
onChangeText={setEmailInput}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
placeholder="E-Mail für Rechnungen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={invoiceEmailInput}
onChangeText={setInvoiceEmailInput}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
placeholder="Telefon"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={phoneInput}
onChangeText={setPhoneInput}
keyboardType="phone-pad"
/>
<TextInput
placeholder="Mobilnummer"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={mobileInput}
onChangeText={setMobileInput}
keyboardType="phone-pad"
/>
<TextInput
placeholder="Webseite"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={websiteInput}
onChangeText={setWebsiteInput}
autoCapitalize="none"
keyboardType="url"
/>
<TextInput
placeholder="USt-Id"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={vatIdInput}
onChangeText={setVatIdInput}
autoCapitalize="characters"
/>
<TextInput
placeholder="Notizen"
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.multilineInput]} style={[styles.searchInput, styles.multilineInput]}
value={notesInput} value={notesInput}
@@ -191,7 +402,8 @@ export default function CustomersScreen() {
multiline multiline
/> />
{createError ? <Text style={styles.error}>{createError}</Text> : null} {createError ? <Text style={styles.formError}>{createError}</Text> : null}
</ScrollView>
<View style={styles.modalActions}> <View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}> <Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
@@ -313,6 +525,7 @@ const styles = StyleSheet.create({
borderRadius: 14, borderRadius: 14,
padding: 16, padding: 16,
gap: 10, gap: 10,
maxHeight: '92%',
}, },
modalTitle: { modalTitle: {
color: '#111827', color: '#111827',
@@ -323,6 +536,63 @@ const styles = StyleSheet.create({
minHeight: 92, minHeight: 92,
textAlignVertical: 'top', 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: { modalActions: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'flex-end', justifyContent: 'flex-end',

View File

@@ -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<CalendarEvent | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}><ActivityIndicator /><Text style={styles.loadingText}>Termin wird geladen...</Text></View>
) : null}
{!loading && event ? (
<>
<View style={styles.card}>
<View style={styles.headingRow}>
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
<Text style={styles.title}>{event.name}</Text>
</View>
<View style={styles.table}>
{rows.map((row) => (
<View key={row.label} style={styles.row}>
<Text style={styles.label}>{row.label}</Text>
<Text style={styles.value}>{row.value}</Text>
</View>
))}
</View>
{event.notes ? <View style={styles.notesBox}><Text style={styles.notesLabel}>Notizen</Text><Text style={styles.notes}>{event.notes}</Text></View> : null}
{event.link ? <Pressable style={styles.linkButton} onPress={openLink}><Text style={styles.linkButtonText}>Link öffnen</Text></Pressable> : null}
</View>
<HistorySection resource="events" resourceId={eventId} />
</>
) : null}
</ScrollView>
);
}
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 },
});

208
mobile/app/more/events.tsx Normal file
View File

@@ -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<CalendarEvent[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
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<string | null>(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 (
<View style={styles.screen}>
<View style={styles.toolbar}>
<TextInput
placeholder="Termine suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={search}
onChangeText={setSearch}
/>
<View style={styles.toolbarActions}>
<Pressable
style={[styles.filterButton, showPast ? styles.filterButtonActive : null]}
onPress={() => setShowPast((value) => !value)}>
<Text style={[styles.filterButtonText, showPast ? styles.filterButtonTextActive : null]}>Vergangene anzeigen</Text>
</Pressable>
<Pressable style={styles.calendarButton} onPress={() => router.push('/more/calendar' as any)}>
<Text style={styles.calendarButtonText}>Kalender</Text>
</Pressable>
</View>
</View>
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Termine werden geladen...</Text>
</View>
) : null}
{!loading && visibleEvents.length === 0 ? <Text style={styles.empty}>Keine Termine gefunden.</Text> : null}
{!loading
? visibleEvents.map((event) => (
<Pressable
key={String(event.id)}
style={({ pressed }) => [styles.eventRow, pressed ? styles.eventRowPressed : null]}
onPress={() => router.push(`/more/event/${event.id}` as any)}>
<View style={[styles.colorBar, { backgroundColor: event.color || PRIMARY }]} />
<View style={styles.eventMain}>
<Text style={styles.eventName}>{event.name}</Text>
<Text style={styles.eventDate}>{formatEventDate(event)}</Text>
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
</View>
<Text style={styles.arrow}></Text>
</Pressable>
))
: null}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<EventCreateModal
visible={createOpen}
projects={projects}
onClose={() => setCreateOpen(false)}
onCreated={() => load(false)}
/>
</View>
);
}
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 },
});

View File

@@ -5,8 +5,10 @@ import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { HistorySection } from '@/components/history-section';
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api'; import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
@@ -89,7 +91,7 @@ export default function PlantDetailScreen() {
async function onOpenFile(file: ProjectFile) { async function onOpenFile(file: ProjectFile) {
if (!file.url) return; if (!file.url) return;
await WebBrowser.openBrowserAsync(file.url, { await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY, controlsColor: PRIMARY,
showTitle: true, showTitle: true,
@@ -233,6 +235,8 @@ export default function PlantDetailScreen() {
</View> </View>
</View> </View>
<HistorySection resource="plants" resourceId={plantId} />
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Dokumente ({files.length})</Text> <Text style={styles.sectionTitle}>Dokumente ({files.length})</Text>

View File

@@ -17,9 +17,13 @@ import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { EventCreateModal } from '@/components/event-create-modal';
import { HistorySection } from '@/components/history-section';
import { import {
CalendarEvent,
createProjectTask, createProjectTask,
fetchProjectById, fetchProjectById,
fetchProjectEvents,
fetchProjectFiles, fetchProjectFiles,
fetchProjectTasks, fetchProjectTasks,
Project, Project,
@@ -30,6 +34,7 @@ import {
updateTask, updateTask,
} from '@/src/lib/api'; } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen']; const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
@@ -69,12 +74,14 @@ export default function ProjectDetailScreen() {
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
const [files, setFiles] = useState<ProjectFile[]>([]); const [files, setFiles] = useState<ProjectFile[]>([]);
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [creatingTask, setCreatingTask] = useState(false); const [creatingTask, setCreatingTask] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null); const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false); const [createTaskModalOpen, setCreateTaskModalOpen] = useState(false);
const [createEventModalOpen, setCreateEventModalOpen] = useState(false);
const [createTaskError, setCreateTaskError] = useState<string | null>(null); const [createTaskError, setCreateTaskError] = useState<string | null>(null);
const [newTaskName, setNewTaskName] = useState(''); const [newTaskName, setNewTaskName] = useState('');
const [newTaskDescription, setNewTaskDescription] = useState(''); const [newTaskDescription, setNewTaskDescription] = useState('');
@@ -121,14 +128,16 @@ export default function ProjectDetailScreen() {
setError(null); setError(null);
try { try {
const [projectData, fileData, taskData] = await Promise.all([ const [projectData, fileData, taskData, eventData] = await Promise.all([
fetchProjectById(token, projectId), fetchProjectById(token, projectId),
fetchProjectFiles(token, projectId), fetchProjectFiles(token, projectId),
fetchProjectTasks(token, projectId), fetchProjectTasks(token, projectId),
fetchProjectEvents(token, projectId),
]); ]);
setProject(projectData); setProject(projectData);
setFiles(fileData); setFiles(fileData);
setTasks(taskData); setTasks(taskData);
setEvents(eventData.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.'); setError(err instanceof Error ? err.message : 'Projektdaten konnten nicht geladen werden.');
} finally { } finally {
@@ -185,7 +194,7 @@ export default function ProjectDetailScreen() {
async function onOpenFile(file: ProjectFile) { async function onOpenFile(file: ProjectFile) {
if (!file.url) return; if (!file.url) return;
await WebBrowser.openBrowserAsync(file.url, { await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY, controlsColor: PRIMARY,
showTitle: true, showTitle: true,
@@ -366,6 +375,37 @@ export default function ProjectDetailScreen() {
) : null} ) : null}
</View> </View>
<HistorySection resource="projects" resourceId={projectId} />
<View style={styles.card}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Termine ({events.length})</Text>
<Pressable style={styles.smallPrimaryButton} onPress={() => setCreateEventModalOpen(true)}>
<Text style={styles.smallPrimaryButtonText}>Neuer Termin</Text>
</Pressable>
</View>
{events.length === 0 ? (
<Text style={styles.empty}>Keine Termine für dieses Projekt vorhanden.</Text>
) : (
events.map((event) => (
<Pressable
key={String(event.id)}
style={styles.eventRow}
onPress={() => router.push(`/more/event/${event.id}` as any)}>
<View style={[styles.eventColor, { backgroundColor: event.color || PRIMARY }]} />
<View style={styles.eventMain}>
<Text style={styles.eventName}>{event.name}</Text>
<Text style={styles.eventDate}>
{formatDateTime(event.startDate)}
{event.endDate ? ` ${formatDateTime(event.endDate)}` : ''}
</Text>
</View>
<Text style={styles.eventArrow}></Text>
</Pressable>
))
)}
</View>
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text> <Text style={styles.sectionTitle}>Aufgaben ({tasks.length})</Text>
@@ -505,6 +545,14 @@ export default function ProjectDetailScreen() {
</KeyboardAvoidingView> </KeyboardAvoidingView>
</View> </View>
</Modal> </Modal>
<EventCreateModal
visible={createEventModalOpen}
projects={project ? [project] : []}
initialProjectId={projectId}
onClose={() => setCreateEventModalOpen(false)}
onCreated={() => load(false)}
/>
</ScrollView> </ScrollView>
); );
} }
@@ -588,6 +636,38 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '700', 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: { sectionHeaderActions: {
alignItems: 'flex-end', alignItems: 'flex-end',
gap: 8, gap: 8,

View File

@@ -0,0 +1,375 @@
import { useEffect, useMemo, useState } from 'react';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
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<void>;
};
function pad(value: number): string {
return String(value).padStart(2, '0');
}
function formatDate(date: Date): string {
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}
function formatTime(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 { start, end };
}
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 [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<number | null>(initialProjectId);
const [projectSearch, setProjectSearch] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!visible) return;
const defaults = createDefaults(initialDate);
setName('');
setNotes('');
setLink('');
setStart(defaults.start);
setEnd(defaults.end);
setActivePicker(null);
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]);
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();
if (!title) {
setError('Bitte einen Namen für den Termin 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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardWrap}>
<View style={styles.card}>
<Text style={styles.title}>Neuer Termin</Text>
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
<TextInput
autoFocus
placeholder="Name *"
placeholderTextColor="#9ca3af"
style={styles.input}
value={name}
onChangeText={setName}
/>
<Text style={styles.label}>Beginn</Text>
<View style={styles.row}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
</Pressable>
</View>
<Text style={styles.label}>Ende</Text>
<View style={styles.row}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
</Pressable>
</View>
{activePicker ? (
<View style={styles.pickerWrap}>
<DateTimePicker
value={activePicker.startsWith('start') ? start : end}
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
locale="de-DE"
minuteInterval={5}
onChange={updatePicker}
/>
{Platform.OS === 'ios' ? (
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
<Text style={styles.pickerDoneText}>Übernehmen</Text>
</Pressable>
) : null}
</View>
) : null}
<Text style={styles.label}>Projekt</Text>
{selectedProject ? (
<View style={styles.selectedProject}>
<View style={styles.selectedProjectMain}>
<Text style={styles.selectedProjectName}>{selectedProject.name}</Text>
{selectedProject.projectNumber ? (
<Text style={styles.selectedProjectNumber}>Nr. {selectedProject.projectNumber}</Text>
) : null}
</View>
<Pressable onPress={() => setSelectedProjectId(null)}>
<Text style={styles.removeProject}>Ändern</Text>
</Pressable>
</View>
) : (
<>
<TextInput
placeholder="Projekt suchen (optional)"
placeholderTextColor="#9ca3af"
style={styles.input}
value={projectSearch}
onChangeText={setProjectSearch}
/>
{projectOptions.map((project) => (
<Pressable
key={String(project.id)}
style={styles.projectOption}
onPress={() => setSelectedProjectId(Number(project.id))}>
<Text style={styles.projectOptionName}>{project.name}</Text>
<Text style={styles.projectOptionNumber}>{project.projectNumber || `#${project.id}`}</Text>
</Pressable>
))}
</>
)}
<TextInput
placeholder="Link (optional)"
placeholderTextColor="#9ca3af"
style={styles.input}
value={link}
onChangeText={setLink}
autoCapitalize="none"
keyboardType="url"
/>
<TextInput
placeholder="Notizen (optional)"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.multiline]}
value={notes}
onChangeText={setNotes}
multiline
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
<View style={styles.actions}>
<Pressable style={styles.secondaryButton} onPress={onClose} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable style={[styles.primaryButton, saving ? styles.disabled : null]} onPress={save} disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
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 },
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',
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 },
});

View File

@@ -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<HistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(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 (
<View style={styles.card}>
<View style={styles.header}>
<Text style={styles.title}>Logbuch ({items.length})</Text>
<Pressable style={styles.addButton} onPress={() => setModalOpen(true)}>
<Text style={styles.addButtonText}>+ Eintrag</Text>
</Pressable>
</View>
{loading ? <ActivityIndicator /> : null}
{!loading && loadError ? <Text style={styles.error}>{loadError}</Text> : null}
{!loading && !loadError && items.length === 0 ? (
<Text style={styles.empty}>Noch keine Logbucheinträge vorhanden.</Text>
) : null}
{!loading && !loadError
? items.map((item) => (
<View key={String(item.id)} style={styles.item}>
<Text style={styles.itemAuthor}>{getAuthor(item)}</Text>
<Text style={styles.itemText}>{item.text}</Text>
<Text style={styles.itemDate}>{formatDateTime(item.created_at || item.createdAt)}</Text>
</View>
))
: null}
<Modal visible={modalOpen} transparent animationType="fade" onRequestClose={closeModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Logbucheintrag hinzufügen</Text>
<TextInput
autoFocus
multiline
placeholder="Was soll im Logbuch festgehalten werden?"
placeholderTextColor="#9ca3af"
style={styles.input}
value={text}
onChangeText={setText}
/>
{formError ? <Text style={styles.error}>{formError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.disabled : null]}
onPress={addItem}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Speichern'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
);
}
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 },
});

125
mobile/package-lock.json generated
View File

@@ -9,14 +9,15 @@
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"expo": "~54.0.36", "expo": "~54.0.37",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.14",
"expo-document-picker": "^14.0.8", "expo-document-picker": "^14.0.8",
"expo-file-system": "~19.0.23", "expo-file-system": "~19.0.24",
"expo-font": "~14.0.12", "expo-font": "~14.0.12",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
@@ -54,12 +55,12 @@
} }
}, },
"node_modules/@0no-co/graphql.web": { "node_modules/@0no-co/graphql.web": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.4.tgz",
"integrity": "sha512-4gFGBdyaFmQ6n9euhp5JtIGS4ZeivwDr1tCPENUxTvy5wyv532yOtFCr9zzYAJh1s6uibgC+TRXUcay+mxzCoQ==", "integrity": "sha512-imSwulOeDQodRy/olQmVEo2PiY6ntjkZ9eiGdw6lMYylh/tay9b7MusyJBmEnkL8GiKRKr6ltr+D42mY5bd8Bg==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "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": { "peerDependenciesMeta": {
"graphql": { "graphql": {
@@ -2222,9 +2223,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@expo/xcpretty": { "node_modules/@expo/xcpretty": {
"version": "4.4.4", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz",
"integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.20.0", "@babel/code-frame": "^7.20.0",
@@ -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": { "node_modules/@react-native/assets-registry": {
"version": "0.81.5", "version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -4054,6 +4078,18 @@
"node": ">= 14" "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": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -6104,13 +6140,13 @@
} }
}, },
"node_modules/expo": { "node_modules/expo": {
"version": "54.0.36", "version": "54.0.37",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.36.tgz", "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.37.tgz",
"integrity": "sha512-HMHp1H+actmnX85NJE6lILKzSJV6pTDNkwghq9EMOP3zTynjvBYVqJGSLlm6sEVzJCC5Z2ZiKgLvtsHrqlY0dg==", "integrity": "sha512-afzbtO4i/K9ZCrzyLgEOaXW56w/yuAK61JkCOMrw/S/VFCAp6YwKWA494X5mIcrVmRAmclv/ec7ga8ndTkzhxw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.26", "@expo/cli": "54.0.27",
"@expo/config": "~12.0.14", "@expo/config": "~12.0.14",
"@expo/config-plugins": "~54.0.5", "@expo/config-plugins": "~54.0.5",
"@expo/devtools": "0.1.8", "@expo/devtools": "0.1.8",
@@ -6121,11 +6157,11 @@
"@ungap/structured-clone": "^1.3.0", "@ungap/structured-clone": "^1.3.0",
"babel-preset-expo": "~54.0.12", "babel-preset-expo": "~54.0.12",
"expo-asset": "~12.0.13", "expo-asset": "~12.0.13",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.14",
"expo-file-system": "~19.0.23", "expo-file-system": "~19.0.24",
"expo-font": "~14.0.12", "expo-font": "~14.0.12",
"expo-keep-awake": "~15.0.8", "expo-keep-awake": "~15.0.8",
"expo-modules-autolinking": "3.0.26", "expo-modules-autolinking": "3.0.27",
"expo-modules-core": "3.0.30", "expo-modules-core": "3.0.30",
"pretty-format": "^29.7.0", "pretty-format": "^29.7.0",
"react-refresh": "^0.14.2", "react-refresh": "^0.14.2",
@@ -6200,13 +6236,13 @@
} }
}, },
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "18.0.13", "version": "18.0.14",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.14.tgz",
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "integrity": "sha512-BUZm9mkl/TX7zNaN0N4C83ws7mEnesCFiKhqK+rMZwTD2+Q4wWB2kBltqnC/LrCRaMyRi40XxhCRVwuKnJkxXQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/config": "~12.0.13", "@expo/config": "~12.0.14",
"@expo/env": "~2.0.8" "@expo/env": "~2.0.12"
}, },
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
@@ -6223,9 +6259,9 @@
} }
}, },
"node_modules/expo-file-system": { "node_modules/expo-file-system": {
"version": "19.0.23", "version": "19.0.24",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.24.tgz",
"integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==", "integrity": "sha512-hZs0Ng+coNjx7YJ8MPwfJpoU1SRul6OZ9LZycFzozUeoR+denbyr944YrMP9NVn+Zf1tdC7p0K0zgah3ZySOYg==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*",
@@ -6318,9 +6354,9 @@
} }
}, },
"node_modules/expo-modules-autolinking": { "node_modules/expo-modules-autolinking": {
"version": "3.0.26", "version": "3.0.27",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.27.tgz",
"integrity": "sha512-WOaud6UKg16ciCOj8raKcMOoKFMHLXKI29U29yhgu1lf+Y7VxJyCktUcYo6AM+ccZ7zLD1uWZdMtgnpf+95OXA==", "integrity": "sha512-Ge216xmfXcba+aUQssfmUo9Hf92wXX3xcXJmOsC/wpuV5B5ozm4BTkRKpoCXEkdD8eJVmFy7ATS8yD3upFOPQA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/spawn-async": "^1.7.2", "@expo/spawn-async": "^1.7.2",
@@ -6868,9 +6904,9 @@
} }
}, },
"node_modules/expo/node_modules/@expo/cli": { "node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.26", "version": "54.0.27",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.26.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.27.tgz",
"integrity": "sha512-BjsAoKINLEo3LRE+sDC6FCgjxuOWsyfOFOKz0txrbEcxSatzIjJDVuX8XaTdmeicZdcoN524yl1sfwCWfxhYMw==", "integrity": "sha512-BxKOr6e8wmUwhGSyEVKY+CHWgS3Cre9ibgx1QWcdCenKTVKAz62qGmYL51q0lCcKg6vQY+4Usw9RK5Il4ZFaIA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@0no-co/graphql.web": "^1.0.8", "@0no-co/graphql.web": "^1.0.8",
@@ -6884,7 +6920,7 @@
"@expo/metro": "~54.2.0", "@expo/metro": "~54.2.0",
"@expo/metro-config": "~54.0.17", "@expo/metro-config": "~54.0.17",
"@expo/osascript": "^2.3.8", "@expo/osascript": "^2.3.8",
"@expo/package-manager": "^1.9.10", "@expo/package-manager": "^1.9.11",
"@expo/plist": "^0.4.9", "@expo/plist": "^0.4.9",
"@expo/prebuild-config": "^54.0.9", "@expo/prebuild-config": "^54.0.9",
"@expo/schema-utils": "^0.1.9", "@expo/schema-utils": "^0.1.9",
@@ -6895,6 +6931,7 @@
"@urql/core": "^5.0.6", "@urql/core": "^5.0.6",
"@urql/exchange-retry": "^1.3.0", "@urql/exchange-retry": "^1.3.0",
"accepts": "^1.3.8", "accepts": "^1.3.8",
"agent-cli-detector": "^0.1.2",
"arg": "^5.0.2", "arg": "^5.0.2",
"better-opn": "~3.0.2", "better-opn": "~3.0.2",
"bplist-creator": "0.1.0", "bplist-creator": "0.1.0",
@@ -7095,9 +7132,9 @@
} }
}, },
"node_modules/expo/node_modules/picomatch": { "node_modules/expo/node_modules/picomatch": {
"version": "4.0.5", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -9040,6 +9077,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9060,6 +9100,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9080,6 +9123,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9100,6 +9146,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -12656,9 +12705,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.28.0", "version": "6.28.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"

View File

@@ -22,14 +22,15 @@
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"expo": "~54.0.36", "expo": "~54.0.37",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.14",
"expo-document-picker": "^14.0.8", "expo-document-picker": "^14.0.8",
"expo-file-system": "~19.0.23", "expo-file-system": "~19.0.24",
"expo-font": "~14.0.12", "expo-font": "~14.0.12",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",

View File

@@ -54,6 +54,23 @@ export type Project = {
[key: string]: unknown; [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 = { export type ProjectFile = {
id: string; id: string;
name?: string | null; name?: string | null;
@@ -76,8 +93,32 @@ export type Customer = {
archived?: boolean; archived?: boolean;
infoData?: { infoData?: {
email?: string | null; email?: string | null;
invoiceEmail?: string | null;
tel?: string | null; tel?: string | null;
mobileTel?: 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; [key: string]: unknown;
} | null; } | null;
[key: string]: unknown; [key: string]: unknown;
@@ -218,27 +259,12 @@ export function isAuthenticationError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401; return error instanceof ApiError && error.status === 401;
} }
export type MatrixStatus = { export type ChatRoom = {
enabled?: boolean;
ready?: boolean;
configured?: boolean;
homeserverUrl?: string | null;
[key: string]: unknown;
};
export type MatrixIdentity = {
matrixUserId: string;
displayName?: string | null;
};
export type MatrixRoom = {
key: string; key: string;
name: string; name: string;
topic?: string | null; topic?: string | null;
type?: 'room' | 'project' | 'direct' | string; type?: 'room' | 'project' | 'direct' | string;
group?: string; group?: string;
roomId?: string | null;
alias?: string | null;
exists?: boolean; exists?: boolean;
projectId?: number; projectId?: number;
projectNumber?: string | null; projectNumber?: string | null;
@@ -253,66 +279,31 @@ export type MatrixRoom = {
[key: string]: unknown; [key: string]: unknown;
}; };
export type MatrixAttachment = { export type ChatMessage = {
fileName?: string | null; id: number;
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;
sender: string; sender: string;
senderDisplayName?: string | null; senderDisplayName?: string | null;
body?: string | null; body?: string | null;
timestamp?: string | number | null; timestamp?: string | number | null;
own?: boolean; own?: boolean;
edited?: boolean;
redacted?: boolean;
msgtype?: string;
attachment?: MatrixAttachment | null;
replyToEventId?: string | null;
reactions?: MatrixReaction[];
[key: string]: unknown; [key: string]: unknown;
}; };
export type MatrixMember = { export type ChatMember = {
matrixUserId: string;
displayName?: string | null;
avatarUrl?: string | null;
membership?: string;
[key: string]: unknown;
};
export type MatrixUser = {
userId: string; userId: string;
matrixUserId: string;
displayName?: string | null; displayName?: string | null;
email?: string | null; email?: string | null;
own?: boolean;
[key: string]: unknown; [key: string]: unknown;
}; };
export type MatrixSyncResponse = { export type ChatSyncResponse = {
nextBatch?: string; nextId?: number;
messages?: MatrixMessage[]; messages?: ChatMessage[];
replacements?: MatrixMessage[];
reactions?: (MatrixReaction & { targetEventId?: string })[];
redactions?: { redacts?: string; eventId?: string; targetEventId?: string }[];
members?: MatrixMember[];
[key: string]: unknown; [key: string]: unknown;
}; };
export type MatrixUnreadCounts = Record<string, { count?: number; mentions?: number }>; export type ChatUnreadCounts = Record<string, { count?: number; mentions?: number }>;
function buildUrl(path: string): string { function buildUrl(path: string): string {
if (path.startsWith('http://') || path.startsWith('https://')) { if (path.startsWith('http://') || path.startsWith('https://')) {
@@ -388,77 +379,26 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
return payload as T; return payload as T;
} }
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> { function chatRoomPath(roomKey: string, suffix = ''): string {
const { signal, cleanup } = createTimeoutSignal(); return `/api/communication/chat/rooms/${encodeURIComponent(roomKey)}${suffix}`;
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}`;
} }
export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> { export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> {
return apiRequest<{ status: string; [key: string]: unknown }>('/health'); return apiRequest<{ status: string; [key: string]: unknown }>('/health');
} }
export async function fetchMatrixStatus(token: string): Promise<MatrixStatus> { export async function fetchChatRooms(token: string): Promise<ChatRoom[]> {
return apiRequest<MatrixStatus>('/api/communication/matrix/status', { token });
}
export async function fetchMatrixIdentity(token: string): Promise<MatrixIdentity> {
return apiRequest<MatrixIdentity>('/api/communication/matrix/me', { token });
}
export async function provisionMatrixUser(token: string): Promise<MatrixIdentity> {
return apiRequest<MatrixIdentity>('/api/communication/matrix/me/provision', {
method: 'POST',
token,
});
}
export async function fetchMatrixRooms(token: string): Promise<MatrixRoom[]> {
const [rooms, projectRooms, directRooms, unread] = await Promise.all([ const [rooms, projectRooms, directRooms, unread] = await Promise.all([
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/rooms', { token }), apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/rooms', { token }),
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/project-rooms', { token }), apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/project-rooms', { token }),
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/direct-rooms', { token }), apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/direct-rooms', { token }),
apiRequest<{ rooms?: Record<string, { count?: number; mentions?: number }> }>('/api/communication/matrix/unread', { apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', {
token, token,
}), }),
]); ]);
const unreadByRoom = unread.rooms || {}; const unreadByRoom = unread.rooms || {};
const decorate = (room: MatrixRoom, group: string): MatrixRoom => ({ const decorate = (room: ChatRoom, group: string): ChatRoom => ({
...room, ...room,
group, group,
unread: unreadByRoom[room.key]?.count || 0, unread: unreadByRoom[room.key]?.count || 0,
@@ -472,165 +412,70 @@ export async function fetchMatrixRooms(token: string): Promise<MatrixRoom[]> {
]; ];
} }
export async function fetchMatrixUnreadCounts(token: string): Promise<MatrixUnreadCounts> { export async function fetchChatUnreadCounts(token: string): Promise<ChatUnreadCounts> {
const response = await apiRequest<{ rooms?: MatrixUnreadCounts }>('/api/communication/matrix/unread', { token }); const response = await apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', { token });
return response.rooms || {}; return response.rooms || {};
} }
export async function fetchMatrixUsers(token: string): Promise<MatrixUser[]> { export async function createChatRoom(
const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token });
return response.users || [];
}
export async function createMatrixRoom(
token: string, token: string,
payload: { key: string; name: string; topic?: string | null; type?: string } payload: { key: string; name: string; topic?: string | null; type?: string }
): Promise<MatrixRoom> { ): Promise<ChatRoom> {
return apiRequest<MatrixRoom>('/api/communication/matrix/rooms', { return apiRequest<ChatRoom>('/api/communication/chat/rooms', {
method: 'POST', method: 'POST',
token, token,
body: payload, body: payload,
}); });
} }
export async function provisionMatrixRoom(token: string, room: MatrixRoom): Promise<MatrixRoom> { export async function provisionChatRoom(token: string, room: ChatRoom): Promise<ChatRoom> {
if (room.provisionEndpoint) {
return apiRequest<MatrixRoom>(room.provisionEndpoint, { method: 'POST', token });
}
if (room.type === 'project' && room.projectId) { if (room.type === 'project' && room.projectId) {
return apiRequest<MatrixRoom>(`/api/communication/matrix/project-rooms/${room.projectId}/provision`, { return apiRequest<ChatRoom>(`/api/communication/chat/project-rooms/${room.projectId}/provision`, {
method: 'POST', method: 'POST',
token, token,
}); });
} }
if (room.type === 'direct' && room.userId) { if (room.type === 'direct' && room.userId) {
return apiRequest<MatrixRoom>(`/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`, { return apiRequest<ChatRoom>(`/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`, {
method: 'POST', method: 'POST',
token, token,
}); });
} }
return apiRequest<MatrixRoom>(matrixRoomPath(room.key, '/provision'), { return room;
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,
},
});
} }
export async function fetchMatrixMessages(token: string, roomKey: string): Promise<MatrixMessage[]> { export async function fetchChatMessages(token: string, roomKey: string): Promise<ChatMessage[]> {
const response = await apiRequest<{ messages?: MatrixMessage[] }>(matrixRoomPath(roomKey, '/messages'), { token }); const response = await apiRequest<{ messages?: ChatMessage[] }>(chatRoomPath(roomKey, '/messages'), { token });
return response.messages || []; return response.messages || [];
} }
export async function syncMatrixRoom( export async function syncChatRoom(token: string, roomKey: string, afterId = 0): Promise<ChatSyncResponse> {
token: string, return apiRequest<ChatSyncResponse>(chatRoomPath(roomKey, `/sync?afterId=${afterId}`), { token });
roomKey: string,
since?: string,
initial = false
): Promise<MatrixSyncResponse> {
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<MatrixSyncResponse>(matrixRoomPath(roomKey, suffix), { token });
} }
export async function fetchMatrixMembers(token: string, roomKey: string): Promise<MatrixMember[]> { export async function fetchChatMembers(token: string, roomKey: string): Promise<ChatMember[]> {
const response = await apiRequest<{ members?: MatrixMember[] }>(matrixRoomPath(roomKey, '/members'), { token }); const response = await apiRequest<{ members?: ChatMember[] }>(chatRoomPath(roomKey, '/members'), { token });
return response.members || []; return response.members || [];
} }
export async function sendMatrixMessage( export async function sendChatMessage(token: string, roomKey: string, text: string): Promise<ChatMessage> {
token: string, return apiRequest<ChatMessage>(chatRoomPath(roomKey, '/messages'), {
roomKey: string,
text: string,
replyToEventId?: string | null
): Promise<MatrixMessage> {
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, '/messages'), {
method: 'POST', method: 'POST',
token, token,
body: { text, replyToEventId },
});
}
export async function editMatrixMessage(token: string, roomKey: string, eventId: string, text: string): Promise<MatrixMessage> {
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
method: 'PUT',
token,
body: { text }, body: { text },
}); });
} }
export async function deleteMatrixMessage(token: string, roomKey: string, eventId: string): Promise<void> { export async function markChatRoomRead(token: string, roomKey: string, messageId?: number): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), { await apiRequest(chatRoomPath(roomKey, '/read'), {
method: 'DELETE',
token,
});
}
export async function reactToMatrixMessage(token: string, roomKey: string, eventId: string, key: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}/reactions`), {
method: 'POST', method: 'POST',
token, token,
body: { key }, body: { messageId },
}); });
} }
export async function markMatrixRoomRead(token: string, roomKey: string, eventId?: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, '/read'), {
method: 'POST',
token,
body: { eventId },
});
}
export async function syncMatrixMembers(token: string, roomKey: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, '/members/sync'), {
method: 'POST',
token,
});
}
export async function inviteMatrixMember(token: string, roomKey: string, userId: string): Promise<void> {
await apiRequest(matrixRoomPath(roomKey, '/members/invite'), {
method: 'POST',
token,
body: { userId },
});
}
export async function removeMatrixMember(token: string, roomKey: string, matrixUserId: string): Promise<void> {
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<MatrixMessage> {
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<MatrixMessage>(matrixRoomPath(roomKey, '/attachments'), token, formData);
}
export async function renderPrintLabel( export async function renderPrintLabel(
token: string, token: string,
context: Record<string, unknown>, context: Record<string, unknown>,
@@ -862,6 +707,49 @@ export async function createProject(
}); });
} }
export async function fetchEvents(token: string, includeArchived = false): Promise<CalendarEvent[]> {
const events = await apiRequest<CalendarEvent[]>('/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<CalendarEvent> {
return apiRequest<CalendarEvent>('/api/resource/events', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchEventById(token: string, eventId: number): Promise<CalendarEvent> {
return apiRequest<CalendarEvent>(`/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<CalendarEvent[]> {
const events = await fetchEvents(token);
return events.filter((event) => resolveEventProjectId(event) === Number(projectId));
}
export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> { export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> {
const customers = await apiRequest<Customer[]>('/api/resource/customers', { token }); const customers = await apiRequest<Customer[]>('/api/resource/customers', { token });
if (includeArchived) return customers || []; if (includeArchived) return customers || [];
@@ -874,6 +762,13 @@ export async function createCustomer(
name: string; name: string;
customerNumber?: string | null; customerNumber?: string | null;
notes?: 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<Customer> { ): Promise<Customer> {
return apiRequest<Customer>('/api/resource/customers', { return apiRequest<Customer>('/api/resource/customers', {
@@ -887,6 +782,33 @@ export async function fetchCustomerById(token: string, customerId: number): Prom
return apiRequest<Customer>(`/api/resource/customers/${customerId}`, { token }); return apiRequest<Customer>(`/api/resource/customers/${customerId}`, { token });
} }
export async function fetchResourceHistory(
token: string,
resource: string,
resourceId: number | string
): Promise<HistoryItem[]> {
return apiRequest<HistoryItem[]>(
`/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`,
{ token }
);
}
export async function createResourceHistoryItem(
token: string,
resource: string,
resourceId: number | string,
text: string
): Promise<HistoryItem> {
return apiRequest<HistoryItem>(
`/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`,
{
method: 'POST',
token,
body: { text },
}
);
}
function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null { function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null {
const rawCustomer = item.customer; const rawCustomer = item.customer;
if (!rawCustomer) return null; if (!rawCustomer) return null;

View File

@@ -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}`;
}

View File

@@ -235,7 +235,7 @@ choose_mode() {
echo echo
echo "Setup-Modus" 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 " 2) advanced - zusätzlich SMTP, externe Schlüssel und optionale Dienste"
echo echo
@@ -266,12 +266,10 @@ FEDEO Selfhost Setup
Dieses Script führt dich durch die lokale Betriebsstruktur: Dieses Script führt dich durch die lokale Betriebsstruktur:
$ROOT_DIR/ $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 .env Zielkonfiguration, wird von diesem Script geschrieben
postgres/ persistente FEDEO-Datenbank postgres/ persistente FEDEO-Datenbank
minio/ lokaler S3-kompatibler Dateispeicher 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/letsencrypt/ Let's-Encrypt-Zertifikate
traefik/logs/ Traefik-Logs traefik/logs/ Traefik-Logs
@@ -279,11 +277,6 @@ Dieses Script führt dich durch die lokale Betriebsstruktur:
https://DOMAIN/ FEDEO Frontend https://DOMAIN/ FEDEO Frontend
https://DOMAIN/backend FEDEO API 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 EOF
} }
@@ -303,32 +296,28 @@ write_env() {
local admin_last_name="${12}" local admin_last_name="${12}"
local tenant_name="${13}" local tenant_name="${13}"
local tenant_short="${14}" local tenant_short="${14}"
local matrix_db_password="${15}" local mailer_host="${15}"
local matrix_turn_secret="${16}" local mailer_port="${16}"
local matrix_registration_secret="${17}" local mailer_ssl="${17}"
local livekit_secret="${18}" local mailer_user="${18}"
local mailer_host="${19}" local mailer_pass="${19}"
local mailer_port="${20}" local mailer_from="${20}"
local mailer_ssl="${21}" local web_push_public="${21}"
local mailer_user="${22}" local web_push_private="${22}"
local mailer_pass="${23}" local pdf_license="${23}"
local mailer_from="${24}" local openai_key="${24}"
local web_push_public="${25}" local stirling_key="${25}"
local web_push_private="${26}" local gocardless_secret_id="${26}"
local pdf_license="${27}" local gocardless_secret_key="${27}"
local openai_key="${28}" local dokubox_host="${28}"
local stirling_key="${29}" local dokubox_port="${29}"
local gocardless_secret_id="${30}" local dokubox_secure="${30}"
local gocardless_secret_key="${31}" local dokubox_user="${31}"
local dokubox_host="${32}" local dokubox_password="${32}"
local dokubox_port="${33}" local central_services_enabled="${33}"
local dokubox_secure="${34}" local central_services_url="${34}"
local dokubox_user="${35}" local central_instance_id="${35}"
local dokubox_password="${36}" local central_instance_secret="${36}"
local central_services_enabled="${37}"
local central_services_url="${38}"
local central_instance_id="${39}"
local central_instance_secret="${40}"
cat >"$ENV_FILE" <<EOF cat >"$ENV_FILE" <<EOF
# FEDEO Selfhosting # FEDEO Selfhosting
@@ -401,22 +390,6 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=$(env_quote "$admin_first_name")
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=$(env_quote "$admin_last_name") FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=$(env_quote "$admin_last_name")
FEDEO_BOOTSTRAP_TENANT_NAME=$(env_quote "$tenant_name") FEDEO_BOOTSTRAP_TENANT_NAME=$(env_quote "$tenant_name")
FEDEO_BOOTSTRAP_TENANT_SHORT=$(env_quote "$tenant_short") FEDEO_BOOTSTRAP_TENANT_SHORT=$(env_quote "$tenant_short")
FEDEO_BOOTSTRAP_MATRIX=$(env_quote "true")
MATRIX_SERVER_NAME=$(env_quote "$domain")
MATRIX_POSTGRES_DB=$(env_quote "synapse")
MATRIX_POSTGRES_USER=$(env_quote "synapse")
MATRIX_POSTGRES_PASSWORD=$(env_quote "$matrix_db_password")
MATRIX_TURN_SHARED_SECRET=$(env_quote "$matrix_turn_secret")
LIVEKIT_KEY=$(env_quote "fedeo-livekit")
LIVEKIT_SECRET=$(env_quote "$livekit_secret")
MATRIX_HOMESERVER_URL=$(env_quote "http://matrix-synapse:8008")
MATRIX_RTC_HOST=$(env_quote "$domain")
MATRIX_RTC_JWT_URL=$(env_quote "https://$domain/livekit/jwt")
MATRIX_LIVEKIT_URL=$(env_quote "wss://$domain/livekit/sfu")
MATRIX_REGISTRATION_SHARED_SECRET=$(env_quote "$matrix_registration_secret")
MATRIX_SERVICE_USER_LOCALPART=$(env_quote "fedeo_service")
NUXT_PUBLIC_MATRIX_ELEMENT_URL=$(env_quote "https://$domain/element")
EOF EOF
} }
@@ -425,9 +398,7 @@ prepare_directories() {
"$ROOT_DIR/traefik/letsencrypt" \ "$ROOT_DIR/traefik/letsencrypt" \
"$ROOT_DIR/traefik/logs" \ "$ROOT_DIR/traefik/logs" \
"$ROOT_DIR/postgres" \ "$ROOT_DIR/postgres" \
"$ROOT_DIR/minio" \ "$ROOT_DIR/minio"
"$ROOT_DIR/matrix/postgres" \
"$ROOT_DIR/matrix/synapse"
touch "$ROOT_DIR/traefik/letsencrypt/acme.json" touch "$ROOT_DIR/traefik/letsencrypt/acme.json"
chmod 600 "$ROOT_DIR/traefik/letsencrypt/acme.json" chmod 600 "$ROOT_DIR/traefik/letsencrypt/acme.json"
@@ -466,13 +437,11 @@ uninstall_stack() {
fi fi
echo echo
echo "Achtung: --purge entfernt lokale Datenbanken, Dateien, Matrix-Daten, Zertifikate und .env." echo "Achtung: --purge entfernt lokale Datenbanken, Dateien, Zertifikate und .env."
echo "Betroffene Pfade:" echo "Betroffene Pfade:"
echo " $ENV_FILE" echo " $ENV_FILE"
echo " $ROOT_DIR/postgres" echo " $ROOT_DIR/postgres"
echo " $ROOT_DIR/minio" echo " $ROOT_DIR/minio"
echo " $ROOT_DIR/matrix/postgres"
echo " $ROOT_DIR/matrix/synapse"
echo " $ROOT_DIR/traefik/letsencrypt" echo " $ROOT_DIR/traefik/letsencrypt"
echo " $ROOT_DIR/traefik/logs" echo " $ROOT_DIR/traefik/logs"
@@ -487,8 +456,6 @@ uninstall_stack() {
"$ENV_FILE" \ "$ENV_FILE" \
"$ROOT_DIR/postgres" \ "$ROOT_DIR/postgres" \
"$ROOT_DIR/minio" \ "$ROOT_DIR/minio" \
"$ROOT_DIR/matrix/postgres" \
"$ROOT_DIR/matrix/synapse" \
"$ROOT_DIR/traefik/letsencrypt" \ "$ROOT_DIR/traefik/letsencrypt" \
"$ROOT_DIR/traefik/logs" "$ROOT_DIR/traefik/logs"
@@ -539,17 +506,12 @@ main() {
echo echo
echo "Secrets werden automatisch erzeugt." echo "Secrets werden automatisch erzeugt."
local db_password minio_password cookie_secret jwt_secret encryption_key m2m_key local db_password minio_password cookie_secret jwt_secret encryption_key m2m_key
local matrix_db_password matrix_turn_secret matrix_registration_secret livekit_secret
db_password="$(random_hex_secret)" db_password="$(random_hex_secret)"
minio_password="$(random_secret)" minio_password="$(random_secret)"
cookie_secret="$(random_secret)" cookie_secret="$(random_secret)"
jwt_secret="$(random_secret)" jwt_secret="$(random_secret)"
encryption_key="$(random_hex_secret)" encryption_key="$(random_hex_secret)"
m2m_key="$(random_secret)" m2m_key="$(random_secret)"
matrix_db_password="$(random_secret)"
matrix_turn_secret="$(random_secret)"
matrix_registration_secret="$(random_secret)"
livekit_secret="$(random_secret)"
local mailer_host="smtp.example.com" local mailer_host="smtp.example.com"
local mailer_port="587" local mailer_port="587"
@@ -613,8 +575,7 @@ main() {
"$domain" "$contact_email" "$db_password" "$minio_password" \ "$domain" "$contact_email" "$db_password" "$minio_password" \
"$cookie_secret" "$jwt_secret" "$encryption_key" "$m2m_key" \ "$cookie_secret" "$jwt_secret" "$encryption_key" "$m2m_key" \
"$admin_email" "$admin_password" "$admin_first_name" "$admin_last_name" \ "$admin_email" "$admin_password" "$admin_first_name" "$admin_last_name" \
"$tenant_name" "$tenant_short" "$matrix_db_password" "$matrix_turn_secret" \ "$tenant_name" "$tenant_short" "$mailer_host" "$mailer_port" \
"$matrix_registration_secret" "$livekit_secret" "$mailer_host" "$mailer_port" \
"$mailer_ssl" "$mailer_user" "$mailer_pass" "$mailer_from" "$web_push_public" \ "$mailer_ssl" "$mailer_user" "$mailer_pass" "$mailer_from" "$web_push_public" \
"$web_push_private" "$pdf_license" "$openai_key" "$stirling_key" \ "$web_push_private" "$pdf_license" "$openai_key" "$stirling_key" \
"$gocardless_secret_id" "$gocardless_secret_key" "$dokubox_host" \ "$gocardless_secret_id" "$gocardless_secret_key" "$dokubox_host" \

View File

@@ -286,7 +286,7 @@ h3 {
.section, .section,
.workflow-section, .workflow-section,
.audience-teaser, .audience-teaser,
.matrix-section, .chat-section,
.open-source-section, .open-source-section,
.contact-section, .contact-section,
footer { footer {
@@ -394,7 +394,7 @@ footer {
.workflow-section, .workflow-section,
.audience-teaser, .audience-teaser,
.matrix-section, .chat-section,
.open-source-section, .open-source-section,
.script-section, .script-section,
.contact-section { .contact-section {
@@ -456,32 +456,32 @@ footer {
max-width: 52rem; max-width: 52rem;
} }
.matrix-section { .chat-section {
border-top: 1px solid rgba(23, 33, 31, 0.12); border-top: 1px solid rgba(23, 33, 31, 0.12);
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding-top: 5rem; padding-top: 5rem;
} }
.matrix-layout { .chat-layout {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.82fr); grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.82fr);
} }
.matrix-stack { .chat-stack {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.matrix-stack article { .chat-stack article {
background: rgba(255, 255, 255, 0.76); background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(23, 33, 31, 0.1); border: 1px solid rgba(23, 33, 31, 0.1);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.35rem; padding: 1.35rem;
} }
.matrix-stack span { .chat-stack span {
color: var(--accent-dark); color: var(--accent-dark);
display: inline-block; display: inline-block;
font-size: 0.82rem; font-size: 0.82rem;
@@ -489,13 +489,13 @@ footer {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.matrix-stack p { .chat-stack p {
color: #51605c; color: #51605c;
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
} }
.matrix-diagram { .chat-diagram {
align-self: stretch; align-self: stretch;
background: #17211f; background: #17211f;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -505,7 +505,7 @@ footer {
padding: clamp(1.25rem, 3vw, 2rem); padding: clamp(1.25rem, 3vw, 2rem);
} }
.matrix-diagram div { .chat-diagram div {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.45rem; border-radius: 0.45rem;
@@ -514,12 +514,12 @@ footer {
padding: 1rem; padding: 1rem;
} }
.matrix-diagram small { .chat-diagram small {
color: rgba(255, 255, 255, 0.64); color: rgba(255, 255, 255, 0.64);
font-weight: 500; font-weight: 500;
} }
.matrix-diagram span { .chat-diagram span {
background: var(--accent); background: var(--accent);
border-radius: 999px; border-radius: 999px;
display: block; display: block;
@@ -948,7 +948,7 @@ footer p {
.hero-section, .hero-section,
.workflow-section, .workflow-section,
.audience-teaser, .audience-teaser,
.matrix-section, .chat-section,
.open-source-section, .open-source-section,
.script-section, .script-section,
.contact-section { .contact-section {
@@ -964,7 +964,7 @@ footer p {
.form-grid, .form-grid,
.audience-grid, .audience-grid,
.audience-fit, .audience-fit,
.matrix-layout, .chat-layout,
.federation-panel, .federation-panel,
.legal-grid { .legal-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1028,7 +1028,7 @@ footer p {
.highlights, .highlights,
.feature-grid, .feature-grid,
.audience-grid, .audience-grid,
.matrix-stack, .chat-stack,
.legal-grid { .legal-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -10,7 +10,7 @@
<NuxtLink to="/#funktionen">Funktionen</NuxtLink> <NuxtLink to="/#funktionen">Funktionen</NuxtLink>
<NuxtLink to="/#open-source">Open Source</NuxtLink> <NuxtLink to="/#open-source">Open Source</NuxtLink>
<NuxtLink to="/#selfhost">Selfhost</NuxtLink> <NuxtLink to="/#selfhost">Selfhost</NuxtLink>
<NuxtLink to="/#matrix">Matrix</NuxtLink> <NuxtLink to="/#chat">Chat</NuxtLink>
<NuxtLink to="/kontakt">Kontakt</NuxtLink> <NuxtLink to="/kontakt">Kontakt</NuxtLink>
<a class="login-link" href="https://app.fedeo.de">Einloggen</a> <a class="login-link" href="https://app.fedeo.de">Einloggen</a>
</nav> </nav>

View File

@@ -10,7 +10,7 @@
<NuxtLink to="/#funktionen">Funktionen</NuxtLink> <NuxtLink to="/#funktionen">Funktionen</NuxtLink>
<NuxtLink to="/#open-source">Open Source</NuxtLink> <NuxtLink to="/#open-source">Open Source</NuxtLink>
<NuxtLink to="/#selfhost">Selfhost</NuxtLink> <NuxtLink to="/#selfhost">Selfhost</NuxtLink>
<NuxtLink to="/#matrix">Matrix</NuxtLink> <NuxtLink to="/#chat">Chat</NuxtLink>
<NuxtLink to="/kontakt">Kontakt</NuxtLink> <NuxtLink to="/kontakt">Kontakt</NuxtLink>
<a class="login-link" href="https://app.fedeo.de">Einloggen</a> <a class="login-link" href="https://app.fedeo.de">Einloggen</a>
</nav> </nav>

View File

@@ -10,7 +10,7 @@
<a href="#funktionen">Funktionen</a> <a href="#funktionen">Funktionen</a>
<a href="#open-source">Open Source</a> <a href="#open-source">Open Source</a>
<a href="#selfhost">Selfhost</a> <a href="#selfhost">Selfhost</a>
<a href="#matrix">Matrix</a> <a href="#chat">Chat</a>
<NuxtLink to="/kontakt">Kontakt</NuxtLink> <NuxtLink to="/kontakt">Kontakt</NuxtLink>
<a class="login-link" href="https://app.fedeo.de">Einloggen</a> <a class="login-link" href="https://app.fedeo.de">Einloggen</a>
</nav> </nav>
@@ -167,42 +167,42 @@
</div> </div>
</section> </section>
<section id="matrix" class="matrix-section"> <section id="chat" class="chat-section">
<div class="section-heading"> <div class="section-heading">
<p class="eyebrow">Tief integriert</p> <p class="eyebrow">Tief integriert</p>
<h2>Matrix als Kommunikationsschicht für FEDEO.</h2> <h2>Chat direkt in FEDEO.</h2>
<p> <p>
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.
</p> </p>
</div> </div>
<div class="matrix-layout"> <div class="chat-layout">
<div class="matrix-stack"> <div class="chat-stack">
<article v-for="item in matrixStack" :key="item.title"> <article v-for="item in chatStack" :key="item.title">
<span>{{ item.tag }}</span> <span>{{ item.tag }}</span>
<h3>{{ item.title }}</h3> <h3>{{ item.title }}</h3>
<p>{{ item.description }}</p> <p>{{ item.description }}</p>
</article> </article>
</div> </div>
<div class="matrix-diagram" aria-label="Matrix Stack Architektur"> <div class="chat-diagram" aria-label="FEDEO Chat Architektur">
<div>FEDEO Web & App</div> <div>FEDEO Web & App</div>
<span></span> <span></span>
<div>FEDEO Backend<br><small>SSO, Rechte, Objektkontext</small></div> <div>FEDEO Backend<br><small>SSO, Rechte, Objektkontext</small></div>
<span></span> <span></span>
<div>Matrix Homeserver<br><small>Synapse, Räume, Events</small></div> <div>FEDEO Chat<br><small>Räume, Nachrichten, Lesestatus</small></div>
<span></span> <span></span>
<div>FEDEO Kommunikation<br><small>Räume, Gäste, Föderation</small></div> <div>FEDEO Datenbank<br><small>Mandantengetrennte Speicherung</small></div>
</div> </div>
</div> </div>
<div class="federation-panel"> <div class="federation-panel">
<div> <div>
<p class="eyebrow">Föderation</p> <p class="eyebrow">Klare Zuständigkeit</p>
<h3>FEDEO-Instanzen können kontrolliert miteinander sprechen.</h3> <h3>Kommunikation folgt den FEDEO-Berechtigungen.</h3>
</div> </div>
<p> <p>
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.
</p> </p>
</div> </div>
</section> </section>
@@ -257,10 +257,10 @@ const features = [
{ {
tag: 'Büro', tag: 'Büro',
title: 'Aufgaben und Kommunikation', 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: [ details: [
'Aufgaben mit Bezug zu Projekten, Vorgängen und Teams', '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' 'Benachrichtigungen und Lesestatus für laufende Arbeit'
] ]
}, },
@@ -316,31 +316,31 @@ const features = [
} }
] ]
const matrixStack = [ const chatStack = [
{ {
tag: 'Chat', tag: 'Chat',
title: 'Projekt- und Vorgangsräume', 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', tag: 'Identität',
title: 'FEDEO bleibt führend', 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', tag: 'Kommunikation',
title: 'Chat, Gäste und Räume', title: 'Nachrichten und Räume',
description: 'Teamräume, externe Gäste und projektnahe Abstimmung bleiben direkt mit FEDEO-Kontexten verbunden.' description: 'Allgemeine, direkte und projektnahe Abstimmung bleibt direkt mit FEDEO-Kontexten verbunden.'
}, },
{ {
tag: 'Betrieb', tag: 'Betrieb',
title: 'Selfhost-ready Stack', title: 'Selfhost-ready',
description: 'Synapse, PostgreSQL, Redis, .well-known, Element und TURN/STUN sind im Selfhost-Stack vorbereitet.' description: 'Chatdaten werden mit der bestehenden FEDEO-Datenbank und ohne zusätzliche Kommunikationsdienste betrieben.'
}, },
{ {
tag: 'Föderation', tag: 'Sicherheit',
title: 'Instanzübergreifende Zusammenarbeit', title: 'Mandantengetrennte Kommunikation',
description: 'Mehrere FEDEO- oder Matrix-Instanzen können über freigegebene Domains föderieren, ohne die lokale Kontrolle über Nutzer und Räume aufzugeben.' description: 'Teilnehmerrechte und Lesestatus werden serverseitig geprüft und innerhalb der jeweiligen FEDEO-Instanz gespeichert.'
} }
] ]

View File

@@ -10,7 +10,7 @@
<NuxtLink to="/#funktionen">Funktionen</NuxtLink> <NuxtLink to="/#funktionen">Funktionen</NuxtLink>
<NuxtLink to="/#open-source">Open Source</NuxtLink> <NuxtLink to="/#open-source">Open Source</NuxtLink>
<NuxtLink to="/#selfhost">Selfhost</NuxtLink> <NuxtLink to="/#selfhost">Selfhost</NuxtLink>
<NuxtLink to="/#matrix">Matrix</NuxtLink> <NuxtLink to="/#chat">Chat</NuxtLink>
<NuxtLink to="/kontakt">Kontakt</NuxtLink> <NuxtLink to="/kontakt">Kontakt</NuxtLink>
<a class="login-link" href="https://app.fedeo.de">Einloggen</a> <a class="login-link" href="https://app.fedeo.de">Einloggen</a>
</nav> </nav>

View File

@@ -10,7 +10,7 @@
<NuxtLink to="/#funktionen">Funktionen</NuxtLink> <NuxtLink to="/#funktionen">Funktionen</NuxtLink>
<NuxtLink to="/#open-source">Open Source</NuxtLink> <NuxtLink to="/#open-source">Open Source</NuxtLink>
<NuxtLink to="/#selfhost">Selfhost</NuxtLink> <NuxtLink to="/#selfhost">Selfhost</NuxtLink>
<NuxtLink to="/#matrix">Matrix</NuxtLink> <NuxtLink to="/#chat">Chat</NuxtLink>
<NuxtLink to="/kontakt">Kontakt</NuxtLink> <NuxtLink to="/kontakt">Kontakt</NuxtLink>
<a class="login-link" href="https://app.fedeo.de">Einloggen</a> <a class="login-link" href="https://app.fedeo.de">Einloggen</a>
</nav> </nav>
@@ -112,7 +112,7 @@ const audiences = [
points: [ points: [
'Vorgänge mit Historie, Dokumentation und Zuständigkeit', 'Vorgänge mit Historie, Dokumentation und Zuständigkeit',
'Rollen und Berechtigungen für unterschiedliche Teams', '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: [ points: [
'Offener Code und nachvollziehbare Architektur', 'Offener Code und nachvollziehbare Architektur',
'Docker-basierter Selfhost-Stack', 'Docker-basierter Selfhost-Stack',
'Matrix-Föderation und Integrationen kontrollierbar betreiben' 'Mandanteninterne Kommunikation kontrolliert betreiben'
] ]
}, },
{ {