diff --git a/backend/db/migrations/0082_archive_users.sql b/backend/db/migrations/0082_archive_users.sql new file mode 100644 index 00000000..a7e4df4a --- /dev/null +++ b/backend/db/migrations/0082_archive_users.sql @@ -0,0 +1,3 @@ +ALTER TABLE "auth_users" ADD COLUMN "archived_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "auth_profiles" ADD COLUMN "archived_at" timestamp with time zone; diff --git a/backend/db/migrations/0083_collaboration_notifications.sql b/backend/db/migrations/0083_collaboration_notifications.sql new file mode 100644 index 00000000..5c422446 --- /dev/null +++ b/backend/db/migrations/0083_collaboration_notifications.sql @@ -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'); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 4185dade..81c77fba 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -554,6 +554,20 @@ "when": 1791244800000, "tag": "0081_chat_media_reactions", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1792022400000, + "tag": "0082_archive_users", + "breakpoints": true + }, + { + "idx": 80, + "version": "7", + "when": 1792022400001, + "tag": "0083_collaboration_notifications", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_profiles.ts b/backend/db/schema/auth_profiles.ts index a9b66274..2f338e76 100644 --- a/backend/db/schema/auth_profiles.ts +++ b/backend/db/schema/auth_profiles.ts @@ -82,6 +82,7 @@ export const authProfiles = pgTable("auth_profiles", { address_city: text("address_city"), active: boolean("active").notNull().default(true), + archived_at: timestamp("archived_at", { withTimezone: true }), }) export type AuthProfile = typeof authProfiles.$inferSelect diff --git a/backend/db/schema/auth_users.ts b/backend/db/schema/auth_users.ts index 0bec516e..85d51a37 100644 --- a/backend/db/schema/auth_users.ts +++ b/backend/db/schema/auth_users.ts @@ -13,6 +13,7 @@ export const authUsers = pgTable("auth_users", { multiTenant: boolean("multi_tenant").notNull().default(true), must_change_password: boolean("must_change_password").notNull().default(false), is_admin: boolean("is_admin").notNull().default(false), + archived_at: timestamp("archived_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }), diff --git a/backend/db/schema/notifications_items.ts b/backend/db/schema/notifications_items.ts index d6c6cb3c..b69f0bed 100644 --- a/backend/db/schema/notifications_items.ts +++ b/backend/db/schema/notifications_items.ts @@ -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 diff --git a/backend/src/modules/collaboration/federation.ts b/backend/src/modules/collaboration/federation.ts index 01555b79..c51592ff 100644 --- a/backend/src/modules/collaboration/federation.ts +++ b/backend/src/modules/collaboration/federation.ts @@ -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 = {}) { diff --git a/backend/src/modules/collaboration/notifications.ts b/backend/src/modules/collaboration/notifications.ts new file mode 100644 index 00000000..2595e44a --- /dev/null +++ b/backend/src/modules/collaboration/notifications.ts @@ -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() + 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, 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 { + 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 } + } +} diff --git a/backend/src/modules/collaboration/runtime.ts b/backend/src/modules/collaboration/runtime.ts index b7005545..05b1ee11 100644 --- a/backend/src/modules/collaboration/runtime.ts +++ b/backend/src/modules/collaboration/runtime.ts @@ -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 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) }) diff --git a/backend/src/modules/collaboration/service.ts b/backend/src/modules/collaboration/service.ts index db96fe97..3c763885 100644 --- a/backend/src/modules/collaboration/service.ts +++ b/backend/src/modules/collaboration/service.ts @@ -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, "execute" | "transaction"> export const rows = async (db: Pick, query: any): Promise => (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; invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise; + 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 } diff --git a/backend/src/modules/notification.service.ts b/backend/src/modules/notification.service.ts index 51c1c4cd..0aabceb8 100644 --- a/backend/src/modules/notification.service.ts +++ b/backend/src/modules/notification.service.ts @@ -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 } diff --git a/backend/src/plugins/auth.m2m.ts b/backend/src/plugins/auth.m2m.ts index 70ebb707..9fc82ad1 100644 --- a/backend/src/plugins/auth.m2m.ts +++ b/backend/src/plugins/auth.m2m.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from "fastify"; import fp from "fastify-plugin"; import { secrets } from "../utils/secrets"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { authUsers, m2mApiKeys } from "../../db/schema"; import { createHash } from "node:crypto"; @@ -44,7 +44,8 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .where(and( eq(m2mApiKeys.keyHash, keyHash), - eq(m2mApiKeys.active, true) + eq(m2mApiKeys.active, true), + isNull(authUsers.archived_at) )) .limit(1) @@ -70,7 +71,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string const users = await server.db .select({ email: authUsers.email }) .from(authUsers) - .where(eq(authUsers.id, userId)) + .where(and(eq(authUsers.id, userId), isNull(authUsers.archived_at))) .limit(1) if (!users[0]) { diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 5a7a207d..cf4769a3 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -12,7 +12,7 @@ import { tenants, } from "../../db/schema" -import { eq, and, inArray } from "drizzle-orm" +import { eq, and, inArray, isNull } from "drizzle-orm" export default fp(async (server: FastifyInstance) => { const hashApiKey = (apiKey: string) => @@ -56,7 +56,8 @@ export default fp(async (server: FastifyInstance) => { .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .where(and( eq(m2mApiKeys.keyHash, keyHash), - eq(m2mApiKeys.active, true) + eq(m2mApiKeys.active, true), + isNull(authUsers.archived_at) )) .limit(1) @@ -141,22 +142,16 @@ export default fp(async (server: FastifyInstance) => { return reply.code(401).send({ error: "Invalid token" }) } - // Payload an Request hängen - req.user = payload - - if (typeof payload.is_admin === "boolean") { - req.user.is_admin = payload.is_admin - } else { - const [currentUser] = await server.db - .select({ - is_admin: authUsers.is_admin, - }) - .from(authUsers) - .where(eq(authUsers.id, payload.user_id)) - .limit(1) - - req.user.is_admin = Boolean(currentUser?.is_admin) + // Check current account state even when a JWT carries cached admin claims. + const [currentUser] = await server.db + .select({ is_admin: authUsers.is_admin }) + .from(authUsers) + .where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at))) + .limit(1) + if (!currentUser) { + return reply.code(401).send({ error: "Invalid or expired token" }) } + req.user = { ...payload, is_admin: Boolean(currentUser.is_admin) } // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung if (!req.user.tenant_id) { diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index df09bb89..03f5fcb0 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -33,9 +33,12 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin"; import { buildSystemStatus } from "../modules/system-status.service"; import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; +import adminUserArchiveRoutes from "./adminUserArchive"; import { getPortalLoginUrl } from "../utils/portalUrl"; export default async function adminRoutes(server: FastifyInstance) { + await adminUserArchiveRoutes(server); + await server.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } }); @@ -602,6 +605,7 @@ export default async function adminRoutes(server: FastifyInstance) { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; + const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true"; const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([ server.db .select({ @@ -621,8 +625,9 @@ export default async function adminRoutes(server: FastifyInstance) { multiTenant: authUsers.multiTenant, must_change_password: authUsers.must_change_password, is_admin: authUsers.is_admin, + archived_at: authUsers.archived_at, }) - .from(authUsers), + .from(authUsers).where(includeArchived ? undefined : isNull(authUsers.archived_at)), server.db .select({ id: authProfiles.id, @@ -633,6 +638,7 @@ export default async function adminRoutes(server: FastifyInstance) { full_name: authProfiles.full_name, email: authProfiles.email, active: authProfiles.active, + archived_at: authProfiles.archived_at, }) .from(authProfiles), server.db @@ -681,16 +687,17 @@ export default async function adminRoutes(server: FastifyInstance) { }; }); + const activeUserIds = new Set(userRows.filter((user) => !user.archived_at).map((user) => user.id)); const tenantsWithCounts = tenantRows.map((tenant) => ({ ...tenant, - user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length, + user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id && activeUserIds.has(membership.user_id)).length, })); return { users, tenants: tenantsWithCounts, roles: roleRows, - unassignedProfiles: profileRows.filter((profile) => !profile.user_id), + unassignedProfiles: profileRows.filter((profile) => !profile.user_id && !profile.archived_at && profile.active), memberships: uniqueMembershipRows, roleAssignments: roleAssignmentRows, }; @@ -809,7 +816,7 @@ export default async function adminRoutes(server: FastifyInstance) { email: authProfiles.email, }) .from(authProfiles) - .where(eq(authProfiles.id, profileId)) + .where(and(eq(authProfiles.id, profileId), isNull(authProfiles.archived_at))) .limit(1); if (!profile) { @@ -951,11 +958,16 @@ export default async function adminRoutes(server: FastifyInstance) { id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin, + archived_at: authUsers.archived_at, }) .from(authUsers) .where(eq(authUsers.email, email)) .limit(1); + if (existingUser?.archived_at) { + return reply.code(409).send({ error: "Dieses Benutzerkonto ist dauerhaft archiviert." }); + } + const derivedName = deriveNameFromEmail(email); const firstName = customerRecord.firstname?.trim() || derivedName.first_name; const lastName = customerRecord.lastname?.trim() || derivedName.last_name; @@ -1187,6 +1199,12 @@ export default async function adminRoutes(server: FastifyInstance) { if (!currentUser) return; const { user_id } = req.params as { user_id: string }; + const [account] = await server.db.select({ archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1); + if (!account) return reply.code(404).send({ error: "User not found" }); + if (account.archived_at) { + return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." }); + } const body = req.body as { email?: string; multiTenant?: boolean; @@ -1236,6 +1254,12 @@ export default async function adminRoutes(server: FastifyInstance) { if (!currentUser) return; const { user_id } = req.params as { user_id: string }; + const [account] = await server.db.select({ archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1); + if (!account) return reply.code(404).send({ error: "User not found" }); + if (account.archived_at) { + return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." }); + } const body = (req.body || {}) as { password?: string }; const requestedPassword = body.password?.trim(); @@ -1636,6 +1660,12 @@ export default async function adminRoutes(server: FastifyInstance) { if (!currentUser) return; const { user_id } = req.params as { user_id: string }; + const [account] = await server.db.select({ archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1); + if (!account) return reply.code(404).send({ error: "User not found" }); + if (account.archived_at) { + return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." }); + } const body = req.body as { tenant_ids?: number[]; role_assignments?: { tenant_id: number; role_id: string }[]; @@ -1712,7 +1742,9 @@ export default async function adminRoutes(server: FastifyInstance) { .where( and( inArray(authProfiles.tenant_id, tenantIds), - isNull(authProfiles.user_id) + isNull(authProfiles.user_id), + isNull(authProfiles.archived_at), + eq(authProfiles.active, true) ) ) : []; @@ -1835,6 +1867,10 @@ export default async function adminRoutes(server: FastifyInstance) { }); } + const [targetUser] = await server.db.select({ id: authUsers.id }) + .from(authUsers).where(and(eq(authUsers.id, body.user_id), isNull(authUsers.archived_at))).limit(1); + if (!targetUser) return reply.code(404).send({ error: "Aktiver Benutzer nicht gefunden." }); + const mode = body.mode ?? "multi"; // ---------------------------- diff --git a/backend/src/routes/adminUserArchive.ts b/backend/src/routes/adminUserArchive.ts new file mode 100644 index 00000000..c3f5aaa2 --- /dev/null +++ b/backend/src/routes/adminUserArchive.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from "fastify" +import { eq } from "drizzle-orm" +import { authProfiles, authRefreshTokens, authUsers, m2mApiKeys } from "../../db/schema" +import { requireSystemAdmin } from "../utils/requireSystemAdmin" + +export default async function adminUserArchiveRoutes(server: FastifyInstance) { + server.post("/admin/users/:user_id/archive", { + schema: { + params: { + type: "object", + required: ["user_id"], + properties: { user_id: { type: "string", format: "uuid" } }, + }, + }, + }, async (req, reply) => { + const currentUser = await requireSystemAdmin(server, req, reply) + if (!currentUser) return + + const { user_id } = req.params as { user_id: string } + if (user_id === currentUser.id) { + return reply.code(409).send({ error: "Das eigene Benutzerkonto kann nicht archiviert werden." }) + } + + const user = await server.db.transaction(async (tx) => { + // Serialize repeated archive requests and keep the original archive date. + const [target] = await tx.select({ id: authUsers.id, archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1).for("update") + if (!target) return null + + const archivedAt = target.archived_at || new Date() + const [archivedUser] = await tx.update(authUsers) + .set({ archived_at: archivedAt, updatedAt: archivedAt }) + .where(eq(authUsers.id, user_id)) + .returning({ id: authUsers.id, archived_at: authUsers.archived_at }) + + // Keep links intact so existing documents and time records retain their authors. + await tx.update(authProfiles) + .set({ active: false, archived_at: archivedAt, token_id: null, calendar_subscription_token: null }) + .where(eq(authProfiles.user_id, user_id)) + await tx.update(authRefreshTokens).set({ revokedAt: archivedAt }) + .where(eq(authRefreshTokens.userId, user_id)) + await tx.update(m2mApiKeys).set({ active: false, updatedAt: archivedAt }) + .where(eq(m2mApiKeys.userId, user_id)) + + return archivedUser + }) + + if (!user) return reply.code(404).send({ error: "Benutzer nicht gefunden." }) + return { user } + }) +} diff --git a/backend/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index ab799a07..612f554a 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -6,7 +6,7 @@ import { sendMail } from "../../utils/mailer"; import { authUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema"; import { tenants } from "../../../db/schema"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { resolveLoginTenant } from "../../utils/loginTenant"; import { createAccessToken, @@ -112,7 +112,7 @@ export default async function authRoutes(server: FastifyInstance) { const [found] = await server.db .select() .from(authUsers) - .where(eq(authUsers.email, body.email.toLowerCase())) + .where(and(eq(authUsers.email, body.email.toLowerCase()), isNull(authUsers.archived_at))) .limit(1); if (!found) { @@ -216,7 +216,7 @@ export default async function authRoutes(server: FastifyInstance) { mustChangePassword: authUsers.must_change_password, }) .from(authUsers) - .where(eq(authUsers.email, email.toLowerCase())) + .where(and(eq(authUsers.email, email.toLowerCase()), isNull(authUsers.archived_at))) .limit(1); if (!user) { diff --git a/backend/src/routes/auth/me.ts b/backend/src/routes/auth/me.ts index ad013c7f..5af4fe53 100644 --- a/backend/src/routes/auth/me.ts +++ b/backend/src/routes/auth/me.ts @@ -58,6 +58,7 @@ export default async function meRoutes(server: FastifyInstance) { features: tenants.features, extraModules: tenants.extraModules, businessInfo: tenants.businessInfo, + portalConfig: tenants.portalConfig, numberRanges: tenants.numberRanges, accountChart: tenants.accountChart, taxEvaluationPeriod: tenants.taxEvaluationPeriod, diff --git a/backend/src/routes/auth/user.ts b/backend/src/routes/auth/user.ts index fde299ad..3cbf647b 100644 --- a/backend/src/routes/auth/user.ts +++ b/backend/src/routes/auth/user.ts @@ -1,5 +1,5 @@ import { FastifyInstance } from "fastify" -import { eq, and } from "drizzle-orm" +import { eq, and, isNull } from "drizzle-orm" import { authUsers, @@ -83,7 +83,8 @@ export default async function userRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.user_id, id), - eq(authProfiles.tenant_id, req.user.tenant_id) + eq(authProfiles.tenant_id, req.user.tenant_id), + isNull(authProfiles.archived_at) ) ) @@ -99,6 +100,7 @@ export default async function userRoutes(server: FastifyInstance) { } const updateData: any = { ...data } + for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete updateData[key] // bekannte Date-Felder prüfen if (data.entry_date !== undefined) @@ -116,9 +118,10 @@ export default async function userRoutes(server: FastifyInstance) { const [updatedProfile] = await server.db .update(authProfiles) .set(updateData) - .where(eq(authProfiles.id, profile.id)) + .where(and(eq(authProfiles.id, profile.id), isNull(authProfiles.archived_at))) .returning() + if (!updatedProfile) return reply.code(404).send({ error: "Profile not found in tenant" }) return { profile: updatedProfile } } catch (err: any) { diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 32e1b5d9..db58e290 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" -import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm" import { authProfiles, authTenantUsers, @@ -80,7 +80,7 @@ export default async function communicationRoutes(server: FastifyInstance) { eq(authProfiles.user_id, authTenantUsers.user_id), eq(authProfiles.tenant_id, tenantId) )) - .where(eq(authTenantUsers.tenant_id, tenantId)) + .where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at))) const addMembers = async (roomId: string, userIds: string[]) => { const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean))) @@ -122,7 +122,8 @@ export default async function communicationRoutes(server: FastifyInstance) { .where(eq(communicationRooms.id, room.id)).returning() } const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers) - .where(eq(authTenantUsers.tenant_id, tenantId)) + .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) + .where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at))) if (users.length) await tx.insert(communicationRoomMembers) .values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId }))) .onConflictDoNothing() @@ -372,7 +373,7 @@ export default async function communicationRoutes(server: FastifyInstance) { const profileIds = (project.profiles || []) as string[] const memberProfiles = profileIds.length ? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles) - .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds))) + .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds), eq(authProfiles.active, true))) : [] const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[] await addMembers(room.id, [req.user.user_id, ...projectMemberIds]) diff --git a/backend/src/routes/devices/rfid.ts b/backend/src/routes/devices/rfid.ts index e8c8e48f..9be7c3ae 100644 --- a/backend/src/routes/devices/rfid.ts +++ b/backend/src/routes/devices/rfid.ts @@ -44,7 +44,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.tenant_id, device.tenant), - eq(authProfiles.token_id, rfid_id) + eq(authProfiles.token_id, rfid_id), + eq(authProfiles.active, true) ) ) .limit(1) diff --git a/backend/src/routes/internal/tenant.ts b/backend/src/routes/internal/tenant.ts index 527e6e52..8b6e4269 100644 --- a/backend/src/routes/internal/tenant.ts +++ b/backend/src/routes/internal/tenant.ts @@ -7,7 +7,7 @@ import { tenants } from "../../../db/schema" -import {and, eq, inArray} from "drizzle-orm" +import {and, eq, inArray, isNull} from "drizzle-orm" import { enrichProfilesWithBranches } from "../../utils/profileBranches" import { enrichProfilesWithTeams } from "../../utils/profileTeams" @@ -52,7 +52,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { const users = await server.db .select() .from(authUsers) - .where(inArray(authUsers.id, userIds)) + .where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) // 3) auth_profiles pro Tenant laden const profileRows = await server.db @@ -61,7 +61,9 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { .where( and( eq(authProfiles.tenant_id, tenantId), - inArray(authProfiles.user_id, userIds) + inArray(authProfiles.user_id, userIds), + eq(authProfiles.active, true), + isNull(authProfiles.archived_at) )) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) @@ -98,7 +100,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { const profileRows = await server.db .select() .from(authProfiles) - .where(eq(authProfiles.tenant_id, tenantId)) + .where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.archived_at))) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) return await enrichProfilesWithTeams(server, profilesWithBranches) diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts index 09c1c66e..a50fdd66 100644 --- a/backend/src/routes/profiles.ts +++ b/backend/src/routes/profiles.ts @@ -1,5 +1,5 @@ import { FastifyInstance } from "fastify"; -import { eq, and } from "drizzle-orm"; +import { eq, and, isNull } from "drizzle-orm"; import { authProfiles, @@ -57,7 +57,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) { const forbidden = [ "id", "user_id", "tenant_id", "created_at", "updated_at", "updatedAt", "updatedBy", "old_profile_id", "full_name", - "branch", "calendar_subscription_token", + "branch", "archived_at", "calendar_subscription_token", "calendar_subscription_path", "calendar_subscription_url" ] forbidden.forEach(f => delete cleaned[f]) @@ -135,7 +135,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.id, id), - eq(authProfiles.tenant_id, tenantId) + eq(authProfiles.tenant_id, tenantId), + isNull(authProfiles.archived_at) ) ) .returning() diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index d69232d6..bdf34cd8 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -9,12 +9,14 @@ import { and, count, inArray, + isNull, or, sql, } from "drizzle-orm" import { authProfiles, + authUsers, createddocuments, customers, entitybankaccounts, @@ -138,7 +140,10 @@ function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"` } -function applyResourceWhereFilters(resource: string, table: any, whereCond: any) { +function applyResourceWhereFilters(resource: string, table: any, whereCond: any, list = false) { + if (list && table === authProfiles) { + return and(whereCond, eq(authProfiles.active, true), isNull(authProfiles.archived_at)) + } if (resource === "members") { return and(whereCond, eq(table.type, "Mitglied")) } @@ -570,7 +575,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const tenantColumn = getTenantColumn(resource, table) let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined - whereCond = applyResourceWhereFilters(resource, table, whereCond) + whereCond = applyResourceWhereFilters(resource, table, whereCond, true) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) let q = server.db.select().from(table).$dynamic() @@ -688,7 +693,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const tenantColumn = getTenantColumn(resource, table); let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined; - whereCond = applyResourceWhereFilters(resource, table, whereCond) + whereCond = applyResourceWhereFilters(resource, table, whereCond, true) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; @@ -789,7 +794,7 @@ export default async function resourceRoutes(server: FastifyInstance) { }); } let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined - distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond) + distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond, true) distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId) if (search) { @@ -878,7 +883,7 @@ export default async function resourceRoutes(server: FastifyInstance) { } const table = resourceConfig[resource].table - let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) + let whereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)) whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) @@ -943,6 +948,15 @@ export default async function resourceRoutes(server: FastifyInstance) { const table = config.table; let createData: Record = { ...body, tenant: req.user.tenant_id, archived: false }; + if (table === authProfiles) { + delete createData.archived_at; + createData.tenant_id = req.user.tenant_id; + if (createData.user_id) { + const [owner] = await server.db.select({ id: authUsers.id }).from(authUsers) + .where(and(eq(authUsers.id, createData.user_id), isNull(authUsers.archived_at))).limit(1); + if (!owner) return reply.code(409).send({ error: "Aktiver Benutzer nicht gefunden." }); + } + } if (resource === "projects") { const error = projectChatSettingError(req, createData) if (error) return reply.code(error.statusCode).send({ error: error.error }) @@ -1093,7 +1107,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const [oldRecord] = await server.db .select() .from(table) - .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId)) + .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)), portalCustomerId)) .limit(1) if (!oldRecord) { @@ -1105,7 +1119,13 @@ export default async function resourceRoutes(server: FastifyInstance) { if (error) return reply.code(error.statusCode).send({ error: error.error }) } + if (table === authProfiles && oldRecord.archived_at) { + return reply.code(409).send({ error: "Archivierte Profile können nicht mehr bearbeitet werden." }) + } let data: Record = { ...body } + if (table === authProfiles) { + for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete data[key] + } delete data.updatedAt delete data.updated_at delete data.updatedBy @@ -1194,7 +1214,8 @@ export default async function resourceRoutes(server: FastifyInstance) { } }) - let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) + let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)) + if (table === authProfiles) updateWhereCond = and(updateWhereCond, isNull(authProfiles.archived_at)) updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond) const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning() @@ -1203,7 +1224,7 @@ export default async function resourceRoutes(server: FastifyInstance) { .update(table) .set({ defaultMandate: false }) .where(and( - eq(table.tenant, tenantId), + eq(getTenantColumn(resource, table), tenantId), eq(table.customer, updated.customer), sql`${table.id} <> ${updated.id}` )) diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index b67fb3a4..8681272a 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -11,7 +11,7 @@ import { m2mApiKeys } from "../../db/schema" -import {and, desc, eq, inArray} from "drizzle-orm" +import {and, desc, eq, inArray, isNull} from "drizzle-orm" import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithTeams } from "../utils/profileTeams" import { rotateRefreshToken } from "../utils/authTokens" @@ -152,7 +152,7 @@ export default async function tenantRoutes(server: FastifyInstance) { const users = await server.db .select() .from(authUsers) - .where(inArray(authUsers.id, userIds)) + .where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) // 3) auth_profiles pro Tenant laden const profileRows = await server.db @@ -161,7 +161,9 @@ export default async function tenantRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.tenant_id, tenantId), - inArray(authProfiles.user_id, userIds) + inArray(authProfiles.user_id, userIds), + eq(authProfiles.active, true), + isNull(authProfiles.archived_at) )) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) @@ -194,10 +196,14 @@ export default async function tenantRoutes(server: FastifyInstance) { const tenantId = req.user?.tenant_id if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) + const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true" const profileRows = await server.db .select() .from(authProfiles) - .where(eq(authProfiles.tenant_id, tenantId)) + .where(and( + eq(authProfiles.tenant_id, tenantId), + ...(includeArchived ? [] : [eq(authProfiles.active, true), isNull(authProfiles.archived_at)]) + )) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const data = await enrichProfilesWithTeams(server, profilesWithBranches) @@ -271,6 +277,16 @@ export default async function tenantRoutes(server: FastifyInstance) { if (!data) return reply.code(400).send({ error: "data required" }) + if (Object.prototype.hasOwnProperty.call(data, "portalConfig")) { + const config = data.portalConfig + if (!config || typeof config !== "object" || Array.isArray(config) + || typeof config.primaryColor !== "string" || !/^#[0-9a-f]{6}$/i.test(config.primaryColor.trim())) { + return reply.code(400).send({ error: "Die Portal-Akzentfarbe muss ein Hex-Farbwert wie #69c350 sein." }) + } + data.portalConfig = { ...config, primaryColor: config.primaryColor.trim().toLowerCase() } + delete data.portalConfig.primayColor + } + const updated = await server.db .update(tenants) .set(data) diff --git a/backend/src/utils/authTokens.ts b/backend/src/utils/authTokens.ts index 16ad5ebe..4876d03d 100644 --- a/backend/src/utils/authTokens.ts +++ b/backend/src/utils/authTokens.ts @@ -70,7 +70,7 @@ export async function rotateRefreshToken( const [user] = await server.db .select({ id: authUsers.id, email: authUsers.email }) .from(authUsers) - .where(eq(authUsers.id, session.userId)) + .where(and(eq(authUsers.id, session.userId), isNull(authUsers.archived_at))) .limit(1) if (!user) return null diff --git a/backend/src/utils/calendarSubscription.ts b/backend/src/utils/calendarSubscription.ts index d388f56d..2d0fb198 100644 --- a/backend/src/utils/calendarSubscription.ts +++ b/backend/src/utils/calendarSubscription.ts @@ -1,5 +1,5 @@ import crypto from "crypto" -import { and, asc, eq, sql } from "drizzle-orm" +import { and, asc, eq, isNull, sql } from "drizzle-orm" import { FastifyInstance } from "fastify" import { authProfiles, events } from "../../db/schema" @@ -105,7 +105,8 @@ export async function generateProfileCalendarSubscriptionToken(server: FastifyIn .where( and( eq(authProfiles.id, profileId), - eq(authProfiles.tenant_id, tenantId) + eq(authProfiles.tenant_id, tenantId), + isNull(authProfiles.archived_at) ) ) .returning() diff --git a/backend/src/utils/requireSystemAdmin.ts b/backend/src/utils/requireSystemAdmin.ts index 2c322e51..6679216e 100644 --- a/backend/src/utils/requireSystemAdmin.ts +++ b/backend/src/utils/requireSystemAdmin.ts @@ -1,5 +1,5 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { authUsers } from "../../db/schema/auth_users"; export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) { @@ -9,7 +9,7 @@ export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRe } // Read the current global role; tenant roles and stale JWT claims do not grant access. const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin }) - .from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1); + .from(authUsers).where(and(eq(authUsers.id, req.user.user_id), isNull(authUsers.archived_at))).limit(1); if (!user?.is_admin) { reply.code(403).send({ error: "Admin access required" }); return null; diff --git a/backend/tests/collaborationNotifications.test.ts b/backend/tests/collaborationNotifications.test.ts new file mode 100644 index 00000000..8a2b0024 --- /dev/null +++ b/backend/tests/collaborationNotifications.test.ts @@ -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>() + 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() + } +}) diff --git a/backend/tests/helpers/collaboration-db.ts b/backend/tests/helpers/collaboration-db.ts index 013c831e..76b79a85 100644 --- a/backend/tests/helpers/collaboration-db.ts +++ b/backend/tests/helpers/collaboration-db.ts @@ -24,7 +24,7 @@ export async function collaborationDatabase(migrations: string[] = []) { const columns = Object.values(getTableColumns(table)).map((column: any) => { let suffix = column.primary ? " PRIMARY KEY" : "" if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()" - if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()" + if (column.columnType === "PgTimestamp" && column.hasDefault) suffix += " DEFAULT now()" if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}` return `"${column.name}" ${column.getSQLType()}${suffix}` }) diff --git a/backend/tests/mcpDocuments.test.ts b/backend/tests/mcpDocuments.test.ts index 1ccb7d9e..b3739407 100644 --- a/backend/tests/mcpDocuments.test.ts +++ b/backend/tests/mcpDocuments.test.ts @@ -151,6 +151,7 @@ test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without e return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] } } if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] } + if (sql.includes('from "auth_users"')) return { rows: [[admin]] } if (sql.includes('from "tenants"')) return { rows: [[null, null]] } if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] } if (sql.includes('from "auth_role_permissions"')) return { rows: [] } diff --git a/backend/tests/userArchive.test.ts b/backend/tests/userArchive.test.ts new file mode 100644 index 00000000..019bec6f --- /dev/null +++ b/backend/tests/userArchive.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import cookie from "@fastify/cookie" +import bcrypt from "bcrypt" +import jwt from "jsonwebtoken" +import { drizzle } from "drizzle-orm/pg-proxy" +import { Pool } from "pg" +import { createHash } from "node:crypto" +import authRoutes from "../src/routes/auth/auth" +import userRoutes from "../src/routes/auth/user" +import authPlugin from "../src/plugins/auth" +import m2mAuthPlugin from "../src/plugins/auth.m2m" +import tenantRoutes from "../src/routes/tenant" +import internalTenantRoutes from "../src/routes/internal/tenant" +import profileRoutes from "../src/routes/profiles" +import resourceRoutes from "../src/routes/resources/main" +import { secrets } from "../src/utils/secrets" + +const adminId = "00000000-0000-4000-8000-000000000001" +const userId = "00000000-0000-4000-8000-000000000002" +const profileId = "00000000-0000-4000-8000-000000000003" +const otherProfileId = "00000000-0000-4000-8000-000000000004" +const passwordHash = bcrypt.hashSync("archive-test-password", 4) +type Row = Record + +// Execute the generated SQL against a small fixture, including its archive filters. +// This keeps the HTTP/authentication tests independent of a configured production DB. +function matches(where: string, params: any[], row: Row) { + for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" = \$(\d+)/g)) { + if (String(row[match[1]]) !== String(params[Number(match[2]) - 1])) return false + } + for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" is null/g)) { + if (row[match[1]] != null) return false + } + for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" in \(([^)]+)\)/g)) { + const values = [...match[2].matchAll(/\$(\d+)/g)].map(value => params[Number(value[1]) - 1]) + if (!values.includes(row[match[1]])) return false + } + return true +} + +function selected(columns: string, row: Row) { + return columns.split(", ").map(column => { + const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1] + return row[name!] ?? null + }) +} + +async function fixture(t: any) { + const data: Record = { + auth_users: [ + { id: adminId, email: "admin@example.test", is_admin: true, archived_at: null }, + { id: userId, email: "former@example.test", password_hash: passwordHash, is_admin: false, archived_at: null }, + ], + auth_profiles: [ + { id: profileId, user_id: userId, tenant_id: 7, active: true, archived_at: null, first_name: "Former", last_name: "User", full_name: "Former User", token_id: "rfid", calendar_subscription_token: "calendar" }, + { id: otherProfileId, user_id: userId, tenant_id: 8, active: false, archived_at: null }, + { id: adminId, user_id: adminId, tenant_id: 7, active: true, archived_at: null }, + ], + auth_tenant_users: [{ tenant_id: 7, user_id: userId }, { tenant_id: 8, user_id: userId }, { tenant_id: 7, user_id: adminId }], + auth_refresh_tokens: [{ id: userId, user_id: userId, tenant_id: 7, token_hash: "old", revoked_at: null }], + m2m_api_keys: [{ id: userId, user_id: userId, tenant_id: 7, active: true, key_hash: createHash("sha256").update("fedeo_mcp_archive_test").digest("hex") }], + tenants: [{ id: 7, name: "First" }, { id: 8, name: "Second" }], + } + const queries: string[] = [] + let inTransaction = false + const db = drizzle(async (sql, params) => { + queries.push(sql) + if (sql.startsWith("select ")) { + const table = sql.match(/from "([^"]+)"/)![1] + const source = table === "m2m_api_keys" + ? data[table].map(row => ({ ...row, ...data.auth_users.find(user => user.id === row.user_id) })) + : data[table] || [] + const rows = source.filter(row => matches(sql.split(" where ")[1] || "", params, row)) + if (sql.startsWith("select count(")) return { rows: [[rows.length]] } + return { rows: rows.map(row => selected(sql.slice(7, sql.indexOf(" from ")), row)) } + } + if (sql.startsWith("update ")) { + const table = sql.match(/update "([^"]+)"/)![1] + const rows = (data[table] || []).filter(row => matches(sql.split(" where ")[1] || "", params, row)) + if (sql.includes('"archived_at" =')) assert.ok(inTransaction, "Archiving must be atomic") + const assignments = sql.slice(sql.indexOf(" set "), sql.indexOf(" where ")) + for (const row of rows) { + for (const match of assignments.matchAll(/"(\w+)" = \$(\d+)/g)) row[match[1]] = params[Number(match[2]) - 1] + } + return { rows: sql.includes(" returning ") ? rows.map(row => selected(sql.split(" returning ")[1], row)) : [] } + } + throw new Error(`Unexpected mutation: ${sql}`) + }) + ;(db as any).transaction = async (fn: any) => { + inTransaction = true + try { return await fn(db) } finally { inTransaction = false } + } + const app = Fastify() + app.decorate("db", db as any) + app.addHook("preHandler", async (req: any) => { + req.user = { user_id: req.headers["x-test-user"] || adminId, tenant_id: 7 } + req.queryConfig = { pagination: { limit: 100, offset: 0 }, filters: {}, sort: [] } + }) + // adminRoutes imports the export utility, whose legacy DB module probes on import. + const queryMock = t.mock.method(Pool.prototype, "query", async () => ({ rows: [{ now: "test" }] })) + const { default: adminRoutes } = await import("../src/routes/admin") + queryMock.mock.restore() + await app.register(adminRoutes) + await app.register(tenantRoutes) + await app.register(profileRoutes) + await app.register(userRoutes) + await app.register(resourceRoutes) + await app.register(internalTenantRoutes, { prefix: "/internal" }) + t.after(() => app.close()) + const archive = (id = userId, caller = adminId) => app.inject({ method: "POST", url: `/admin/users/${id}/archive`, headers: { "x-test-user": caller } }) + return { app, db, data, queries, archive } +} + +test("archiving preserves history, archives every tenant profile and revokes credentials atomically", async t => { + const f = await fixture(t) + const memberships = structuredClone(f.data.auth_tenant_users) + const response = await f.archive() + assert.equal(response.statusCode, 200, response.body) + const archivedAt = response.json().user.archived_at + assert.ok(archivedAt) + for (const profile of f.data.auth_profiles.filter(profile => profile.user_id === userId)) { + assert.equal(profile.active, false) + assert.equal(profile.archived_at, archivedAt) + assert.equal(profile.token_id, null) + assert.equal(profile.calendar_subscription_token, null) + } + assert.equal(f.data.auth_profiles[2].active, true) + assert.equal(f.data.auth_users[0].archived_at, null) + assert.equal(f.data.auth_refresh_tokens[0].revoked_at, archivedAt) + assert.equal(f.data.m2m_api_keys[0].active, false) + assert.deepEqual(f.data.auth_tenant_users, memberships) + assert.equal((await f.archive()).json().user.archived_at, archivedAt) + assert.ok(f.queries.some(sql => sql.endsWith("for update"))) +}) + +test("only an active system admin can archive another existing user", async t => { + const f = await fixture(t) + assert.equal((await f.archive(adminId, userId)).statusCode, 403) + assert.equal((await f.archive(adminId)).statusCode, 409) + assert.equal((await f.archive(profileId)).statusCode, 404) + assert.equal((await f.archive("invalid-id")).statusCode, 400) + f.data.auth_users[0].archived_at = new Date().toISOString() + assert.equal((await f.archive()).statusCode, 403) + assert.equal(f.data.auth_users[1].archived_at, null) +}) + +test("archived users disappear from active administration and tenant lists but retain a readable archive", async t => { + const f = await fixture(t) + await f.archive() + const overview = (await f.app.inject("/admin/overview")).json() + assert.deepEqual(overview.users.map((row: Row) => row.id), [adminId]) + assert.equal(overview.tenants.find((row: Row) => row.id === 7).user_count, 1) + const archived = (await f.app.inject("/admin/overview?includeArchived=true")).json().users.find((row: Row) => row.id === userId) + assert.ok(archived.archived_at) + assert.equal(archived.profiles.length, 2) + for (const url of ["/tenant/users", "/internal/tenant/users"]) { + const response = await f.app.inject(url) + assert.equal(response.statusCode, 200, response.body) + assert.deepEqual(response.json().users.map((row: Row) => row.id), [adminId]) + } + for (const url of ["/tenant/profiles", "/internal/tenant/7/profiles", "/resource/profiles", "/resource/authProfile", "/resource/profiles/paginated"]) { + const response = await f.app.inject(url) + assert.equal(response.statusCode, 200, response.body) + const body = response.json() + assert.deepEqual((body.data || body).map((row: Row) => row.id), [adminId], url) + } + const historic = (await f.app.inject("/tenant/profiles?includeArchived=true")).json().data + assert.equal(historic.find((row: Row) => row.id === profileId).full_name, "Former User") + const detail = await f.app.inject(`/resource/profiles/${profileId}`) + assert.equal(detail.statusCode, 200, detail.body) + assert.equal(detail.json().full_name, "Former User") +}) + +test("archived accounts and profiles cannot be edited, reassigned or reactivated", async t => { + const f = await fixture(t) + await f.archive() + for (const [method, url, payload, status] of [ + ["PUT", `/admin/users/${userId}`, { archived_at: null, is_admin: true }, 409], + ["PUT", `/admin/users/${userId}/access`, { tenant_ids: [9] }, 409], + ["POST", `/admin/users/${userId}/reset-password`, {}, 409], + ["POST", "/admin/add-user-to-tenant", { user_id: userId, tenant_id: 9 }, 404], + ["PUT", `/user/${userId}/profile`, { data: { active: true, archived_at: null, user_id: adminId } }, 404], + ["PUT", `/profiles/${profileId}`, { active: true, archived_at: null }, 404], + ["PUT", `/resource/profiles/${profileId}`, { active: true, archived_at: null, user_id: adminId }, 409], + ["POST", `/profiles/${profileId}/calendar-subscription-token`, {}, 404], + ["POST", "/resource/profiles", { user_id: userId, first_name: "New", last_name: "Profile" }, 409], + ] as const) { + const response = await f.app.inject({ method, url, payload }) + assert.equal(response.statusCode, status, `${url}: ${response.body}`) + } + assert.equal(f.data.auth_profiles[0].active, false) + assert.equal(f.data.auth_profiles[0].user_id, userId) +}) + +test("archiving blocks password login, refresh, existing JWTs and legacy API keys", async t => { + const f = await fixture(t) + const oldSecret = secrets.JWT_SECRET, oldM2mKey = secrets.M2M_API_KEY + secrets.JWT_SECRET = "archive-test-secret" + secrets.M2M_API_KEY = "archive-test-legacy-key" + t.after(() => { secrets.JWT_SECRET = oldSecret; secrets.M2M_API_KEY = oldM2mKey }) + const app = Fastify() + app.decorate("db", f.db as any) + await app.register(cookie) + await app.register(authRoutes) + await app.register(async protectedApp => { + await protectedApp.register(authPlugin) + protectedApp.get("/check", async () => ({ success: true })) + protectedApp.get("/mcp", async () => ({ success: true })) + }, { prefix: "/api" }) + await app.register(async internal => { + await internal.register(m2mAuthPlugin) + internal.get("/check", async () => ({ success: true })) + }, { prefix: "/internal" }) + t.after(() => app.close()) + const token = jwt.sign({ user_id: userId, tenant_id: null, is_admin: true }, secrets.JWT_SECRET) + const headers = { authorization: `Bearer ${token}` } + assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 200) + await f.archive() + assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 401) + assert.equal((await app.inject({ method: "POST", url: "/auth/login", payload: { email: "former@example.test", password: "archive-test-password" } })).statusCode, 401) + assert.equal((await app.inject({ method: "POST", url: "/auth/password/reset", payload: { email: "former@example.test" } })).statusCode, 404) + // Even a session issued concurrently with archiving must fail the current-user check. + f.data.auth_refresh_tokens[0].revoked_at = null + const { hashRefreshToken } = await import("../src/utils/authTokens") + f.data.auth_refresh_tokens[0].token_hash = hashRefreshToken("old-refresh-token") + assert.equal((await app.inject({ method: "POST", url: "/auth/refresh", payload: { refreshToken: "old-refresh-token" } })).statusCode, 401) + f.data.m2m_api_keys[0].active = true + assert.equal((await app.inject({ url: "/api/mcp", headers: { authorization: "Bearer fedeo_mcp_archive_test" } })).statusCode, 401) + assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": "fedeo_mcp_archive_test" } })).statusCode, 401) + assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": secrets.M2M_API_KEY, "x-user-id": userId, "x-tenant-id": "7" } })).statusCode, 401) +}) diff --git a/docs/chat-foederation-konzept.md b/docs/chat-foederation-konzept.md index 12d3f770..f549f8d5 100644 --- a/docs/chat-foederation-konzept.md +++ b/docs/chat-foederation-konzept.md @@ -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. diff --git a/docs/notification-preferences.md b/docs/notification-preferences.md index 246c0f71..4e9ef90a 100644 --- a/docs/notification-preferences.md +++ b/docs/notification-preferences.md @@ -1,6 +1,6 @@ # Persönliche Benachrichtigungen -Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil. +Unter Einstellungen → Benachrichtigungen (`/settings/notifications`) im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Der Web-Menüeintrag ist für alle angemeldeten Mitarbeiter unabhängig von Administrationsrechten sichtbar. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil. ## Ereignisse @@ -10,16 +10,23 @@ Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in | `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 diff --git a/frontend/components/CollaborationRoom.vue b/frontend/components/CollaborationRoom.vue index 773baf7a..08bf8ba5 100644 --- a/frontend/components/CollaborationRoom.vue +++ b/frontend/components/CollaborationRoom.vue @@ -1,9 +1,11 @@ + + diff --git a/frontend/components/EntityEdit.vue b/frontend/components/EntityEdit.vue index 0510cee9..644bfeca 100644 --- a/frontend/components/EntityEdit.vue +++ b/frontend/components/EntityEdit.vue @@ -385,6 +385,8 @@ const getEntityModalCreateQuery = (datapoint) => { const getPostSaveRoute = () => { if (type !== "events") return null + if (route.query.returnTo === "events") return "/events" + if (route.query.returnTo === "plantafel") { return { path: "/organisation/plantafel", @@ -475,25 +477,21 @@ const updateItem = async () => {