feat(notifications): include federated chat messages and invitations
This commit is contained in:
@@ -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 = {}) {
|
||||
|
||||
139
backend/src/modules/collaboration/notifications.ts
Normal file
139
backend/src/modules/collaboration/notifications.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user