feat(notifications): include federated chat messages and invitations

This commit is contained in:
2026-10-15 17:50:38 +02:00
parent 697f538b6e
commit 7b61857fb8
11 changed files with 413 additions and 16 deletions

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 }