feat(#380): Workspace-to-Matrix-Room mapping and provisioning
Some checks failed
ci/woodpecker/push/api Pipeline failed
Some checks failed
ci/woodpecker/push/api Pipeline failed
- Add matrix_room_id column to workspace table (migration) - Create MatrixRoomService for room provisioning and mapping - Auto-create Matrix room on workspace provisioning (when configured) - Support manual room linking for existing workspaces - Unit tests for all mapping operations Refs #380
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT;
|
||||||
@@ -261,12 +261,13 @@ model UserPreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Workspace {
|
model Workspace {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
ownerId String @map("owner_id") @db.Uuid
|
ownerId String @map("owner_id") @db.Uuid
|
||||||
settings Json @default("{}")
|
settings Json @default("{}")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
matrixRoomId String? @map("matrix_room_id")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { MatrixService } from "./matrix.service";
|
export { MatrixService } from "./matrix.service";
|
||||||
|
export { MatrixRoomService } from "./matrix-room.service";
|
||||||
|
|||||||
186
apps/api/src/bridge/matrix/matrix-room.service.spec.ts
Normal file
186
apps/api/src/bridge/matrix/matrix-room.service.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { MatrixRoomService } from "./matrix-room.service";
|
||||||
|
import { MatrixService } from "./matrix.service";
|
||||||
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock matrix-bot-sdk to avoid native module import errors
|
||||||
|
vi.mock("matrix-bot-sdk", () => {
|
||||||
|
return {
|
||||||
|
MatrixClient: class MockMatrixClient {},
|
||||||
|
SimpleFsStorageProvider: class MockStorageProvider {
|
||||||
|
constructor(_filename: string) {
|
||||||
|
// No-op for testing
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AutojoinRoomsMixin: {
|
||||||
|
setupOnClient: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MatrixRoomService", () => {
|
||||||
|
let service: MatrixRoomService;
|
||||||
|
|
||||||
|
const mockCreateRoom = vi.fn().mockResolvedValue("!new-room:example.com");
|
||||||
|
|
||||||
|
const mockMatrixService = {
|
||||||
|
isConnected: vi.fn().mockReturnValue(true),
|
||||||
|
// Private field accessed by MatrixRoomService.getMatrixClient()
|
||||||
|
client: {
|
||||||
|
createRoom: mockCreateRoom,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
workspace: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.MATRIX_SERVER_NAME = "example.com";
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixRoomService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MatrixService,
|
||||||
|
useValue: mockMatrixService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MatrixRoomService>(MatrixRoomService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Restore defaults after clearing
|
||||||
|
mockMatrixService.isConnected.mockReturnValue(true);
|
||||||
|
mockCreateRoom.mockResolvedValue("!new-room:example.com");
|
||||||
|
mockPrismaService.workspace.update.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("provisionRoom", () => {
|
||||||
|
it("should create a Matrix room and store the mapping", async () => {
|
||||||
|
const roomId = await service.provisionRoom(
|
||||||
|
"workspace-uuid-1",
|
||||||
|
"My Workspace",
|
||||||
|
"my-workspace"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(roomId).toBe("!new-room:example.com");
|
||||||
|
|
||||||
|
expect(mockCreateRoom).toHaveBeenCalledWith({
|
||||||
|
name: "Mosaic: My Workspace",
|
||||||
|
room_alias_name: "mosaic-my-workspace",
|
||||||
|
topic: "Mosaic workspace: My Workspace",
|
||||||
|
preset: "private_chat",
|
||||||
|
visibility: "private",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "workspace-uuid-1" },
|
||||||
|
data: { matrixRoomId: "!new-room:example.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when Matrix is not configured (no MatrixService)", async () => {
|
||||||
|
// Create a service without MatrixService
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixRoomService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const serviceWithoutMatrix = module.get<MatrixRoomService>(MatrixRoomService);
|
||||||
|
|
||||||
|
const roomId = await serviceWithoutMatrix.provisionRoom(
|
||||||
|
"workspace-uuid-1",
|
||||||
|
"My Workspace",
|
||||||
|
"my-workspace"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(roomId).toBeNull();
|
||||||
|
expect(mockCreateRoom).not.toHaveBeenCalled();
|
||||||
|
expect(mockPrismaService.workspace.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when Matrix is not connected", async () => {
|
||||||
|
mockMatrixService.isConnected.mockReturnValue(false);
|
||||||
|
|
||||||
|
const roomId = await service.provisionRoom(
|
||||||
|
"workspace-uuid-1",
|
||||||
|
"My Workspace",
|
||||||
|
"my-workspace"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(roomId).toBeNull();
|
||||||
|
expect(mockCreateRoom).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRoomForWorkspace", () => {
|
||||||
|
it("should return the room ID for a mapped workspace", async () => {
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue({
|
||||||
|
matrixRoomId: "!mapped-room:example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomId = await service.getRoomForWorkspace("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(roomId).toBe("!mapped-room:example.com");
|
||||||
|
expect(mockPrismaService.workspace.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "workspace-uuid-1" },
|
||||||
|
select: { matrixRoomId: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for an unmapped workspace", async () => {
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue({
|
||||||
|
matrixRoomId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomId = await service.getRoomForWorkspace("workspace-uuid-2");
|
||||||
|
|
||||||
|
expect(roomId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for a non-existent workspace", async () => {
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const roomId = await service.getRoomForWorkspace("non-existent-uuid");
|
||||||
|
|
||||||
|
expect(roomId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linkWorkspaceToRoom", () => {
|
||||||
|
it("should store the room mapping in the workspace", async () => {
|
||||||
|
await service.linkWorkspaceToRoom("workspace-uuid-1", "!existing-room:example.com");
|
||||||
|
|
||||||
|
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "workspace-uuid-1" },
|
||||||
|
data: { matrixRoomId: "!existing-room:example.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlinkWorkspace", () => {
|
||||||
|
it("should remove the room mapping from the workspace", async () => {
|
||||||
|
await service.unlinkWorkspace("workspace-uuid-1");
|
||||||
|
|
||||||
|
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "workspace-uuid-1" },
|
||||||
|
data: { matrixRoomId: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
137
apps/api/src/bridge/matrix/matrix-room.service.ts
Normal file
137
apps/api/src/bridge/matrix/matrix-room.service.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable, Logger, Optional, Inject } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
|
import { MatrixService } from "./matrix.service";
|
||||||
|
import type { MatrixClient, RoomCreateOptions } from "matrix-bot-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MatrixRoomService - Workspace-to-Matrix-Room mapping and provisioning
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Provision Matrix rooms for Mosaic workspaces
|
||||||
|
* - Map workspaces to Matrix room IDs
|
||||||
|
* - Link/unlink existing rooms to workspaces
|
||||||
|
*
|
||||||
|
* Room provisioning creates a private Matrix room with:
|
||||||
|
* - Name: "Mosaic: {workspace_name}"
|
||||||
|
* - Alias: #mosaic-{workspace_slug}:{server_name}
|
||||||
|
* - Room ID stored in workspace.matrixRoomId
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MatrixRoomService {
|
||||||
|
private readonly logger = new Logger(MatrixRoomService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Optional() @Inject(MatrixService) private readonly matrixService: MatrixService | null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision a Matrix room for a workspace and store the mapping.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace UUID
|
||||||
|
* @param workspaceName - Human-readable workspace name
|
||||||
|
* @param workspaceSlug - URL-safe workspace identifier for the room alias
|
||||||
|
* @returns The Matrix room ID, or null if Matrix is not configured
|
||||||
|
*/
|
||||||
|
async provisionRoom(
|
||||||
|
workspaceId: string,
|
||||||
|
workspaceName: string,
|
||||||
|
workspaceSlug: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!this.matrixService?.isConnected()) {
|
||||||
|
this.logger.warn("Matrix is not configured or not connected; skipping room provisioning");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = this.getMatrixClient();
|
||||||
|
if (!client) {
|
||||||
|
this.logger.warn("Matrix client is not available; skipping room provisioning");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomOptions: RoomCreateOptions = {
|
||||||
|
name: `Mosaic: ${workspaceName}`,
|
||||||
|
room_alias_name: `mosaic-${workspaceSlug}`,
|
||||||
|
topic: `Mosaic workspace: ${workspaceName}`,
|
||||||
|
preset: "private_chat",
|
||||||
|
visibility: "private",
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Provisioning Matrix room for workspace "${workspaceName}" (${workspaceId})...`
|
||||||
|
);
|
||||||
|
|
||||||
|
const roomId = await client.createRoom(roomOptions);
|
||||||
|
|
||||||
|
// Store the room mapping
|
||||||
|
await this.prisma.workspace.update({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
data: { matrixRoomId: roomId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Matrix room ${roomId} provisioned and linked to workspace ${workspaceId}`);
|
||||||
|
|
||||||
|
return roomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the Matrix room ID mapped to a workspace.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace UUID
|
||||||
|
* @returns The Matrix room ID, or null if no room is mapped
|
||||||
|
*/
|
||||||
|
async getRoomForWorkspace(workspaceId: string): Promise<string | null> {
|
||||||
|
const workspace = await this.prisma.workspace.findUnique({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
select: { matrixRoomId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return workspace?.matrixRoomId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually link an existing Matrix room to a workspace.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace UUID
|
||||||
|
* @param roomId - The Matrix room ID to link
|
||||||
|
*/
|
||||||
|
async linkWorkspaceToRoom(workspaceId: string, roomId: string): Promise<void> {
|
||||||
|
await this.prisma.workspace.update({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
data: { matrixRoomId: roomId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Linked workspace ${workspaceId} to Matrix room ${roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the Matrix room mapping from a workspace.
|
||||||
|
*
|
||||||
|
* @param workspaceId - The workspace UUID
|
||||||
|
*/
|
||||||
|
async unlinkWorkspace(workspaceId: string): Promise<void> {
|
||||||
|
await this.prisma.workspace.update({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
data: { matrixRoomId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Unlinked Matrix room from workspace ${workspaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access the underlying MatrixClient from the MatrixService.
|
||||||
|
*
|
||||||
|
* The MatrixService stores the client as a private field, so we
|
||||||
|
* access it via a known private property name. This is intentional
|
||||||
|
* to avoid exposing the client publicly on the service interface.
|
||||||
|
*/
|
||||||
|
private getMatrixClient(): MatrixClient | null {
|
||||||
|
if (!this.matrixService) return null;
|
||||||
|
|
||||||
|
// Access the private client field from MatrixService.
|
||||||
|
// MatrixService stores `client` as a private property; we use a type assertion
|
||||||
|
// to access it since exposing it publicly is not appropriate for the service API.
|
||||||
|
const service = this.matrixService as unknown as { client: MatrixClient | null };
|
||||||
|
return service.client;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user