feat(communication): add room visibility and project chat controls
This commit is contained in:
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "chat_enabled" boolean NOT NULL DEFAULT true;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "communication_hidden_rooms" (
|
||||
"tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE CASCADE,
|
||||
"room_key" text NOT NULL,
|
||||
PRIMARY KEY ("tenant_id", "user_id", "room_key")
|
||||
);
|
||||
@@ -526,6 +526,13 @@
|
||||
"when": 1790899200000,
|
||||
"tag": "0077_scanner_inbox_preparation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1790985600000,
|
||||
"tag": "0078_chat_visibility",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { bigint, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { tenants } from "./tenants"
|
||||
|
||||
// Room keys also cover collaboration bindings and not-yet-provisioned chats.
|
||||
export const communicationHiddenRooms = pgTable("communication_hidden_rooms", {
|
||||
tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
roomKey: text("room_key").notNull(),
|
||||
}, table => ({ pk: primaryKey({ columns: [table.tenantId, table.userId, table.roomKey] }) }))
|
||||
@@ -19,6 +19,7 @@ export * from "./communication_rooms"
|
||||
export * from "./communication_room_members"
|
||||
export * from "./communication_messages"
|
||||
export * from "./communication_room_reads"
|
||||
export * from "./communication_hidden_rooms"
|
||||
export * from "./contacts"
|
||||
export * from "./contracts"
|
||||
export * from "./contracttypes"
|
||||
|
||||
@@ -66,6 +66,8 @@ export const projects = pgTable("projects", {
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
chatEnabled: boolean("chat_enabled").notNull().default(true),
|
||||
|
||||
customerRef: text("customerRef"),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
|
||||
@@ -15,9 +15,18 @@ export function managesProjects(req: any): boolean {
|
||||
return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write"))
|
||||
}
|
||||
|
||||
export function projectChatSettingError(req: any, body: Record<string, unknown>, previous = true) {
|
||||
if (!Object.prototype.hasOwnProperty.call(body, "chatEnabled")) return null
|
||||
if (typeof body.chatEnabled !== "boolean") return { statusCode: 400, error: "Projektchat aktiviert muss ein Wahrheitswert sein" }
|
||||
if (body.chatEnabled !== previous && !managesProjects(req)) {
|
||||
return { statusCode: 403, error: "Keine Berechtigung zum Ändern des Projektchats" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function projectChatAccess(db: any, req: any, projectId?: number) {
|
||||
const tenantId = Number(req.user?.tenant_id)
|
||||
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false)]
|
||||
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false), eq(projects.chatEnabled, true)]
|
||||
if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
|
||||
const rows = await db.select().from(projects).where(and(...conditions))
|
||||
if (managesProjects(req)) return rows
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
communicationRoomMembers,
|
||||
communicationRoomReads,
|
||||
communicationRooms,
|
||||
communicationHiddenRooms,
|
||||
notificationsItems,
|
||||
projects,
|
||||
} from "../../db/schema"
|
||||
@@ -244,6 +245,28 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
|
||||
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" }))
|
||||
|
||||
server.get("/communication/chat/room-preferences", async (req: any) => {
|
||||
const tenantId = await requireTenant(req)
|
||||
const hidden = await server.db.select({ key: communicationHiddenRooms.roomKey }).from(communicationHiddenRooms)
|
||||
.where(and(eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id)))
|
||||
return { hiddenRoomKeys: hidden.map(room => room.key) }
|
||||
})
|
||||
|
||||
server.put("/communication/chat/room-preferences", async (req: any, reply) => {
|
||||
const tenantId = await requireTenant(req)
|
||||
const { roomKey, hidden } = req.body || {}
|
||||
if (typeof hidden !== "boolean" || typeof roomKey !== "string" || roomKey.length > 200
|
||||
|| !/^(?:[a-z0-9._=-]+|collaboration:[a-f0-9-]{36})$/.test(roomKey)) {
|
||||
return reply.code(400).send({ error: "Ungültige Raumeinstellung" })
|
||||
}
|
||||
const value = { tenantId, userId: req.user.user_id, roomKey }
|
||||
if (hidden) await server.db.insert(communicationHiddenRooms).values(value).onConflictDoNothing()
|
||||
else await server.db.delete(communicationHiddenRooms).where(and(
|
||||
eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id),
|
||||
eq(communicationHiddenRooms.roomKey, roomKey)))
|
||||
return { roomKey, hidden }
|
||||
})
|
||||
|
||||
server.get("/communication/chat/users", async (req: any) => {
|
||||
const tenantId = await requireTenant(req)
|
||||
return { users: (await tenantUsers(tenantId)).map((user) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
|
||||
import { projectChatSettingError } from "../../modules/communication-access"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
eq,
|
||||
@@ -942,6 +943,10 @@ 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 (resource === "projects") {
|
||||
const error = projectChatSettingError(req, createData)
|
||||
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
||||
}
|
||||
|
||||
if (resource === "members") {
|
||||
createData = normalizeMemberPayload(createData)
|
||||
@@ -1095,6 +1100,11 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
return reply.code(404).send({ error: "Resource not found" })
|
||||
}
|
||||
|
||||
if (resource === "projects") {
|
||||
const error = projectChatSettingError(req, body, oldRecord.chatEnabled)
|
||||
if (error) return reply.code(error.statusCode).send({ error: error.error })
|
||||
}
|
||||
|
||||
let data: Record<string, any> = { ...body }
|
||||
delete data.updatedAt
|
||||
delete data.updated_at
|
||||
|
||||
110
backend/tests/chatVisibility.test.ts
Normal file
110
backend/tests/chatVisibility.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
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 communicationRoutes from "../src/routes/communication"
|
||||
import resourceRoutes from "../src/routes/resources/main"
|
||||
import { projectChatSettingError } from "../src/modules/communication-access"
|
||||
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
|
||||
|
||||
test("changing the project chat switch requires project management permission and a boolean", () => {
|
||||
const member = { user: {}, hasPermission: () => false }
|
||||
const manager = { user: {}, hasPermission: (permission: string) => permission === "organisation.projects.write" }
|
||||
assert.equal(projectChatSettingError(member, {}), null)
|
||||
assert.equal(projectChatSettingError(member, { chatEnabled: true }), null)
|
||||
assert.equal(projectChatSettingError(member, { chatEnabled: false }, false), null)
|
||||
assert.equal(projectChatSettingError(member, { chatEnabled: false })?.statusCode, 403)
|
||||
assert.equal(projectChatSettingError(member, { chatEnabled: true }, false)?.statusCode, 403)
|
||||
assert.equal(projectChatSettingError(manager, { chatEnabled: false }), null)
|
||||
assert.equal(projectChatSettingError({ user: { is_admin: true } }, { chatEnabled: true }, false), null)
|
||||
for (const invalid of [null, "false", 0, {}]) assert.equal(projectChatSettingError(manager, { chatEnabled: invalid })?.statusCode, 400)
|
||||
})
|
||||
|
||||
test("migration keeps existing and newly created project chats enabled", { skip: !testDatabaseUrl }, async () => {
|
||||
const fixture = await collaborationDatabase()
|
||||
try {
|
||||
await fixture.pool.query("ALTER TABLE projects DROP COLUMN chat_enabled")
|
||||
await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (1,1,'Existing')")
|
||||
await fixture.pool.query(await readFile(new URL("../db/migrations/0078_chat_visibility.sql", import.meta.url), "utf8"))
|
||||
await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (2,1,'New')")
|
||||
assert.deepEqual((await fixture.pool.query("SELECT chat_enabled FROM projects ORDER BY id")).rows, [{ chat_enabled: true }, { chat_enabled: true }])
|
||||
} finally { await fixture.close() }
|
||||
})
|
||||
|
||||
test("hidden rooms persist per user and tenant without changing room memberships", { skip: !testDatabaseUrl }, async () => {
|
||||
const fixture = await collaborationDatabase(["0078_chat_visibility.sql"]), app = Fastify()
|
||||
const user = randomUUID(), other = randomUUID(), collaborationKey = `collaboration:${randomUUID()}`
|
||||
try {
|
||||
await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')")
|
||||
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test'),($2,'b@example.test')", [user, other])
|
||||
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1),(1,$2)", [user, other])
|
||||
app.decorate("db", fixture.db)
|
||||
app.addHook("preHandler", async (req: any) => {
|
||||
req.user = { tenant_id: Number(req.headers["x-tenant"] || 1), user_id: req.headers["x-user"] || user }
|
||||
req.hasPermission = () => false
|
||||
})
|
||||
await app.register(communicationRoutes)
|
||||
const url = "/communication/chat/room-preferences"
|
||||
for (const roomKey of ["allgemein", "project_1", collaborationKey, collaborationKey]) {
|
||||
const result = await app.inject({ method: "PUT", url, payload: { roomKey, hidden: true, userId: other, tenantId: 2 } })
|
||||
assert.equal(result.statusCode, 200, result.body)
|
||||
}
|
||||
assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1", collaborationKey].sort())
|
||||
assert.deepEqual((await app.inject({ url, headers: { "x-user": other } })).json().hiddenRoomKeys, [])
|
||||
assert.deepEqual((await app.inject({ url, headers: { "x-tenant": "2" } })).json().hiddenRoomKeys, [])
|
||||
assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_room_members")).rows[0].count, "0")
|
||||
await app.inject({ method: "PUT", url, payload: { roomKey: collaborationKey, hidden: false } })
|
||||
assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1"])
|
||||
for (const payload of [{ roomKey: "allgemein", hidden: "false" }, { roomKey: "../secret", hidden: true }, { roomKey: "", hidden: true }]) {
|
||||
assert.equal((await app.inject({ method: "PUT", url, payload })).statusCode, 400)
|
||||
}
|
||||
await fixture.pool.query("DELETE FROM auth_tenant_users WHERE tenant_id=1 AND user_id=$1", [user])
|
||||
assert.equal((await app.inject(url)).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "PUT", url, payload: { roomKey: "allgemein", hidden: false } })).statusCode, 403)
|
||||
} finally { await app.close(); await fixture.close() }
|
||||
})
|
||||
|
||||
test("disabling in the project form blocks list, provision, reads and writes until re-enabled", { skip: !testDatabaseUrl }, async () => {
|
||||
const fixture = await collaborationDatabase(), app = Fastify()
|
||||
const user = randomUUID(), profile = randomUUID()
|
||||
try {
|
||||
await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')")
|
||||
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test')", [user])
|
||||
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)", [user])
|
||||
await fixture.pool.query("INSERT INTO auth_profiles(id,tenant_id,user_id) VALUES ($1,1,$2)", [profile, user])
|
||||
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles) VALUES (1,1,'Project',ARRAY[$1]::uuid[]),(2,2,'Other tenant','{}')", [profile])
|
||||
app.decorate("db", fixture.db)
|
||||
app.addHook("preHandler", async (req: any) => {
|
||||
req.user = { tenant_id: 1, user_id: user, is_admin: req.headers["x-admin"] === "true" }
|
||||
req.hasPermission = (permission: string) => permission === "organisation.projects.write" && req.headers["x-manager"] === "true"
|
||||
})
|
||||
await app.register(communicationRoutes)
|
||||
await app.register(resourceRoutes)
|
||||
const update = (chatEnabled: unknown, headers = { "x-manager": "true" }) => app.inject({ method: "PUT", url: "/resource/projects/1", headers, payload: { chatEnabled } })
|
||||
const provision = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })
|
||||
assert.equal(provision.statusCode, 200, provision.body)
|
||||
const roomId = provision.json().id
|
||||
await fixture.pool.query("INSERT INTO communication_messages(tenant_id,room_id,author_user_id,body) VALUES (1,$1,$2,'Preserved history')", [roomId, user])
|
||||
assert.equal((await update(false, { "x-manager": "false" })).statusCode, 403)
|
||||
assert.equal((await update("false")).statusCode, 400)
|
||||
assert.equal((await app.inject({ method: "PUT", url: "/resource/projects/2", headers: { "x-manager": "true" }, payload: { chatEnabled: false } })).statusCode, 404)
|
||||
assert.equal((await update(false)).statusCode, 200)
|
||||
assert.deepEqual((await app.inject("/communication/chat/project-rooms")).json().rooms, [])
|
||||
assert.equal((await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })).statusCode, 404)
|
||||
for (const suffix of ["messages", "sync", "members"]) {
|
||||
assert.equal((await app.inject(`/communication/chat/rooms/project_1/${suffix}`)).statusCode, 404)
|
||||
assert.equal((await app.inject({ url: `/communication/chat/rooms/project_1/${suffix}`, headers: { "x-admin": "true" } })).statusCode, 404)
|
||||
}
|
||||
for (const suffix of ["messages", "read"]) assert.equal((await app.inject({ method: "POST", url: `/communication/chat/rooms/project_1/${suffix}`, payload: { text: "Rejected", messageId: 1 } })).statusCode, 404)
|
||||
assert.deepEqual((await app.inject("/communication/chat/unread")).json().rooms, {})
|
||||
assert.equal((await update(true, { "x-manager": "false" })).statusCode, 403)
|
||||
assert.equal((await update(true)).statusCode, 200)
|
||||
const history = await app.inject("/communication/chat/rooms/project_1/messages")
|
||||
assert.equal(history.statusCode, 200, history.body)
|
||||
assert.deepEqual(history.json().messages.map(message => message.body), ["Preserved history"])
|
||||
assert.equal((await app.inject("/communication/chat/project-rooms")).json().rooms.length, 1)
|
||||
const create = await app.inject({ method: "POST", url: "/resource/projects", payload: { id: 3, name: "Disabled", projectNumber: "P-3", chatEnabled: false } })
|
||||
assert.equal(create.statusCode, 403)
|
||||
} finally { await app.close(); await fixture.close() }
|
||||
})
|
||||
@@ -25,7 +25,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
|
||||
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.getSQLType() === "boolean") suffix += " DEFAULT false"
|
||||
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
|
||||
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
||||
})
|
||||
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)
|
||||
|
||||
Reference in New Issue
Block a user