31 Commits

Author SHA1 Message Date
f3b4fac4c0 Merge pull request 'dev' (#247) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m25s
Reviewed-on: #247
2026-10-15 16:47:32 +00:00
f56a07f8e3 feat(chat): add sidebar search for conversations
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m4s
2026-10-15 17:54:22 +02:00
7b61857fb8 feat(notifications): include federated chat messages and invitations 2026-10-15 17:50:38 +02:00
697f538b6e feat(chat): preserve message drafts in session temp store 2026-10-15 17:45:17 +02:00
a8c6c2625c Merge pull request 'dev' (#242) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m6s
Build and Release FEDEO / build-and-release (push) Successful in 6m31s
Reviewed-on: #242
2026-10-13 16:18:53 +00:00
a2a1151ccb Merge pull request 'dev' (#241) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m29s
Reviewed-on: #241
2026-10-07 17:11:45 +00:00
daaed01d02 Merge pull request 'dev' (#240) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #240
2026-10-02 16:30:02 +00:00
e7d6c482ad Merge pull request 'dev' (#239) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m13s
Reviewed-on: #239
2026-10-02 15:17:16 +00:00
ed305f656c Merge pull request 'dev' (#238) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m14s
Reviewed-on: #238
2026-10-02 14:18:01 +00:00
30e932a874 Merge pull request 'dev' (#237) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #237
2026-10-02 12:51:46 +00:00
0a74e07288 Merge pull request 'fix(scanner): accept Epson empty write probes' (#234) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m8s
Reviewed-on: #234
2026-10-01 15:36:56 +00:00
4c3de8c942 Merge pull request 'dev' (#233) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m11s
Reviewed-on: #233
2026-09-30 17:23:15 +00:00
49d44d9701 Merge pull request 'dev' (#232) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m18s
Reviewed-on: #232
2026-09-30 15:43:45 +00:00
96a7acff33 Merge pull request 'feat(selfhost): show sanitized update logs in admin UI' (#231) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #231
2026-09-25 17:22:30 +00:00
65588686c7 Merge pull request 'fix(documents): normalize units when selecting and loading rows' (#230) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 6m3s
Reviewed-on: #230
2026-09-25 09:54:08 +00:00
fcf9cc3fc9 Merge pull request 'dev' (#229) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 6m6s
Reviewed-on: #229
2026-09-25 09:24:39 +00:00
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
f708d0a2b2 Merge pull request 'dev' (#227) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 44s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #227
2026-09-23 16:15:44 +00:00
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
8473221c13 Merge pull request '3.0.0' (#220) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 22s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #220
2026-09-20 16:10:46 +00:00
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
21 changed files with 896 additions and 65 deletions

View File

@@ -2,37 +2,18 @@
name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] '
labels: bug
labels: Problem
assignees: ''
---
**Beschreibung**
Eine klare und prägnante Beschreibung des Fehlers.
**Reproduktion**
Schritte, um den Fehler zu reproduzieren:
Entweder:
1. Gehe zu '...'
2. Klicke auf '...'
3. Scrolle runter zu '...'
4. Siehe Fehler
Oder Link zur Seite
**Erwartetes Verhalten**
Eine klare Beschreibung dessen, was du erwartet hast.
**Screenshots**
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
**Umgebung:**
- Betriebssystem: [z.B. Windows, macOS, Linux]
- Browser / Version (falls relevant): [z.B. Chrome 120]
- Projekt-Version: [z.B. v1.0.2]
**Zusätzlicher Kontext**
Füge hier alle anderen Informationen zum Problem hinzu.

View File

@@ -2,19 +2,16 @@
name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] '
labels: enhancement
labels: Funktionswunsch
assignees: ''
---
**Ist dein Feature-Wunsch mit einem Problem verbunden?**
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
**Lösungsvorschlag**
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
**Alternativen**
Hast du über alternative Lösungen oder Workarounds nachgedacht?
**Zusätzlicher Kontext**
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.

View File

@@ -0,0 +1,7 @@
INSERT INTO notifications_event_types (event_key, display_name, description, category, allowed_channels) VALUES
('collaboration.message.new', 'Zusammenarbeit: Chatnachrichten', 'Neue Nachrichten in freigegebenen Unterräumen und föderierten Chats.', 'collaboration', '["inapp","email","push"]'),
('collaboration.room.invited', 'Zusammenarbeit: Einladungen', 'Persönliche Einladungen in freigegebene Unterräume und föderierte Chats.', 'collaboration', '["inapp","email","push"]')
ON CONFLICT (event_key) DO NOTHING;
--> statement-breakpoint
CREATE INDEX notifications_collaboration_queued ON notifications_items(created_at)
WHERE status='queued' AND event_type IN ('collaboration.message.new','collaboration.room.invited');

View File

@@ -561,6 +561,13 @@
"when": 1792022400000,
"tag": "0082_archive_users",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1792022400001,
"tag": "0083_collaboration_notifications",
"breakpoints": true
}
]
}

View File

@@ -5,7 +5,9 @@ import {
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
@@ -48,7 +50,8 @@ export const notificationsItems = pgTable("notifications_items", {
sentAt: timestamp("sent_at", { withTimezone: true }),
readAt: timestamp("read_at", { withTimezone: true }),
})
}, t => ({ collaborationQueued: index("notifications_collaboration_queued").on(t.createdAt)
.where(sql`${t.status}='queued' AND ${t.eventType} IN ('collaboration.message.new','collaboration.room.invited')`) }))
export type NotificationItem = typeof notificationsItems.$inferSelect
export type NewNotificationItem = typeof notificationsItems.$inferInsert

View File

@@ -207,6 +207,7 @@ export class FederationService {
if (updated) {
await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${updated.id}`)
await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${updated.id} AND status='pending'`)
await this.service.options.notifications?.remoteInvitation(db, principal, updated.id, invitationId, snapshot.name)
}
})
return { received: true }
@@ -322,6 +323,11 @@ export class FederationService {
for (const message of projected.filter(m => m.seq >= from)) await db.execute(sql`INSERT INTO collaboration_remote_messages(binding_id,id,seq,payload)
VALUES (${binding.id},${message.id},${message.seq},${JSON.stringify(message)}::jsonb)
ON CONFLICT(binding_id,id) DO UPDATE SET payload=excluded.payload`)
if (snapshot) {
const unreadAfter = Math.max(Number(current.sync_seq), Number(current.last_read_seq), snapshot.lastReadSeq)
const incoming = projected.filter(m => !m.own && m.seq >= from && m.seq > unreadAfter)
await this.service.options.notifications?.remoteMessages(db, current, snapshot.name, incoming)
}
})
}
async memberRequest(ctx: UserContext, id: string, action: string, input: any = {}) {

View File

@@ -0,0 +1,139 @@
import type { FastifyInstance } from "fastify"
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
import { authUsers, notificationsItems } from "../../../db/schema"
import { NotificationService } from "../notification.service"
import { CollaborationService, rows } from "./service"
import type { FederationService } from "./federation"
import type { Principal } from "./types"
const MESSAGE = "collaboration.message.new"
const INVITATION = "collaboration.room.invited"
/** Queue inside the source transaction; external delivery never holds up a chat command. */
export class CollaborationNotifications {
private working = false
private deferredUntil = new Map<string, number>()
constructor(readonly server: FastifyInstance, readonly service: CollaborationService, readonly federation: FederationService) {}
delivery(db: any) {
return new NotificationService({ db, log: this.server.log } as FastifyInstance, async (server, userId) => {
const [user] = await server.db.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).limit(1)
return user || null
})
}
async enqueue(db: any, principal: Principal, name: string, payload: Record<string, unknown>, message?: { body: string; senderName: string }) {
if (principal.kind !== "user" || principal.disabled) return
try { await this.service.assertPrincipal(principal, db) }
catch (error: any) { if ([403, 404].includes(error.statusCode)) return; throw error }
await this.delivery(db).enqueue({
tenantId: Number(principal.tenant_id), userId: principal.user_id!,
eventType: message ? MESSAGE : INVITATION,
title: message ? `Neue Nachricht in ${name}` : "Einladung zur Zusammenarbeit",
message: message ? `${message.senderName}: ${message.body || "Bild gesendet"}` : `Du wurdest in den Chat „${name}“ eingeladen.`,
channels: ["inapp", "email", "push"],
payload: { ...payload, principalId: principal.id,
link: message ? `/communication/chat?room=${encodeURIComponent(`collaboration:${payload.bindingId || payload.roomId}`)}` : "/communication/chat" },
})
}
async localMessage(db: any, room: any, sender: Principal, message: any) {
const recipients = await rows(db, sql`SELECT p.* FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id
WHERE m.room_id=${room.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
AND m.history_from_seq<=${message.seq} AND m.last_read_seq<${message.seq}
AND p.kind='user' AND NOT p.disabled AND p.id<>${sender.id}`)
for (const principal of recipients) await this.enqueue(db, principal, room.name,
{ roomId: room.id, messageId: message.id, seq: message.seq }, { body: message.body, senderName: sender.display_name })
}
async localInvitation(db: any, room: any, target: Principal, invitationId: string) {
await this.enqueue(db, target, room.name, { roomId: room.id, invitationId })
}
async remoteInvitation(db: any, principal: Principal, bindingId: string, invitationId: string, name: string) {
await this.enqueue(db, principal, name, { bindingId, invitationId })
}
async remoteMessages(db: any, binding: any, name: string, messages: any[]) {
if (!messages.length) return
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${binding.principal_id}`)
if (!principal) return
for (const message of new Map(messages.map(message => [message.id, message])).values()) await this.enqueue(db, principal, name,
{ bindingId: binding.id, invitationId: binding.invitation_id, messageId: message.id, seq: message.seq }, message)
}
async eligible(db: any, item: typeof notificationsItems.$inferSelect): Promise<boolean | "defer"> {
const payload = item.payload as any
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${payload.principalId}`)
if (!principal || principal.kind !== "user" || principal.user_id !== item.userId || Number(principal.tenant_id) !== item.tenantId) return false
try {
await this.service.assertPrincipal(principal, db)
if (payload.bindingId) {
if (!this.federation.options.enabled) return "defer"
const ctx = { principal, userId: item.userId, tenantId: item.tenantId, manage: false }
const binding = await this.federation.binding(ctx, payload.bindingId)
if (!binding || binding.invitation_id !== payload.invitationId) return false
await this.federation.allowed(item.tenantId, binding.origin, db)
if (item.eventType === INVITATION) return binding.status === "pending" && new Date(binding.invitation.expiresAt).getTime() > Date.now()
if (binding.status !== "active") return false
// Do not send cached content while current access cannot be confirmed by its owner.
const detail = await this.federation.sync(ctx, binding)
if (detail.offline) return "defer"
const current = await this.federation.binding(ctx, binding.id)
return current?.status === "active" && current.invitation_id === payload.invitationId
&& payload.seq >= detail.historyFromSeq && (item.channel === "inapp" || payload.seq > Number(current.last_read_seq))
}
const room = await this.service.roomRow(db, payload.roomId)
if (item.eventType === INVITATION) {
this.service.assertOpen(room, true)
const [invite] = await rows(db, sql`SELECT id FROM collaboration_invitations
WHERE id=${payload.invitationId} AND room_id=${room.id} AND target_principal_id=${principal.id}
AND status='pending' AND expires_at>now() AND (access_expires_at IS NULL OR access_expires_at>now())`)
return Boolean(invite)
}
const member = await this.service.membership(db, room, principal)
return payload.seq >= Number(member.history_from_seq) && (item.channel === "inapp" || payload.seq > Number(member.last_read_seq))
} catch (error: any) {
if ([401, 403, 404, 409, 410].includes(error.statusCode)) return false
throw error
}
}
async tick() {
if (this.working) return
this.working = true
try {
for (const [id, until] of this.deferredUntil) if (until <= Date.now()) this.deferredUntil.delete(id)
const pending = await this.server.db.select({ id: notificationsItems.id }).from(notificationsItems)
.where(and(eq(notificationsItems.status, "queued"), inArray(notificationsItems.eventType, [MESSAGE, INVITATION]),
this.deferredUntil.size ? notInArray(notificationsItems.id, [...this.deferredUntil.keys()]) : undefined))
.orderBy(asc(notificationsItems.createdAt)).limit(20)
for (const { id } of pending) {
try {
await this.server.db.transaction(async db => {
const [item] = await db.select().from(notificationsItems)
.where(and(eq(notificationsItems.id, id), eq(notificationsItems.status, "queued")))
.for("update", { skipLocked: true })
if (!item) return
const eligible = await this.eligible(db, item)
if (eligible === "defer") {
this.deferredUntil.set(id, Date.now() + 30000)
return
}
const delivery = this.delivery(db)
const enabled = item.channel === "inapp" || (await delivery.preferences(item.tenantId, item.userId)).events
.some(event => event.eventKey === item.eventType && event.channels[item.channel] === true)
if (!eligible || !enabled) {
await db.delete(notificationsItems).where(eq(notificationsItems.id, item.id))
return
}
await delivery.deliver(item)
})
} catch (error) {
this.deferredUntil.set(id, Date.now() + 30000)
this.server.log.error({ err: error, notificationId: id }, "Collaboration notification failed")
}
}
} finally { this.working = false }
}
}

View File

@@ -5,6 +5,7 @@ import { CollaborationService } from "./service"
import { FederationService } from "./federation"
import { encrypt, decrypt } from "../../utils/crypt"
import { collaborationConfig } from "./config"
import { CollaborationNotifications } from "./notifications"
export function collaborationRuntime(server: FastifyInstance) {
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
@@ -16,12 +17,17 @@ export function collaborationRuntime(server: FastifyInstance) {
sendMail,
})
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
const notifications = new CollaborationNotifications(server, service, federation)
service.options.notifications = notifications
service.options.changed = (roomId, db) => federation.changed(roomId, db)
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
let timer: ReturnType<typeof setInterval>
server.addHook("onReady", async () => {
if (federation.options.enabled) await federation.keys()
timer = setInterval(() => federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed")), 5000)
timer = setInterval(() => {
void federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed"))
void notifications.tick().catch(error => server.log.error({ err: error }, "Collaboration notification worker failed"))
}, 5000)
timer.unref()
})
server.addHook("onClose", async () => { clearInterval(timer) })

View File

@@ -4,6 +4,7 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres"
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content"
import type { CollaborationNotifications } from "./notifications"
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
@@ -13,6 +14,7 @@ export type CollaborationOptions = {
sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>;
changed?: (roomId: string, db: any) => Promise<void>;
invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise<void>;
notifications?: CollaborationNotifications;
}
/** Owner-side authority. All membership changes and sends serialize on the room row. */
@@ -201,6 +203,7 @@ export class CollaborationService {
const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image)
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
await this.options.changed?.(room.id, db)
await this.options.notifications?.localMessage(db, room, principal, message)
return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind }
})
}
@@ -302,6 +305,7 @@ export class CollaborationService {
if (!this.options.invited) fail("Föderation ist nicht konfiguriert", 503)
await this.options.invited(ctx, room.id, target, invite.id, db)
}
await this.options.notifications?.localInvitation(db, room, target, invite.id)
return invite
})
return { ...result, token }

