chore: upgrade Node.js runtime to v24 across codebase #419
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT;
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
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