Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3b4fac4c0 | |||
| f56a07f8e3 | |||
| 7b61857fb8 | |||
| 697f538b6e | |||
| e933782731 | |||
| ee8f16056f | |||
| 683be7761d | |||
| 42242390f3 | |||
| 4ffafef3f1 | |||
| 5dc9e686fc | |||
| 6eea3c0687 |
3
backend/db/migrations/0082_archive_users.sql
Normal file
3
backend/db/migrations/0082_archive_users.sql
Normal file
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 {
|
||||
// Check current account state even when a JWT carries cached admin claims.
|
||||
const [currentUser] = await server.db
|
||||
.select({
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.select({ is_admin: authUsers.is_admin })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, payload.user_id))
|
||||
.where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at)))
|
||||
.limit(1)
|
||||
|
||||
req.user.is_admin = Boolean(currentUser?.is_admin)
|
||||
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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
// ----------------------------
|
||||
|
||||
51
backend/src/routes/adminUserArchive.ts
Normal file
51
backend/src/routes/adminUserArchive.ts
Normal file
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, any> = { ...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<string, any> = { ...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}`
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
201
backend/tests/collaborationNotifications.test.ts
Normal file
201
backend/tests/collaborationNotifications.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import Fastify from "fastify"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { notificationsItems } from "../db/schema"
|
||||
import { CollaborationService } from "../src/modules/collaboration/service"
|
||||
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
|
||||
import { CollaborationNotifications } from "../src/modules/collaboration/notifications"
|
||||
import type { Transport } from "../src/modules/collaboration/transport"
|
||||
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
|
||||
|
||||
const MESSAGE = "collaboration.message.new", INVITATION = "collaboration.room.invited"
|
||||
|
||||
test("collaboration notifications use real preferences, transactions and signed federation routes", { skip: !testDatabaseUrl }, async t => {
|
||||
const servers = new Map<string, ReturnType<typeof Fastify>>()
|
||||
let offline = false
|
||||
const transport: Transport = async (url, method, headers, body) => {
|
||||
if (offline) throw Object.assign(new Error("Offline"), { statusCode: 503 })
|
||||
const origin = [...servers.keys()].find(value => url.startsWith(value + "/"))!
|
||||
const response = await servers.get(origin)!.inject({ method, url: url.slice(origin.length), headers, ...(body ? { payload: body } : {}) })
|
||||
if (response.statusCode >= 400) throw Object.assign(new Error(response.json().message), { statusCode: response.statusCode })
|
||||
return response.json()
|
||||
}
|
||||
const make = async (name: string) => {
|
||||
const fixture = await collaborationDatabase(["0075_project_collaboration.sql", "0076_collaboration_federation.sql"])
|
||||
const original = await readFile(new URL("../db/migrations/0000_brief_dark_beast.sql", import.meta.url), "utf8")
|
||||
for (const type of ["notification_channel", "notification_status", "notification_severity"]) {
|
||||
const definition = original.match(new RegExp(`CREATE TYPE "public"\\."${type}" AS ENUM\\([^;]+;`))![0]
|
||||
await fixture.pool.query(definition.replace('"public".', ""))
|
||||
}
|
||||
for (const table of ["notifications_event_types", "notifications_items", "notifications_preferences", "notifications_preferences_defaults"]) {
|
||||
await fixture.pool.query(original.match(new RegExp(`CREATE TABLE "${table}" \\([\\s\\S]*?\\n\\);`))![0])
|
||||
}
|
||||
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences(tenant_id,user_id,event_type,channel)")
|
||||
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences_defaults(tenant_id,event_key,channel)")
|
||||
await fixture.pool.query(await readFile(new URL("../db/migrations/0083_collaboration_notifications.sql", import.meta.url), "utf8"))
|
||||
const userId = randomUUID()
|
||||
await fixture.pool.query("INSERT INTO tenants VALUES (1,$1,null,null),(2,'Other tenant',null,null)", [name])
|
||||
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,$2,true)", [userId, `${name}@example.test`])
|
||||
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1)", [userId])
|
||||
const service = new CollaborationService(fixture.db, { publicUrl: `https://${name}.example`, guestPepper: () => "a".repeat(64), sendMail: async () => ({ success: true }) })
|
||||
const federation = new FederationService(service, { apiUrl: `https://${name}.example/backend`, enabled: true, transport,
|
||||
seal: value => ({ key: value }), unseal: value => value.key })
|
||||
service.options.changed = (id, db) => federation.changed(id, db)
|
||||
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
|
||||
const app = Fastify()
|
||||
app.decorate("db", fixture.db)
|
||||
const notifications = new CollaborationNotifications(app, service, federation)
|
||||
service.options.notifications = notifications
|
||||
const delivered: any[] = []
|
||||
const delivery = notifications.delivery.bind(notifications)
|
||||
// Keep persistence and preference resolution real; replace only external channel delivery.
|
||||
notifications.delivery = db => {
|
||||
const sender = delivery(db)
|
||||
sender.deliver = async item => {
|
||||
delivered.push(item)
|
||||
await db.update(notificationsItems).set({ status: "sent", sentAt: new Date() }).where(eq(notificationsItems.id, item.id))
|
||||
return { success: true, id: item.id, channel: item.channel }
|
||||
}
|
||||
return sender
|
||||
}
|
||||
await app.register(federationRoutes, { federation })
|
||||
await app.ready()
|
||||
servers.set(federation.origin, app)
|
||||
return { ...fixture, service, federation, notifications, app, delivered, ctx: await service.userContext(userId, 1), other: await service.userContext(userId, 2),
|
||||
items: async () => (await fixture.pool.query("SELECT * FROM notifications_items ORDER BY created_at,id")).rows }
|
||||
}
|
||||
const a = await make("alpha"), b = await make("beta")
|
||||
try {
|
||||
await a.federation.settings(a.ctx, { origin: b.federation.origin, active: true })
|
||||
await b.federation.settings(b.ctx, { origin: a.federation.origin, active: true })
|
||||
const contact = await a.federation.resolveContact(a.ctx, b.federation.contact(b.ctx.principal))
|
||||
const room = await a.service.createRoom(a.ctx, { name: "Gemeinsamer Chat" })
|
||||
const before = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Private history" })
|
||||
let bindingId: string
|
||||
const jobFor = async (invitationId: string) => (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitationId}`])).rows[0]
|
||||
const itemsFor = async (instance: typeof a, id: string) => (await instance.items()).filter(item => item.payload.messageId === id)
|
||||
const enable = async (instance: typeof a, tenantId = 1, enabled = true) => instance.notifications.delivery(instance.db).savePreferences(tenantId, instance.ctx.userId,
|
||||
["email", "push"].map(channel => ({ eventType: MESSAGE, channel, enabled })))
|
||||
|
||||
await t.test("new matrix events have quiet message defaults and per-tenant overrides", async () => {
|
||||
const preferences = await b.notifications.delivery(b.db).preferences(1, b.ctx.userId)
|
||||
assert.deepEqual(preferences.events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
|
||||
assert.deepEqual(preferences.events.find(e => e.eventKey === INVITATION)!.channels, { email: false, push: true })
|
||||
await enable(a); await enable(b)
|
||||
assert.deepEqual((await b.notifications.delivery(b.db).preferences(2, b.ctx.userId)).events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
|
||||
})
|
||||
await t.test("signed invitations notify only their named recipient, once despite redelivery", async () => {
|
||||
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
|
||||
const job = await jobFor(invitation.id)
|
||||
await a.federation.deliver(job)
|
||||
bindingId = (await b.federation.list(b.ctx)).invitations[0].id
|
||||
await a.pool.query("UPDATE collaboration_outbox SET status='pending' WHERE id=$1", [job.id])
|
||||
await a.federation.deliver(job)
|
||||
const items = await b.items()
|
||||
assert.equal(items.length, 2)
|
||||
assert.deepEqual(items.map(i => i.channel).sort(), ["inapp", "push"])
|
||||
assert.ok(items.every(i => i.user_id === b.ctx.userId && Number(i.tenant_id) === 1 && i.event_type === INVITATION))
|
||||
assert.equal((await a.items()).length, 0)
|
||||
await b.notifications.tick()
|
||||
assert.equal(b.delivered.length, 2)
|
||||
await b.federation.accept(b.ctx, bindingId)
|
||||
assert.equal((await itemsFor(b, before.id)).length, 0)
|
||||
})
|
||||
await t.test("new remote messages notify once with the local room link, including simultaneous syncs", async () => {
|
||||
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Hallo Beta" })
|
||||
await Promise.all([b.federation.memberRequest(b.ctx, bindingId, "messages"), b.federation.memberRequest(b.ctx, bindingId, "messages")])
|
||||
const items = await itemsFor(b, message.id)
|
||||
assert.equal(items.length, 3)
|
||||
assert.ok(items.every(i => i.message.includes("Hallo Beta") && i.payload.link === `/communication/chat?room=collaboration%3A${bindingId}`))
|
||||
assert.equal((await itemsFor(a, message.id)).length, 0)
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "messages", { before: message.seq + 1 })
|
||||
assert.equal((await itemsFor(b, message.id)).length, 3)
|
||||
await b.notifications.tick()
|
||||
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 3)
|
||||
})
|
||||
await t.test("remote replies notify the owner's local members, never the sender or retry twice", async () => {
|
||||
const input = { clientId: randomUUID(), text: "Hallo Alpha" }
|
||||
const reply = (await b.federation.memberRequest(b.ctx, bindingId, "send", input)).result
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "send", input)
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||
assert.equal((await itemsFor(a, reply.id)).length, 3)
|
||||
assert.equal((await itemsFor(b, reply.id)).length, 0)
|
||||
assert.ok((await itemsFor(a, reply.id)).every(i => Number(i.tenant_id) === 1 && i.user_id === a.ctx.userId))
|
||||
await a.notifications.tick()
|
||||
})
|
||||
await t.test("reading before delivery suppresses email and push, and new preferences take effect", async () => {
|
||||
const read = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Already read" })
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "read", { seq: read.seq })
|
||||
await b.notifications.tick()
|
||||
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === read.id).map(i => i.channel), ["inapp"])
|
||||
const muted = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Muted before delivery" })
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||
await enable(b, 1, false)
|
||||
await b.notifications.tick()
|
||||
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === muted.id).map(i => i.channel), ["inapp"])
|
||||
await enable(b)
|
||||
})
|
||||
await t.test("offline jobs survive a worker restart and concurrent workers deliver each item once", async () => {
|
||||
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Wait for access confirmation" })
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||
offline = true
|
||||
await b.notifications.tick()
|
||||
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
|
||||
assert.ok((await itemsFor(b, message.id)).every(i => i.status === "queued"))
|
||||
offline = false
|
||||
const restarted = new CollaborationNotifications(b.app, b.service, b.federation)
|
||||
restarted.delivery = b.notifications.delivery
|
||||
await Promise.all([b.notifications.tick(), restarted.tick()])
|
||||
const delivered = b.delivered.filter(i => i.payload.messageId === message.id)
|
||||
assert.equal(delivered.length, 3)
|
||||
assert.equal(new Set(delivered.map(i => i.id)).size, 3)
|
||||
})
|
||||
await t.test("revoked access cancels queued remote messages and reinvitation cannot replay old ones", async () => {
|
||||
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Revoked before delivery" })
|
||||
await b.federation.memberRequest(b.ctx, bindingId, "messages")
|
||||
const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id)
|
||||
await a.service.revoke(a.ctx, room.id, member.id)
|
||||
await b.notifications.tick()
|
||||
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
|
||||
assert.equal((await itemsFor(b, message.id)).length, 0)
|
||||
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
|
||||
await a.federation.deliver(await jobFor(invitation.id))
|
||||
await b.federation.accept(b.ctx, bindingId)
|
||||
await b.notifications.tick()
|
||||
assert.equal((await itemsFor(b, message.id)).length, 0)
|
||||
})
|
||||
await t.test("local cross-tenant invitations and messages use recipient scope and honor expiry", async () => {
|
||||
const local = await a.service.createRoom(a.ctx, { name: "Tenant exchange" })
|
||||
const invitation = await a.service.invite(a.ctx, local.id, a.other.principal, {})
|
||||
const invites = (await a.items()).filter(i => i.payload.invitationId === invitation.id)
|
||||
assert.equal(invites.length, 2)
|
||||
assert.ok(invites.every(i => Number(i.tenant_id) === 2))
|
||||
await a.service.accept(a.other.principal, invitation.id)
|
||||
const first = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Only tenant two" })
|
||||
assert.deepEqual((await itemsFor(a, first.id)).map(i => [Number(i.tenant_id), i.channel]), [[2, "inapp"]])
|
||||
await a.pool.query("UPDATE collaboration_members SET expires_at=now()-interval '1 second' WHERE room_id=$1 AND principal_id=$2", [local.id, a.other.principal.id])
|
||||
await a.notifications.tick()
|
||||
assert.equal(a.delivered.filter(i => i.payload.messageId === first.id).length, 0)
|
||||
const second = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Expired member" })
|
||||
assert.equal((await itemsFor(a, second.id)).length, 0)
|
||||
})
|
||||
await t.test("message and notification records roll back together", async () => {
|
||||
const hook = a.notifications.localMessage.bind(a.notifications)
|
||||
a.notifications.localMessage = async (...args) => { await hook(...args); throw new Error("Rollback after enqueue") }
|
||||
const clientId = randomUUID(), count = (await a.items()).length
|
||||
try {
|
||||
await assert.rejects(b.federation.call(1, a.federation.origin, "messages.send", { actorId: b.ctx.principal.id, roomId: room.id,
|
||||
input: { clientId, text: "Must roll back", revision: Number((await b.federation.binding(b.ctx, bindingId)).revision) } }), /Rollback/)
|
||||
} finally { a.notifications.localMessage = hook }
|
||||
assert.equal((await a.items()).length, count)
|
||||
assert.equal((await a.pool.query("SELECT id FROM collaboration_messages WHERE client_id=$1", [clientId])).rowCount, 0)
|
||||
})
|
||||
} finally {
|
||||
await a.app.close(); await b.app.close()
|
||||
await a.close(); await b.close()
|
||||
}
|
||||
})
|
||||
@@ -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}`
|
||||
})
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
233
backend/tests/userArchive.test.ts
Normal file
233
backend/tests/userArchive.test.ts
Normal file
@@ -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<string, any>
|
||||
|
||||
// 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<string, Row[]> = {
|
||||
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)
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['changed', 'unauthorized'])
|
||||
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('')
|
||||
const room = ref(null), messages = ref([]), members = ref([]), error = ref('')
|
||||
// Guest access has separate credentials and must never reuse an internal user's drafts.
|
||||
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => `collaboration:${props.roomId}`, { persist: () => props.base === '/api/collaboration' })
|
||||
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
||||
let generation = 0, timer, polling = false, pending = null
|
||||
let generation = 0, timer, polling = false
|
||||
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
||||
const merge = (incoming) => {
|
||||
const byId = new Map(messages.value.map(m => [m.id, m]))
|
||||
@@ -20,7 +22,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
|
||||
function failed(e) {
|
||||
error.value = messageError(e)
|
||||
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
||||
room.value = null; messages.value = []; members.value = []; draftImage.value = null
|
||||
room.value = null; messages.value = []; members.value = []
|
||||
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
||||
}
|
||||
}
|
||||
@@ -32,7 +34,7 @@ async function read(id, version) {
|
||||
}
|
||||
async function load() {
|
||||
const version = ++generation, id = props.roomId
|
||||
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
|
||||
room.value = null; messages.value = []; members.value = []; error.value = ''
|
||||
loading.value = true; sending.value = false; olderLoading.value = false
|
||||
try {
|
||||
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
|
||||
@@ -78,15 +80,18 @@ async function send() {
|
||||
const text = draft.value.trim(), image = draftImage.value
|
||||
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
||||
const version = generation, id = props.roomId
|
||||
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() }
|
||||
const savedDraft = startDraftSend()
|
||||
if (!savedDraft) return
|
||||
const pending = { text, image, clientId: savedDraft.value.clientId }
|
||||
sending.value = true; error.value = ''
|
||||
try {
|
||||
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
||||
clearDraft(savedDraft)
|
||||
if (version !== generation) return
|
||||
merge([message]); draft.value = ''; draftImage.value = null; pending = null
|
||||
merge([message])
|
||||
await scroll(); await read(id, version)
|
||||
} catch (e) { if (version === generation) failed(e) }
|
||||
finally { if (version === generation) sending.value = false }
|
||||
finally { finishDraftSend(savedDraft); if (version === generation) sending.value = false }
|
||||
}
|
||||
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||
watch(() => props.roomId, load, { immediate: true })
|
||||
@@ -126,6 +131,6 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
||||
</div>
|
||||
</ChatReactions>
|
||||
</div>
|
||||
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
|
||||
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
57
frontend/components/ColorInput.vue
Normal file
57
frontend/components/ColorInput.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Farbe'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const pickerValue = computed(() => {
|
||||
const value = props.modelValue?.trim() || ''
|
||||
if (/^#[\da-f]{6}$/i.test(value)) return value
|
||||
if (/^#[\da-f]{3}$/i.test(value)) {
|
||||
return `#${value.slice(1).split('').map(character => character.repeat(2)).join('')}`
|
||||
}
|
||||
return '#2563eb'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InputGroup class="w-full">
|
||||
<input
|
||||
type="color"
|
||||
:value="pickerValue"
|
||||
:disabled="disabled"
|
||||
:aria-label="`${label} auswählen`"
|
||||
class="h-9 w-14 shrink-0 cursor-pointer rounded border border-default bg-transparent p-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@input="emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<UInput
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:aria-label="`${label} als Farbwert`"
|
||||
class="min-w-0 flex-1"
|
||||
placeholder="Standardfarbe"
|
||||
@update:model-value="emit('update:modelValue', $event || null)"
|
||||
/>
|
||||
<UButton
|
||||
type="button"
|
||||
:disabled="disabled || !modelValue"
|
||||
:aria-label="`${label} zurücksetzen`"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="emit('update:modelValue', null)"
|
||||
/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
@@ -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 () => {
|
||||
<template>
|
||||
<UDashboardNavbar
|
||||
v-if="!props.inModal"
|
||||
:title="item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`"
|
||||
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
|
||||
>
|
||||
<template #toggle>
|
||||
<div v-if="platform === 'mobile'"></div>
|
||||
</template>
|
||||
<template #left>
|
||||
<template #leading>
|
||||
<UButton
|
||||
icon="i-heroicons-chevron-left"
|
||||
variant="outline"
|
||||
aria-label="Zurück"
|
||||
@click="router.back()"
|
||||
>
|
||||
</UButton>
|
||||
</template>
|
||||
<template #center>
|
||||
<h1
|
||||
v-if="item"
|
||||
:class="['text-xl','font-medium', 'text-center']"
|
||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
</template>
|
||||
<template #right>
|
||||
<ArchiveButton
|
||||
color="error"
|
||||
@@ -517,7 +515,7 @@ const updateItem = async () => {
|
||||
Erstellen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`)"
|
||||
@click="router.push(getPostSaveRoute() || (item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`))"
|
||||
color="red"
|
||||
class="ml-1"
|
||||
>
|
||||
@@ -527,14 +525,9 @@ const updateItem = async () => {
|
||||
</UDashboardNavbar>
|
||||
<UDashboardNavbar
|
||||
v-else
|
||||
:title="props.floatingWindow ? '' : (item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`)"
|
||||
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
|
||||
>
|
||||
<template #center>
|
||||
<h1
|
||||
v-if="item && !props.floatingWindow"
|
||||
:class="['text-xl', 'font-medium']"
|
||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
</template>
|
||||
<template #right>
|
||||
<UButton
|
||||
v-if="item.id"
|
||||
@@ -586,7 +579,14 @@ const updateItem = async () => {
|
||||
:item="item"
|
||||
/>
|
||||
</template>
|
||||
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
|
||||
<ColorInput
|
||||
v-if="datapoint.inputType === 'color'"
|
||||
:model-value="getDatapointValue(datapoint)"
|
||||
:label="datapoint.label"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
@update:model-value="(value) => setDatapointValue(datapoint, value)"
|
||||
/>
|
||||
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
|
||||
<UInput
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@@ -833,7 +833,14 @@ const updateItem = async () => {
|
||||
:item="item"
|
||||
/>
|
||||
</template>
|
||||
<InputGroup class="w-full" v-if="datapoint.key.includes('.')">
|
||||
<ColorInput
|
||||
v-if="datapoint.inputType === 'color'"
|
||||
:model-value="getDatapointValue(datapoint)"
|
||||
:label="datapoint.label"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
@update:model-value="(value) => setDatapointValue(datapoint, value)"
|
||||
/>
|
||||
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
|
||||
<UInput
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
|
||||
@@ -124,6 +124,16 @@ const invoiceDeliveryNotes = () => {
|
||||
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`)
|
||||
}
|
||||
|
||||
const createAdvanceInvoice = () => {
|
||||
if (props.topLevelType !== 'projects' || !props.item.id) return
|
||||
|
||||
const query = new URLSearchParams(props.queryStringData)
|
||||
query.set('type', 'advanceInvoices')
|
||||
query.set('loadMode', 'advanceInvoice')
|
||||
query.set('project', props.item.id)
|
||||
router.push(`/createDocument/edit?${query.toString()}`)
|
||||
}
|
||||
|
||||
const showFinalInvoiceConfig = ref(false)
|
||||
const referenceDocument = ref(null)
|
||||
const advanceInvoicesToAdd = ref([])
|
||||
@@ -169,6 +179,13 @@ const selectItem = (item) => {
|
||||
>
|
||||
Dokument aus Vorlage
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="props.topLevelType === 'projects'"
|
||||
icon="i-heroicons-plus"
|
||||
@click="createAdvanceInvoice"
|
||||
>
|
||||
Abschlagsrechnung
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="showFinalInvoiceConfig = true"
|
||||
v-if="props.topLevelType === 'projects'"
|
||||
|
||||
@@ -38,6 +38,12 @@ const isRouteActive = (to) => {
|
||||
|
||||
const links = computed(() => {
|
||||
const organisationChildren = [
|
||||
has("events") && featureEnabled("calendar") ? {
|
||||
label: "Termine",
|
||||
to: "/events",
|
||||
icon: "i-heroicons-calendar-days",
|
||||
active: route.path.startsWith("/standardEntity/events")
|
||||
} : null,
|
||||
has("tasks") && featureEnabled("tasks") ? {
|
||||
label: "Aufgaben",
|
||||
to: "/tasks",
|
||||
@@ -318,6 +324,11 @@ const links = computed(() => {
|
||||
]
|
||||
|
||||
const settingsChildren = [
|
||||
{
|
||||
label: "Benachrichtigungen",
|
||||
to: "/settings/notifications",
|
||||
icon: "i-heroicons-bell",
|
||||
},
|
||||
featureEnabled("settingsNumberRanges") ? {
|
||||
label: "Nummernkreise",
|
||||
to: "/settings/numberRanges",
|
||||
|
||||
@@ -18,9 +18,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(["updateNeeded","returnData"])
|
||||
|
||||
const documentTypeToUse = ref(props.type)
|
||||
const documentTypeToUse = ref(props.type === 'advanceInvoices' ? null : props.type)
|
||||
const documentTypeItems = computed(() => {
|
||||
return Object.keys(dataStore.documentTypesForCreation).map((key) => ({
|
||||
return Object.keys(dataStore.documentTypesForCreation).filter(key => key !== 'advanceInvoices').map((key) => ({
|
||||
...dataStore.documentTypesForCreation[key],
|
||||
key
|
||||
}))
|
||||
@@ -83,6 +83,8 @@ const mappings = ref({
|
||||
})
|
||||
|
||||
const startImport = () => {
|
||||
if (!selectedDocumentType.value) return
|
||||
|
||||
router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`)
|
||||
modal.close()
|
||||
}
|
||||
@@ -147,7 +149,7 @@ const startImport = () => {
|
||||
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton @click="startImport">
|
||||
<UButton :disabled="!selectedDocumentType" @click="startImport">
|
||||
Kopieren
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,11 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
|
||||
.filter(([key]) => key !== 'serialInvoices')
|
||||
.filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key))
|
||||
.map(([key, value]) => ({ key, ...value })))
|
||||
const visibleTemplates = computed(() => templates.value.filter(template => {
|
||||
return !selectedType.value || template.documentType === selectedType.value
|
||||
return template.documentType !== 'advanceInvoices'
|
||||
&& (!selectedType.value || template.documentType === selectedType.value)
|
||||
}))
|
||||
|
||||
const loadTemplates = async () => {
|
||||
@@ -25,6 +26,8 @@ const loadTemplates = async () => {
|
||||
}
|
||||
|
||||
const selectTemplate = (template) => {
|
||||
if (template.documentType === 'advanceInvoices') return
|
||||
|
||||
const query = new URLSearchParams(props.queryStringData)
|
||||
query.set('templateId', template.id)
|
||||
query.set('type', template.documentType)
|
||||
|
||||
@@ -9,10 +9,12 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
|
||||
.filter(([key]) => key !== 'serialInvoices')
|
||||
.filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key))
|
||||
.map(([key, value]) => ({ key, ...value })))
|
||||
|
||||
const createDocument = (type) => {
|
||||
if (!documentTypes.value.some(documentType => documentType.key === type)) return
|
||||
|
||||
const query = new URLSearchParams(props.queryStringData)
|
||||
query.set('type', type)
|
||||
if (props.projectId) query.set('project', props.projectId)
|
||||
|
||||
@@ -23,11 +23,13 @@ export type AdminUserProfile = {
|
||||
last_name: string
|
||||
email?: string | null
|
||||
active: boolean
|
||||
archived_at: string | null
|
||||
}
|
||||
|
||||
export type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
archived_at: string | null
|
||||
display_name: string
|
||||
multiTenant: boolean
|
||||
must_change_password: boolean
|
||||
@@ -161,8 +163,8 @@ export type CreateInstanceAgentResult = {
|
||||
export const useAdmin = () => {
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
const getOverview = async (): Promise<AdminOverview> => {
|
||||
const response = await $api("/api/admin/overview") as any
|
||||
const getOverview = async (includeArchived = false): Promise<AdminOverview> => {
|
||||
const response = await $api("/api/admin/overview", { query: { includeArchived } }) as any
|
||||
|
||||
return {
|
||||
users: response?.users || [],
|
||||
@@ -193,6 +195,10 @@ export const useAdmin = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const archiveUser = async (id: string) => {
|
||||
return await $api(`/api/admin/users/${id}/archive`, { method: "POST" })
|
||||
}
|
||||
|
||||
const resetUserPassword = async (id: string, password?: string) => {
|
||||
return await $api(`/api/admin/users/${id}/reset-password`, {
|
||||
method: "POST",
|
||||
@@ -294,6 +300,7 @@ export const useAdmin = () => {
|
||||
createUserForProfile,
|
||||
updateUser,
|
||||
resetUserPassword,
|
||||
archiveUser,
|
||||
updateUserAccess,
|
||||
createTenant,
|
||||
invitePortalUser,
|
||||
|
||||
43
frontend/composables/useChatDraft.js
Normal file
43
frontend/composables/useChatDraft.js
Normal file
@@ -0,0 +1,43 @@
|
||||
export function useChatDraft(roomKey, { persist = true } = {}) {
|
||||
const auth = useAuthStore()
|
||||
const temp = useTempStore()
|
||||
const apiBase = useRuntimeConfig().public.apiBase
|
||||
const localDraft = ref(null)
|
||||
const key = computed(() => {
|
||||
const room = toValue(roomKey), user = auth.user?.id || auth.user?.user_id
|
||||
return toValue(persist) && room && user && auth.activeTenant
|
||||
? JSON.stringify([apiBase, user, String(auth.activeTenant), auth.profile?.id || '', room])
|
||||
: null
|
||||
})
|
||||
watch([key, () => toValue(roomKey), () => auth.user?.id || auth.user?.user_id,
|
||||
() => auth.activeTenant, () => auth.profile?.id], () => { localDraft.value = null }, { flush: 'sync' })
|
||||
const current = () => key.value ? temp.getChatDraft(key.value) : localDraft.value
|
||||
function update(patch) {
|
||||
const value = { text: '', image: null, ...current(), ...patch, clientId: crypto.randomUUID() }
|
||||
const next = value.text || value.image ? value : null
|
||||
if (key.value) temp.setChatDraft(key.value, next)
|
||||
else localDraft.value = next
|
||||
}
|
||||
const draft = computed({ get: () => current()?.text || '', set: text => update({ text }) })
|
||||
const draftImage = computed({ get: () => current()?.image || null, set: image => update({ image }) })
|
||||
const draftSending = computed(() => !!key.value && temp.isChatDraftSending(key.value))
|
||||
const captureDraft = () => ({ key: key.value, value: current() })
|
||||
function startDraftSend() {
|
||||
if (draftSending.value) return null
|
||||
const snapshot = captureDraft()
|
||||
if (!snapshot.value) return null
|
||||
if (snapshot.key) temp.startChatDraftSend(snapshot.key, snapshot.value.clientId)
|
||||
return snapshot
|
||||
}
|
||||
function finishDraftSend(snapshot) {
|
||||
if (snapshot.key) temp.finishChatDraftSend(snapshot.key, snapshot.value.clientId)
|
||||
}
|
||||
function clearDraft(snapshot) {
|
||||
const saved = snapshot.key ? temp.getChatDraft(snapshot.key) : localDraft.value
|
||||
// A late send response must only remove the draft that was actually sent.
|
||||
if (!snapshot.value || saved?.clientId !== snapshot.value.clientId) return
|
||||
if (snapshot.key) temp.setChatDraft(snapshot.key, null)
|
||||
else localDraft.value = null
|
||||
}
|
||||
return { draft, draftImage, draftSending, captureDraft, clearDraft, startDraftSend, finishDraftSend }
|
||||
}
|
||||
@@ -11,6 +11,10 @@ const userId = route.params.id as string
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const resettingPassword = ref(false)
|
||||
const archiving = ref(false)
|
||||
const archiveModalOpen = ref(false)
|
||||
const profileStore = useProfileStore()
|
||||
const isOwnUser = computed(() => userId === (auth.user?.id || auth.user?.user_id))
|
||||
const resetPasswordModalOpen = ref(false)
|
||||
const resetPasswordInput = ref("")
|
||||
const resetPasswordResult = ref("")
|
||||
@@ -102,7 +106,7 @@ const fetchUser = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const overview = await admin.getOverview()
|
||||
const overview = await admin.getOverview(true)
|
||||
roles.value = overview.roles
|
||||
tenants.value = overview.tenants
|
||||
unassignedProfiles.value = overview.unassignedProfiles
|
||||
@@ -136,7 +140,7 @@ const fetchUser = async () => {
|
||||
}
|
||||
|
||||
const saveUser = async () => {
|
||||
if (!userForm.value || saving.value) return
|
||||
if (!userForm.value || userForm.value.archived_at || saving.value || archiving.value) return
|
||||
|
||||
saving.value = true
|
||||
normalizeUserAssignments()
|
||||
@@ -172,8 +176,24 @@ const saveUser = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const archiveUser = async () => {
|
||||
if (!userForm.value || userForm.value.archived_at || archiving.value || isOwnUser.value) return
|
||||
archiving.value = true
|
||||
try {
|
||||
await admin.archiveUser(userForm.value.id)
|
||||
archiveModalOpen.value = false
|
||||
await fetchUser()
|
||||
if (auth.activeTenant) await profileStore.fetchData()
|
||||
toast.add({ title: "Benutzer dauerhaft archiviert", description: "Alle zugehörigen Profile wurden archiviert und ausgeblendet.", color: "success" })
|
||||
} catch (err: any) {
|
||||
toast.add({ title: "Benutzer konnte nicht archiviert werden", description: err?.data?.error || err?.message || "Unbekannter Fehler", color: "error" })
|
||||
} finally {
|
||||
archiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (!userForm.value || resettingPassword.value) return
|
||||
if (!userForm.value || userForm.value.archived_at || resettingPassword.value || archiving.value) return
|
||||
|
||||
const password = resetPasswordInput.value.trim()
|
||||
if (password && password.length < 8) {
|
||||
@@ -248,21 +268,40 @@ onMounted(async () => {
|
||||
</UButton>
|
||||
</template>
|
||||
<template #right>
|
||||
<UButton
|
||||
v-if="userForm && !userForm.archived_at && !isOwnUser"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-heroicons-archive-box"
|
||||
:disabled="saving || resettingPassword || archiving"
|
||||
@click="archiveModalOpen = true"
|
||||
>
|
||||
Dauerhaft archivieren
|
||||
</UButton>
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-heroicons-key"
|
||||
:disabled="loading || !userForm || !!userForm.archived_at || archiving || saving"
|
||||
@click="openResetPasswordModal"
|
||||
>
|
||||
Passwort zurücksetzen
|
||||
</UButton>
|
||||
<UButton color="primary" :loading="saving" @click="saveUser">
|
||||
<UButton color="primary" :loading="saving" :disabled="loading || !userForm || !!userForm.archived_at || archiving || resettingPassword" @click="saveUser">
|
||||
Speichern
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<UAlert
|
||||
v-if="userForm?.archived_at"
|
||||
title="Benutzer dauerhaft archiviert"
|
||||
:description="`Archiviert am ${new Date(userForm.archived_at).toLocaleString('de-DE')}. Die Anmeldung ist gesperrt. Alle zugehörigen Profile sind archiviert und ausgeblendet.`"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
class="mb-4"
|
||||
/>
|
||||
<UCard v-if="!loading && userForm">
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
@@ -273,7 +312,7 @@ onMounted(async () => {
|
||||
|
||||
<USeparator label="Benutzer" />
|
||||
|
||||
<UForm :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UForm :disabled="!!userForm.archived_at || archiving" :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormField label="E-Mail">
|
||||
<UInput v-model="userForm.email" />
|
||||
</UFormField>
|
||||
@@ -341,6 +380,7 @@ onMounted(async () => {
|
||||
|
||||
<UFormField label="Rolle">
|
||||
<USelectMenu
|
||||
:disabled="!!userForm.archived_at || archiving"
|
||||
:model-value="getRoleForTenant(tenantId)"
|
||||
:items="getRoleOptionsForTenant(tenantId)"
|
||||
value-key="value"
|
||||
@@ -352,6 +392,7 @@ onMounted(async () => {
|
||||
|
||||
<UFormField label="Freies Profil">
|
||||
<USelectMenu
|
||||
:disabled="!!userForm.archived_at || archiving"
|
||||
:model-value="getProfileAssignmentForTenant(tenantId)"
|
||||
:items="[
|
||||
{ label: 'Neues Profil erzeugen', value: null },
|
||||
@@ -390,7 +431,7 @@ onMounted(async () => {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
>
|
||||
{{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}
|
||||
{{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}{{ profile.archived_at ? " · Archiviert" : "" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</UCard>
|
||||
@@ -398,6 +439,27 @@ onMounted(async () => {
|
||||
<USkeleton v-if="loading" class="h-80" />
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="archiveModalOpen" :dismissible="!archiving">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="text-lg font-semibold">Benutzer dauerhaft archivieren</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<p><strong>{{ userForm?.display_name }}</strong> ({{ userForm?.email }}) wird dauerhaft archiviert.</p>
|
||||
<p>Die Anmeldung wird gesperrt, bestehende Sitzungen und API-Schlüssel werden ungültig. Alle {{ userForm?.profiles.length || 0 }} zugehörigen Profile werden in sämtlichen Tenants archiviert und aus Listen und Auswahllisten ausgeblendet.</p>
|
||||
<p>Bestehende Dokumente, Zeiterfassungen und Zuordnungen bleiben erhalten. Die Archivierung kann nicht rückgängig gemacht werden.</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton variant="soft" color="neutral" :disabled="archiving" @click="archiveModalOpen = false">Abbrechen</UButton>
|
||||
<UButton color="error" icon="i-heroicons-archive-box" :loading="archiving" @click="archiveUser">Dauerhaft archivieren</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="resetPasswordModalOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
|
||||
@@ -12,6 +12,7 @@ const createUserModalOpen = ref(false)
|
||||
const createdUserPassword = ref("")
|
||||
const users = ref<AdminUser[]>([])
|
||||
const searchString = ref("")
|
||||
const showArchived = ref(false)
|
||||
|
||||
const createUserForm = ref({
|
||||
email: "",
|
||||
@@ -27,13 +28,15 @@ const templateColumns = [
|
||||
{ key: "email", label: "E-Mail" },
|
||||
{ key: "tenant_count", label: "Tenants" },
|
||||
{ key: "is_admin", label: "Admin" },
|
||||
{ key: "status", label: "Status" },
|
||||
]
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const search = searchString.value.trim().toLowerCase()
|
||||
const rows = users.value.map((user) => ({
|
||||
const rows = users.value.filter((user) => showArchived.value || !user.archived_at).map((user) => ({
|
||||
...user,
|
||||
tenant_count: user.tenant_ids.length,
|
||||
status: user.archived_at ? "Archiviert" : "Aktiv",
|
||||
is_admin: user.is_admin ? "Ja" : "Nein",
|
||||
}))
|
||||
|
||||
@@ -50,7 +53,7 @@ const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const overview = await admin.getOverview()
|
||||
const overview = await admin.getOverview(true)
|
||||
users.value = overview.users
|
||||
} catch (err: any) {
|
||||
console.error("[administration/users/index]", err)
|
||||
@@ -119,6 +122,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<UDashboardNavbar title="Administration: Benutzer" :badge="filteredRows.length">
|
||||
<template #right>
|
||||
<UCheckbox v-model="showArchived" label="Archivierte anzeigen" />
|
||||
<UInput
|
||||
v-model="searchString"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
|
||||
@@ -7,12 +7,13 @@ const router = useRouter()
|
||||
|
||||
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
|
||||
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
|
||||
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null)
|
||||
const unreadRooms = ref({}), messages = ref([]), members = ref([])
|
||||
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
|
||||
const activeRoomKey = ref(requestedRoom())
|
||||
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
|
||||
const showRoomForm = ref(false), messagesViewport = ref(null)
|
||||
const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('')
|
||||
const roomSearch = ref('')
|
||||
const preferencesReady = ref(false)
|
||||
const roomForm = reactive({ name: '', key: '', topic: '' })
|
||||
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
|
||||
@@ -42,14 +43,24 @@ const decoratedRooms = computed(() => [
|
||||
])
|
||||
const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key)))
|
||||
const hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key)))
|
||||
const normalizeRoomSearch = value => String(value || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss')
|
||||
const roomSearchTerms = computed(() => normalizeRoomSearch(roomSearch.value).trim().split(/\s+/).filter(Boolean))
|
||||
function matchesRoomSearch(room) {
|
||||
const text = normalizeRoomSearch([room.name, room.topic, room.projectNumber, room.email, room.ownerName].filter(value => value != null).join(' '))
|
||||
return roomSearchTerms.value.every(term => text.includes(term))
|
||||
}
|
||||
// Filter only the sidebar; the open conversation and its draft keep their selection.
|
||||
const filteredRooms = computed(() => visibleRooms.value.filter(matchesRoomSearch))
|
||||
const filteredHiddenRooms = computed(() => hiddenRooms.value.filter(matchesRoomSearch))
|
||||
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
|
||||
.map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) }))
|
||||
.map(label => ({ label, rooms: filteredRooms.value.filter(room => room.group === label) }))
|
||||
.filter(group => group.rooms.length))
|
||||
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
|
||||
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
|
||||
const isInternal = computed(() => activeRoom.value?.source === 'internal')
|
||||
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => isInternal.value ? activeRoomKey.value : '')
|
||||
|
||||
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false }
|
||||
function clearMessages() { messages.value = []; members.value = []; syncAfterId = 0; roomLoading.value = false; sending.value = false }
|
||||
function mergeMessages(incoming) {
|
||||
const byId = new Map(messages.value.map(message => [message.id, message]))
|
||||
for (const message of incoming || []) byId.set(message.id, message)
|
||||
@@ -210,14 +221,17 @@ async function respondToInvitation(id, accept) {
|
||||
async function sendMessage() {
|
||||
const text = draft.value.trim(), image = draftImage.value, request = selection()
|
||||
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
|
||||
sending.value = true; draft.value = ''
|
||||
const savedDraft = startDraftSend()
|
||||
if (!savedDraft) return
|
||||
sending.value = true
|
||||
try {
|
||||
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
|
||||
clearDraft(savedDraft)
|
||||
if (!currentSelection(request)) return
|
||||
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request)
|
||||
mergeMessages([message]); await markRead(request); await scrollToBottom(request)
|
||||
} catch (error) {
|
||||
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
} finally { if (currentSelection(request)) sending.value = false }
|
||||
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
|
||||
} finally { finishDraftSend(savedDraft); if (currentSelection(request)) sending.value = false }
|
||||
}
|
||||
async function pollMessages() {
|
||||
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
|
||||
@@ -242,6 +256,7 @@ async function initialize() {
|
||||
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
|
||||
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
|
||||
hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false
|
||||
roomSearch.value = ''
|
||||
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
|
||||
activeRoomKey.value = requestedRoom(); loading.value = true
|
||||
await Promise.all([refreshLists(), loadRoomPreferences(scope)])
|
||||
@@ -287,6 +302,12 @@ onBeforeUnmount(() => {
|
||||
<UButton icon="i-heroicons-plus" color="neutral" variant="ghost" size="sm" @click="showRoomForm = !showRoomForm" />
|
||||
</div>
|
||||
|
||||
<UInput v-model="roomSearch" icon="i-heroicons-magnifying-glass" placeholder="Chats suchen …" aria-label="Chats suchen" class="mb-4 w-full" @keydown.esc="roomSearch = ''">
|
||||
<template v-if="roomSearch" #trailing>
|
||||
<UButton icon="i-heroicons-x-mark" color="neutral" variant="link" size="xs" aria-label="Chatsuche löschen" @click="roomSearch = ''" />
|
||||
</template>
|
||||
</UInput>
|
||||
|
||||
<form v-if="showRoomForm" class="mb-4 space-y-2 rounded-lg bg-gray-50 p-3" @submit.prevent="createRoom">
|
||||
<UInput v-model="roomForm.name" placeholder="Raumname" autofocus />
|
||||
<UInput v-model="roomForm.key" :placeholder="roomKeyPreview || 'raumschluessel'" />
|
||||
@@ -339,13 +360,14 @@ onBeforeUnmount(() => {
|
||||
<UButton icon="i-heroicons-eye-slash" color="neutral" variant="ghost" size="xs" class="mr-1 shrink-0" :aria-label="`${room.name} ausblenden`" title="Für mich ausblenden" :disabled="!preferencesReady || !!preferenceBusy" @click="setRoomHidden(room, true)" />
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="!visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
|
||||
<section v-if="hiddenRooms.length" aria-label="Ausgeblendete Räume">
|
||||
<p v-if="roomSearchTerms.length && !filteredRooms.length && !filteredHiddenRooms.length" role="status" class="px-2 text-sm text-gray-500">Keine Chats für diese Suche gefunden.</p>
|
||||
<p v-else-if="!roomSearchTerms.length && !visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
|
||||
<section v-if="filteredHiddenRooms.length" aria-label="Ausgeblendete Räume">
|
||||
<button type="button" class="flex w-full items-center justify-between px-2 py-2 text-left text-xs font-semibold text-gray-500" :aria-expanded="showHiddenRooms" @click="showHiddenRooms = !showHiddenRooms">
|
||||
<span>Ausgeblendete Räume ({{ hiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
|
||||
<span>Ausgeblendete Räume ({{ filteredHiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
|
||||
</button>
|
||||
<div v-if="showHiddenRooms" class="space-y-1">
|
||||
<div v-for="room in hiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
|
||||
<div v-for="room in filteredHiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-gray-500">{{ room.name }}</span>
|
||||
<UButton icon="i-heroicons-eye" variant="ghost" color="neutral" size="xs" :aria-label="`${room.name} einblenden`" :disabled="!!preferenceBusy" @click="setRoomHidden(room, false)">Einblenden</UButton>
|
||||
</div>
|
||||
@@ -386,7 +408,7 @@ onBeforeUnmount(() => {
|
||||
</ChatReactions>
|
||||
</div>
|
||||
|
||||
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
|
||||
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
|
||||
</main>
|
||||
|
||||
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
||||
|
||||
@@ -104,10 +104,36 @@ const bankaccounts = ref([])
|
||||
const texttemplates = ref([])
|
||||
const units = ref([])
|
||||
const tenantUsers = ref([])
|
||||
const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation).map((key) => ({
|
||||
const originalDocumentType = ref(null)
|
||||
const canUseAdvanceInvoice = computed(() => originalDocumentType.value === 'advanceInvoices' || (
|
||||
route.query.type === 'advanceInvoices'
|
||||
&& route.query.loadMode === 'advanceInvoice'
|
||||
&& Number.isSafeInteger(Number(route.query.project))
|
||||
&& Number(route.query.project) > 0
|
||||
&& !route.params.id
|
||||
&& !route.query.createddocument
|
||||
&& !route.query.optionsToImport
|
||||
&& !route.query.linkedDocuments
|
||||
&& !route.query.templateId
|
||||
&& !isTemplateMode.value
|
||||
))
|
||||
const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation)
|
||||
.filter(key => key !== 'advanceInvoices' || canUseAdvanceInvoice.value)
|
||||
.map((key) => ({
|
||||
label: dataStore.documentTypesForCreation[key].labelSingle,
|
||||
type: key
|
||||
})))
|
||||
const validateAdvanceInvoiceCreation = () => {
|
||||
if (itemInfo.value.type !== 'advanceInvoices' || isTemplateMode.value) return true
|
||||
if (canUseAdvanceInvoice.value && (route.params.id || normalizeEntityId(itemInfo.value.project))) return true
|
||||
|
||||
toast.add({
|
||||
title: 'Abschlagsrechnung nur im Projekt erstellen',
|
||||
description: 'Bitte den Button „Abschlagsrechnung“ innerhalb eines Projekts verwenden.',
|
||||
color: 'error'
|
||||
})
|
||||
return false
|
||||
}
|
||||
const taxTypeItems = [
|
||||
{ key: 'Standard', label: 'Standard' },
|
||||
{ key: '13b UStG', label: '13b UStG' },
|
||||
@@ -391,6 +417,7 @@ const setupPage = async () => {
|
||||
if (route.params.id) {
|
||||
console.log(route.params)
|
||||
itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false)
|
||||
originalDocumentType.value = itemInfo.value.type
|
||||
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
|
||||
itemInfo.value.taxType = normalizeTaxTypeValue(itemInfo.value.taxType)
|
||||
await setContactPersonData()
|
||||
@@ -699,6 +726,10 @@ const setupPage = async () => {
|
||||
|
||||
|
||||
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
|
||||
if (!validateAdvanceInvoiceCreation()) {
|
||||
await router.replace('/createDocument')
|
||||
return
|
||||
}
|
||||
loaded.value = true
|
||||
|
||||
}
|
||||
@@ -1817,6 +1848,7 @@ const saveDocumentTemplate = async () => {
|
||||
}
|
||||
|
||||
const saveDocument = async (state, resetup = false) => {
|
||||
if (!validateAdvanceInvoiceCreation()) return
|
||||
|
||||
itemInfo.value.state = state
|
||||
normalizeCustomSurchargePercentage()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
import { getPortalColor, portalThemeStyle } from "~/utils/portalTheme"
|
||||
|
||||
definePageMeta({
|
||||
layout: "notLoggedIn"
|
||||
@@ -7,6 +8,7 @@ definePageMeta({
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const portalTheme = computed(() => portalThemeStyle(getPortalColor(auth.activeTenantData?.portalConfig)))
|
||||
|
||||
const customer = ref<any | null>(null)
|
||||
const contracts = ref<any[]>([])
|
||||
@@ -337,7 +339,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100">
|
||||
<div :style="portalTheme" class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100">
|
||||
<UContainer class="py-8 lg:py-12">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-3xl border border-neutral-200 bg-white/90 p-6 shadow-sm lg:p-8">
|
||||
@@ -392,7 +394,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<UCard v-else :ui="{ body: 'p-4 sm:p-6' }">
|
||||
<UTabs v-model="activeTab" :items="portalTabs" class="portal-tabs">
|
||||
<UTabs v-model="activeTab" :items="portalTabs" :ui="{ trigger: 'data-[state=active]:text-[var(--portal-accent-contrast)]' }" class="portal-tabs">
|
||||
<template #customer>
|
||||
<div class="pt-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
@@ -470,7 +472,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<UButton icon="i-heroicons-check" :loading="saving" @click="saveCustomerData">
|
||||
<UButton icon="i-heroicons-check" class="text-[var(--portal-accent-contrast)]" :loading="saving" @click="saveCustomerData">
|
||||
Kundendaten speichern
|
||||
</UButton>
|
||||
</div>
|
||||
@@ -770,7 +772,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</UContainer>
|
||||
|
||||
<UModal v-model:open="contractChangeModalOpen">
|
||||
<UModal v-model:open="contractChangeModalOpen" :content="{ style: portalTheme }">
|
||||
<template #content>
|
||||
<div class="space-y-5 p-6">
|
||||
<div>
|
||||
@@ -787,6 +789,7 @@ onMounted(async () => {
|
||||
<USelectMenu
|
||||
v-model="contractChangeForm.contracttype"
|
||||
:items="selectedContractAllowedContracttypes"
|
||||
:content="{ style: portalTheme }"
|
||||
value-key="id"
|
||||
label-key="name"
|
||||
class="w-full"
|
||||
@@ -813,6 +816,7 @@ onMounted(async () => {
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
class="text-[var(--portal-accent-contrast)]"
|
||||
:loading="submittingContractRequest"
|
||||
:disabled="!contractChangeForm.contracttype"
|
||||
@click="submitContractChangeRequest"
|
||||
@@ -824,7 +828,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="cancellationModalOpen">
|
||||
<UModal v-model:open="cancellationModalOpen" :content="{ style: portalTheme }">
|
||||
<template #content>
|
||||
<div class="space-y-5 p-6">
|
||||
<div>
|
||||
|
||||
206
frontend/pages/events/index.vue
Normal file
206
frontend/pages/events/index.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
definePageMeta({ alias: ['/standardEntity/events'] })
|
||||
useHead({ title: 'Termine · FEDEO' })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { has } = usePermission()
|
||||
const eventEntities = useEntities('events')
|
||||
const canView = computed(() => has('events') && auth.activeTenantData?.features?.calendar !== false)
|
||||
const canCreate = computed(() => canView.value && has('events-create'))
|
||||
const events = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const status = ref('all')
|
||||
const page = ref(1)
|
||||
const pageSize = 25
|
||||
const sortDirection = ref('desc')
|
||||
const createRoute = { path: '/standardEntity/events/create', query: { returnTo: 'events' } }
|
||||
const statusOptions = [
|
||||
{ label: 'Alle Status', value: 'all' },
|
||||
{ label: 'Entwurf', value: 'Entwurf' },
|
||||
{ label: 'Final', value: 'Final' }
|
||||
]
|
||||
const sortOptions = [
|
||||
{ label: 'Beginn: neueste zuerst', value: 'desc' },
|
||||
{ label: 'Beginn: älteste zuerst', value: 'asc' }
|
||||
]
|
||||
const columns = [
|
||||
{ accessorKey: 'name', header: 'Termin' },
|
||||
{ accessorKey: 'startDate', header: 'Zeitraum' },
|
||||
{ accessorKey: 'project', header: 'Projekt' },
|
||||
{ accessorKey: 'state', header: 'Status' },
|
||||
{ id: 'actions', header: '' }
|
||||
]
|
||||
let requestVersion = 0
|
||||
let searchTimer
|
||||
|
||||
const editRoute = (event) => ({
|
||||
path: `/standardEntity/events/edit/${event.id}`,
|
||||
query: { returnTo: 'events' }
|
||||
})
|
||||
const eventTitle = (event) => event.name || event.project?.name || 'Termin ohne Titel'
|
||||
const projectLabel = (event) => {
|
||||
const project = event.project
|
||||
if (!project) return 'Ohne Projekt'
|
||||
if (typeof project !== 'object') return `Projekt ${project}`
|
||||
return [project.projectNumber, project.name].filter(Boolean).join(' · ') || 'Ohne Projekt'
|
||||
}
|
||||
const formatDate = (value) => value && dayjs(value).isValid() ? dayjs(value).format('DD.MM.YYYY HH:mm') : 'Nicht angegeben'
|
||||
const eventColor = (event) => event.color
|
||||
|| (event.quick ? auth.activeTenantData?.calendarConfig?.quickEntry?.color : null)
|
||||
|| auth.activeTenantData?.calendarConfig?.eventTypes?.find((type) => type.label === event.eventtype)?.color
|
||||
|| '#2563eb'
|
||||
|
||||
async function loadEvents() {
|
||||
const version = ++requestVersion
|
||||
if (!canView.value) {
|
||||
events.value = []
|
||||
total.value = 0
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await eventEntities.selectPaginated({
|
||||
select: '*, project(id,name,projectNumber)',
|
||||
filters: { archived: false, ...(status.value === 'all' ? {} : { state: status.value }) },
|
||||
search: search.value.trim(),
|
||||
sort: [{ field: 'startDate', direction: sortDirection.value }],
|
||||
page: page.value,
|
||||
limit: pageSize
|
||||
})
|
||||
if (version !== requestVersion) return
|
||||
events.value = result.data
|
||||
total.value = result.meta?.total || 0
|
||||
} catch {
|
||||
if (version !== requestVersion) return
|
||||
events.value = []
|
||||
total.value = 0
|
||||
error.value = 'Die Termine konnten nicht geladen werden. Bitte versuche es erneut.'
|
||||
} finally {
|
||||
if (version === requestVersion) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reloadFromFirstPage() {
|
||||
clearTimeout(searchTimer)
|
||||
if (page.value !== 1) page.value = 1
|
||||
else loadEvents()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
search.value = ''
|
||||
status.value = 'all'
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
++requestVersion
|
||||
loading.value = true
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(reloadFromFirstPage, 250)
|
||||
})
|
||||
watch([status, sortDirection, canView, () => auth.activeTenant], reloadFromFirstPage)
|
||||
watch(page, loadEvents)
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(searchTimer)
|
||||
++requestVersion
|
||||
})
|
||||
loadEvents()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Termine" :badge="total">
|
||||
<template #right>
|
||||
<UButton v-if="canCreate" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent class="space-y-4">
|
||||
<UAlert v-if="!canView" color="neutral" variant="subtle" title="Termine sind für deinen Zugang nicht verfügbar." />
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Termin, Projekt oder Projektnummer suchen …"
|
||||
aria-label="Termine durchsuchen"
|
||||
class="w-full lg:flex-1"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<USelect v-model="status" :items="statusOptions" aria-label="Status filtern" class="min-w-36 flex-1 lg:flex-none" />
|
||||
<USelect v-model="sortDirection" :items="sortOptions" aria-label="Termine sortieren" class="min-w-48 flex-1 lg:flex-none" />
|
||||
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-path" aria-label="Termine aktualisieren" :loading="loading" @click="loadEvents" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert v-if="error" color="error" variant="subtle" title="Laden fehlgeschlagen" :description="error">
|
||||
<template #actions>
|
||||
<UButton color="error" variant="outline" label="Erneut versuchen" @click="loadEvents" />
|
||||
</template>
|
||||
</UAlert>
|
||||
<div v-else-if="loading" class="space-y-3 py-4" role="status" aria-label="Termine werden geladen">
|
||||
<USkeleton v-for="index in 5" :key="index" class="h-20 w-full rounded-lg" />
|
||||
</div>
|
||||
<div v-else-if="!events.length" class="rounded-xl border border-dashed border-default px-4 py-14 text-center">
|
||||
<UIcon name="i-heroicons-calendar-days" class="mx-auto mb-3 size-10 text-muted" />
|
||||
<h2 class="text-lg font-semibold">{{ search || status !== 'all' ? 'Keine passenden Termine' : 'Noch keine Termine' }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ search || status !== 'all' ? 'Passe deine Suche oder den Statusfilter an.' : 'Erstelle deinen ersten Termin direkt über diese Übersicht.' }}</p>
|
||||
<UButton v-if="search || status !== 'all'" class="mt-4" color="neutral" variant="outline" label="Filter zurücksetzen" @click="resetFilters" />
|
||||
<UButton v-else-if="canCreate" class="mt-4" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<UTable :data="events" :columns="columns" class="hidden rounded-xl border border-default md:block" :ui="{ td: 'align-top' }">
|
||||
<template #name-cell="{ row }">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(row.original) }" aria-hidden="true" />
|
||||
<div class="min-w-0">
|
||||
<NuxtLink :to="editRoute(row.original)" class="block max-w-sm whitespace-normal break-words font-medium text-highlighted hover:text-primary">{{ eventTitle(row.original) }}</NuxtLink>
|
||||
<p v-if="row.original.repeatInterval && row.original.repeatInterval !== 'Keine Wiederholung'" class="mt-1 text-xs text-muted">{{ row.original.repeatInterval }}</p>
|
||||
<p v-if="row.original.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #startDate-cell="{ row }">
|
||||
<div>{{ formatDate(row.original.startDate) }}</div>
|
||||
<div v-if="row.original.endDate" class="mt-1 text-xs text-muted">bis {{ formatDate(row.original.endDate) }}</div>
|
||||
</template>
|
||||
<template #project-cell="{ row }">
|
||||
<span class="block max-w-xs whitespace-normal break-words">{{ projectLabel(row.original) }}</span>
|
||||
</template>
|
||||
<template #state-cell="{ row }">
|
||||
<UBadge :color="row.original.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle">{{ row.original.state || 'Final' }}</UBadge>
|
||||
</template>
|
||||
<template #actions-cell="{ row }">
|
||||
<UButton :to="editRoute(row.original)" color="neutral" variant="ghost" icon="i-heroicons-pencil-square" :aria-label="`${eventTitle(row.original)} bearbeiten`" />
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<div class="space-y-3 md:hidden">
|
||||
<NuxtLink v-for="event in events" :key="event.id" :to="editRoute(event)" class="block rounded-xl border border-default p-4 hover:border-primary">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(event) }" aria-hidden="true" />
|
||||
<h2 class="min-w-0 flex-1 break-words font-semibold text-highlighted">{{ eventTitle(event) }}</h2>
|
||||
<UBadge :color="event.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle" class="shrink-0">{{ event.state || 'Final' }}</UBadge>
|
||||
</div>
|
||||
<p class="mt-3 text-sm">{{ formatDate(event.startDate) }}</p>
|
||||
<p v-if="event.endDate" class="text-sm text-muted">bis {{ formatDate(event.endDate) }}</p>
|
||||
<p class="mt-2 break-words text-sm text-muted">{{ projectLabel(event) }}</p>
|
||||
<p v-if="event.repeatInterval && event.repeatInterval !== 'Keine Wiederholung'" class="mt-2 text-xs text-muted">{{ event.repeatInterval }}</p>
|
||||
<p v-if="event.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-default pt-4">
|
||||
<p class="text-sm text-muted">{{ total }} {{ total === 1 ? 'Termin' : 'Termine' }}</p>
|
||||
<UPagination v-model:page="page" :total="total" :items-per-page="pageSize" :sibling-count="0" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
9
frontend/pages/settings/notifications.vue
Normal file
9
frontend/pages/settings/notifications.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<UDashboardNavbar title="Benachrichtigungen" />
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<UCard class="mx-auto w-full max-w-4xl">
|
||||
<NotificationPreferences />
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TAX_EVALUATION_PERIOD_OPTIONS,
|
||||
normalizeTaxEvaluationPeriod
|
||||
} from "~/composables/useTaxEvaluation"
|
||||
import { DEFAULT_PORTAL_COLOR, getPortalColor, normalizePortalColor, portalThemeStyle } from "~/utils/portalTheme"
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
@@ -260,6 +261,16 @@ const setupPage = async () => {
|
||||
|
||||
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
||||
const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo))
|
||||
const portalColor = ref(getPortalColor(auth.activeTenantData?.portalConfig))
|
||||
const portalColorSaving = ref(false)
|
||||
const validPortalColor = computed(() => normalizePortalColor(portalColor.value))
|
||||
const portalPreviewStyle = computed(() => portalThemeStyle(portalColor.value))
|
||||
watch(() => auth.activeTenant, () => {
|
||||
portalColor.value = getPortalColor(auth.activeTenantData?.portalConfig)
|
||||
})
|
||||
watch(() => auth.activeTenantData?.portalConfig, (config) => {
|
||||
portalColor.value = getPortalColor(config)
|
||||
})
|
||||
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
||||
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
|
||||
const accountChartOptions = [
|
||||
@@ -288,6 +299,18 @@ const saveFeatures = async () => {
|
||||
await updateTenant({features: features.value})
|
||||
}
|
||||
|
||||
const savePortalColor = async () => {
|
||||
if (!validPortalColor.value || portalColorSaving.value) return
|
||||
portalColorSaving.value = true
|
||||
try {
|
||||
const { primayColor, ...portalConfig } = auth.activeTenantData?.portalConfig || {}
|
||||
await updateTenant({ portalConfig: { ...portalConfig, primaryColor: validPortalColor.value } })
|
||||
toast.add({ title: "Akzentfarbe für das Kundenportal gespeichert", color: "success" })
|
||||
} finally {
|
||||
portalColorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatMcpTokenDate = (value) => {
|
||||
if (!value) return "Nie"
|
||||
|
||||
@@ -614,6 +637,8 @@ onMounted(() => {
|
||||
label: 'Dokubox'
|
||||
},{
|
||||
label: 'Rechnung & Kontakt'
|
||||
},{
|
||||
label: 'Kundenportal'
|
||||
},{
|
||||
label: 'Plantafel'
|
||||
},{
|
||||
@@ -755,6 +780,43 @@ onMounted(() => {
|
||||
</UCard>
|
||||
|
||||
</div>
|
||||
<div v-else-if="item.label === 'Kundenportal'">
|
||||
<UCard class="mt-5">
|
||||
<div class="max-w-xl space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Akzentfarbe im Kundenportal</h2>
|
||||
<p class="mt-1 text-sm text-muted">Wähle die Farbe für Schaltflächen, Reiter und Hervorhebungen im Kundenportal deiner Firma.</p>
|
||||
</div>
|
||||
<UFormField
|
||||
label="Akzentfarbe"
|
||||
help="Hex-Farbwert mit sechs Zeichen, zum Beispiel #69c350."
|
||||
:error="!validPortalColor ? 'Bitte einen gültigen Hex-Farbwert eingeben.' : undefined"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
:value="validPortalColor || DEFAULT_PORTAL_COLOR"
|
||||
aria-label="Akzentfarbe auswählen"
|
||||
class="h-10 w-14 shrink-0 cursor-pointer rounded border border-default bg-default p-1"
|
||||
@input="portalColor = $event.target.value"
|
||||
>
|
||||
<UInput v-model="portalColor" aria-label="Akzentfarbe als Hex-Wert" placeholder="#69c350" class="w-40" maxlength="7" />
|
||||
</div>
|
||||
</UFormField>
|
||||
<div :style="portalPreviewStyle" class="rounded-xl border border-default p-5" aria-label="Vorschau der Portal-Akzentfarbe">
|
||||
<p class="mb-3 text-sm font-medium text-primary-600">Vorschau Kundenportal</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<UButton class="text-[var(--portal-accent-contrast)]" tabindex="-1">Kundendaten speichern</UButton>
|
||||
<UBadge color="primary" variant="subtle">Rechnung</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<UButton :loading="portalColorSaving" :disabled="!validPortalColor" @click="savePortalColor">Akzentfarbe speichern</UButton>
|
||||
<UButton color="neutral" variant="outline" :disabled="portalColorSaving" @click="portalColor = DEFAULT_PORTAL_COLOR">FEDEO-Grün verwenden</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
<div v-else-if="item.label === 'Plantafel'">
|
||||
<UCard class="mt-5">
|
||||
<UForm class="w-1/2">
|
||||
|
||||
@@ -3231,7 +3231,7 @@ export const useDataStore = defineStore('data', () => {
|
||||
{
|
||||
key: "color",
|
||||
label: "Farbe",
|
||||
inputType: "text",
|
||||
inputType: "color",
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
@@ -3270,7 +3270,7 @@ export const useDataStore = defineStore('data', () => {
|
||||
inputType: "select",
|
||||
selectDataType: "projects",
|
||||
selectOptionAttribute: "name",
|
||||
selectSearchAttributes: ['name'],
|
||||
selectSearchAttributes: ['name', 'projectNumber'],
|
||||
},{
|
||||
key: "vehicles",
|
||||
label: "Fahrzeuge",
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
})
|
||||
|
||||
const profiles = ref([])
|
||||
const allProfiles = ref([])
|
||||
const ownProfiles = ref([])
|
||||
const activeProfile = ref(null)
|
||||
const tenants = ref([])
|
||||
@@ -60,8 +61,8 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
await fetchOwnProfiles()
|
||||
await fetchProfiles()
|
||||
await fetchOwnProfiles()
|
||||
await fetchTenants()
|
||||
await fetchOwnTenant()
|
||||
loaded.value = true
|
||||
@@ -71,6 +72,7 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
loaded.value = false
|
||||
ownTenant.value = {}
|
||||
profiles.value = []
|
||||
allProfiles.value = []
|
||||
ownProfiles.value = []
|
||||
tenants.value = []
|
||||
}
|
||||
@@ -92,10 +94,13 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
|
||||
async function fetchProfiles() {
|
||||
try {
|
||||
const res = await useNuxtApp().$api("/api/tenant/profiles")
|
||||
profiles.value = res?.data || []
|
||||
const res = await useNuxtApp().$api("/api/tenant/profiles", { query: { includeArchived: true } })
|
||||
// Retain historical names while keeping archived profiles out of selections.
|
||||
allProfiles.value = res?.data || []
|
||||
profiles.value = allProfiles.value.filter(profile => profile.active && !profile.archived_at)
|
||||
} catch (e) {
|
||||
profiles.value = activeProfile.value ? [activeProfile.value] : []
|
||||
allProfiles.value = profiles.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +117,8 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
})
|
||||
|
||||
const getProfileById = computed(() => (itemId) => {
|
||||
return profiles.value.find((item) => item.id === itemId || item.user_id === itemId)
|
||||
const profile = allProfiles.value.find((item) => item.id === itemId || item.user_id === itemId)
|
||||
return profile ? { ...profile, fullName: profile.full_name || profile.fullName } : { fullName: "Unbekanntes Profil" }
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,71 @@ export const useTempStore = defineStore('temp', () => {
|
||||
const pages = ref({})
|
||||
const settings = ref({})
|
||||
|
||||
// Drafts stay local: temp_config is also returned in tenant profile lists.
|
||||
const chatDrafts = ref({})
|
||||
const sendingChatDrafts = ref({})
|
||||
const loadedChatDrafts = new Set()
|
||||
const draftStoragePrefix = 'fedeo:chat-draft:v1:'
|
||||
const toast = useToast()
|
||||
let draftStorageWarningShown = false
|
||||
|
||||
function getChatDraft(key) {
|
||||
if (!loadedChatDrafts.has(key)) {
|
||||
loadedChatDrafts.add(key)
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem(draftStoragePrefix + key) || 'null')
|
||||
if (saved && typeof saved.text === 'string' && typeof saved.clientId === 'string'
|
||||
&& saved.text.length <= 10000 && (!saved.image || (
|
||||
typeof saved.image.name === 'string' && typeof saved.image.dataUrl === 'string'
|
||||
&& saved.image.dataUrl.length <= 480000
|
||||
&& /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/.test(saved.image.dataUrl)
|
||||
))) chatDrafts.value[key] = saved
|
||||
} catch { /* Disabled storage or an invalid entry must not block the chat. */ }
|
||||
}
|
||||
return chatDrafts.value[key] || null
|
||||
}
|
||||
|
||||
function setChatDraft(key, draft) {
|
||||
loadedChatDrafts.add(key)
|
||||
if (draft) chatDrafts.value[key] = draft
|
||||
else delete chatDrafts.value[key]
|
||||
try {
|
||||
if (draft) sessionStorage.setItem(draftStoragePrefix + key, JSON.stringify(draft))
|
||||
else sessionStorage.removeItem(draftStoragePrefix + key)
|
||||
} catch {
|
||||
// Do not restore an older version after a failed storage update.
|
||||
try { sessionStorage.removeItem(draftStoragePrefix + key) } catch {}
|
||||
if (!draftStorageWarningShown) {
|
||||
draftStorageWarningShown = true
|
||||
toast.add({ title: 'Entwurf nur vorübergehend gespeichert', description: 'Der Browserspeicher ist voll oder gesperrt. Bis zum Neuladen bleibt dein Entwurf im Chat erhalten.', color: 'warning' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isChatDraftSending(key) {
|
||||
return !!sendingChatDrafts.value[key]
|
||||
}
|
||||
|
||||
function startChatDraftSend(key, clientId) {
|
||||
sendingChatDrafts.value[key] = clientId
|
||||
}
|
||||
|
||||
function finishChatDraftSend(key, clientId) {
|
||||
if (sendingChatDrafts.value[key] === clientId) delete sendingChatDrafts.value[key]
|
||||
}
|
||||
|
||||
watch(() => auth.user?.id || auth.user?.user_id, (user, previous) => {
|
||||
if (!previous || user === previous) return
|
||||
chatDrafts.value = {}
|
||||
sendingChatDrafts.value = {}
|
||||
loadedChatDrafts.clear()
|
||||
try {
|
||||
for (const key of Object.keys(sessionStorage)) {
|
||||
if (key.startsWith(draftStoragePrefix)) sessionStorage.removeItem(key)
|
||||
}
|
||||
} catch { /* Storage may be unavailable. In-memory drafts are still cleared. */ }
|
||||
}, { flush: 'sync' })
|
||||
|
||||
const storeTempConfig = async () => {
|
||||
const config = {
|
||||
searchStrings: searchStrings.value,
|
||||
@@ -92,6 +157,11 @@ export const useTempStore = defineStore('temp', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
getChatDraft,
|
||||
setChatDraft,
|
||||
isChatDraftSending,
|
||||
startChatDraftSend,
|
||||
finishChatDraftSend,
|
||||
setStoredTempConfig,
|
||||
searchStrings,
|
||||
modifySearchString,
|
||||
|
||||
131
frontend/tests/advance-invoice-creation.test.cjs
Normal file
131
frontend/tests/advance-invoice-creation.test.cjs
Normal file
@@ -0,0 +1,131 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const { parse, compileScript } = require('@vue/compiler-sfc')
|
||||
|
||||
const documentTypesForCreation = Object.fromEntries([
|
||||
'invoices', 'advanceInvoices', 'serialInvoices', 'quotes', 'costEstimates',
|
||||
'deliveryNotes', 'packingSlips', 'confirmationOrders', 'cancellationInvoices',
|
||||
].map(key => [key, { labelSingle: key }]))
|
||||
|
||||
function fixture(filename, names, globals = {}) {
|
||||
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../${filename}`, 'utf8'))
|
||||
compileScript(descriptor, { id: filename, inlineTemplate: true })
|
||||
const script = ts.createSourceFile(filename, descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
|
||||
const source = script.statements.filter(statement => ts.isVariableStatement(statement)
|
||||
&& statement.declarationList.declarations.some(declaration => names.includes(declaration.name.getText(script))))
|
||||
.map(statement => statement.getText(script)).join('\n')
|
||||
const navigations = []
|
||||
const notices = []
|
||||
const context = vm.createContext({
|
||||
ref: value => ({ value }), computed: getter => ({ get value() { return getter() } }),
|
||||
dataStore: { documentTypesForCreation }, URLSearchParams,
|
||||
router: { push: url => navigations.push(url), replace: url => navigations.push(url) },
|
||||
modal: { close() {} }, toast: { add: notice => notices.push(notice) },
|
||||
...globals,
|
||||
})
|
||||
vm.runInContext(source, context)
|
||||
return { context, navigations, notices, run: code => vm.runInContext(code, context) }
|
||||
}
|
||||
|
||||
test('copying any document excludes advance invoices and requires an allowed target', () => {
|
||||
for (const type of Object.keys(documentTypesForCreation)) {
|
||||
const { run, navigations } = fixture('components/copyCreatedDocumentModal.vue', [
|
||||
'documentTypeToUse', 'documentTypeItems', 'selectedDocumentType', 'optionsToImport', 'startImport',
|
||||
], { props: { id: '12', type } })
|
||||
assert.equal(run("documentTypeItems.value.some(item => item.key === 'advanceInvoices')"), false)
|
||||
assert.equal(run('documentTypeToUse.value'), type === 'advanceInvoices' ? null : type)
|
||||
run("documentTypeToUse.value = 'advanceInvoices'; startImport()")
|
||||
assert.equal(navigations.length, 0)
|
||||
run("documentTypeToUse.value = 'invoices'; startImport()")
|
||||
const query = new URL(navigations[0], 'http://localhost').searchParams
|
||||
assert.equal(query.get('type'), 'invoices')
|
||||
assert.equal(query.get('createddocument'), '12')
|
||||
}
|
||||
})
|
||||
|
||||
test('+ Dokument excludes advance invoices even when opened inside a project', () => {
|
||||
for (const queryStringData of ['', 'project=7&customer=8']) {
|
||||
const { run, navigations } = fixture('components/createDocumentModal.vue', ['documentTypes', 'createDocument'], {
|
||||
props: { queryStringData },
|
||||
})
|
||||
assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false)
|
||||
run("createDocument('advanceInvoices')")
|
||||
assert.equal(navigations.length, 0)
|
||||
run("createDocument('quotes')")
|
||||
assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('type'), 'quotes')
|
||||
}
|
||||
})
|
||||
|
||||
test('advance invoice templates cannot create outgoing documents', () => {
|
||||
const { run, navigations } = fixture('components/createDocumentFromTemplateModal.vue', [
|
||||
'templates', 'selectedType', 'documentTypes', 'visibleTemplates', 'selectTemplate',
|
||||
], { props: { queryStringData: 'project=7' } })
|
||||
run("templates.value = [{ id: 1, documentType: 'advanceInvoices' }, { id: 2, documentType: 'quotes' }]")
|
||||
assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false)
|
||||
assert.equal(run('visibleTemplates.value.length'), 1)
|
||||
assert.equal(run('visibleTemplates.value[0].id'), 2)
|
||||
run('selectTemplate(templates.value[0])')
|
||||
assert.equal(navigations.length, 0)
|
||||
run('selectTemplate(templates.value[1])')
|
||||
assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('templateId'), '2')
|
||||
})
|
||||
|
||||
function editorFixture(query = {}, id) {
|
||||
return fixture('pages/createDocument/edit/[[id]].vue', [
|
||||
'originalDocumentType', 'canUseAdvanceInvoice', 'documentTypeItems', 'normalizeEntityId',
|
||||
'validateAdvanceInvoiceCreation', 'saveDocument',
|
||||
], {
|
||||
route: { params: { id }, query }, isTemplateMode: { value: false },
|
||||
itemInfo: { value: { type: 'advanceInvoices', project: 7 } },
|
||||
})
|
||||
}
|
||||
|
||||
test('only the project action opens a new advance invoice with its project and customer', () => {
|
||||
const { run, navigations, context } = fixture('components/EntityShowSubCreatedDocuments.vue', ['createAdvanceInvoice'], {
|
||||
props: { topLevelType: 'projects', item: { id: 7 }, queryStringData: 'customer=8&project=99' },
|
||||
})
|
||||
run('createAdvanceInvoice()')
|
||||
const query = Object.fromEntries(new URL(navigations[0], 'http://localhost').searchParams)
|
||||
assert.equal(query.project, '7')
|
||||
assert.equal(query.customer, '8')
|
||||
const editor = editorFixture(query)
|
||||
assert.equal(editor.run('validateAdvanceInvoiceCreation()'), true)
|
||||
assert.equal(editor.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true)
|
||||
context.props.topLevelType = 'customers'
|
||||
run('createAdvanceInvoice()')
|
||||
assert.equal(navigations.length, 1)
|
||||
})
|
||||
|
||||
test('editor rejects alternate creation routes and saving a switched document type', async () => {
|
||||
const projectQuery = { type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' }
|
||||
for (const query of [
|
||||
{}, { type: 'advanceInvoices' }, { type: 'advanceInvoices', project: '7' },
|
||||
{ ...projectQuery, project: '' }, { ...projectQuery, project: 'invalid' },
|
||||
{ ...projectQuery, createddocument: '12' }, { ...projectQuery, optionsToImport: '{}' },
|
||||
{ ...projectQuery, linkedDocuments: '[12]' }, { ...projectQuery, templateId: '5' },
|
||||
]) {
|
||||
const { run, notices, navigations } = editorFixture(query)
|
||||
assert.equal(run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), false)
|
||||
assert.equal(run('validateAdvanceInvoiceCreation()'), false)
|
||||
await run("saveDocument('Entwurf')")
|
||||
assert.equal(notices.length, 2)
|
||||
assert.equal(navigations.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('new advance invoices require a project; existing advance invoices remain editable', () => {
|
||||
const fresh = editorFixture({ type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' })
|
||||
fresh.run('itemInfo.value.project = null')
|
||||
assert.equal(fresh.run('validateAdvanceInvoiceCreation()'), false)
|
||||
const existing = editorFixture({}, '12')
|
||||
existing.run("originalDocumentType.value = 'advanceInvoices'; itemInfo.value.project = null")
|
||||
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true)
|
||||
assert.equal(existing.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true)
|
||||
existing.run("originalDocumentType.value = 'invoices'")
|
||||
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), false)
|
||||
existing.run("itemInfo.value.type = 'invoices'")
|
||||
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
|
||||
|
||||
const settle = () => new Promise(setImmediate)
|
||||
const deferred = () => {
|
||||
@@ -33,6 +34,7 @@ function fixture(override = () => undefined) {
|
||||
|
||||
function setup(api = fixture(), query = {}) {
|
||||
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
|
||||
const drafts = createDraftHarness({ auth })
|
||||
const route = vue.reactive({ query })
|
||||
const calls = [], toasts = [], timers = new Map()
|
||||
const listeners = new Map()
|
||||
@@ -47,6 +49,7 @@ function setup(api = fixture(), query = {}) {
|
||||
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
|
||||
useToast: () => ({ add: item => toasts.push(item) }),
|
||||
useAuthStore: () => auth, useRoute: () => route,
|
||||
useChatDraft: drafts.useChatDraft,
|
||||
useRouter: () => ({ replace: async location => { route.query = location.query } }),
|
||||
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
|
||||
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
|
||||
@@ -54,7 +57,7 @@ function setup(api = fixture(), query = {}) {
|
||||
clearInterval: id => timers.delete(id), document,
|
||||
})
|
||||
scope.run(() => vm.runInContext(source, context))
|
||||
return { auth, route, calls, toasts, timers, listeners, document, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
|
||||
return { auth, route, calls, toasts, timers, listeners, document, mount, drafts, close: () => { unmount(); scope.stop(); drafts.close() }, run: code => vm.runInContext(code, context) }
|
||||
}
|
||||
|
||||
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
|
||||
@@ -69,6 +72,34 @@ test('only member rooms enter normal chat, with separate keys, groups and unread
|
||||
assert.equal(ui.run('decoratedRooms.value.some(r => r.id === "admin-only")'), false)
|
||||
})
|
||||
|
||||
test('searching the sidebar preserves the open conversation and draft, including hidden-room matches', async t => {
|
||||
const ui = setup(fixture(url => {
|
||||
if (url.endsWith('/project-rooms')) return { rooms: [{ key: 'project_1', name: 'Büro Süd', projectNumber: 'P-2026-42', exists: true, type: 'project' }] }
|
||||
if (url.endsWith('/direct-rooms')) return { rooms: [{ key: 'direct_1', name: 'Anna Müller', email: 'anna@example.test', exists: true, type: 'direct' }] }
|
||||
}))
|
||||
t.after(ui.close)
|
||||
ui.mount()
|
||||
await settle()
|
||||
ui.run('draft.value = "Noch nicht gesendet"; roomSearch.value = " BÜRO 2026-42 "')
|
||||
assert.equal(ui.run('groupedRooms.value.length'), 1)
|
||||
assert.equal(ui.run('groupedRooms.value[0].rooms[0].key'), 'project_1')
|
||||
ui.run('roomSearch.value = "muller ANNA@"')
|
||||
assert.equal(ui.run('groupedRooms.value[0].rooms[0].key'), 'direct_1')
|
||||
await ui.run('setRoomHidden(decoratedRooms.value.find(room => room.key === "direct_1"), true)')
|
||||
assert.equal(ui.run('groupedRooms.value.length'), 0)
|
||||
assert.equal(ui.run('filteredHiddenRooms.value[0].key'), 'direct_1')
|
||||
ui.run('roomSearch.value = "kein Treffer"')
|
||||
await settle()
|
||||
assert.equal(ui.run('filteredRooms.value.length'), 0)
|
||||
assert.equal(ui.run('filteredHiddenRooms.value.length'), 0)
|
||||
assert.equal(ui.run('activeRoom.value.key'), 'allgemein')
|
||||
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
||||
assert.equal(ui.run('draft.value'), 'Noch nicht gesendet')
|
||||
ui.run('roomSearch.value = ""')
|
||||
assert.equal(ui.run('groupedRooms.value.length'), 3)
|
||||
assert.equal(ui.run('hiddenRooms.value[0].key'), 'direct_1')
|
||||
})
|
||||
|
||||
test('opening a collaboration deep link never provisions, reads or sends through native chat APIs', async t => {
|
||||
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
|
||||
t.after(ui.close)
|
||||
@@ -377,3 +408,69 @@ test('native image-only sends retain the draft after an error and clear it after
|
||||
assert.equal(ui.run('draftImage.value'), null)
|
||||
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
|
||||
})
|
||||
|
||||
test('native drafts survive switching chats, hiding a room and reloading its history', async t => {
|
||||
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: [{ key: 'project_1', exists: true, type: 'project' }] } : undefined))
|
||||
t.after(ui.close)
|
||||
ui.mount()
|
||||
await settle()
|
||||
ui.run('draft.value = " Hallo\\nWelt 👋 "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Skizze" }')
|
||||
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "project_1"))')
|
||||
assert.equal(ui.run('draft.value'), '')
|
||||
assert.equal(ui.run('draftImage.value'), null)
|
||||
ui.run('draft.value = "Projektentwurf"')
|
||||
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
|
||||
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
|
||||
assert.equal(ui.run('draftImage.value.name'), 'Skizze')
|
||||
await ui.run('loadActiveRoom()')
|
||||
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
|
||||
await ui.run('setRoomHidden(activeRoom.value, true)')
|
||||
assert.equal(ui.run('draft.value'), 'Projektentwurf')
|
||||
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
|
||||
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
|
||||
})
|
||||
|
||||
test('failed sends retain the exact draft after switching away, and late success clears only the sent room', async t => {
|
||||
const pending = deferred()
|
||||
let fail = true
|
||||
const ui = setup(fixture((url, options) => {
|
||||
if (url.endsWith('/messages') && options?.method === 'POST') {
|
||||
if (fail) throw { data: { message: 'Offline' } }
|
||||
return pending.promise
|
||||
}
|
||||
}))
|
||||
t.after(ui.close)
|
||||
ui.mount()
|
||||
await settle()
|
||||
ui.run('draft.value = " Noch nicht fertig\\n "')
|
||||
await ui.run('sendMessage()')
|
||||
assert.equal(ui.run('draft.value'), ' Noch nicht fertig\n ')
|
||||
fail = false
|
||||
const sending = ui.run('sendMessage()')
|
||||
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
|
||||
pending.resolve({ id: 2, body: 'Noch nicht fertig' })
|
||||
await sending
|
||||
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
|
||||
assert.equal(ui.run('draft.value'), '')
|
||||
assert.equal(Object.keys(ui.drafts.storage).length, 0)
|
||||
})
|
||||
|
||||
test('editing a room after returning to a pending send preserves the new draft', async t => {
|
||||
const pending = deferred()
|
||||
const ui = setup(fixture((url, options) => url.endsWith('/messages') && options?.method === 'POST' ? pending.promise : undefined))
|
||||
t.after(ui.close)
|
||||
ui.mount()
|
||||
await settle()
|
||||
ui.run('draft.value = "Vorheriger Entwurf"')
|
||||
const sending = ui.run('sendMessage()')
|
||||
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
|
||||
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
|
||||
assert.equal(ui.run('draftSending.value'), true)
|
||||
await ui.run('sendMessage()')
|
||||
assert.equal(ui.calls.filter(({ url, options }) => url.endsWith('/messages') && options?.method === 'POST').length, 1)
|
||||
ui.run('draft.value = "Weitergeschrieben"')
|
||||
pending.resolve({ id: 2, body: 'Vorheriger Entwurf' })
|
||||
await sending
|
||||
assert.equal(ui.run('draft.value'), 'Weitergeschrieben')
|
||||
assert.equal(ui.run('draftSending.value'), false)
|
||||
})
|
||||
|
||||
123
frontend/tests/chat-drafts.test.cjs
Normal file
123
frontend/tests/chat-drafts.test.cjs
Normal file
@@ -0,0 +1,123 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const vue = require('vue')
|
||||
const { createDraftHarness, memoryStorage } = require('./helpers/chat-drafts.cjs')
|
||||
const image = { dataUrl: 'data:image/webp;base64,AAAA', name: 'Skizze.webp' }
|
||||
|
||||
test('text and image drafts survive room changes and a browser reload in the same session', t => {
|
||||
const storage = memoryStorage(), first = createDraftHarness({ storage })
|
||||
t.after(first.close)
|
||||
const room = vue.ref('allgemein'), composer = first.useChatDraft(room)
|
||||
composer.draft.value = ' Hallo\nWelt 👋 '
|
||||
composer.draftImage.value = image
|
||||
room.value = 'project_1'
|
||||
assert.equal(composer.draft.value, '')
|
||||
assert.equal(composer.draftImage.value, null)
|
||||
composer.draft.value = 'Projektentwurf'
|
||||
room.value = 'allgemein'
|
||||
assert.equal(composer.draft.value, ' Hallo\nWelt 👋 ')
|
||||
assert.equal(composer.draftImage.value.name, image.name)
|
||||
|
||||
const reloaded = createDraftHarness({ storage })
|
||||
t.after(reloaded.close)
|
||||
const restored = reloaded.useChatDraft('allgemein')
|
||||
assert.equal(restored.draft.value, composer.draft.value)
|
||||
assert.equal(restored.draftImage.value.dataUrl, image.dataUrl)
|
||||
assert.equal(restored.captureDraft().value.clientId, composer.captureDraft().value.clientId)
|
||||
assert.equal(reloaded.useChatDraft('project_1').draft.value, 'Projektentwurf')
|
||||
assert.equal(first.calls.length, 0)
|
||||
})
|
||||
|
||||
test('tenant and profile scopes cannot read each other; logout removes the session drafts', t => {
|
||||
const ui = createDraftHarness()
|
||||
t.after(ui.close)
|
||||
const composer = ui.useChatDraft('allgemein')
|
||||
composer.draft.value = 'Privater Entwurf'
|
||||
ui.auth.activeTenant = '2'
|
||||
assert.equal(composer.draft.value, '')
|
||||
composer.draft.value = 'Mandant 2'
|
||||
ui.auth.activeTenant = '1'
|
||||
assert.equal(composer.draft.value, 'Privater Entwurf')
|
||||
ui.auth.profile = { id: 'other-profile' }
|
||||
assert.equal(composer.draft.value, '')
|
||||
ui.auth.profile = { id: 'profile-1' }
|
||||
assert.equal(composer.draft.value, 'Privater Entwurf')
|
||||
ui.auth.user = { id: 'another-user' }
|
||||
assert.equal(composer.draft.value, '')
|
||||
assert.equal(Object.keys(ui.storage).length, 0)
|
||||
composer.draft.value = 'Neuer Benutzer'
|
||||
ui.auth.user = null
|
||||
assert.equal(composer.draft.value, '')
|
||||
assert.equal(Object.keys(ui.storage).length, 0)
|
||||
})
|
||||
|
||||
test('successful sends remove only the captured version, even after changing rooms', t => {
|
||||
const ui = createDraftHarness()
|
||||
t.after(ui.close)
|
||||
const room = vue.ref('allgemein'), composer = ui.useChatDraft(room)
|
||||
composer.draft.value = 'Gesendet'
|
||||
const sent = composer.captureDraft()
|
||||
room.value = 'project_1'
|
||||
composer.draft.value = 'Noch nicht gesendet'
|
||||
composer.clearDraft(sent)
|
||||
assert.equal(composer.draft.value, 'Noch nicht gesendet')
|
||||
room.value = 'allgemein'
|
||||
assert.equal(composer.draft.value, '')
|
||||
composer.draftImage.value = image
|
||||
const older = composer.captureDraft()
|
||||
composer.draft.value = 'Neuer Text zum Bild'
|
||||
composer.clearDraft(older)
|
||||
assert.equal(composer.draft.value, 'Neuer Text zum Bild')
|
||||
assert.equal(composer.draftImage.value.name, image.name)
|
||||
composer.clearDraft(composer.captureDraft())
|
||||
assert.equal(composer.draft.value, '')
|
||||
assert.equal(composer.draftImage.value, null)
|
||||
})
|
||||
|
||||
test('empty drafts are removed and never enter the shared profile temp_config', async t => {
|
||||
const ui = createDraftHarness()
|
||||
t.after(ui.close)
|
||||
const composer = ui.useChatDraft('direct_user-2')
|
||||
composer.draft.value = 'Geheim'
|
||||
ui.store.modifySearchString('contacts', 'Beispiel')
|
||||
assert.equal(JSON.stringify(ui.calls).includes('Geheim'), false)
|
||||
ui.store.setStoredTempConfig({ settings: { density: 'compact' } })
|
||||
assert.equal(composer.draft.value, 'Geheim')
|
||||
composer.draft.value = ''
|
||||
assert.equal(Object.keys(ui.storage).length, 0)
|
||||
})
|
||||
|
||||
test('guest conversations never reuse internal drafts and keep temporary text through a retry', t => {
|
||||
const ui = createDraftHarness()
|
||||
t.after(ui.close)
|
||||
ui.useChatDraft('collaboration:one').draft.value = 'Interner Entwurf'
|
||||
const room = vue.ref('collaboration:one'), guest = ui.useChatDraft(room, { persist: false })
|
||||
assert.equal(guest.draft.value, '')
|
||||
guest.draft.value = 'Gastentwurf'
|
||||
assert.equal(guest.draft.value, 'Gastentwurf')
|
||||
room.value = 'collaboration:two'
|
||||
assert.equal(guest.draft.value, '')
|
||||
assert.equal(Object.values(ui.storage).some(value => value.includes('Gastentwurf')), false)
|
||||
})
|
||||
|
||||
test('corrupt or unavailable browser storage does not prevent writing or sending', t => {
|
||||
const storage = memoryStorage(), initial = createDraftHarness({ storage })
|
||||
t.after(initial.close)
|
||||
initial.useChatDraft('allgemein').draft.value = 'Alt'
|
||||
storage[Object.keys(storage)[0]] = 'invalid json'
|
||||
const corrupt = createDraftHarness({ storage })
|
||||
t.after(corrupt.close)
|
||||
assert.equal(corrupt.useChatDraft('allgemein').draft.value, '')
|
||||
const blocked = createDraftHarness({ storage: {
|
||||
getItem() { throw Error('Blocked') }, setItem() { throw Error('Quota exceeded') }, removeItem() { throw Error('Blocked') },
|
||||
} })
|
||||
t.after(blocked.close)
|
||||
const composer = blocked.useChatDraft('allgemein')
|
||||
composer.draft.value = 'Im Speicher'
|
||||
composer.draftImage.value = image
|
||||
assert.equal(composer.draft.value, 'Im Speicher')
|
||||
assert.equal(composer.draftImage.value.name, image.name)
|
||||
assert.equal(blocked.toasts.length, 1)
|
||||
composer.clearDraft(composer.captureDraft())
|
||||
assert.equal(composer.draft.value, '')
|
||||
})
|
||||
@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
|
||||
const settle = () => new Promise(setImmediate)
|
||||
const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } }
|
||||
|
||||
@@ -89,3 +90,62 @@ test('image display rejects external URLs and ignores responses after a room cha
|
||||
await ui.run('load()')
|
||||
assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA')
|
||||
})
|
||||
|
||||
function collaborationApi(onSend) {
|
||||
return async (url, options) => {
|
||||
if (options?.method === 'POST' && url.endsWith('/messages')) return onSend(options.body)
|
||||
if (url.endsWith('/messages')) return { messages: [], hasMore: false }
|
||||
if (url.endsWith('/members')) return { members: [] }
|
||||
if (url.endsWith('/read')) return {}
|
||||
return { canSend: true, name: 'Partnerchat' }
|
||||
}
|
||||
}
|
||||
|
||||
test('collaboration drafts retain text, images and the retry ID when reopening after a failed send', async t => {
|
||||
const drafts = createDraftHarness(), attempts = []
|
||||
t.after(drafts.close)
|
||||
let fail = true
|
||||
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(body => {
|
||||
attempts.push(body)
|
||||
if (fail) throw { data: { message: 'Offline' } }
|
||||
return { id: 'message-1', seq: 1, own: true, clientId: body.clientId, body: body.text }
|
||||
}) }
|
||||
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
||||
await settle()
|
||||
first.run('draft.value = " Partnerentwurf\\n "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Plan" }')
|
||||
await first.run('send()')
|
||||
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
|
||||
assert.equal(first.run('draftImage.value.name'), 'Plan')
|
||||
await first.run('load()')
|
||||
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
|
||||
first.close()
|
||||
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
||||
t.after(second.close)
|
||||
await settle()
|
||||
assert.equal(second.run('draft.value'), ' Partnerentwurf\n ')
|
||||
fail = false
|
||||
await second.run('send()')
|
||||
assert.equal(attempts[0].clientId, attempts[1].clientId)
|
||||
assert.equal(second.run('draft.value'), '')
|
||||
assert.equal(second.run('draftImage.value'), null)
|
||||
assert.equal(Object.keys(drafts.storage).length, 0)
|
||||
})
|
||||
|
||||
test('collaboration send completion after unmount does not erase a newer draft in the same room', async t => {
|
||||
const drafts = createDraftHarness(), pending = deferred()
|
||||
t.after(drafts.close)
|
||||
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(() => pending.promise) }
|
||||
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
||||
await settle()
|
||||
first.run('draft.value = "Gesendet"')
|
||||
const sending = first.run('send()')
|
||||
first.close()
|
||||
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
||||
t.after(second.close)
|
||||
await settle()
|
||||
second.run('draft.value = "Neuer Entwurf"')
|
||||
pending.resolve({ id: 'message-1', seq: 1, body: 'Gesendet' })
|
||||
await sending
|
||||
assert.equal(second.run('draft.value'), 'Neuer Entwurf')
|
||||
assert.equal(second.run('messages.value.length'), 0)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ const { parse } = require('@vue/compiler-sfc')
|
||||
|
||||
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8'))
|
||||
const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
|
||||
const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'setupPage', 'setRowData'])
|
||||
const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'originalDocumentType', 'canUseAdvanceInvoice', 'validateAdvanceInvoiceCreation', 'setupPage', 'setRowData'])
|
||||
const source = script.statements
|
||||
.filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script))))
|
||||
.map(statement => statement.getText(script)).join('\n')
|
||||
@@ -18,6 +18,8 @@ function fixture(unit) {
|
||||
const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 }
|
||||
const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 }
|
||||
const context = vm.createContext({
|
||||
ref, computed: getter => ({ get value() { return getter() } }),
|
||||
isTemplateMode: ref(false),
|
||||
dayjs, uuidv4: () => 'row-id', console: { log() {} },
|
||||
products: ref([product]), services: ref([service]),
|
||||
productcategories: ref([]), servicecategories: ref([]),
|
||||
|
||||
166
frontend/tests/events.browser.mjs
Normal file
166
frontend/tests/events.browser.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
// Run against a built frontend with PLAYWRIGHT_MODULE pointing to an installed Playwright package.
|
||||
// Requests use isolated fixtures; no real account or application data is changed.
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright')
|
||||
const base = process.env.EVENTS_FRONTEND_URL || 'http://127.0.0.1:3157'
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
page.setDefaultTimeout(10000)
|
||||
const errors = []
|
||||
const writes = []
|
||||
const listRequests = []
|
||||
const iconCollections = Object.fromEntries(await Promise.all(['heroicons', 'lucide'].map(async prefix => [
|
||||
prefix, JSON.parse(await readFile(new URL(`../node_modules/@iconify/json/json/${prefix}.json`, import.meta.url), 'utf8'))
|
||||
])))
|
||||
let failList = false
|
||||
let allowCreate = true
|
||||
const project = { id: 42, name: 'Umbau Hafenstraße', projectNumber: 'P-2026-042' }
|
||||
const profile = { id: '11111111-1111-4111-8111-111111111111', full_name: 'Anna Beispiel', pinned_on_navigation: [], temp_config: {}, role: { rights: [] } }
|
||||
const tenant = { id: 1, name: 'Testbetrieb', extraModules: [], features: {}, locked: 'none' }
|
||||
let events = Array.from({ length: 26 }, (_, index) => ({
|
||||
id: index + 1, name: index ? `Termin ${index + 1}` : 'Baubesprechung Hafen',
|
||||
startDate: `2026-10-${String(index + 1).padStart(2, '0')}T08:00:00.000Z`,
|
||||
endDate: index ? null : '2026-10-01T09:30:00.000Z',
|
||||
project: index ? null : project, state: index ? 'Final' : 'Entwurf',
|
||||
color: '#2563eb', quick: false, archived: false, profiles: null,
|
||||
repeatInterval: index ? 'Keine Wiederholung' : 'Wöchentlich'
|
||||
}))
|
||||
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.addInitScript(() => localStorage.setItem('token', 'fixture-only'))
|
||||
await page.route('**/*', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.hostname === 'api.iconify.design') {
|
||||
const collection = iconCollections[url.pathname.slice(1).replace('.json', '')]
|
||||
if (collection) return route.fulfill({ contentType: 'application/json', body: JSON.stringify(collection) })
|
||||
}
|
||||
if (url.origin !== new URL(base).origin) return route.abort()
|
||||
if (url.pathname.startsWith('/api/_nuxt_icon/')) return route.continue()
|
||||
if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/auth/')) return route.continue()
|
||||
let body = []
|
||||
if (url.pathname === '/api/me') body = {
|
||||
user: { id: profile.id, email: 'anna@example.test', is_admin: false }, profile, tenants: [tenant], activeTenant: 1,
|
||||
permissions: ['events', 'projects', ...(allowCreate ? ['events-create'] : [])]
|
||||
}
|
||||
else if (url.pathname === '/api/tenant') body = tenant
|
||||
else if (url.pathname === '/api/tenant/profiles') body = { data: [profile] }
|
||||
else if (url.pathname === '/api/resource/projects') body = [project]
|
||||
else if (url.pathname === '/api/resource/events/paginated') {
|
||||
if (failList) return route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"Fixture unavailable"}' })
|
||||
const search = (url.searchParams.get('search') || '').toLowerCase()
|
||||
const state = url.searchParams.get('filter[state]')
|
||||
const limit = Number(url.searchParams.get('limit'))
|
||||
const currentPage = Number(url.searchParams.get('page'))
|
||||
listRequests.push(Object.fromEntries(url.searchParams))
|
||||
const matches = events.filter(event => !event.archived && (!state || state === event.state)
|
||||
&& [event.name, event.project?.name, event.project?.projectNumber].some(value => String(value || '').toLowerCase().includes(search)))
|
||||
matches.sort((a, b) => url.searchParams.get('sort')?.endsWith(':asc')
|
||||
? a.startDate.localeCompare(b.startDate) : b.startDate.localeCompare(a.startDate))
|
||||
body = { data: matches.slice((currentPage - 1) * limit, currentPage * limit), queryConfig: { total: matches.length } }
|
||||
}
|
||||
else if (url.pathname === '/api/resource/events' && route.request().method() === 'POST') {
|
||||
const payload = route.request().postDataJSON()
|
||||
writes.push(payload)
|
||||
body = { id: 100, state: 'Final', repeatInterval: 'Keine Wiederholung', ...payload, archived: false }
|
||||
events.push({ ...body, project: payload.project === project.id ? project : null })
|
||||
}
|
||||
else if (/^\/api\/resource\/events\/\d+(\/true)?$/.test(url.pathname)) {
|
||||
const event = events.find(item => item.id === Number(url.pathname.split('/')[4]))
|
||||
body = { ...event, project: event?.project?.id || null }
|
||||
if (route.request().method() === 'PUT') {
|
||||
Object.assign(event, route.request().postDataJSON())
|
||||
writes.push(route.request().postDataJSON())
|
||||
body = event
|
||||
}
|
||||
}
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
|
||||
const searchInput = () => page.getByRole('textbox', { name: 'Termine durchsuchen' })
|
||||
const waitForList = () => page.getByRole('table').waitFor()
|
||||
|
||||
try {
|
||||
await page.goto(`${base}/events`, { waitUntil: 'networkidle' })
|
||||
await waitForList()
|
||||
assert.equal(await page.getByRole('columnheader').count(), 5)
|
||||
assert.equal(await page.locator('tbody tr').count(), 25)
|
||||
await page.getByRole('button', { name: 'Next Page', exact: true }).click()
|
||||
await page.getByRole('link', { name: 'Baubesprechung Hafen', exact: true }).first().waitFor()
|
||||
assert.equal(listRequests.at(-1).page, '2')
|
||||
await searchInput().fill('P-2026-042')
|
||||
await page.getByText('P-2026-042 · Umbau Hafenstraße', { exact: true }).first().waitFor()
|
||||
await waitForList()
|
||||
assert.equal(listRequests.at(-1).page, '1')
|
||||
assert.equal(await page.locator('tbody tr').count(), 1)
|
||||
await page.screenshot({ path: '/tmp/fedeo-events-desktop.png', fullPage: true })
|
||||
|
||||
await page.getByRole('combobox', { name: 'Status filtern' }).click()
|
||||
await page.getByRole('option', { name: 'Final', exact: true }).click()
|
||||
await page.getByRole('heading', { name: 'Keine passenden Termine' }).waitFor()
|
||||
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click()
|
||||
await waitForList()
|
||||
|
||||
await page.getByRole('link', { name: 'Neuer Termin', exact: true }).click()
|
||||
await page.getByRole('heading', { name: 'Termin erstellen' }).waitFor()
|
||||
await page.getByLabel('Name', { exact: true }).fill('Neu aus Terminübersicht')
|
||||
for (let index = 0; index < 2; index++) {
|
||||
await page.getByRole('button', { name: 'Datum auswählen', exact: true }).first().click()
|
||||
await page.getByRole('button', { name: 'Heute', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await page.getByLabel('Farbe auswählen', { exact: true }).fill('#f43f5e')
|
||||
const projectInputId = await page.locator('label').filter({ hasText: /^Projekt$/ }).getAttribute('for')
|
||||
await page.locator(`[id="${projectInputId}"]`).click()
|
||||
await page.getByPlaceholder('Suche...', { exact: true }).fill('2026-042')
|
||||
await page.getByRole('option', { name: project.name, exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Erstellen', exact: true }).click()
|
||||
await page.waitForURL(`${base}/events`)
|
||||
await searchInput().fill('Neu aus Terminübersicht')
|
||||
await page.getByRole('link', { name: 'Neu aus Terminübersicht', exact: true }).first().waitFor()
|
||||
assert.equal(writes[0].project, 42)
|
||||
assert.equal(writes[0].color, '#f43f5e')
|
||||
assert.ok(writes[0].startDate && writes[0].endDate)
|
||||
|
||||
await page.getByRole('link', { name: 'Neu aus Terminübersicht', exact: true }).first().click()
|
||||
await page.getByRole('heading', { name: 'Termin bearbeiten' }).waitFor()
|
||||
await page.getByLabel('Name', { exact: true }).fill('Bearbeiteter Termin')
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click()
|
||||
await page.waitForURL(`${base}/events`)
|
||||
await waitForList()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await searchInput().fill('P-2026-042')
|
||||
await page.getByRole('heading', { name: 'Baubesprechung Hafen' }).waitFor()
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||
await page.getByRole('link', { name: 'Neuer Termin', exact: true }).waitFor()
|
||||
await page.screenshot({ path: '/tmp/fedeo-events-mobile.png', fullPage: true })
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
await page.goto(`${base}/standardEntity/events`, { waitUntil: 'networkidle' })
|
||||
await searchInput().waitFor()
|
||||
failList = true
|
||||
await page.getByRole('button', { name: 'Termine aktualisieren' }).click()
|
||||
await page.getByText('Laden fehlgeschlagen', { exact: true }).waitFor()
|
||||
failList = false
|
||||
await page.getByRole('button', { name: 'Erneut versuchen' }).click()
|
||||
await waitForList()
|
||||
|
||||
events = []
|
||||
await page.getByRole('button', { name: 'Termine aktualisieren' }).click()
|
||||
await page.getByRole('heading', { name: 'Noch keine Termine' }).waitFor()
|
||||
assert.equal(await page.getByRole('link', { name: 'Neuer Termin', exact: true }).count(), 2)
|
||||
allowCreate = false
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await page.getByRole('heading', { name: 'Noch keine Termine' }).waitFor()
|
||||
assert.equal(await page.getByRole('link', { name: 'Neuer Termin', exact: true }).count(), 0)
|
||||
assert.deepEqual(errors, [], 'Browser runtime errors')
|
||||
console.log('Events browser checks passed: pagination, project-number search, status filter, create and edit with return to list, mobile layout, legacy URL, errors, empty state and create permission.')
|
||||
} catch (error) {
|
||||
await writeFile('/tmp/fedeo-events-error.html', await page.content())
|
||||
await page.screenshot({ path: '/tmp/fedeo-events-error.png', fullPage: true })
|
||||
throw error
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
36
frontend/tests/helpers/chat-drafts.cjs
Normal file
36
frontend/tests/helpers/chat-drafts.cjs
Normal file
@@ -0,0 +1,36 @@
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const vue = require('vue')
|
||||
|
||||
function memoryStorage() {
|
||||
const storage = Object.create(null)
|
||||
Object.defineProperties(storage, {
|
||||
getItem: { value: key => storage[key] ?? null },
|
||||
setItem: { value: (key, value) => { storage[key] = value } },
|
||||
removeItem: { value: key => { delete storage[key] } },
|
||||
})
|
||||
return storage
|
||||
}
|
||||
|
||||
function createDraftHarness({ auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: { id: 'profile-1' } }), storage = memoryStorage() } = {}) {
|
||||
const scope = vue.effectScope(), toasts = [], calls = []
|
||||
const context = vm.createContext({ ...vue, crypto: { randomUUID }, sessionStorage: storage,
|
||||
defineStore: (_, setup) => setup,
|
||||
useAuthStore: () => auth,
|
||||
useRuntimeConfig: () => ({ public: { apiBase: 'https://api.example.test' } }),
|
||||
useToast: () => ({ add: toast => toasts.push(toast) }),
|
||||
useNuxtApp: () => ({ $api: async (url, options) => { calls.push({ url, options }) } }),
|
||||
})
|
||||
const storeSource = fs.readFileSync(`${__dirname}/../../stores/temp.js`, 'utf8').replace(/^import .*$/mg, '').replace('export const', 'const')
|
||||
const store = scope.run(() => vm.runInContext(`${storeSource}\nuseTempStore()`, context))
|
||||
context.useTempStore = () => store
|
||||
const composable = fs.readFileSync(`${__dirname}/../../composables/useChatDraft.js`, 'utf8').replace('export function', 'function')
|
||||
vm.runInContext(composable, context)
|
||||
return { auth, storage, store, calls, toasts,
|
||||
useChatDraft: (key, options) => scope.run(() => context.useChatDraft(key, options)),
|
||||
close: () => scope.stop(),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createDraftHarness, memoryStorage }
|
||||
32
frontend/utils/portalTheme.ts
Normal file
32
frontend/utils/portalTheme.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const DEFAULT_PORTAL_COLOR = "#69c350"
|
||||
|
||||
export function normalizePortalColor(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null
|
||||
const color = value.trim().toLowerCase()
|
||||
return /^#[0-9a-f]{6}$/.test(color) ? color : null
|
||||
}
|
||||
|
||||
export function getPortalColor(config: any): string {
|
||||
// Older tenant records contain the misspelled key from the original schema.
|
||||
return normalizePortalColor(config?.primaryColor)
|
||||
|| normalizePortalColor(config?.primayColor)
|
||||
|| DEFAULT_PORTAL_COLOR
|
||||
}
|
||||
|
||||
export function portalThemeStyle(value: unknown): Record<string, string> {
|
||||
const color = normalizePortalColor(value) || DEFAULT_PORTAL_COLOR
|
||||
const rgb = [1, 3, 5].map(offset => parseInt(color.slice(offset, offset + 2), 16) / 255)
|
||||
const linear = rgb.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
|
||||
const luminance = linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
|
||||
const style: Record<string, string> = {
|
||||
"--ui-primary": color,
|
||||
"--portal-accent-contrast": luminance > 0.179 ? "#000000" : "#ffffff",
|
||||
}
|
||||
const shades = { 50: 5, 100: 10, 200: 25, 300: 45, 400: 70, 500: 100, 600: 80, 700: 65, 800: 50, 900: 35, 950: 20 }
|
||||
for (const [shade, percent] of Object.entries(shades)) {
|
||||
style[`--ui-color-primary-${shade}`] = Number(shade) === 500
|
||||
? color
|
||||
: `color-mix(in srgb, ${color} ${percent}%, ${Number(shade) < 500 ? "white" : "black"})`
|
||||
}
|
||||
return style
|
||||
}
|
||||
Reference in New Issue
Block a user