View File

@@ -56,6 +56,24 @@ export class NotificationService {
) {}
async trigger(input: TriggerInput) {
const { items, skipped } = await this.createItems(input)
if (skipped !== undefined) return { success: true, created: 0, delivered: 0, skipped }
const results = []
for (const item of items) results.push(await this.deliver(item))
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
}
/** Persist first; callers using a transaction can deliver after it commits. */
async enqueue(input: TriggerInput) {
return (await this.createItems(input)).items
}
private async createItems(input: TriggerInput): Promise<{ items: (typeof notificationsItems.$inferSelect)[]; skipped?: number }> {
const tenantId = input.tenantId
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
@@ -68,7 +86,7 @@ export class NotificationService {
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
if (!channels.length) {
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
return { items: [], skipped: userIds.length }
}
const results = []
@@ -99,16 +117,11 @@ export class NotificationService {
const item = itemRows[0]
if (!item) continue
results.push(await this.deliver(item))
results.push(item)
}
}
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
return { items: results }
}
async preferences(tenantId: number, userId: string) {
@@ -295,11 +308,12 @@ export class NotificationService {
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
if (input.eventType === "collaboration.message.new") return channel === "inapp"
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
})
}
private async deliver(item: typeof notificationsItems.$inferSelect) {
async deliver(item: typeof notificationsItems.$inferSelect) {
if (item.channel === "inapp") {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }

View File

@@ -0,0 +1,201 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { readFile } from "node:fs/promises"
import Fastify from "fastify"
import { eq } from "drizzle-orm"
import { notificationsItems } from "../db/schema"
import { CollaborationService } from "../src/modules/collaboration/service"
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
import { CollaborationNotifications } from "../src/modules/collaboration/notifications"
import type { Transport } from "../src/modules/collaboration/transport"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
const MESSAGE = "collaboration.message.new", INVITATION = "collaboration.room.invited"
test("collaboration notifications use real preferences, transactions and signed federation routes", { skip: !testDatabaseUrl }, async t => {
const servers = new Map<string, ReturnType<typeof Fastify>>()
let offline = false
const transport: Transport = async (url, method, headers, body) => {
if (offline) throw Object.assign(new Error("Offline"), { statusCode: 503 })
const origin = [...servers.keys()].find(value => url.startsWith(value + "/"))!
const response = await servers.get(origin)!.inject({ method, url: url.slice(origin.length), headers, ...(body ? { payload: body } : {}) })
if (response.statusCode >= 400) throw Object.assign(new Error(response.json().message), { statusCode: response.statusCode })
return response.json()
}
const make = async (name: string) => {
const fixture = await collaborationDatabase(["0075_project_collaboration.sql", "0076_collaboration_federation.sql"])
const original = await readFile(new URL("../db/migrations/0000_brief_dark_beast.sql", import.meta.url), "utf8")
for (const type of ["notification_channel", "notification_status", "notification_severity"]) {
const definition = original.match(new RegExp(`CREATE TYPE "public"\\."${type}" AS ENUM\\([^;]+;`))![0]
await fixture.pool.query(definition.replace('"public".', ""))
}
for (const table of ["notifications_event_types", "notifications_items", "notifications_preferences", "notifications_preferences_defaults"]) {
await fixture.pool.query(original.match(new RegExp(`CREATE TABLE "${table}" \\([\\s\\S]*?\\n\\);`))![0])
}
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences(tenant_id,user_id,event_type,channel)")
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences_defaults(tenant_id,event_key,channel)")
await fixture.pool.query(await readFile(new URL("../db/migrations/0083_collaboration_notifications.sql", import.meta.url), "utf8"))
const userId = randomUUID()
await fixture.pool.query("INSERT INTO tenants VALUES (1,$1,null,null),(2,'Other tenant',null,null)", [name])
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,$2,true)", [userId, `${name}@example.test`])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1)", [userId])
const service = new CollaborationService(fixture.db, { publicUrl: `https://${name}.example`, guestPepper: () => "a".repeat(64), sendMail: async () => ({ success: true }) })
const federation = new FederationService(service, { apiUrl: `https://${name}.example/backend`, enabled: true, transport,
seal: value => ({ key: value }), unseal: value => value.key })
service.options.changed = (id, db) => federation.changed(id, db)
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
const app = Fastify()
app.decorate("db", fixture.db)
const notifications = new CollaborationNotifications(app, service, federation)
service.options.notifications = notifications
const delivered: any[] = []
const delivery = notifications.delivery.bind(notifications)
// Keep persistence and preference resolution real; replace only external channel delivery.
notifications.delivery = db => {
const sender = delivery(db)
sender.deliver = async item => {
delivered.push(item)
await db.update(notificationsItems).set({ status: "sent", sentAt: new Date() }).where(eq(notificationsItems.id, item.id))
return { success: true, id: item.id, channel: item.channel }
}
return sender
}
await app.register(federationRoutes, { federation })
await app.ready()
servers.set(federation.origin, app)
return { ...fixture, service, federation, notifications, app, delivered, ctx: await service.userContext(userId, 1), other: await service.userContext(userId, 2),
items: async () => (await fixture.pool.query("SELECT * FROM notifications_items ORDER BY created_at,id")).rows }
}
const a = await make("alpha"), b = await make("beta")
try {
await a.federation.settings(a.ctx, { origin: b.federation.origin, active: true })
await b.federation.settings(b.ctx, { origin: a.federation.origin, active: true })
const contact = await a.federation.resolveContact(a.ctx, b.federation.contact(b.ctx.principal))
const room = await a.service.createRoom(a.ctx, { name: "Gemeinsamer Chat" })
const before = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Private history" })
let bindingId: string
const jobFor = async (invitationId: string) => (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitationId}`])).rows[0]
const itemsFor = async (instance: typeof a, id: string) => (await instance.items()).filter(item => item.payload.messageId === id)
const enable = async (instance: typeof a, tenantId = 1, enabled = true) => instance.notifications.delivery(instance.db).savePreferences(tenantId, instance.ctx.userId,
["email", "push"].map(channel => ({ eventType: MESSAGE, channel, enabled })))
await t.test("new matrix events have quiet message defaults and per-tenant overrides", async () => {
const preferences = await b.notifications.delivery(b.db).preferences(1, b.ctx.userId)
assert.deepEqual(preferences.events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
assert.deepEqual(preferences.events.find(e => e.eventKey === INVITATION)!.channels, { email: false, push: true })
await enable(a); await enable(b)
assert.deepEqual((await b.notifications.delivery(b.db).preferences(2, b.ctx.userId)).events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
})
await t.test("signed invitations notify only their named recipient, once despite redelivery", async () => {
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
const job = await jobFor(invitation.id)
await a.federation.deliver(job)
bindingId = (await b.federation.list(b.ctx)).invitations[0].id
await a.pool.query("UPDATE collaboration_outbox SET status='pending' WHERE id=$1", [job.id])
await a.federation.deliver(job)
const items = await b.items()
assert.equal(items.length, 2)
assert.deepEqual(items.map(i => i.channel).sort(), ["inapp", "push"])
assert.ok(items.every(i => i.user_id === b.ctx.userId && Number(i.tenant_id) === 1 && i.event_type === INVITATION))
assert.equal((await a.items()).length, 0)
await b.notifications.tick()
assert.equal(b.delivered.length, 2)
await b.federation.accept(b.ctx, bindingId)
assert.equal((await itemsFor(b, before.id)).length, 0)
})
await t.test("new remote messages notify once with the local room link, including simultaneous syncs", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Hallo Beta" })
await Promise.all([b.federation.memberRequest(b.ctx, bindingId, "messages"), b.federation.memberRequest(b.ctx, bindingId, "messages")])
const items = await itemsFor(b, message.id)
assert.equal(items.length, 3)
assert.ok(items.every(i => i.message.includes("Hallo Beta") && i.payload.link === `/communication/chat?room=collaboration%3A${bindingId}`))
assert.equal((await itemsFor(a, message.id)).length, 0)
await b.federation.memberRequest(b.ctx, bindingId, "messages", { before: message.seq + 1 })
assert.equal((await itemsFor(b, message.id)).length, 3)
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 3)
})
await t.test("remote replies notify the owner's local members, never the sender or retry twice", async () => {
const input = { clientId: randomUUID(), text: "Hallo Alpha" }
const reply = (await b.federation.memberRequest(b.ctx, bindingId, "send", input)).result
await b.federation.memberRequest(b.ctx, bindingId, "send", input)
await b.federation.memberRequest(b.ctx, bindingId, "messages")
assert.equal((await itemsFor(a, reply.id)).length, 3)
assert.equal((await itemsFor(b, reply.id)).length, 0)
assert.ok((await itemsFor(a, reply.id)).every(i => Number(i.tenant_id) === 1 && i.user_id === a.ctx.userId))
await a.notifications.tick()
})
await t.test("reading before delivery suppresses email and push, and new preferences take effect", async () => {
const read = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Already read" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
await b.federation.memberRequest(b.ctx, bindingId, "read", { seq: read.seq })
await b.notifications.tick()
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === read.id).map(i => i.channel), ["inapp"])
const muted = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Muted before delivery" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
await enable(b, 1, false)
await b.notifications.tick()
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === muted.id).map(i => i.channel), ["inapp"])
await enable(b)
})
await t.test("offline jobs survive a worker restart and concurrent workers deliver each item once", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Wait for access confirmation" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
offline = true
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
assert.ok((await itemsFor(b, message.id)).every(i => i.status === "queued"))
offline = false
const restarted = new CollaborationNotifications(b.app, b.service, b.federation)
restarted.delivery = b.notifications.delivery
await Promise.all([b.notifications.tick(), restarted.tick()])
const delivered = b.delivered.filter(i => i.payload.messageId === message.id)
assert.equal(delivered.length, 3)
assert.equal(new Set(delivered.map(i => i.id)).size, 3)
})
await t.test("revoked access cancels queued remote messages and reinvitation cannot replay old ones", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Revoked before delivery" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id)
await a.service.revoke(a.ctx, room.id, member.id)
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
assert.equal((await itemsFor(b, message.id)).length, 0)
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
await a.federation.deliver(await jobFor(invitation.id))
await b.federation.accept(b.ctx, bindingId)
await b.notifications.tick()
assert.equal((await itemsFor(b, message.id)).length, 0)
})
await t.test("local cross-tenant invitations and messages use recipient scope and honor expiry", async () => {
const local = await a.service.createRoom(a.ctx, { name: "Tenant exchange" })
const invitation = await a.service.invite(a.ctx, local.id, a.other.principal, {})
const invites = (await a.items()).filter(i => i.payload.invitationId === invitation.id)
assert.equal(invites.length, 2)
assert.ok(invites.every(i => Number(i.tenant_id) === 2))
await a.service.accept(a.other.principal, invitation.id)
const first = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Only tenant two" })
assert.deepEqual((await itemsFor(a, first.id)).map(i => [Number(i.tenant_id), i.channel]), [[2, "inapp"]])
await a.pool.query("UPDATE collaboration_members SET expires_at=now()-interval '1 second' WHERE room_id=$1 AND principal_id=$2", [local.id, a.other.principal.id])
await a.notifications.tick()
assert.equal(a.delivered.filter(i => i.payload.messageId === first.id).length, 0)
const second = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Expired member" })
assert.equal((await itemsFor(a, second.id)).length, 0)
})
await t.test("message and notification records roll back together", async () => {
const hook = a.notifications.localMessage.bind(a.notifications)
a.notifications.localMessage = async (...args) => { await hook(...args); throw new Error("Rollback after enqueue") }
const clientId = randomUUID(), count = (await a.items()).length
try {
await assert.rejects(b.federation.call(1, a.federation.origin, "messages.send", { actorId: b.ctx.principal.id, roomId: room.id,
input: { clientId, text: "Must roll back", revision: Number((await b.federation.binding(b.ctx, bindingId)).revision) } }), /Rollback/)
} finally { a.notifications.localMessage = hook }
assert.equal((await a.items()).length, count)
assert.equal((await a.pool.query("SELECT id FROM collaboration_messages WHERE client_id=$1", [clientId])).rowCount, 0)
})
} finally {
await a.app.close(); await b.app.close()
await a.close(); await b.close()
}
})

View File

@@ -328,18 +328,21 @@ Datenbank und `ENCRYPTION_KEY` gemeinsam sichern; der private Instanzschlüssel
### Prüfung und verbleibender Ausbau
Geprüft wurden 21 Tests ohne Überspringen auf einer isolierten PostgreSQL-17-Testdatenbank. Darunter sind die Absicherung bestehender Chatgrenzen, die Trennung von Gast- und regulären Sitzungen, benachbarte Unterräume, der Historienbeginn, abgelaufene und widerrufene Einladungen sowie zwei unabhängige Instanzdatenbanken mit echten Fastify-Routen und Signaturprüfung. Der Transport zwischen diesen Instanzen ist im Test simuliert: Verbindungsfehler, verlorene Bestätigungen, doppelte Zustellung, veraltete Teilnahmeversionen, verspätete Widerrufe und erneute Einladungen werden gezielt erzeugt. HTTPS/DNS-Einschränkungen und Signaturmanipulationen besitzen zusätzliche Tests. Eine Verbindung zwischen zwei produktiven HTTPS-Installationen wurde nicht ausgeführt.
Geprüft wurden 50 Tests ohne Überspringen auf einer isolierten PostgreSQL-17-Testdatenbank. Darunter sind die Absicherung bestehender Chatgrenzen, die Trennung von Gast- und regulären Sitzungen, benachbarte Unterräume, der Historienbeginn, abgelaufene und widerrufene Einladungen sowie zwei unabhängige Instanzdatenbanken mit echten Fastify-Routen und Signaturprüfung. Die Benachrichtigungstests prüfen zusätzlich Empfängermandanten, persönliche Kanalwahl, Lesestatus, wiederholte Synchronisation, konkurrierende Worker, Neustarts und gemeinsame Transaktions-Rollbacks. Der Transport zwischen diesen Instanzen und die externe Benachrichtigungszustellung sind im Test simuliert: Verbindungsfehler, verlorene Bestätigungen, doppelte Zustellung, veraltete Teilnahmeversionen, verspätete Widerrufe und erneute Einladungen werden gezielt erzeugt. HTTPS/DNS-Einschränkungen und Signaturmanipulationen besitzen zusätzliche Tests. Eine Verbindung zwischen zwei produktiven HTTPS-Installationen wurde nicht ausgeführt.
Reproduzierbar aus `backend/` mit einer ausdrücklich bereitgestellten, entbehrlichen Datenbank namens `collaboration_test`:
```sh
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts \
tests/collaborationNotifications.test.ts tests/notificationPreferences.test.ts
```
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Die spätere Erweiterung um Emojis, Bilder und Reaktionen wurde zusätzlich mit einer lokalen Browser-Vorschau der tatsächlichen Vue-Komponenten und simulierten API-Antworten geprüft. Die automatisierten Medientests prüfen unter anderem Bildvalidierung, Bildnachrichten ohne Text, Reaktionszähler, Historiengrenzen, Gastrechte, deaktivierte Projektchats und signierte Zustellung zwischen zwei Instanzen.
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Push-Benachrichtigungen, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.
Nachrichten und persönliche Einladungen aus der Zusammenarbeit unterstützen jetzt In-App-, E-Mail- und Push-Benachrichtigungen. Die Einstellungen gelten im Heimatmandanten des Empfängers. Die Migration `0083_collaboration_notifications.sql` muss dafür eingespielt sein. Der Versand erfolgt nach dem Speichern über den Hintergrundworker und prüft die aktuellen Zugriffsrechte; wiederholte Synchronisationen erzeugen keine doppelten Benachrichtigungen. Details und Standardwerte stehen in [Persönliche Benachrichtigungen](notification-preferences.md). Die Zustellung auf realen Endgeräten hängt weiterhin von der jeweiligen Push-Konfiguration ab.
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.
Die automatische Einrichtung wurde zusätzlich mit sechs Konfigurationstests und 58 Updater-Tests geprüft. Die Update-Regressionen verwenden echte Compose-Auswertung mit simuliertem Containerbetrieb und prüfen sowohl Installationen ohne Föderationsvariablen als auch den Erhalt einer ausdrücklichen Abschaltung und eigener URL-Vorgaben.

View File

@@ -10,16 +10,23 @@ Unter Einstellungen → Benachrichtigungen (`/settings/notifications`) im Web un
| `communication.message.mentioned` | Persönliche @-Erwähnung im Raum oder Projektchat | aus | an |
| `communication.message.room` | Sonstige Nachricht in einem Raum oder Projektchat mit Mitgliedschaft | aus | aus |
| `communication.room.invited` | Aufnahme in einen neu angelegten allgemeinen Chatraum | aus | aus |
| `collaboration.message.new` | Neue Nachricht in einem freigegebenen Unterraum oder föderierten Chat, ohne Absender | aus | aus |
| `collaboration.room.invited` | Persönliche Einladung in einen Unterraum oder föderierten Chat | aus | an |
Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume; föderierte Zusammenarbeit wird hier noch nicht erfasst.
Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume. Die beiden Ereignisse unter „Zusammenarbeit“ gelten für lokale Unterräume, mandantenübergreifende Teilnahmen und föderierte Chats. Sie erscheinen automatisch in den Einstellungen im Web und in der App.
Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten und Erwähnungen aktiv; die neuen optionalen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix.
Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten, Erwähnungen und die Zusammenarbeit aktiv; die optionalen nativen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix.
Für die Zusammenarbeit zählt immer der Heimatmandant des empfangenden Benutzers, auch wenn der Raum einem anderen Mandanten oder einer Partnerinstanz gehört. Benachrichtigt werden nur aktive, berechtigte FEDEO-Benutzer. Gäste ohne Benutzerkonto behalten ihren gesonderten Einladungsversand und ihre Anmeldung per E-Mail-Code.
Nachrichten und Einladungen werden mit ihren Benachrichtigungen atomar gespeichert. Ein Worker verarbeitet die vorgemerkten Kanäle alle fünf Sekunden unabhängig von einem geöffneten Browser. Wiederholte Nachrichtenbefehle, Einladungslieferungen und Synchronisationen erzeugen keine zusätzlichen Einträge; eigene Nachrichten und rückwärts nachgeladener Verlauf lösen keine Benachrichtigung aus. Bei Partnerausfällen wartet der Versand auf eine erneute Zugriffsprüfung. Widerrufene oder abgelaufene Teilnahmen und erledigte Einladungen werden vor dem Versand verworfen. Bereits gelesene Nachrichten lösen keine ausstehenden E-Mails oder Pushs mehr aus. Die persönlichen Einstellungen werden vor dem Versand erneut geprüft. Fehler des eigentlichen E-Mail-/Push-Zustellers werden wie bisher als fehlgeschlagen gespeichert; dafür gibt es noch keinen automatischen Wiederholungsversand.
## API und Migration
- `GET /api/notifications/preferences`: wirksame Einstellungen für den Benutzer und Tenant der authentifizierten Session.
- `PUT /api/notifications/preferences`: Array aus `{eventType, channel, enabled}`; Kanäle `email` und `push`. Unbekannte Ereignisse, nicht erlaubte Kanäle, nicht-boolsche Werte und Duplikate werden mit 400 abgewiesen. Änderungen werden atomar gespeichert. Benutzer- oder Tenant-IDs im Body ändern den Geltungsbereich nicht.
- Migration `0079_notification_matrix` registriert die neuen Ereignisse und übernimmt persönliche Einstellungen und Tenant-Standards von `communication.message.new` für Direktnachrichten und Erwähnungen. Sie deaktiviert den alten Sammelauslöser sowie die bisher nicht angebundenen Anrufereignisse. Historische Benachrichtigungen bleiben erhalten.
- Migration `0083_collaboration_notifications` registriert die beiden Zusammenarbeitsereignisse und indiziert deren wartende Zustellungen. Es werden keine historischen lokalen Nachrichten oder Einladungen nachträglich verschickt. Bei der nächsten Synchronisation können bisher noch nicht abgeholte, ungelesene Partnernachrichten Benachrichtigungen auslösen.
- E-Mail verwendet den SMTP-Versand der Instanz, mobiles Push den zentralen Service, Browser-Push die Web-Push-Konfiguration der Instanz. Ein aktivierter Kanal setzt eine funktionierende Zustellkonfiguration voraus.
## Sinnvolle nächste Auslöser

View File

@@ -1,9 +1,11 @@
<script setup>
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
const emit = defineEmits(['changed', 'unauthorized'])
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('')
const room = ref(null), messages = ref([]), members = ref([]), error = ref('')
// Guest access has separate credentials and must never reuse an internal user's drafts.
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => `collaboration:${props.roomId}`, { persist: () => props.base === '/api/collaboration' })
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
let generation = 0, timer, polling = false, pending = null
let generation = 0, timer, polling = false
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
const merge = (incoming) => {
const byId = new Map(messages.value.map(m => [m.id, m]))
@@ -20,7 +22,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
function failed(e) {
error.value = messageError(e)
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
room.value = null; messages.value = []; members.value = []; draftImage.value = null
room.value = null; messages.value = []; members.value = []
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
}
}
@@ -32,7 +34,7 @@ async function read(id, version) {
}
async function load() {
const version = ++generation, id = props.roomId
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
room.value = null; messages.value = []; members.value = []; error.value = ''
loading.value = true; sending.value = false; olderLoading.value = false
try {
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
@@ -78,15 +80,18 @@ async function send() {
const text = draft.value.trim(), image = draftImage.value
if ((!text && !image) || sending.value || !room.value?.canSend) return
const version = generation, id = props.roomId
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() }
const savedDraft = startDraftSend()
if (!savedDraft) return
const pending = { text, image, clientId: savedDraft.value.clientId }
sending.value = true; error.value = ''
try {
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
clearDraft(savedDraft)
if (version !== generation) return
merge([message]); draft.value = ''; draftImage.value = null; pending = null
merge([message])
await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) }
finally { if (version === generation) sending.value = false }
finally { finishDraftSend(savedDraft); if (version === generation) sending.value = false }
}
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
watch(() => props.roomId, load, { immediate: true })
@@ -126,6 +131,6 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
</div>
</ChatReactions>
</div>
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
</section>
</template>

View File

@@ -0,0 +1,43 @@
export function useChatDraft(roomKey, { persist = true } = {}) {
const auth = useAuthStore()
const temp = useTempStore()
const apiBase = useRuntimeConfig().public.apiBase
const localDraft = ref(null)
const key = computed(() => {
const room = toValue(roomKey), user = auth.user?.id || auth.user?.user_id
return toValue(persist) && room && user && auth.activeTenant
? JSON.stringify([apiBase, user, String(auth.activeTenant), auth.profile?.id || '', room])
: null
})
watch([key, () => toValue(roomKey), () => auth.user?.id || auth.user?.user_id,
() => auth.activeTenant, () => auth.profile?.id], () => { localDraft.value = null }, { flush: 'sync' })
const current = () => key.value ? temp.getChatDraft(key.value) : localDraft.value
function update(patch) {
const value = { text: '', image: null, ...current(), ...patch, clientId: crypto.randomUUID() }
const next = value.text || value.image ? value : null
if (key.value) temp.setChatDraft(key.value, next)
else localDraft.value = next
}
const draft = computed({ get: () => current()?.text || '', set: text => update({ text }) })
const draftImage = computed({ get: () => current()?.image || null, set: image => update({ image }) })
const draftSending = computed(() => !!key.value && temp.isChatDraftSending(key.value))
const captureDraft = () => ({ key: key.value, value: current() })
function startDraftSend() {
if (draftSending.value) return null
const snapshot = captureDraft()
if (!snapshot.value) return null
if (snapshot.key) temp.startChatDraftSend(snapshot.key, snapshot.value.clientId)
return snapshot
}
function finishDraftSend(snapshot) {
if (snapshot.key) temp.finishChatDraftSend(snapshot.key, snapshot.value.clientId)
}
function clearDraft(snapshot) {
const saved = snapshot.key ? temp.getChatDraft(snapshot.key) : localDraft.value
// A late send response must only remove the draft that was actually sent.
if (!snapshot.value || saved?.clientId !== snapshot.value.clientId) return
if (snapshot.key) temp.setChatDraft(snapshot.key, null)
else localDraft.value = null
}
return { draft, draftImage, draftSending, captureDraft, clearDraft, startDraftSend, finishDraftSend }
}

View File

@@ -7,12 +7,13 @@ const router = useRouter()
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null)
const unreadRooms = ref({}), messages = ref([]), members = ref([])
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
const activeRoomKey = ref(requestedRoom())
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
const showRoomForm = ref(false), messagesViewport = ref(null)
const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('')
const roomSearch = ref('')
const preferencesReady = ref(false)
const roomForm = reactive({ name: '', key: '', topic: '' })
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
@@ -42,14 +43,24 @@ const decoratedRooms = computed(() => [
])
const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key)))
const hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key)))
const normalizeRoomSearch = value => String(value || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss')
const roomSearchTerms = computed(() => normalizeRoomSearch(roomSearch.value).trim().split(/\s+/).filter(Boolean))
function matchesRoomSearch(room) {
const text = normalizeRoomSearch([room.name, room.topic, room.projectNumber, room.email, room.ownerName].filter(value => value != null).join(' '))
return roomSearchTerms.value.every(term => text.includes(term))
}
// Filter only the sidebar; the open conversation and its draft keep their selection.
const filteredRooms = computed(() => visibleRooms.value.filter(matchesRoomSearch))
const filteredHiddenRooms = computed(() => hiddenRooms.value.filter(matchesRoomSearch))
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
.map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) }))
.map(label => ({ label, rooms: filteredRooms.value.filter(room => room.group === label) }))
.filter(group => group.rooms.length))
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
const isInternal = computed(() => activeRoom.value?.source === 'internal')
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => isInternal.value ? activeRoomKey.value : '')
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function clearMessages() { messages.value = []; members.value = []; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function mergeMessages(incoming) {
const byId = new Map(messages.value.map(message => [message.id, message]))
for (const message of incoming || []) byId.set(message.id, message)
@@ -210,14 +221,17 @@ async function respondToInvitation(id, accept) {
async function sendMessage() {
const text = draft.value.trim(), image = draftImage.value, request = selection()
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
sending.value = true; draft.value = ''
const savedDraft = startDraftSend()
if (!savedDraft) return
sending.value = true
try {
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
clearDraft(savedDraft)
if (!currentSelection(request)) return
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request)
mergeMessages([message]); await markRead(request); await scrollToBottom(request)
} catch (error) {
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
} finally { if (currentSelection(request)) sending.value = false }
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
} finally { finishDraftSend(savedDraft); if (currentSelection(request)) sending.value = false }
}
async function pollMessages() {
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
@@ -242,6 +256,7 @@ async function initialize() {
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false
roomSearch.value = ''
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
activeRoomKey.value = requestedRoom(); loading.value = true
await Promise.all([refreshLists(), loadRoomPreferences(scope)])
@@ -287,6 +302,12 @@ onBeforeUnmount(() => {
<UButton icon="i-heroicons-plus" color="neutral" variant="ghost" size="sm" @click="showRoomForm = !showRoomForm" />
</div>
<UInput v-model="roomSearch" icon="i-heroicons-magnifying-glass" placeholder="Chats suchen …" aria-label="Chats suchen" class="mb-4 w-full" @keydown.esc="roomSearch = ''">
<template v-if="roomSearch" #trailing>
<UButton icon="i-heroicons-x-mark" color="neutral" variant="link" size="xs" aria-label="Chatsuche löschen" @click="roomSearch = ''" />
</template>
</UInput>
<form v-if="showRoomForm" class="mb-4 space-y-2 rounded-lg bg-gray-50 p-3" @submit.prevent="createRoom">
<UInput v-model="roomForm.name" placeholder="Raumname" autofocus />
<UInput v-model="roomForm.key" :placeholder="roomKeyPreview || 'raumschluessel'" />
@@ -339,13 +360,14 @@ onBeforeUnmount(() => {
<UButton icon="i-heroicons-eye-slash" color="neutral" variant="ghost" size="xs" class="mr-1 shrink-0" :aria-label="`${room.name} ausblenden`" title="Für mich ausblenden" :disabled="!preferencesReady || !!preferenceBusy" @click="setRoomHidden(room, true)" />
</div>
</section>
<p v-if="!visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
<section v-if="hiddenRooms.length" aria-label="Ausgeblendete Räume">
<p v-if="roomSearchTerms.length && !filteredRooms.length && !filteredHiddenRooms.length" role="status" class="px-2 text-sm text-gray-500">Keine Chats für diese Suche gefunden.</p>
<p v-else-if="!roomSearchTerms.length && !visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
<section v-if="filteredHiddenRooms.length" aria-label="Ausgeblendete Räume">
<button type="button" class="flex w-full items-center justify-between px-2 py-2 text-left text-xs font-semibold text-gray-500" :aria-expanded="showHiddenRooms" @click="showHiddenRooms = !showHiddenRooms">
<span>Ausgeblendete Räume ({{ hiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
<span>Ausgeblendete Räume ({{ filteredHiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
</button>
<div v-if="showHiddenRooms" class="space-y-1">
<div v-for="room in hiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
<div v-for="room in filteredHiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
<span class="min-w-0 flex-1 truncate text-sm text-gray-500">{{ room.name }}</span>
<UButton icon="i-heroicons-eye" variant="ghost" color="neutral" size="xs" :aria-label="`${room.name} einblenden`" :disabled="!!preferenceBusy" @click="setRoomHidden(room, false)">Einblenden</UButton>
</div>
@@ -386,7 +408,7 @@ onBeforeUnmount(() => {
</ChatReactions>
</div>
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
</main>
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">

View File

@@ -10,6 +10,71 @@ export const useTempStore = defineStore('temp', () => {
const pages = ref({})
const settings = ref({})
// Drafts stay local: temp_config is also returned in tenant profile lists.
const chatDrafts = ref({})
const sendingChatDrafts = ref({})
const loadedChatDrafts = new Set()
const draftStoragePrefix = 'fedeo:chat-draft:v1:'
const toast = useToast()
let draftStorageWarningShown = false
function getChatDraft(key) {
if (!loadedChatDrafts.has(key)) {
loadedChatDrafts.add(key)
try {
const saved = JSON.parse(sessionStorage.getItem(draftStoragePrefix + key) || 'null')
if (saved && typeof saved.text === 'string' && typeof saved.clientId === 'string'
&& saved.text.length <= 10000 && (!saved.image || (
typeof saved.image.name === 'string' && typeof saved.image.dataUrl === 'string'
&& saved.image.dataUrl.length <= 480000
&& /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/.test(saved.image.dataUrl)
))) chatDrafts.value[key] = saved
} catch { /* Disabled storage or an invalid entry must not block the chat. */ }
}
return chatDrafts.value[key] || null
}
function setChatDraft(key, draft) {
loadedChatDrafts.add(key)
if (draft) chatDrafts.value[key] = draft
else delete chatDrafts.value[key]
try {
if (draft) sessionStorage.setItem(draftStoragePrefix + key, JSON.stringify(draft))
else sessionStorage.removeItem(draftStoragePrefix + key)
} catch {
// Do not restore an older version after a failed storage update.
try { sessionStorage.removeItem(draftStoragePrefix + key) } catch {}
if (!draftStorageWarningShown) {
draftStorageWarningShown = true
toast.add({ title: 'Entwurf nur vorübergehend gespeichert', description: 'Der Browserspeicher ist voll oder gesperrt. Bis zum Neuladen bleibt dein Entwurf im Chat erhalten.', color: 'warning' })
}
}
}
function isChatDraftSending(key) {
return !!sendingChatDrafts.value[key]
}
function startChatDraftSend(key, clientId) {
sendingChatDrafts.value[key] = clientId
}
function finishChatDraftSend(key, clientId) {
if (sendingChatDrafts.value[key] === clientId) delete sendingChatDrafts.value[key]
}
watch(() => auth.user?.id || auth.user?.user_id, (user, previous) => {
if (!previous || user === previous) return
chatDrafts.value = {}
sendingChatDrafts.value = {}
loadedChatDrafts.clear()
try {
for (const key of Object.keys(sessionStorage)) {
if (key.startsWith(draftStoragePrefix)) sessionStorage.removeItem(key)
}
} catch { /* Storage may be unavailable. In-memory drafts are still cleared. */ }
}, { flush: 'sync' })
const storeTempConfig = async () => {
const config = {
searchStrings: searchStrings.value,
@@ -92,6 +157,11 @@ export const useTempStore = defineStore('temp', () => {
}
return {
getChatDraft,
setChatDraft,
isChatDraftSending,
startChatDraftSend,
finishChatDraftSend,
setStoredTempConfig,
searchStrings,
modifySearchString,

View File

@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
const settle = () => new Promise(setImmediate)
const deferred = () => {
@@ -33,6 +34,7 @@ function fixture(override = () => undefined) {
function setup(api = fixture(), query = {}) {
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
const drafts = createDraftHarness({ auth })
const route = vue.reactive({ query })
const calls = [], toasts = [], timers = new Map()
const listeners = new Map()
@@ -47,6 +49,7 @@ function setup(api = fixture(), query = {}) {
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
useToast: () => ({ add: item => toasts.push(item) }),
useAuthStore: () => auth, useRoute: () => route,
useChatDraft: drafts.useChatDraft,
useRouter: () => ({ replace: async location => { route.query = location.query } }),
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
@@ -54,7 +57,7 @@ function setup(api = fixture(), query = {}) {
clearInterval: id => timers.delete(id), document,
})
scope.run(() => vm.runInContext(source, context))
return { auth, route, calls, toasts, timers, listeners, document, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
return { auth, route, calls, toasts, timers, listeners, document, mount, drafts, close: () => { unmount(); scope.stop(); drafts.close() }, run: code => vm.runInContext(code, context) }
}
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
@@ -69,6 +72,34 @@ test('only member rooms enter normal chat, with separate keys, groups and unread
assert.equal(ui.run('decoratedRooms.value.some(r => r.id === "admin-only")'), false)
})
test('searching the sidebar preserves the open conversation and draft, including hidden-room matches', async t => {
const ui = setup(fixture(url => {
if (url.endsWith('/project-rooms')) return { rooms: [{ key: 'project_1', name: 'Büro Süd', projectNumber: 'P-2026-42', exists: true, type: 'project' }] }
if (url.endsWith('/direct-rooms')) return { rooms: [{ key: 'direct_1', name: 'Anna Müller', email: 'anna@example.test', exists: true, type: 'direct' }] }
}))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = "Noch nicht gesendet"; roomSearch.value = " BÜRO 2026-42 "')
assert.equal(ui.run('groupedRooms.value.length'), 1)
assert.equal(ui.run('groupedRooms.value[0].rooms[0].key'), 'project_1')
ui.run('roomSearch.value = "muller ANNA@"')
assert.equal(ui.run('groupedRooms.value[0].rooms[0].key'), 'direct_1')
await ui.run('setRoomHidden(decoratedRooms.value.find(room => room.key === "direct_1"), true)')
assert.equal(ui.run('groupedRooms.value.length'), 0)
assert.equal(ui.run('filteredHiddenRooms.value[0].key'), 'direct_1')
ui.run('roomSearch.value = "kein Treffer"')
await settle()
assert.equal(ui.run('filteredRooms.value.length'), 0)
assert.equal(ui.run('filteredHiddenRooms.value.length'), 0)
assert.equal(ui.run('activeRoom.value.key'), 'allgemein')
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
assert.equal(ui.run('draft.value'), 'Noch nicht gesendet')
ui.run('roomSearch.value = ""')
assert.equal(ui.run('groupedRooms.value.length'), 3)
assert.equal(ui.run('hiddenRooms.value[0].key'), 'direct_1')
})
test('opening a collaboration deep link never provisions, reads or sends through native chat APIs', async t => {
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
t.after(ui.close)
@@ -377,3 +408,69 @@ test('native image-only sends retain the draft after an error and clear it after
assert.equal(ui.run('draftImage.value'), null)
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
})
test('native drafts survive switching chats, hiding a room and reloading its history', async t => {
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: [{ key: 'project_1', exists: true, type: 'project' }] } : undefined))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = " Hallo\\nWelt 👋 "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Skizze" }')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "project_1"))')
assert.equal(ui.run('draft.value'), '')
assert.equal(ui.run('draftImage.value'), null)
ui.run('draft.value = "Projektentwurf"')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
assert.equal(ui.run('draftImage.value.name'), 'Skizze')
await ui.run('loadActiveRoom()')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
await ui.run('setRoomHidden(activeRoom.value, true)')
assert.equal(ui.run('draft.value'), 'Projektentwurf')
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
})
test('failed sends retain the exact draft after switching away, and late success clears only the sent room', async t => {
const pending = deferred()
let fail = true
const ui = setup(fixture((url, options) => {
if (url.endsWith('/messages') && options?.method === 'POST') {
if (fail) throw { data: { message: 'Offline' } }
return pending.promise
}
}))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = " Noch nicht fertig\\n "')
await ui.run('sendMessage()')
assert.equal(ui.run('draft.value'), ' Noch nicht fertig\n ')
fail = false
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
pending.resolve({ id: 2, body: 'Noch nicht fertig' })
await sending
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draft.value'), '')
assert.equal(Object.keys(ui.drafts.storage).length, 0)
})
test('editing a room after returning to a pending send preserves the new draft', async t => {
const pending = deferred()
const ui = setup(fixture((url, options) => url.endsWith('/messages') && options?.method === 'POST' ? pending.promise : undefined))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = "Vorheriger Entwurf"')
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draftSending.value'), true)
await ui.run('sendMessage()')
assert.equal(ui.calls.filter(({ url, options }) => url.endsWith('/messages') && options?.method === 'POST').length, 1)
ui.run('draft.value = "Weitergeschrieben"')
pending.resolve({ id: 2, body: 'Vorheriger Entwurf' })
await sending
assert.equal(ui.run('draft.value'), 'Weitergeschrieben')
assert.equal(ui.run('draftSending.value'), false)
})

View File

@@ -0,0 +1,123 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const vue = require('vue')
const { createDraftHarness, memoryStorage } = require('./helpers/chat-drafts.cjs')
const image = { dataUrl: 'data:image/webp;base64,AAAA', name: 'Skizze.webp' }
test('text and image drafts survive room changes and a browser reload in the same session', t => {
const storage = memoryStorage(), first = createDraftHarness({ storage })
t.after(first.close)
const room = vue.ref('allgemein'), composer = first.useChatDraft(room)
composer.draft.value = ' Hallo\nWelt 👋 '
composer.draftImage.value = image
room.value = 'project_1'
assert.equal(composer.draft.value, '')
assert.equal(composer.draftImage.value, null)
composer.draft.value = 'Projektentwurf'
room.value = 'allgemein'
assert.equal(composer.draft.value, ' Hallo\nWelt 👋 ')
assert.equal(composer.draftImage.value.name, image.name)
const reloaded = createDraftHarness({ storage })
t.after(reloaded.close)
const restored = reloaded.useChatDraft('allgemein')
assert.equal(restored.draft.value, composer.draft.value)
assert.equal(restored.draftImage.value.dataUrl, image.dataUrl)
assert.equal(restored.captureDraft().value.clientId, composer.captureDraft().value.clientId)
assert.equal(reloaded.useChatDraft('project_1').draft.value, 'Projektentwurf')
assert.equal(first.calls.length, 0)
})
test('tenant and profile scopes cannot read each other; logout removes the session drafts', t => {
const ui = createDraftHarness()
t.after(ui.close)
const composer = ui.useChatDraft('allgemein')
composer.draft.value = 'Privater Entwurf'
ui.auth.activeTenant = '2'
assert.equal(composer.draft.value, '')
composer.draft.value = 'Mandant 2'
ui.auth.activeTenant = '1'
assert.equal(composer.draft.value, 'Privater Entwurf')
ui.auth.profile = { id: 'other-profile' }
assert.equal(composer.draft.value, '')
ui.auth.profile = { id: 'profile-1' }
assert.equal(composer.draft.value, 'Privater Entwurf')
ui.auth.user = { id: 'another-user' }
assert.equal(composer.draft.value, '')
assert.equal(Object.keys(ui.storage).length, 0)
composer.draft.value = 'Neuer Benutzer'
ui.auth.user = null
assert.equal(composer.draft.value, '')
assert.equal(Object.keys(ui.storage).length, 0)
})
test('successful sends remove only the captured version, even after changing rooms', t => {
const ui = createDraftHarness()
t.after(ui.close)
const room = vue.ref('allgemein'), composer = ui.useChatDraft(room)
composer.draft.value = 'Gesendet'
const sent = composer.captureDraft()
room.value = 'project_1'
composer.draft.value = 'Noch nicht gesendet'
composer.clearDraft(sent)
assert.equal(composer.draft.value, 'Noch nicht gesendet')
room.value = 'allgemein'
assert.equal(composer.draft.value, '')
composer.draftImage.value = image
const older = composer.captureDraft()
composer.draft.value = 'Neuer Text zum Bild'
composer.clearDraft(older)
assert.equal(composer.draft.value, 'Neuer Text zum Bild')
assert.equal(composer.draftImage.value.name, image.name)
composer.clearDraft(composer.captureDraft())
assert.equal(composer.draft.value, '')
assert.equal(composer.draftImage.value, null)
})
test('empty drafts are removed and never enter the shared profile temp_config', async t => {
const ui = createDraftHarness()
t.after(ui.close)
const composer = ui.useChatDraft('direct_user-2')
composer.draft.value = 'Geheim'
ui.store.modifySearchString('contacts', 'Beispiel')
assert.equal(JSON.stringify(ui.calls).includes('Geheim'), false)
ui.store.setStoredTempConfig({ settings: { density: 'compact' } })
assert.equal(composer.draft.value, 'Geheim')
composer.draft.value = ''
assert.equal(Object.keys(ui.storage).length, 0)
})
test('guest conversations never reuse internal drafts and keep temporary text through a retry', t => {
const ui = createDraftHarness()
t.after(ui.close)
ui.useChatDraft('collaboration:one').draft.value = 'Interner Entwurf'
const room = vue.ref('collaboration:one'), guest = ui.useChatDraft(room, { persist: false })
assert.equal(guest.draft.value, '')
guest.draft.value = 'Gastentwurf'
assert.equal(guest.draft.value, 'Gastentwurf')
room.value = 'collaboration:two'
assert.equal(guest.draft.value, '')
assert.equal(Object.values(ui.storage).some(value => value.includes('Gastentwurf')), false)
})
test('corrupt or unavailable browser storage does not prevent writing or sending', t => {
const storage = memoryStorage(), initial = createDraftHarness({ storage })
t.after(initial.close)
initial.useChatDraft('allgemein').draft.value = 'Alt'
storage[Object.keys(storage)[0]] = 'invalid json'
const corrupt = createDraftHarness({ storage })
t.after(corrupt.close)
assert.equal(corrupt.useChatDraft('allgemein').draft.value, '')
const blocked = createDraftHarness({ storage: {
getItem() { throw Error('Blocked') }, setItem() { throw Error('Quota exceeded') }, removeItem() { throw Error('Blocked') },
} })
t.after(blocked.close)
const composer = blocked.useChatDraft('allgemein')
composer.draft.value = 'Im Speicher'
composer.draftImage.value = image
assert.equal(composer.draft.value, 'Im Speicher')
assert.equal(composer.draftImage.value.name, image.name)
assert.equal(blocked.toasts.length, 1)
composer.clearDraft(composer.captureDraft())
assert.equal(composer.draft.value, '')
})

View File

@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
const settle = () => new Promise(setImmediate)
const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } }
@@ -89,3 +90,62 @@ test('image display rejects external URLs and ignores responses after a room cha
await ui.run('load()')
assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA')
})
function collaborationApi(onSend) {
return async (url, options) => {
if (options?.method === 'POST' && url.endsWith('/messages')) return onSend(options.body)
if (url.endsWith('/messages')) return { messages: [], hasMore: false }
if (url.endsWith('/members')) return { members: [] }
if (url.endsWith('/read')) return {}
return { canSend: true, name: 'Partnerchat' }
}
}
test('collaboration drafts retain text, images and the retry ID when reopening after a failed send', async t => {
const drafts = createDraftHarness(), attempts = []
t.after(drafts.close)
let fail = true
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(body => {
attempts.push(body)
if (fail) throw { data: { message: 'Offline' } }
return { id: 'message-1', seq: 1, own: true, clientId: body.clientId, body: body.text }
}) }
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
await settle()
first.run('draft.value = " Partnerentwurf\\n "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Plan" }')
await first.run('send()')
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
assert.equal(first.run('draftImage.value.name'), 'Plan')
await first.run('load()')
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
first.close()
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
t.after(second.close)
await settle()
assert.equal(second.run('draft.value'), ' Partnerentwurf\n ')
fail = false
await second.run('send()')
assert.equal(attempts[0].clientId, attempts[1].clientId)
assert.equal(second.run('draft.value'), '')
assert.equal(second.run('draftImage.value'), null)
assert.equal(Object.keys(drafts.storage).length, 0)
})
test('collaboration send completion after unmount does not erase a newer draft in the same room', async t => {
const drafts = createDraftHarness(), pending = deferred()
t.after(drafts.close)
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(() => pending.promise) }
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
await settle()
first.run('draft.value = "Gesendet"')
const sending = first.run('send()')
first.close()
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
t.after(second.close)
await settle()
second.run('draft.value = "Neuer Entwurf"')
pending.resolve({ id: 'message-1', seq: 1, body: 'Gesendet' })
await sending
assert.equal(second.run('draft.value'), 'Neuer Entwurf')
assert.equal(second.run('messages.value.length'), 0)
})

View File

@@ -0,0 +1,36 @@
const fs = require('node:fs')
const vm = require('node:vm')
const { randomUUID } = require('node:crypto')
const vue = require('vue')
function memoryStorage() {
const storage = Object.create(null)
Object.defineProperties(storage, {
getItem: { value: key => storage[key] ?? null },
setItem: { value: (key, value) => { storage[key] = value } },
removeItem: { value: key => { delete storage[key] } },
})
return storage
}
function createDraftHarness({ auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: { id: 'profile-1' } }), storage = memoryStorage() } = {}) {
const scope = vue.effectScope(), toasts = [], calls = []
const context = vm.createContext({ ...vue, crypto: { randomUUID }, sessionStorage: storage,
defineStore: (_, setup) => setup,
useAuthStore: () => auth,
useRuntimeConfig: () => ({ public: { apiBase: 'https://api.example.test' } }),
useToast: () => ({ add: toast => toasts.push(toast) }),
useNuxtApp: () => ({ $api: async (url, options) => { calls.push({ url, options }) } }),
})
const storeSource = fs.readFileSync(`${__dirname}/../../stores/temp.js`, 'utf8').replace(/^import .*$/mg, '').replace('export const', 'const')
const store = scope.run(() => vm.runInContext(`${storeSource}\nuseTempStore()`, context))
context.useTempStore = () => store
const composable = fs.readFileSync(`${__dirname}/../../composables/useChatDraft.js`, 'utf8').replace('export function', 'function')
vm.runInContext(composable, context)
return { auth, storage, store, calls, toasts,
useChatDraft: (key, options) => scope.run(() => context.useChatDraft(key, options)),
close: () => scope.stop(),
}
}
module.exports = { createDraftHarness, memoryStorage }