From 7d22c2490a909ab778732f5d8e6f1ddeacbf861e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Feb 2026 02:16:29 -0600 Subject: [PATCH] feat(#380): Workspace-to-Matrix-Room mapping and provisioning - 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 --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 13 +- apps/api/src/bridge/matrix/index.ts | 1 + .../bridge/matrix/matrix-room.service.spec.ts | 186 ++++++++++++++++++ .../src/bridge/matrix/matrix-room.service.ts | 137 +++++++++++++ 5 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 apps/api/prisma/migrations/20260215000000_add_matrix_room_id/migration.sql create mode 100644 apps/api/src/bridge/matrix/matrix-room.service.spec.ts create mode 100644 apps/api/src/bridge/matrix/matrix-room.service.ts diff --git a/apps/api/prisma/migrations/20260215000000_add_matrix_room_id/migration.sql b/apps/api/prisma/migrations/20260215000000_add_matrix_room_id/migration.sql new file mode 100644 index 0000000..ed78f01 --- /dev/null +++ b/apps/api/prisma/migrations/20260215000000_add_matrix_room_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fd088f4..c562279 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -261,12 +261,13 @@ model UserPreference { } model Workspace { - id String @id @default(uuid()) @db.Uuid - name String - ownerId String @map("owner_id") @db.Uuid - settings Json @default("{}") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + id String @id @default(uuid()) @db.Uuid + name String + ownerId String @map("owner_id") @db.Uuid + settings Json @default("{}") + matrixRoomId String? @map("matrix_room_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz // Relations owner User @relation("WorkspaceOwner", fields: [ownerId], references: [id], onDelete: Cascade) diff --git a/apps/api/src/bridge/matrix/index.ts b/apps/api/src/bridge/matrix/index.ts index 056621f..34c67f7 100644 --- a/apps/api/src/bridge/matrix/index.ts +++ b/apps/api/src/bridge/matrix/index.ts @@ -1 +1,2 @@ export { MatrixService } from "./matrix.service"; +export { MatrixRoomService } from "./matrix-room.service"; diff --git a/apps/api/src/bridge/matrix/matrix-room.service.spec.ts b/apps/api/src/bridge/matrix/matrix-room.service.spec.ts new file mode 100644 index 0000000..2ae342c --- /dev/null +++ b/apps/api/src/bridge/matrix/matrix-room.service.spec.ts @@ -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); + + 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); + + 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 }, + }); + }); + }); +}); diff --git a/apps/api/src/bridge/matrix/matrix-room.service.ts b/apps/api/src/bridge/matrix/matrix-room.service.ts new file mode 100644 index 0000000..f1189d8 --- /dev/null +++ b/apps/api/src/bridge/matrix/matrix-room.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +}