From 0c789231387657ef4d39f76944d36e4f95ac6037 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 31 Jan 2026 13:15:36 -0600 Subject: [PATCH] feat(#133): add workspace-scoped LLM configuration Implement per-workspace LLM provider and personality configuration with proper hierarchy (workspace > user > system fallback). Schema: - Add WorkspaceLlmSettings model with provider/personality FKs - One-to-one relation with Workspace - JSON settings field for extensibility Service: - getSettings: Retrieves/creates workspace settings - updateSettings: Updates with null value support - getEffectiveLlmProvider: Hierarchy-based provider selection - getEffectivePersonality: Hierarchy-based personality selection Endpoints: - GET /workspaces/:id/settings/llm - Get settings - PATCH /workspaces/:id/settings/llm - Update settings - GET /workspaces/:id/settings/llm/effective-provider - GET /workspaces/:id/settings/llm/effective-personality Configuration hierarchy: 1. Workspace-configured provider/personality 2. User-specific provider (for providers) 3. System default fallback Tests: 34 passing with 100% coverage Fixes #133 Co-Authored-By: Claude Opus 4.5 --- apps/api/prisma/schema.prisma | 31 +- apps/api/src/workspace-settings/dto/index.ts | 1 + .../dto/update-workspace-settings.dto.ts | 19 + apps/api/src/workspace-settings/index.ts | 4 + .../workspace-settings.controller.spec.ts | 268 ++++++++++++ .../workspace-settings.controller.ts | 58 +++ .../workspace-settings.module.ts | 12 + .../workspace-settings.service.spec.ts | 382 ++++++++++++++++++ .../workspace-settings.service.ts | 187 +++++++++ 9 files changed, 959 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/workspace-settings/dto/index.ts create mode 100644 apps/api/src/workspace-settings/dto/update-workspace-settings.dto.ts create mode 100644 apps/api/src/workspace-settings/index.ts create mode 100644 apps/api/src/workspace-settings/workspace-settings.controller.spec.ts create mode 100644 apps/api/src/workspace-settings/workspace-settings.controller.ts create mode 100644 apps/api/src/workspace-settings/workspace-settings.module.ts create mode 100644 apps/api/src/workspace-settings/workspace-settings.service.spec.ts create mode 100644 apps/api/src/workspace-settings/workspace-settings.service.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5d38e26..62076cd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -214,6 +214,7 @@ model Workspace { knowledgeTags KnowledgeTag[] cronSchedules CronSchedule[] personalities Personality[] + llmSettings WorkspaceLlmSettings? @@index([ownerId]) @@map("workspaces") @@ -942,7 +943,8 @@ model Personality { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz // Relations - llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull) + llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull) + workspaceLlmSettings WorkspaceLlmSettings[] @relation("WorkspacePersonality") @@unique([id, workspaceId]) @@unique([workspaceId, name]) @@ -969,8 +971,9 @@ model LlmProviderInstance { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz // Relations - user User? @relation("UserLlmProviders", fields: [userId], references: [id], onDelete: Cascade) - personalities Personality[] @relation("PersonalityLlmProvider") + user User? @relation("UserLlmProviders", fields: [userId], references: [id], onDelete: Cascade) + personalities Personality[] @relation("PersonalityLlmProvider") + workspaceLlmSettings WorkspaceLlmSettings[] @relation("WorkspaceLlmProvider") @@index([userId]) @@index([providerType]) @@ -978,3 +981,25 @@ model LlmProviderInstance { @@index([isEnabled]) @@map("llm_provider_instances") } + +// ============================================ +// WORKSPACE LLM SETTINGS +// ============================================ + +model WorkspaceLlmSettings { + id String @id @default(uuid()) @db.Uuid + workspaceId String @unique @map("workspace_id") @db.Uuid + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + defaultLlmProviderId String? @map("default_llm_provider_id") @db.Uuid + defaultLlmProvider LlmProviderInstance? @relation("WorkspaceLlmProvider", fields: [defaultLlmProviderId], references: [id], onDelete: SetNull) + defaultPersonalityId String? @map("default_personality_id") @db.Uuid + defaultPersonality Personality? @relation("WorkspacePersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) + settings Json? @default("{}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + @@index([workspaceId]) + @@index([defaultLlmProviderId]) + @@index([defaultPersonalityId]) + @@map("workspace_llm_settings") +} diff --git a/apps/api/src/workspace-settings/dto/index.ts b/apps/api/src/workspace-settings/dto/index.ts new file mode 100644 index 0000000..c947ee5 --- /dev/null +++ b/apps/api/src/workspace-settings/dto/index.ts @@ -0,0 +1 @@ +export { UpdateWorkspaceSettingsDto } from "./update-workspace-settings.dto"; diff --git a/apps/api/src/workspace-settings/dto/update-workspace-settings.dto.ts b/apps/api/src/workspace-settings/dto/update-workspace-settings.dto.ts new file mode 100644 index 0000000..da23e4b --- /dev/null +++ b/apps/api/src/workspace-settings/dto/update-workspace-settings.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsUUID, IsObject } from "class-validator"; + +/** + * DTO for updating workspace LLM settings + * All fields are optional to support partial updates + */ +export class UpdateWorkspaceSettingsDto { + @IsOptional() + @IsUUID("4", { message: "defaultLlmProviderId must be a valid UUID" }) + defaultLlmProviderId?: string | null; + + @IsOptional() + @IsUUID("4", { message: "defaultPersonalityId must be a valid UUID" }) + defaultPersonalityId?: string | null; + + @IsOptional() + @IsObject({ message: "settings must be an object" }) + settings?: Record; +} diff --git a/apps/api/src/workspace-settings/index.ts b/apps/api/src/workspace-settings/index.ts new file mode 100644 index 0000000..3d99e41 --- /dev/null +++ b/apps/api/src/workspace-settings/index.ts @@ -0,0 +1,4 @@ +export { WorkspaceSettingsModule } from "./workspace-settings.module"; +export { WorkspaceSettingsService } from "./workspace-settings.service"; +export { WorkspaceSettingsController } from "./workspace-settings.controller"; +export * from "./dto"; diff --git a/apps/api/src/workspace-settings/workspace-settings.controller.spec.ts b/apps/api/src/workspace-settings/workspace-settings.controller.spec.ts new file mode 100644 index 0000000..bf1bd39 --- /dev/null +++ b/apps/api/src/workspace-settings/workspace-settings.controller.spec.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ExecutionContext } from "@nestjs/common"; +import { WorkspaceSettingsController } from "./workspace-settings.controller"; +import { WorkspaceSettingsService } from "./workspace-settings.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client"; +import type { AuthenticatedRequest } from "../common/types/user.types"; + +describe("WorkspaceSettingsController", () => { + let controller: WorkspaceSettingsController; + let service: WorkspaceSettingsService; + + const mockWorkspaceId = "workspace-123"; + const mockUserId = "user-123"; + + const mockSettings: WorkspaceLlmSettings = { + id: "settings-123", + workspaceId: mockWorkspaceId, + defaultLlmProviderId: "provider-123", + defaultPersonalityId: "personality-123", + settings: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockProvider: LlmProviderInstance = { + id: "provider-123", + providerType: "ollama", + displayName: "Test Provider", + userId: null, + config: { endpoint: "http://localhost:11434" }, + isDefault: true, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockPersonality: Personality = { + id: "personality-123", + workspaceId: mockWorkspaceId, + name: "default", + displayName: "Default", + description: "Default personality", + systemPrompt: "You are a helpful assistant", + temperature: null, + maxTokens: null, + llmProviderInstanceId: null, + isDefault: true, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockAuthGuard = { + canActivate: vi.fn((context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + request.user = { + id: mockUserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + image: null, + authProviderId: null, + preferences: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + return true; + }), + }; + + const mockAuthRequest: AuthenticatedRequest = { + user: { + id: mockUserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + image: null, + authProviderId: null, + preferences: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + } as AuthenticatedRequest; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkspaceSettingsController], + providers: [ + { + provide: WorkspaceSettingsService, + useValue: { + getSettings: vi.fn(), + updateSettings: vi.fn(), + getEffectiveLlmProvider: vi.fn(), + getEffectivePersonality: vi.fn(), + }, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .compile(); + + controller = module.get(WorkspaceSettingsController); + service = module.get(WorkspaceSettingsService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getSettings", () => { + it("should return workspace settings", async () => { + vi.spyOn(service, "getSettings").mockResolvedValue(mockSettings); + + const result = await controller.getSettings(mockWorkspaceId); + + expect(result).toEqual(mockSettings); + expect(service.getSettings).toHaveBeenCalledWith(mockWorkspaceId); + }); + + it("should handle service errors", async () => { + vi.spyOn(service, "getSettings").mockRejectedValue(new Error("Service error")); + + await expect(controller.getSettings(mockWorkspaceId)).rejects.toThrow("Service error"); + }); + + it("should work with valid workspace ID", async () => { + vi.spyOn(service, "getSettings").mockResolvedValue(mockSettings); + + const result = await controller.getSettings(mockWorkspaceId); + + expect(result.workspaceId).toBe(mockWorkspaceId); + }); + }); + + describe("updateSettings", () => { + it("should update workspace settings", async () => { + const updateDto = { + defaultLlmProviderId: "new-provider-123", + defaultPersonalityId: "new-personality-123", + }; + + const updatedSettings = { ...mockSettings, ...updateDto }; + vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings); + + const result = await controller.updateSettings(mockWorkspaceId, updateDto); + + expect(result).toEqual(updatedSettings); + expect(service.updateSettings).toHaveBeenCalledWith(mockWorkspaceId, updateDto); + }); + + it("should allow partial updates", async () => { + const updateDto = { + defaultLlmProviderId: "new-provider-123", + }; + + const updatedSettings = { + ...mockSettings, + defaultLlmProviderId: updateDto.defaultLlmProviderId, + }; + vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings); + + const result = await controller.updateSettings(mockWorkspaceId, updateDto); + + expect(result.defaultLlmProviderId).toBe(updateDto.defaultLlmProviderId); + }); + + it("should handle null values", async () => { + const updateDto = { + defaultLlmProviderId: null, + }; + + const updatedSettings = { ...mockSettings, defaultLlmProviderId: null }; + vi.spyOn(service, "updateSettings").mockResolvedValue(updatedSettings); + + const result = await controller.updateSettings(mockWorkspaceId, updateDto); + + expect(result.defaultLlmProviderId).toBeNull(); + }); + + it("should handle service errors", async () => { + const updateDto = { defaultLlmProviderId: "invalid-id" }; + vi.spyOn(service, "updateSettings").mockRejectedValue(new Error("Provider not found")); + + await expect(controller.updateSettings(mockWorkspaceId, updateDto)).rejects.toThrow( + "Provider not found" + ); + }); + }); + + describe("getEffectiveProvider", () => { + it("should return effective provider with authenticated user", async () => { + vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider); + + const result = await controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest); + + expect(result).toEqual(mockProvider); + expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, mockUserId); + }); + + it("should return effective provider without user ID when not authenticated", async () => { + const unauthRequest = { user: undefined } as AuthenticatedRequest; + vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider); + + const result = await controller.getEffectiveProvider(mockWorkspaceId, unauthRequest); + + expect(result).toEqual(mockProvider); + expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, undefined); + }); + + it("should handle no provider available error", async () => { + vi.spyOn(service, "getEffectiveLlmProvider").mockRejectedValue( + new Error("No LLM provider available") + ); + + await expect( + controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest) + ).rejects.toThrow("No LLM provider available"); + }); + + it("should pass user ID to service when available", async () => { + vi.spyOn(service, "getEffectiveLlmProvider").mockResolvedValue(mockProvider); + + await controller.getEffectiveProvider(mockWorkspaceId, mockAuthRequest); + + expect(service.getEffectiveLlmProvider).toHaveBeenCalledWith(mockWorkspaceId, mockUserId); + }); + }); + + describe("getEffectivePersonality", () => { + it("should return effective personality", async () => { + vi.spyOn(service, "getEffectivePersonality").mockResolvedValue(mockPersonality); + + const result = await controller.getEffectivePersonality(mockWorkspaceId); + + expect(result).toEqual(mockPersonality); + expect(service.getEffectivePersonality).toHaveBeenCalledWith(mockWorkspaceId); + }); + + it("should handle no personality available error", async () => { + vi.spyOn(service, "getEffectivePersonality").mockRejectedValue( + new Error("No personality available") + ); + + await expect(controller.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow( + "No personality available" + ); + }); + + it("should work with valid workspace ID", async () => { + vi.spyOn(service, "getEffectivePersonality").mockResolvedValue(mockPersonality); + + const result = await controller.getEffectivePersonality(mockWorkspaceId); + + expect(result.workspaceId).toBe(mockWorkspaceId); + }); + }); + + describe("endpoint paths", () => { + it("should be accessible at /workspaces/:workspaceId/settings/llm", () => { + const metadata = Reflect.getMetadata("path", WorkspaceSettingsController); + expect(metadata).toBe("workspaces/:workspaceId/settings/llm"); + }); + }); +}); diff --git a/apps/api/src/workspace-settings/workspace-settings.controller.ts b/apps/api/src/workspace-settings/workspace-settings.controller.ts new file mode 100644 index 0000000..028fd49 --- /dev/null +++ b/apps/api/src/workspace-settings/workspace-settings.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Patch, Body, Param, Request, UseGuards } from "@nestjs/common"; +import { WorkspaceSettingsService } from "./workspace-settings.service"; +import { UpdateWorkspaceSettingsDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import type { AuthenticatedRequest } from "../common/types/user.types"; + +/** + * Controller for workspace LLM settings endpoints + * All endpoints require authentication + */ +@Controller("workspaces/:workspaceId/settings/llm") +@UseGuards(AuthGuard) +export class WorkspaceSettingsController { + constructor(private readonly workspaceSettingsService: WorkspaceSettingsService) {} + + /** + * GET /api/workspaces/:workspaceId/settings/llm + * Get workspace LLM settings + */ + @Get() + async getSettings(@Param("workspaceId") workspaceId: string) { + return this.workspaceSettingsService.getSettings(workspaceId); + } + + /** + * PATCH /api/workspaces/:workspaceId/settings/llm + * Update workspace LLM settings + */ + @Patch() + async updateSettings( + @Param("workspaceId") workspaceId: string, + @Body() dto: UpdateWorkspaceSettingsDto + ) { + return this.workspaceSettingsService.updateSettings(workspaceId, dto); + } + + /** + * GET /api/workspaces/:workspaceId/settings/llm/effective-provider + * Get effective LLM provider for workspace + */ + @Get("effective-provider") + async getEffectiveProvider( + @Param("workspaceId") workspaceId: string, + @Request() req: AuthenticatedRequest + ) { + const userId = req.user?.id; + return this.workspaceSettingsService.getEffectiveLlmProvider(workspaceId, userId); + } + + /** + * GET /api/workspaces/:workspaceId/settings/llm/effective-personality + * Get effective personality for workspace + */ + @Get("effective-personality") + async getEffectivePersonality(@Param("workspaceId") workspaceId: string) { + return this.workspaceSettingsService.getEffectivePersonality(workspaceId); + } +} diff --git a/apps/api/src/workspace-settings/workspace-settings.module.ts b/apps/api/src/workspace-settings/workspace-settings.module.ts new file mode 100644 index 0000000..49728a6 --- /dev/null +++ b/apps/api/src/workspace-settings/workspace-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { WorkspaceSettingsController } from "./workspace-settings.controller"; +import { WorkspaceSettingsService } from "./workspace-settings.service"; +import { PrismaModule } from "../prisma/prisma.module"; + +@Module({ + imports: [PrismaModule], + controllers: [WorkspaceSettingsController], + providers: [WorkspaceSettingsService], + exports: [WorkspaceSettingsService], +}) +export class WorkspaceSettingsModule {} diff --git a/apps/api/src/workspace-settings/workspace-settings.service.spec.ts b/apps/api/src/workspace-settings/workspace-settings.service.spec.ts new file mode 100644 index 0000000..39ff127 --- /dev/null +++ b/apps/api/src/workspace-settings/workspace-settings.service.spec.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WorkspaceSettingsService } from "./workspace-settings.service"; +import { PrismaService } from "../prisma/prisma.service"; +import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client"; + +describe("WorkspaceSettingsService", () => { + let service: WorkspaceSettingsService; + let prisma: PrismaService; + + const mockWorkspaceId = "workspace-123"; + const mockUserId = "user-123"; + + const mockSettings: WorkspaceLlmSettings = { + id: "settings-123", + workspaceId: mockWorkspaceId, + defaultLlmProviderId: "provider-123", + defaultPersonalityId: "personality-123", + settings: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockProvider: LlmProviderInstance = { + id: "provider-123", + providerType: "ollama", + displayName: "Test Provider", + userId: null, + config: { endpoint: "http://localhost:11434" }, + isDefault: true, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUserProvider: LlmProviderInstance = { + id: "user-provider-123", + providerType: "ollama", + displayName: "User Provider", + userId: mockUserId, + config: { endpoint: "http://user-ollama:11434" }, + isDefault: false, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockPersonality: Personality = { + id: "personality-123", + workspaceId: mockWorkspaceId, + name: "default", + displayName: "Default", + description: "Default personality", + systemPrompt: "You are a helpful assistant", + temperature: null, + maxTokens: null, + llmProviderInstanceId: null, + isDefault: true, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceSettingsService, + { + provide: PrismaService, + useValue: { + workspaceLlmSettings: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + llmProviderInstance: { + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + personality: { + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + }, + }, + ], + }).compile(); + + service = module.get(WorkspaceSettingsService); + prisma = module.get(PrismaService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getSettings", () => { + it("should return existing settings for workspace", async () => { + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings); + + const result = await service.getSettings(mockWorkspaceId); + + expect(result).toEqual(mockSettings); + expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + }); + }); + + it("should create default settings if not exists", async () => { + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null); + vi.spyOn(prisma.workspaceLlmSettings, "create").mockResolvedValue(mockSettings); + + const result = await service.getSettings(mockWorkspaceId); + + expect(result).toEqual(mockSettings); + expect(prisma.workspaceLlmSettings.create).toHaveBeenCalledWith({ + data: { + workspaceId: mockWorkspaceId, + settings: {}, + }, + }); + }); + + it("should handle workspace with no settings gracefully", async () => { + const newSettings = { + ...mockSettings, + defaultLlmProviderId: null, + defaultPersonalityId: null, + }; + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null); + vi.spyOn(prisma.workspaceLlmSettings, "create").mockResolvedValue(newSettings); + + const result = await service.getSettings(mockWorkspaceId); + + expect(result).toBeDefined(); + expect(result.workspaceId).toBe(mockWorkspaceId); + }); + }); + + describe("updateSettings", () => { + it("should update existing settings", async () => { + const updateDto = { + defaultLlmProviderId: "new-provider-123", + defaultPersonalityId: "new-personality-123", + }; + + const updatedSettings = { ...mockSettings, ...updateDto }; + vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings); + + const result = await service.updateSettings(mockWorkspaceId, updateDto); + + expect(result).toEqual(updatedSettings); + expect(prisma.workspaceLlmSettings.update).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + data: updateDto, + }); + }); + + it("should allow setting provider to null", async () => { + const updateDto = { + defaultLlmProviderId: null, + }; + + const updatedSettings = { ...mockSettings, defaultLlmProviderId: null }; + vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings); + + const result = await service.updateSettings(mockWorkspaceId, updateDto); + + expect(result.defaultLlmProviderId).toBeNull(); + }); + + it("should allow setting personality to null", async () => { + const updateDto = { + defaultPersonalityId: null, + }; + + const updatedSettings = { ...mockSettings, defaultPersonalityId: null }; + vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings); + + const result = await service.updateSettings(mockWorkspaceId, updateDto); + + expect(result.defaultPersonalityId).toBeNull(); + }); + + it("should update custom settings object", async () => { + const updateDto = { + settings: { customKey: "customValue" }, + }; + + const updatedSettings = { ...mockSettings, settings: updateDto.settings }; + vi.spyOn(prisma.workspaceLlmSettings, "update").mockResolvedValue(updatedSettings); + + const result = await service.updateSettings(mockWorkspaceId, updateDto); + + expect(result.settings).toEqual(updateDto.settings); + }); + }); + + describe("getEffectiveLlmProvider", () => { + it("should return workspace provider when set", async () => { + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings); + vi.spyOn(prisma.llmProviderInstance, "findUnique").mockResolvedValue(mockProvider); + + const result = await service.getEffectiveLlmProvider(mockWorkspaceId); + + expect(result).toEqual(mockProvider); + expect(prisma.llmProviderInstance.findUnique).toHaveBeenCalledWith({ + where: { id: mockSettings.defaultLlmProviderId! }, + }); + }); + + it("should return user provider when workspace provider not set and userId provided", async () => { + const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null }; + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue( + settingsWithoutProvider + ); + vi.spyOn(prisma.llmProviderInstance, "findFirst") + .mockResolvedValueOnce(mockUserProvider) + .mockResolvedValueOnce(null); + + const result = await service.getEffectiveLlmProvider(mockWorkspaceId, mockUserId); + + expect(result).toEqual(mockUserProvider); + expect(prisma.llmProviderInstance.findFirst).toHaveBeenCalledWith({ + where: { + userId: mockUserId, + isEnabled: true, + }, + }); + }); + + it("should fall back to system default when workspace and user providers not set", async () => { + const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null }; + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue( + settingsWithoutProvider + ); + vi.spyOn(prisma.llmProviderInstance, "findFirst") + .mockResolvedValueOnce(null) // No user provider + .mockResolvedValueOnce(mockProvider); // System default + + const result = await service.getEffectiveLlmProvider(mockWorkspaceId, mockUserId); + + expect(result).toEqual(mockProvider); + expect(prisma.llmProviderInstance.findFirst).toHaveBeenNthCalledWith(2, { + where: { + userId: null, + isDefault: true, + isEnabled: true, + }, + }); + }); + + it("should throw error when no provider available", async () => { + const settingsWithoutProvider = { ...mockSettings, defaultLlmProviderId: null }; + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue( + settingsWithoutProvider + ); + vi.spyOn(prisma.llmProviderInstance, "findFirst").mockResolvedValue(null); + + await expect(service.getEffectiveLlmProvider(mockWorkspaceId)).rejects.toThrow( + `No LLM provider available for workspace ${mockWorkspaceId}` + ); + }); + + it("should throw error when workspace provider is set but not found", async () => { + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings); + vi.spyOn(prisma.llmProviderInstance, "findUnique").mockResolvedValue(null); + + await expect(service.getEffectiveLlmProvider(mockWorkspaceId)).rejects.toThrow( + `LLM provider ${mockSettings.defaultLlmProviderId} not found` + ); + }); + }); + + describe("getEffectivePersonality", () => { + it("should return workspace personality when set", async () => { + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings); + vi.spyOn(prisma.personality, "findUnique").mockResolvedValue(mockPersonality); + + const result = await service.getEffectivePersonality(mockWorkspaceId); + + expect(result).toEqual(mockPersonality); + expect(prisma.personality.findUnique).toHaveBeenCalledWith({ + where: { + id: mockSettings.defaultPersonalityId!, + }, + }); + }); + + it("should fall back to default personality when workspace personality not set", async () => { + const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null }; + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue( + settingsWithoutPersonality + ); + vi.spyOn(prisma.personality, "findFirst").mockResolvedValue(mockPersonality); + + const result = await service.getEffectivePersonality(mockWorkspaceId); + + expect(result).toEqual(mockPersonality); + expect(prisma.personality.findFirst).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + isDefault: true, + isEnabled: true, + }, + }); + }); + + it("should fall back to any enabled personality when no default exists", async () => { + const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null }; + const nonDefaultPersonality = { ...mockPersonality, isDefault: false }; + + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue( + settingsWithoutPersonality + ); + vi.spyOn(prisma.personality, "findFirst") + .mockResolvedValueOnce(null) // No default personality + .mockResolvedValueOnce(nonDefaultPersonality); // Any enabled personality + + const result = await service.getEffectivePersonality(mockWorkspaceId); + + expect(result).toEqual(nonDefaultPersonality); + expect(prisma.personality.findFirst).toHaveBeenNthCalledWith(2, { + where: { + workspaceId: mockWorkspaceId, + isEnabled: true, + }, + }); + }); + + it("should throw error when no personality available", async () => { + const settingsWithoutPersonality = { ...mockSettings, defaultPersonalityId: null }; + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue( + settingsWithoutPersonality + ); + vi.spyOn(prisma.personality, "findFirst").mockResolvedValue(null); + + await expect(service.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow( + `No personality available for workspace ${mockWorkspaceId}` + ); + }); + + it("should throw error when workspace personality is set but not found", async () => { + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings); + vi.spyOn(prisma.personality, "findUnique").mockResolvedValue(null); + + await expect(service.getEffectivePersonality(mockWorkspaceId)).rejects.toThrow( + `Personality ${mockSettings.defaultPersonalityId} not found` + ); + }); + }); + + describe("workspace isolation", () => { + it("should only access settings for specified workspace", async () => { + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(mockSettings); + + await service.getSettings(mockWorkspaceId); + + expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + }); + }); + + it("should not allow cross-workspace settings access", async () => { + const otherWorkspaceId = "other-workspace-123"; + + vi.spyOn(prisma.workspaceLlmSettings, "findUnique").mockResolvedValue(null); + + const result1 = await service.getSettings(mockWorkspaceId); + const result2 = await service.getSettings(otherWorkspaceId); + + // Each workspace should have separate calls + expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledTimes(2); + expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + }); + expect(prisma.workspaceLlmSettings.findUnique).toHaveBeenCalledWith({ + where: { workspaceId: otherWorkspaceId }, + }); + }); + }); +}); diff --git a/apps/api/src/workspace-settings/workspace-settings.service.ts b/apps/api/src/workspace-settings/workspace-settings.service.ts new file mode 100644 index 0000000..da65efd --- /dev/null +++ b/apps/api/src/workspace-settings/workspace-settings.service.ts @@ -0,0 +1,187 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import type { WorkspaceLlmSettings, LlmProviderInstance, Personality } from "@prisma/client"; +import type { UpdateWorkspaceSettingsDto } from "./dto"; + +/** + * Service for managing workspace LLM settings + * Handles configuration hierarchy: workspace > user > system + */ +@Injectable() +export class WorkspaceSettingsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get settings for a workspace (creates default if not exists) + * + * @param workspaceId - Workspace ID + * @returns Workspace LLM settings + */ + async getSettings(workspaceId: string): Promise { + let settings = await this.prisma.workspaceLlmSettings.findUnique({ + where: { workspaceId }, + }); + + // Create default settings if they don't exist + settings ??= await this.prisma.workspaceLlmSettings.create({ + data: { + workspaceId, + settings: {} as unknown as Prisma.InputJsonValue, + }, + }); + + return settings; + } + + /** + * Update workspace LLM settings + * + * @param workspaceId - Workspace ID + * @param dto - Update data + * @returns Updated settings + */ + async updateSettings( + workspaceId: string, + dto: UpdateWorkspaceSettingsDto + ): Promise { + const data: Prisma.WorkspaceLlmSettingsUncheckedUpdateInput = {}; + + if (dto.defaultLlmProviderId !== undefined) { + data.defaultLlmProviderId = dto.defaultLlmProviderId; + } + + if (dto.defaultPersonalityId !== undefined) { + data.defaultPersonalityId = dto.defaultPersonalityId; + } + + if (dto.settings !== undefined) { + data.settings = dto.settings as unknown as Prisma.InputJsonValue; + } + + const settings = await this.prisma.workspaceLlmSettings.update({ + where: { workspaceId }, + data, + }); + + return settings; + } + + /** + * Get effective LLM provider for a workspace + * Priority: workspace > user > system default + * + * @param workspaceId - Workspace ID + * @param userId - Optional user ID for user-level provider + * @returns Effective LLM provider instance + * @throws {Error} If no provider available + */ + async getEffectiveLlmProvider( + workspaceId: string, + userId?: string + ): Promise { + // Get workspace settings + const settings = await this.prisma.workspaceLlmSettings.findUnique({ + where: { workspaceId }, + }); + + // 1. Check workspace-level provider + if (settings?.defaultLlmProviderId) { + const provider = await this.prisma.llmProviderInstance.findUnique({ + where: { id: settings.defaultLlmProviderId }, + }); + + if (!provider) { + throw new Error(`LLM provider ${settings.defaultLlmProviderId} not found`); + } + + return provider; + } + + // 2. Check user-level provider + if (userId) { + const userProvider = await this.prisma.llmProviderInstance.findFirst({ + where: { + userId, + isEnabled: true, + }, + }); + + if (userProvider) { + return userProvider; + } + } + + // 3. Fall back to system default + const systemDefault = await this.prisma.llmProviderInstance.findFirst({ + where: { + userId: null, + isDefault: true, + isEnabled: true, + }, + }); + + if (!systemDefault) { + throw new Error(`No LLM provider available for workspace ${workspaceId}`); + } + + return systemDefault; + } + + /** + * Get effective personality for a workspace + * Priority: workspace default > workspace enabled > any enabled + * + * @param workspaceId - Workspace ID + * @returns Effective personality + * @throws {Error} If no personality available + */ + async getEffectivePersonality(workspaceId: string): Promise { + // Get workspace settings + const settings = await this.prisma.workspaceLlmSettings.findUnique({ + where: { workspaceId }, + }); + + // 1. Check workspace-configured personality + if (settings?.defaultPersonalityId) { + const personality = await this.prisma.personality.findUnique({ + where: { + id: settings.defaultPersonalityId, + }, + }); + + if (!personality) { + throw new Error(`Personality ${settings.defaultPersonalityId} not found`); + } + + return personality; + } + + // 2. Fall back to default personality in workspace + const defaultPersonality = await this.prisma.personality.findFirst({ + where: { + workspaceId, + isDefault: true, + isEnabled: true, + }, + }); + + if (defaultPersonality) { + return defaultPersonality; + } + + // 3. Fall back to any enabled personality + const anyPersonality = await this.prisma.personality.findFirst({ + where: { + workspaceId, + isEnabled: true, + }, + }); + + if (!anyPersonality) { + throw new Error(`No personality available for workspace ${workspaceId}`); + } + + return anyPersonality; + } +}